GSAP carries the counter roll-ups, the bandeja card physics, the dialog transitions and the three success moments FLOWS.md allows. Every one of them checks prefers-reduced-motion first and does nothing when it is set. boneyard and canvas-ui are not what SPEC.md's stack table says they are: on npm the names belong to two abandoned projects that do neither job. The skeletons were already ours; the two canvas spots are now sixty lines each with no dependency. DECISIONS.md records the substitution. The app installs, keeps a scan taken with no network in IndexedDB and sends it when there is one, falls back to a page that explains itself, and can push a deadline notice. Reading the log of what is queued is the source of truth, so the notice clears when the capture actually lands. The CSP now allows scripts by per-request nonce rather than by 'unsafe-inline'. That forced /offline to render per request: a prerendered page carries a build-time nonce no live policy matches, so its scripts were blocked and it never hydrated. Two crashes fixed on the way. web-push throws on a VAPID subject that is not https: or mailto:, and the code handed it APP_PUBLIC_URL, so any machine with push keys died at boot; a misconfigured optional channel now switches itself off and says why. And a subscription the push service answers 410 for is deleted rather than retried forever. Lighthouse on the production build: accessibility 100, best practices 96, SEO 100, performance 73. The performance number is not trustworthy on this machine and DECISIONS.md says why; total blocking time did fall from 17.6s to 1.7s once the hero canvas stopped drawing at full resolution every frame and the landing page stopped importing GSAP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
9.1 KiB
TypeScript
256 lines
9.1 KiB
TypeScript
'use client';
|
|
|
|
import { isApiError, type DeclarationDto } from '@impuestos/contracts';
|
|
import { formatDateLong, formatGs, formatGsAmount, type Locale } from '@impuestos/i18n';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useRef, useState } from 'react';
|
|
import { ConfettiMoment } from '@/components/confetti-moment';
|
|
import { FormPreview } from '@/components/form-preview';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card } from '@/components/ui/card';
|
|
import { Reveal } from '@/components/ui/reveal';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { useT } from '@/i18n/t';
|
|
import { api } from '@/lib/api';
|
|
import { FilingChecklist } from './filing-checklist';
|
|
|
|
/**
|
|
* Flow D2 and D3. The human summary first, the official layout underneath, and one
|
|
* primary action. Nothing here files anything: the checklist that follows approval is how
|
|
* the user presents it themselves.
|
|
*/
|
|
export function DeclarationDetail({ id, locale }: { id: string; locale: string }) {
|
|
const t = useT();
|
|
const queryClient = useQueryClient();
|
|
const confirmDialog = useRef<HTMLDialogElement>(null);
|
|
const [staleWarning, setStaleWarning] = useState<string | null>(null);
|
|
const [celebrating, setCelebrating] = useState(false);
|
|
|
|
const declaration = useQuery({
|
|
queryKey: ['declarations', id],
|
|
queryFn: ({ signal }) => api.getDeclaration(id, signal),
|
|
retry: (count, error) => !isApiError(error) && count < 1,
|
|
});
|
|
|
|
const approve = useMutation({
|
|
mutationFn: () => api.approveDeclaration(id),
|
|
onSuccess: async () => {
|
|
// One of the three moments FLOWS.md section 8 allows a celebration.
|
|
setCelebrating(true);
|
|
setStaleWarning(null);
|
|
confirmDialog.current?.close();
|
|
await queryClient.invalidateQueries({ queryKey: ['declarations'] });
|
|
await queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
|
},
|
|
onError: (error) => {
|
|
confirmDialog.current?.close();
|
|
// A 409 means the documents moved: the numbers on screen are stale, not wrong.
|
|
setStaleWarning(isApiError(error) ? error.message : t('common.error.generic'));
|
|
void queryClient.invalidateQueries({ queryKey: ['declarations', id] });
|
|
},
|
|
});
|
|
|
|
if (declaration.isPending) {
|
|
return (
|
|
<Card className="my-4">
|
|
<Skeleton name="declaration-summary" />
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (declaration.isError || !declaration.data) {
|
|
const missing = isApiError(declaration.error) && declaration.error.code === 'not_found';
|
|
return (
|
|
<Card className="my-4 space-y-3">
|
|
<p role="alert" className="text-sm">
|
|
{missing ? t('error.not_found') : t('common.error.generic')}
|
|
</p>
|
|
{!missing ? (
|
|
<Button type="button" onClick={() => void declaration.refetch()}>
|
|
{t('common.retry')}
|
|
</Button>
|
|
) : null}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const data = declaration.data;
|
|
const approved = data.status === 'approved';
|
|
// Spanish in every locale, like the form it names (COPY.md multi-language policy).
|
|
const officialTitle = `Formulario ${data.formCode}`;
|
|
|
|
return (
|
|
<Reveal className="space-y-4 py-4 pb-28">
|
|
<ConfettiMoment play={celebrating} />
|
|
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">{officialTitle}</h1>
|
|
<p className="tnum text-sm text-[var(--text-muted)]">{data.period}</p>
|
|
</div>
|
|
|
|
{staleWarning ? (
|
|
<Card className="border-attention">
|
|
<p role="alert" className="text-sm text-attention text-pretty">
|
|
{staleWarning}
|
|
</p>
|
|
</Card>
|
|
) : null}
|
|
|
|
<Card className="space-y-3">
|
|
<h2 className="font-semibold tracking-tight">{t('decl.summary.title')}</h2>
|
|
<p className="text-lg text-pretty">{humanSummary(data, t)}</p>
|
|
<Breakdown declaration={data} />
|
|
</Card>
|
|
|
|
<FormPreview
|
|
formCode={data.formCode}
|
|
period={data.period}
|
|
values={data.values}
|
|
draft={!approved}
|
|
labels={{
|
|
title: t('decl.preview.title'),
|
|
spanishNote: t('decl.preview.spanishNote'),
|
|
casilla: t('decl.preview.casilla'),
|
|
concept: t('decl.preview.concept'),
|
|
amount: t('decl.amount'),
|
|
draftMark: t('decl.preview.draftMark'),
|
|
}}
|
|
/>
|
|
|
|
{approved ? (
|
|
<FilingChecklist declaration={data} locale={locale as Locale} />
|
|
) : (
|
|
<>
|
|
{/* Sticky footer: the primary action stays reachable however long the form is. */}
|
|
<div className="fixed inset-x-0 bottom-16 z-10 border-t bg-[var(--surface-raised)] px-5 py-3">
|
|
<div className="mx-auto flex max-w-2xl gap-2">
|
|
<a
|
|
href={api.declarationPdfUrl(data.id)}
|
|
download
|
|
className="flex h-11 flex-1 items-center justify-center rounded-full border text-sm font-medium"
|
|
>
|
|
{t('decl.downloadPdf')}
|
|
</a>
|
|
<Button
|
|
type="button"
|
|
className="flex-1"
|
|
onClick={() => confirmDialog.current?.showModal()}
|
|
>
|
|
{t('decl.approve')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<dialog
|
|
ref={confirmDialog}
|
|
aria-label={t('decl.approve.confirmTitle')}
|
|
className="m-auto w-[min(28rem,calc(100vw-2rem))] rounded-2xl border bg-[var(--surface-raised)] p-6 text-[var(--text)] backdrop:bg-black/40"
|
|
>
|
|
<div className="space-y-4">
|
|
<h2 className="text-lg font-semibold tracking-tight">
|
|
{t('decl.approve.confirmTitle')}
|
|
</h2>
|
|
<p className="text-sm text-[var(--text-muted)] text-pretty">
|
|
{t('decl.approve.confirmBody', {
|
|
form: data.formCode,
|
|
period: data.period,
|
|
amount: formatGsAmount(payable(data)),
|
|
})}
|
|
</p>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => confirmDialog.current?.close()}
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button type="button" disabled={approve.isPending} onClick={() => approve.mutate()}>
|
|
{approve.isPending ? t('common.saving') : t('decl.approve')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
</>
|
|
)}
|
|
|
|
{approved && data.approvedAt ? (
|
|
<p className="text-center text-xs text-[var(--text-muted)]">
|
|
{t('decl.approvedAt', { date: formatDateLong(locale as Locale, data.approvedAt.slice(0, 10)) })}
|
|
</p>
|
|
) : null}
|
|
</Reveal>
|
|
);
|
|
}
|
|
|
|
type Translate = ReturnType<typeof useT>;
|
|
|
|
/** FLOWS.md D2: the sentence comes before the form, always. */
|
|
function humanSummary(declaration: DeclarationDto, t: Translate): string {
|
|
const summary = declaration.summary;
|
|
|
|
if (declaration.formCode === '120') {
|
|
const aPagar = Number(summary['aPagar'] ?? 0);
|
|
const result =
|
|
aPagar > 0
|
|
? t('decl.f120.toPay', { amount: formatGsAmount(aPagar) })
|
|
: t('decl.f120.inFavor', { amount: formatGsAmount(Number(summary['saldoAFavor'] ?? 0)) });
|
|
|
|
return t('decl.f120.summary', {
|
|
sales: formatGsAmount(Number(summary['sales'] ?? 0)),
|
|
purchases: formatGsAmount(Number(summary['purchases'] ?? 0)),
|
|
result,
|
|
});
|
|
}
|
|
|
|
return t('decl.f515.summary', {
|
|
income: formatGsAmount(Number(summary['grossIncome'] ?? 0)),
|
|
deductions: formatGsAmount(Number(summary['totalDeductions'] ?? 0)),
|
|
tax: formatGsAmount(Number(summary['tax'] ?? 0)),
|
|
});
|
|
}
|
|
|
|
function Breakdown({ declaration }: { declaration: DeclarationDto }) {
|
|
const t = useT();
|
|
const summary = declaration.summary;
|
|
|
|
const rows: [string, number][] =
|
|
declaration.formCode === '120'
|
|
? [
|
|
[t('decl.breakdown.debito'), Number(summary['debito'] ?? 0)],
|
|
[t('decl.breakdown.credito'), Number(summary['credito'] ?? 0)],
|
|
[t('decl.breakdown.saldoAnterior'), Number(summary['saldoAnterior'] ?? 0)],
|
|
]
|
|
: [
|
|
[t('profile.income'), Number(summary['grossIncome'] ?? 0)],
|
|
[t('docs.detail.deductible'), Number(summary['totalDeductions'] ?? 0)],
|
|
[t('decl.f515.netIncome'), Number(summary['netIncome'] ?? 0)],
|
|
];
|
|
|
|
const capExcess = Number(summary['capExcess'] ?? 0);
|
|
|
|
return (
|
|
<dl className="space-y-2 text-sm">
|
|
{rows.map(([label, amount]) => (
|
|
<div key={label} className="flex items-baseline justify-between gap-4">
|
|
<dt className="text-[var(--text-muted)]">{label}</dt>
|
|
<dd className="tnum font-medium">{formatGs(amount)}</dd>
|
|
</div>
|
|
))}
|
|
|
|
{capExcess > 0 ? (
|
|
<p className="pt-2 text-[var(--text-muted)] text-pretty">
|
|
{t('decl.f515.capNote', { amount: formatGsAmount(capExcess) })}
|
|
</p>
|
|
) : null}
|
|
</dl>
|
|
);
|
|
}
|
|
|
|
function payable(declaration: DeclarationDto): number {
|
|
const summary = declaration.summary;
|
|
return declaration.formCode === '120'
|
|
? Number(summary['aPagar'] ?? 0) || Number(summary['saldoAFavor'] ?? 0)
|
|
: Number(summary['tax'] ?? 0);
|
|
}
|