'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 { ConfettiMoment } from '@/components/confetti-moment'; import { Button, buttonClasses } from '@/components/ui/button'; import { CheckDraw } from '@/components/ui/check-draw'; 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 { DURATION, EASE, gsap, usePrefersReducedMotion } from '@/lib/motion'; 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 [celebrating, setCelebrating] = useState(false); const reduced = usePrefersReducedMotion(); // The drag lives on the element rather than in state: a re-render per pointer move is // both slower and a fight with GSAP over who owns the transform. const card = useRef(null); const nextCard = useRef(null); const dragStart = useRef(null); const pending = useQuery({ queryKey: ['documents', { status: 'needs_review' }], queryFn: ({ signal }) => api.listDocuments({ status: 'needs_review' }, signal), }); /** * Whether anything has ever been confirmed. FLOWS.md A6 makes the first confirmed * document the moment worth celebrating, and it is only the first one: the count is read * before the confirm so the answer is about what the user had, not what they now have. */ const confirmed = useQuery({ queryKey: ['documents', { status: 'confirmed' }], queryFn: ({ signal }) => api.listDocuments({ status: 'confirmed' }, signal), }); const nothingConfirmedYet = confirmed.data?.total === 0; 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 next = documents[safeIndex + 1]; const invalidate = useCallback( () => queryClient.invalidateQueries({ queryKey: ['documents'] }), [queryClient], ); const confirm = useMutation({ mutationFn: (id: string) => api.confirmDocument(id), onSuccess: async () => { if (nothingConfirmedYet) setCelebrating(true); await invalidate(); }, }); const reject = useMutation({ mutationFn: (args: { id: string; reason: 'not_mine' | 'duplicate' | 'other' }) => api.rejectDocument(args.id, { reason: args.reason }), onSuccess: invalidate, }); const reclassify = useMutation({ mutationFn: (args: { id: string; irpCategory: IrpCategory | 'none' }) => api.patchClassification(args.id, { irpCategory: args.irpCategory }), onSuccess: async () => { setPicking(false); await invalidate(); }, }); /** * Sends the card off the way it was pushed, brings the one behind it forward, and only * then does the thing (FLOWS.md B4: fly out on commit, next card scales up). Under * reduced motion the action happens immediately and nothing moves. */ const flyOut = useCallback( (direction: 1 | -1, run: () => void) => { const node = card.current; if (reduced || !node) { run(); return; } gsap.to(node, { x: direction * 520, rotation: direction * 16, autoAlpha: 0, duration: DURATION.base, ease: 'power2.in', onComplete: () => { gsap.set(node, { clearProps: 'all' }); run(); }, }); if (nextCard.current) { gsap.to(nextCard.current, { scale: 1, opacity: 1, duration: DURATION.base, ease: EASE }); } }, [reduced], ); const settle = useCallback(() => { const node = card.current; if (!node) return; if (reduced) { gsap.set(node, { x: 0, rotation: 0 }); return; } gsap.to(node, { x: 0, rotation: 0, duration: DURATION.base, ease: EASE }); }, [reduced]); // 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') flyOut(1, () => confirm.mutate(current.id)); else if (event.key === 'x' || event.key === 'X') flyOut(-1, () => 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, flyOut]); 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 })}
{/* The shoulder of the next card, so the stack is visible and committing this one has somewhere to go. A shoulder rather than a whole card behind: the front card's height depends on its content, and a full card would sit entirely hidden behind a tall one and entirely exposed behind a short one. */} {next ? (
) : null} { dragStart.current = event.clientX; event.currentTarget.setPointerCapture(event.pointerId); }} onPointerMove={(event) => { if (dragStart.current === null) return; const offset = event.clientX - dragStart.current; gsap.set(card.current, { x: offset, rotation: offset / 40 }); }} onPointerUp={(event) => { const start = dragStart.current; dragStart.current = null; if (start === null) return; const offset = event.clientX - start; if (offset > COMMIT_PX) flyOut(1, () => confirm.mutate(current.id)); else if (offset < -COMMIT_PX) { settle(); setPicking(true); } else settle(); }} >

{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')}

); }