Files
MichilisandClaude Opus 5 e4eb1617d1 phase-7: motion, an installable app, and a capture that survives no signal
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>
2026-09-05 20:48:48 +00:00

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);
}