make the invoices dashboard tiles and rows real links

This commit is contained in:
2026-08-07 13:20:22 +00:00
parent 19beafa9dc
commit ea1dae5280
2 changed files with 29 additions and 20 deletions
@@ -1,10 +1,11 @@
import type { Estimate, Invoice } from './shared';
import { useMemo } from 'react';
import { useNavigate } from 'react-router';
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 { 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.
@@ -15,7 +16,6 @@ import { useSummary } from './useInvoiceShelfData';
export const DashboardView = () => {
const { dashboard, currency, isLoading, error } = useSummary();
const navigate = useNavigate();
const chartRows = useMemo(() => {
const chart = dashboard?.chart_data;
@@ -46,21 +46,21 @@ export const DashboardView = () => {
label="Amount due"
value={formatMoney(dashboard.total_amount_due, currency)}
hint={`${dashboard.total_invoice_count} invoices`}
onClick={() => navigate('/invoices/invoices?status=UNPAID')}
to={`${invoicesSectionPath('invoices')}?status=UNPAID`}
/>
<MetricCard
icon={FileText}
tone="text-sky-500 bg-sky-500/10"
label="Sales"
value={formatMoney(dashboard.total_sales, currency)}
onClick={() => navigate('/invoices/invoices')}
to={invoicesSectionPath('invoices')}
/>
<MetricCard
icon={Wallet}
tone="text-emerald-500 bg-emerald-500/10"
label="Receipts"
value={formatMoney(dashboard.total_receipts, currency)}
onClick={() => navigate('/invoices/payments')}
to={invoicesSectionPath('payments')}
/>
<MetricCard
icon={Receipt}
@@ -68,7 +68,7 @@ export const DashboardView = () => {
label="Net income"
value={formatMoney(dashboard.total_net_income, currency)}
hint={`${formatMoney(dashboard.total_expenses, currency)} expenses`}
onClick={() => navigate('/invoices/expenses')}
to={invoicesSectionPath('expenses')}
/>
</div>
@@ -143,7 +143,7 @@ export const DashboardView = () => {
title="Due invoices"
empty="Nothing outstanding."
rows={dashboard.recent_due_invoices ?? []}
onOpen={(row) => navigate(`/invoices/invoices?selected=${row.id}`)}
href={(row) => invoicesRecordPath('invoices', row.id)}
render={(row: Invoice) => ({
primary: row.invoice_number,
secondary: row.customer?.name ?? '—',
@@ -156,7 +156,7 @@ export const DashboardView = () => {
title="Recent estimates"
empty="No estimates yet."
rows={dashboard.recent_estimates ?? []}
onOpen={(row) => navigate(`/invoices/estimates?selected=${row.id}`)}
href={(row) => invoicesRecordPath('estimates', row.id)}
render={(row: Estimate) => ({
primary: row.estimate_number,
secondary: row.customer?.name ?? '—',
@@ -192,24 +192,26 @@ const compact = (v: number): string => {
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,
onClick,
to,
}: {
icon: typeof Wallet;
tone: string;
label: string;
value: string;
hint?: string;
onClick?: () => void;
to: string;
}) => (
<button
type="button"
onClick={onClick}
<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">
@@ -222,7 +224,7 @@ const MetricCard = ({
{value}
</div>
{hint && <div className="truncate text-[10px] text-muted-foreground">{hint}</div>}
</button>
</Link>
);
type RecentRender = { primary: string; secondary: string; amount: string; status: string; note: string };
@@ -231,13 +233,13 @@ const RecentPanel = <T extends { id: number }>({
title,
rows,
render,
onOpen,
href,
empty,
}: {
title: string;
rows: T[];
render: (row: T) => RecentRender;
onOpen: (row: T) => void;
href: (row: T) => string;
empty: string;
}) => (
<div className="rounded-xl border border-border">
@@ -249,10 +251,9 @@ const RecentPanel = <T extends { id: number }>({
{rows.map((row) => {
const r = render(row);
return (
<button
<Link
key={row.id}
type="button"
onClick={() => onOpen(row)}
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">
@@ -266,7 +267,7 @@ const RecentPanel = <T extends { id: number }>({
</div>
</div>
<span className="shrink-0 text-xs tabular-nums">{r.amount}</span>
</button>
</Link>
);
})}
</div>
@@ -34,6 +34,14 @@ export const isInvoicesSection = (value: string | undefined): value is InvoicesS
export const invoicesSectionPath = (id: InvoicesSectionId) => `/invoices/${id}`;
/**
* A record open inside a list section — `/invoices/invoices?selected=12`. `useInvoicesSection` reads
* `selected` out of the query string, so this is the addressable form of "this row is open" and the
* thing a row anchor points at.
*/
export const invoicesRecordPath = (section: InvoicesSectionId, id: number) =>
`${invoicesSectionPath(section)}?selected=${id}`;
/** The sidecar resource slug behind each list section. `dashboard` and `reports` have none. */
export const SECTION_RESOURCE = {
invoices: 'invoices',