Files
invoices/web/DashboardView.tsx
officer 6c70eb8eb3 invoices, extracted from the platform as a plugin
Ninth plugin out, third resurrection: switched off since 2026-08-13 with the
permission reduced to api: [], so the server booted clean and every panel
request 404'd. invoiceshelf/schema was also commented out of the drizzle
barrel.

Named `invoices`, not `invoiceshelf`. The appName drives the API prefix, the
frontend route, the table prefix and the install key all at once, and before
extraction those had drifted: the API was /api/invoiceshelf while the screen
was /invoices. The URL is what a person sees and should name what the plugin
shows rather than the daemon behind it — the permission key and label were
already `invoices`, so the API prefix was the odd one out.

Cost: three call sites, and renaming invoiceshelf_accounts to invoices_accounts
to satisfy the table-prefix rule. That rename was free ONLY because the table
existed on no server, which was checked before deciding. With rows in it,
keeping the vendor name would have been the better trade: db:push sees a rename
as a drop plus a create and this project has no migration mechanism.

The sidecar keeps its Invoiceshelf-named functions deliberately — they name the
upstream API it actually speaks.

91 palette classes converted, 52 of them in STATUS_TONE, which was already the
single source of status colour but held raw palette strings. SENT and VIEWED
now share `info`: the closed six-tone set has no second shade of "in flight,
not yet actioned". Recorded in PLUGIN.md as a real loss.
2026-08-19 03:47:44 +00:00

280 lines
10 KiB
TypeScript

