'use client'; import type { IrpCategory } from '@impuestos/contracts'; import { formatDateLong, formatGs, type Locale } from '@impuestos/i18n'; import { IRP_CATEGORIES } from '@impuestos/rules'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Check, X } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { CategoryPicker } from '@/components/category-picker'; import { Button, buttonClasses } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { EmptyState } from '@/components/ui/empty-state'; import { Skeleton } from '@/components/ui/skeleton'; import { Link } from '@/i18n/navigation'; import { useT } from '@/i18n/t'; import { api } from '@/lib/api'; import { cn } from '@/lib/utils'; /** Past this many pixels, letting go commits the swipe. */ const COMMIT_PX = 96; /** * Flow B4. A stack of documents waiting for a yes. Every gesture has a button and a key, * because a swipe is unusable with a keyboard and invisible to a screen reader. */ export function Bandeja({ locale }: { locale: string }) { const t = useT(); const queryClient = useQueryClient(); const [index, setIndex] = useState(0); const [picking, setPicking] = useState(false); const [drag, setDrag] = useState(0); const [dragging, setDragging] = useState(false); const dragStart = useRef(null); const pending = useQuery({ queryKey: ['documents', { status: 'needs_review' }], queryFn: ({ signal }) => api.listDocuments({ status: 'needs_review' }, signal), }); const documents = pending.data?.items ?? []; // Confirming the last card shortens the list under us. Clamping here rather than in an // effect keeps the render consistent and avoids a cascading update. const safeIndex = Math.min(index, Math.max(0, documents.length - 1)); const current = documents[safeIndex]; const invalidate = useCallback( () => queryClient.invalidateQueries({ queryKey: ['documents'] }), [queryClient], ); const confirm = useMutation({ mutationFn: (id: string) => api.confirmDocument(id), onSuccess: async () => { setDrag(0); await invalidate(); }, }); const reject = useMutation({ mutationFn: (args: { id: string; reason: 'not_mine' | 'duplicate' | 'other' }) => api.rejectDocument(args.id, { reason: args.reason }), onSuccess: async () => { setDrag(0); await invalidate(); }, }); const reclassify = useMutation({ mutationFn: (args: { id: string; irpCategory: IrpCategory | 'none' }) => api.patchClassification(args.id, { irpCategory: args.irpCategory }), onSuccess: async () => { setPicking(false); await invalidate(); }, }); // Desktop keyboard mirrors of every gesture (FLOWS.md B4). useEffect(() => { function onKey(event: KeyboardEvent) { if (!current || picking) return; if (event.key === 'j') setIndex(Math.min(safeIndex + 1, documents.length - 1)); else if (event.key === 'k') setIndex(Math.max(safeIndex - 1, 0)); else if (event.key === 'Enter') confirm.mutate(current.id); else if (event.key === 'x' || event.key === 'X') reject.mutate({ id: current.id, reason: 'other' }); else if (/^[1-8]$/.test(event.key)) { const category = IRP_CATEGORIES[Number(event.key) - 1]; if (category) reclassify.mutate({ id: current.id, irpCategory: category }); } else return; event.preventDefault(); } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [current, documents.length, safeIndex, picking, confirm, reject, reclassify]); if (pending.isPending) { return (
); } if (pending.isError) { return (

{t('common.error.generic')}

); } if (!current) { return (
} title={t('bandeja.empty')} body={t('bandeja.emptyBody')} action={ {t('scan.fab')} } />
); } const category = current.classification?.irpCategory ?? 'none'; const highConfidence = (current.classification?.confidence ?? 0) >= 0.8; return (

{t('bandeja.title')}

{t('bandeja.count', { count: documents.length })}
{ dragStart.current = event.clientX; setDragging(true); event.currentTarget.setPointerCapture(event.pointerId); }} onPointerMove={(event) => { if (dragStart.current === null) return; setDrag(event.clientX - dragStart.current); }} onPointerUp={() => { const offset = drag; dragStart.current = null; setDragging(false); if (offset > COMMIT_PX) confirm.mutate(current.id); else if (offset < -COMMIT_PX) { setPicking(true); setDrag(0); } else setDrag(0); }} >

{current.emitterName}

{formatDateLong(locale as Locale, current.issueDate)}

{highConfidence ? t('bandeja.confidence.high') : t('bandeja.confidence.low')}

{formatGs(current.total)}

{t('scan.result.suggested', { category: t(`categories.${category}` as const) })}

{current.classification?.reasons.length ? (
    {current.classification.reasons.map((reason) => (
  • {t(`classification.reason.${reason}` as 'classification.reason.irp_no_category')}
  • ))}
) : null}
{picking ? (

{t('scan.result.changeCat')}

reclassify.mutate({ id: current.id, irpCategory: next })} />
) : ( /* Confirm leads and takes the full width: it is the action on almost every card, and three buttons across a 390px screen wraps the longest label. */
)}

{t('bandeja.swipeHint')}

{t('bandeja.keyboardHint')}

{t('bandeja.autoConfirmNote', { days: 7 })}{' '} {t('bandeja.autoConfirmLink')}

); }