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>
305 lines
9.2 KiB
TypeScript
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 };
|