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 ; if (isLoading) return ; if (!dashboard) return ; const hasChart = chartRows.some((r) => r.sales || r.receipts || r.expenses || r.net); return (
{hasChart && (
Last 12 months
compact(v)} /> [ formatAmount(Math.round((value ?? 0) * 100), currency), name ?? '', ]} />
)}
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), })} /> 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), })} />
); }; const Gradient = ({ id, color }: { id: string; color: string }) => ( ); const LegendDot = ({ className, label }: { className: string; label: string }) => ( {label} ); /** 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; }) => (
{label}
{value}
{hint &&
{hint}
} ); type RecentRender = { primary: string; secondary: string; amount: string; status: string; note: string }; const RecentPanel = ({ title, rows, render, href, empty, }: { title: string; rows: T[]; render: (row: T) => RecentRender; href: (row: T) => string; empty: string; }) => (
{title}
{rows.length === 0 ? (
{empty}
) : (
{rows.map((row) => { const r = render(row); return (
{r.primary}
{r.secondary} {r.note && ` · ${r.note}`}
{r.amount} ); })}
)}
); export { ArrowDownRight, ArrowUpRight };