make the invoices dashboard tiles and rows real links
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user