import type { Estimate, Invoice } from './shared';
import { useMemo } from 'react';
import { Link } from 'react-router';
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { ArrowDownRight, ArrowUpRight, CircleDollarSign, FileText, Receipt, Wallet } from 'lucide-react';
import { toneFill } from '@/components/Data/tone';
import { formatAmount, formatDate, formatMoney, relativeDue, toMajor } from './format';
import { EmptyState, ErrorState, LoadingState, StatusBadge } from './components';
import { invoicesRecordPath, invoicesSectionPath } from './shared';
import { useSummary } from './useInvoiceShelfData';
// The /invoices landing section — InvoiceShelf's own dashboard, rebuilt.
//
// Every figure here comes from the single /summary call the nav already makes, so arriving on this section
// costs no extra request. Upstream hands the chart over as five parallel arrays (months + four series)
// rather than as rows, so they are zipped into recharts' row shape below.
export const DashboardView = () => {
const { dashboard, currency, isLoading, error } = useSummary();
const chartRows = useMemo(() => {
const chart = dashboard?.chart_data;
if (!chart?.months?.length) return [];
return chart.months.map((month, i) => ({
month,
// recharts needs major units or the axis reads in cents; the tooltip re-formats from these.
// toMajor divides by 100, matching formatMoney — see the note on MINOR_PER_MAJOR in format.ts.
sales: toMajor(chart.invoice_totals?.[i]),
receipts: toMajor(chart.receipt_totals?.[i]),
expenses: toMajor(chart.expense_totals?.[i]),
net: toMajor(chart.net_income_totals?.[i]),
}));
}, [dashboard]);
if (error) return <ErrorState error={error} />;
if (isLoading) return <LoadingState />;
if (!dashboard) return <EmptyState title="No dashboard data" />;
const hasChart = chartRows.some((r) => r.sales || r.receipts || r.expenses || r.net);
return (
<div className="h-full overflow-y-auto p-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<MetricCard
icon={CircleDollarSign}
tone={toneFill.warning}
label="Amount due"
value={formatMoney(dashboard.total_amount_due, currency)}
hint={`${dashboard.total_invoice_count} invoices`}
to={`${invoicesSectionPath('invoices')}?status=UNPAID`}
/>
<MetricCard
icon={FileText}
tone={toneFill.info}
label="Sales"
value={formatMoney(dashboard.total_sales, currency)}
to={invoicesSectionPath('invoices')}
/>
<MetricCard
icon={Wallet}
tone={toneFill.success}
label="Receipts"
value={formatMoney(dashboard.total_receipts, currency)}
to={invoicesSectionPath('payments')}
/>
<MetricCard
icon={Receipt}
tone="bg-primary/15 text-primary"
label="Net income"
value={formatMoney(dashboard.total_net_income, currency)}
hint={`${formatMoney(dashboard.total_expenses, currency)} expenses`}
to={invoicesSectionPath('expenses')}
/>
</div>
{hasChart && (
<div className="mt-4 rounded-xl border border-border p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium">Last 12 months</span>
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
<LegendDot className="bg-info" label="Sales" />
<LegendDot className="bg-success" label="Receipts" />
<LegendDot className="bg-destructive" label="Expenses" />
</div>
</div>
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartRows} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<defs>
<Gradient id="isSales" color="var(--info)" />
<Gradient id="isReceipts" color="var(--success)" />
<Gradient id="isExpenses" color="var(--destructive)" />
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis
tick={{ fontSize: 10 }}
tickLine={false}
axisLine={false}
width={56}
tickFormatter={(v: number) => compact(v)}
/>
<Tooltip
contentStyle={{ fontSize: 11, borderRadius: 8 }}
// recharts types both arguments as possibly-undefined — a series can have gaps, and a
// datum can carry no name — so neither may be annotated as required here.
formatter={(value: number | undefined, name: string | undefined) => [
formatAmount(Math.round((value ?? 0) * 100), currency),
name ?? '',
]}
/>
<Area
type="monotone"
dataKey="sales"
name="Sales"
stroke="var(--info)"
fill="url(#isSales)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="receipts"
name="Receipts"
stroke="var(--success)"
fill="url(#isReceipts)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="expenses"
name="Expenses"
stroke="var(--destructive)"
fill="url(#isExpenses)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)}
<div className="mt-4 grid gap-3 lg:grid-cols-2">
<RecentPanel
title="Due invoices"
empty="Nothing outstanding."
rows={dashboard.recent_due_invoices ?? []}
href={(row) => invoicesRecordPath('invoices', row.id)}
render={(row: Invoice) => ({
primary: row.invoice_number,
secondary: row.customer?.name ?? '—',
amount: formatMoney(row.due_amount, currency),
status: row.overdue ? 'OVERDUE' : row.paid_status,
note: relativeDue(row.due_date),
})}
/>
<RecentPanel
title="Recent estimates"
empty="No estimates yet."
rows={dashboard.recent_estimates ?? []}
href={(row) => invoicesRecordPath('estimates', row.id)}
render={(row: Estimate) => ({
primary: row.estimate_number,
secondary: row.customer?.name ?? '—',
amount: formatMoney(row.total, currency),
status: row.status,
note: formatDate(row.estimate_date, row.formatted_estimate_date),
})}
/>
</div>
</div>
);
};
const Gradient = ({ id, color }: { id: string; color: string }) => (
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
);
const LegendDot = ({ className, label }: { className: string; label: string }) => (
<span className="flex items-center gap-1">
<span className={`h-1.5 w-1.5 rounded-full ${className}`} />
{label}
</span>
);
/** Axis labels only — a full currency render would not fit and the tooltip carries the exact figure. */
const compact = (v: number): string => {
const abs = Math.abs(v);
if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (abs >= 1_000) return `${(v / 1_000).toFixed(0)}k`;
return String(v);
};
// Every tile here only ever *goes somewhere* — no tile mutates anything — so they are anchors, not
// buttons. That is what makes the figures middle-clickable into a new tab and copyable as a link,
// which is the whole point of the section reading its filter out of the query string.
const MetricCard = ({
icon: Icon,
tone,
label,
value,
hint,
to,
}: {
icon: typeof Wallet;
tone: string;
label: string;
value: string;
hint?: string;
to: string;
}) => (
<Link
to={to}
className="flex flex-col gap-2 rounded-xl border border-border p-3 text-left transition-colors hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<span className={`flex h-7 w-7 items-center justify-center rounded-lg ${tone}`}>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
<div className="truncate text-lg font-semibold tabular-nums" title={value}>
{value}
</div>
{hint && <div className="truncate text-[10px] text-muted-foreground">{hint}</div>}
</Link>
);
type RecentRender = { primary: string; secondary: string; amount: string; status: string; note: string };
const RecentPanel = <T extends { id: number }>({
title,
rows,
render,
href,
empty,
}: {
title: string;
rows: T[];
render: (row: T) => RecentRender;
href: (row: T) => string;
empty: string;
}) => (
<div className="rounded-xl border border-border">
<div className="border-b border-border px-3 py-2 text-xs font-medium">{title}</div>
{rows.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">{empty}</div>
) : (
<div className="divide-y divide-border/50">
{rows.map((row) => {
const r = render(row);
return (
<Link
key={row.id}
to={href(row)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-xs font-medium">{r.primary}</span>
<StatusBadge value={r.status} />
</div>
<div className="truncate text-[10px] text-muted-foreground">
{r.secondary}
{r.note && ` · ${r.note}`}
</div>
</div>
<span className="shrink-0 text-xs tabular-nums">{r.amount}</span>
</Link>
);
})}
</div>
)}
</div>
);
export { ArrowDownRight, ArrowUpRight };