// CancelWithReason — cancelación guiada de 2 pasos (benchmark §5.5 / §7): motivo // del catálogo del tipo de documento → (documento sustituto si el motivo lo // exige) → confirmación con consecuencias. Genérico: el catálogo de motivos, las // consecuencias y la política de aceptación las declara el llamador; el // componente no sabe de CFDI. Solo una acción primaria por paso (verbo + // sustantivo) y, si la acción falla, el modal sigue abierto con el error. import { useEffect, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { AlertTriangle, Loader2 } from 'lucide-react' import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, RadioGroup, RadioGroupItem, } from '@asteby/metacore-ui/primitives' export interface CancelReason { value: string label: string /** Ayuda corta bajo el motivo. */ hint?: string /** Exige apuntar a un documento sustituto (motivo 01 del SAT). */ requiresSubstitute?: boolean /** El motivo no aplica a este documento (se ve deshabilitado con `disabledReason`). */ disabled?: boolean disabledReason?: string } export interface CancelPayload { reason: string substitute?: string } export interface CancelWithReasonProps { open: boolean onOpenChange: (open: boolean) => void /** Identificación del documento («Factura A-1042»). */ documentLabel: string title?: string reasons: CancelReason[] /** Consecuencias que se muestran en el paso de confirmación (§6.2). */ consequences: string[] /** * Aviso según el motivo elegido, p. ej. «requiere aceptación del receptor». * Devuelve `undefined` si no hay. */ notice?: (reason: CancelReason) => string | undefined /** Formato del identificador del sustituto; si se define, valida en línea. */ substitutePattern?: RegExp substituteLabel?: string substituteHelp?: string /** Zona para crear el sustituto desde el mismo asistente (el host la implementa). */ substituteSlot?: (props: { value: string; onChange: (v: string) => void }) => ReactNode /** Ejecuta la cancelación; debe lanzar con `message` si falla. */ onConfirm: (payload: CancelPayload) => Promise /** Se llama tras un éxito, antes de cerrar. */ onDone?: () => void confirmLabel?: string } type Step = 'reason' | 'confirm' export function CancelWithReason(props: CancelWithReasonProps) { const { open, onOpenChange, reasons, consequences, onConfirm, onDone } = props const { t } = useTranslation() const [step, setStep] = useState('reason') const [reasonValue, setReasonValue] = useState('') const [substitute, setSubstitute] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState(null) useEffect(() => { if (open) { setStep('reason') setReasonValue('') setSubstitute('') setError(null) setBusy(false) } }, [open]) const reason = reasons.find((r) => r.value === reasonValue) const substituteValid = !reason?.requiresSubstitute || (substitute.trim().length > 0 && (!props.substitutePattern || props.substitutePattern.test(substitute.trim()))) const notice = reason ? props.notice?.(reason) : undefined const tr = (s: string) => t(s, { defaultValue: s }) const confirm = async () => { if (!reason) return setBusy(true) setError(null) try { await onConfirm({ reason: reason.value, substitute: reason.requiresSubstitute ? substitute.trim() : undefined }) onDone?.() onOpenChange(false) } catch (e) { setError((e as { message?: string })?.message || t('document.cancel.failed', { defaultValue: 'No se pudo cancelar. Revisa el detalle e intenta de nuevo.' })) setBusy(false) } } return ( (!busy ? onOpenChange(o) : undefined)}> {props.title ?? t('document.cancel.title', { defaultValue: 'Solicitar cancelación' })} {props.documentLabel} ·{' '} {step === 'reason' ? t('document.cancel.step_reason', { defaultValue: 'Paso 1 de 2: elige el motivo' }) : t('document.cancel.step_confirm', { defaultValue: 'Paso 2 de 2: confirma' })} {step === 'reason' ? (
{reasons.map((r) => ( ))} {reason?.requiresSubstitute ? ( props.substituteSlot ? ( props.substituteSlot({ value: substitute, onChange: setSubstitute }) ) : (
setSubstitute(e.target.value)} className='font-mono text-sm' /> {props.substituteHelp ?

{tr(props.substituteHelp)}

: null} {substitute && !substituteValid ? (

{t('document.cancel.substitute_invalid', { defaultValue: 'El formato del identificador no es válido.' })}

) : null}
) ) : null} {notice ?

{tr(notice)}

: null}
) : (
    {consequences.map((c) => (
  • {tr(c)}
  • ))}
{t('document.cancel.reason', { defaultValue: 'Motivo' })}
{reason ? tr(reason.label) : ''}
{reason?.requiresSubstitute ? ( <>
{t('document.cancel.substitute_short', { defaultValue: 'Sustituto' })}
{substitute}
) : null}
{notice ?

{tr(notice)}

: null} {error ? (

{error}

) : null}
)} {step === 'reason' ? ( <> ) : ( <> )}
) }