Files
impuestospy/apps/web/app/[locale]/(app)/inicio/dashboard.tsx
T
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

305 lines
9.2 KiB
TypeScript

'use client';
import type { DashboardDto } from '@impuestos/contracts';
import { formatDateLong, formatGs, formatGsAmount, formatMonthName, type Locale } from '@impuestos/i18n';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ScanLine, X } from 'lucide-react';
import { TraceableNumber } from '@/components/traceable-number';
import { Button, buttonClasses } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { EmptyState } from '@/components/ui/empty-state';
import { Reveal } from '@/components/ui/reveal';
import { Skeleton } from '@/components/ui/skeleton';
import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
/** Flow C: position header, then exactly one next action, then the insight feed. */
export function Dashboard({ locale, fullName }: { locale: Locale; fullName: string }) {
const t = useT();
const dashboard = useQuery({
queryKey: ['dashboard'],
queryFn: ({ signal }) => api.getDashboard(signal),
});
if (dashboard.isPending) {
return (
<div className="space-y-4 py-4">
<Card>
<Skeleton name="dashboard-position" />
</Card>
<Card>
<Skeleton name="bandeja-card" />
</Card>
</div>
);
}
if (dashboard.isError || !dashboard.data) {
return (
<Card className="my-4 space-y-3">
<p role="alert" className="text-sm">
{t('common.error.generic')}
</p>
<Button type="button" onClick={() => void dashboard.refetch()}>
{t('common.retry')}
</Button>
</Card>
);
}
const data = dashboard.data;
// Flow A6: until the first comprobante arrives there is nothing to show but the way in.
const nothingYet = !data.hasDocuments;
return (
<Reveal className="space-y-4 py-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('home.title')}</h1>
<p className="text-sm text-[var(--text-muted)]">{fullName}</p>
</div>
{nothingYet ? (
<Card>
<EmptyState
icon={<ScanLine aria-hidden className="size-8" />}
title={t('home.firstRun.title')}
body={t('home.noPosition')}
action={
<Link href="/escanear" className={buttonClasses({ size: 'lg' })}>
{t('home.firstRun.scan')}
</Link>
}
/>
</Card>
) : null}
{!nothingYet ? (
<>
{/* Zone 1: the position, one card per obligation, swipeable on a phone. */}
<div className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-1">
{data.iva ? <IvaCard iva={data.iva} locale={locale} /> : null}
{data.irp ? <IrpCard irp={data.irp} locale={locale} /> : null}
</div>
{/* Zone 2: exactly one next action. */}
<NextAction action={data.nextAction} locale={locale} />
{/* Zone 3: the insight feed. */}
<InsightFeed insights={data.insights} locale={locale} />
</>
) : null}
</Reveal>
);
}
function IvaCard({ iva, locale }: { iva: NonNullable<DashboardDto['iva']>; locale: Locale }) {
const t = useT();
const owes = iva.aPagar > 0;
return (
<Card className="min-w-[85%] shrink-0 snap-start space-y-2 sm:min-w-0 sm:flex-1">
<p className="text-sm font-medium text-[var(--text-muted)]">
{t('home.iva.title', { month: formatMonthName(locale, iva.period) })}
</p>
<p className="text-sm text-[var(--text-muted)]">
{owes ? t('home.iva.aPagar') : t('home.iva.aFavor')}
</p>
{/* Owing tax on time is neutral, never red (FLOWS.md section 1). */}
<TraceableNumber
value={owes ? iva.aPagar : iva.aFavor}
kind={owes ? 'iva_debito' : 'iva_credito'}
locale={locale}
className={owes ? undefined : 'text-positive'}
/>
<p className="text-sm text-[var(--text-muted)]">
{t('home.iva.breakdown', {
debito: formatGsAmount(iva.debito),
credito: formatGsAmount(iva.credito),
})}
</p>
</Card>
);
}
function IrpCard({ irp, locale }: { irp: NonNullable<DashboardDto['irp']>; locale: Locale }) {
const t = useT();
return (
<Card className="min-w-[85%] shrink-0 snap-start space-y-2 sm:min-w-0 sm:flex-1">
<p className="text-sm font-medium text-[var(--text-muted)]">
{t('home.irp.title', { year: irp.year })}
</p>
<TraceableNumber value={irp.projectedTax} kind="irp_deductions" locale={locale} />
{irp.monthDelta > 0 ? (
<p className="text-sm text-positive">
{t('home.irp.delta', { amount: formatGsAmount(irp.monthDelta) })}
</p>
) : null}
{irp.belowThreshold ? (
<p className="text-sm text-[var(--text-muted)] text-pretty">
{t('home.irp.belowThreshold')}
</p>
) : null}
</Card>
);
}
function NextAction({
action,
locale,
}: {
action: DashboardDto['nextAction'];
locale: Locale;
}) {
const t = useT();
switch (action.kind) {
case 'overdue':
return (
<Card className="space-y-3 border-overdue">
<p className="font-medium text-overdue">
{t('home.next.overdue', {
form: action.form ?? '120',
date: action.dueDate ? formatDateLong(locale, action.dueDate) : '',
})}
</p>
<Link href="/vencimientos" className={buttonClasses({ block: true })}>
{t('home.next.overdueCta')}
</Link>
</Card>
);
case 'declaration_ready':
return (
<Card className="space-y-3 border-attention">
<p className="font-medium">{t('decl.status.ready')}</p>
<Link
href={`/declaraciones/${action.declarationId ?? ''}`}
className={buttonClasses({ block: true })}
>
{t('home.next.reviewCta')}
</Link>
</Card>
);
case 'deadline_soon':
return (
<Card className="space-y-3 border-attention">
<p className="font-medium text-attention">
{t('home.next.upcoming', {
date: action.dueDate ? formatDateLong(locale, action.dueDate) : '',
})}
</p>
<Link href="/vencimientos" className={buttonClasses({ block: true })}>
{t('home.next.deadlineCta')}
</Link>
</Card>
);
case 'bandeja':
return (
<Card className="space-y-3">
<p className="font-medium">{t('home.next.bandeja', { count: action.count ?? 0 })}</p>
<Link href="/bandeja" className={buttonClasses({ block: true })}>
{t('home.next.bandejaCta')}
</Link>
</Card>
);
case 'all_clear':
return (
<Card className="space-y-1">
<p className="font-medium text-positive">{t('home.next.allClear')}</p>
{action.dueDate ? (
<p className="text-sm text-[var(--text-muted)]">
{t('home.next.upcoming', { date: formatDateLong(locale, action.dueDate) })}
</p>
) : null}
</Card>
);
}
}
function InsightFeed({
insights,
locale,
}: {
insights: DashboardDto['insights'];
locale: Locale;
}) {
const t = useT();
const queryClient = useQueryClient();
const dismiss = useMutation({
mutationFn: (id: string) => api.dismissInsight(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
});
if (insights.length === 0) return null;
return (
<ul className="space-y-3">
{insights.map((insight) => (
<li key={insight.id}>
<Card className="space-y-2">
<p className="font-medium">{insightTitle(insight, t)}</p>
<p className="text-sm text-[var(--text-muted)] text-pretty">
{insightBody(insight, t, locale)}
</p>
<Button
type="button"
variant="ghost"
disabled={dismiss.isPending}
onClick={() => dismiss.mutate(insight.id)}
>
<X aria-hidden className="size-4" />
{t('home.insight.dismiss')}
</Button>
</Card>
</li>
))}
</ul>
);
}
type Translate = ReturnType<typeof useT>;
function insightTitle(insight: DashboardDto['insights'][number], t: Translate): string {
if (insight.kind === 'deduction_gap') return t('home.insight.gapTitle');
if (insight.kind === 'month_close') return t('decl.status.ready');
return t('vto.title');
}
function insightBody(
insight: DashboardDto['insights'][number],
t: Translate,
locale: Locale,
): string {
if (insight.kind === 'deduction_gap') {
const category = String(insight.params['category'] ?? 'none');
return t('home.insight.gapBody', {
category: t(`categories.${category}` as 'categories.none'),
});
}
if (insight.kind === 'month_close') {
return t('home.insight.monthClose', {
month: formatMonthName(locale, String(insight.params['month'] ?? '')),
savings: formatGsAmount(Number(insight.params['savings'] ?? 0)),
});
}
return t('home.insight.deadline', {
form: String(insight.params['form'] ?? '120'),
date: formatDateLong(locale, String(insight.params['date'] ?? '')),
});
}
export { formatGs };