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 type { Estimate, Invoice } from './shared';
|
||||||
import { useMemo } from 'react';
|
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 { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
import { ArrowDownRight, ArrowUpRight, CircleDollarSign, FileText, Receipt, Wallet } from 'lucide-react';
|
import { ArrowDownRight, ArrowUpRight, CircleDollarSign, FileText, Receipt, Wallet } from 'lucide-react';
|
||||||
import { formatAmount, formatDate, formatMoney, relativeDue, toMajor } from './format';
|
import { formatAmount, formatDate, formatMoney, relativeDue, toMajor } from './format';
|
||||||
import { EmptyState, ErrorState, LoadingState, StatusBadge } from './components';
|
import { EmptyState, ErrorState, LoadingState, StatusBadge } from './components';
|
||||||
|
import { invoicesRecordPath, invoicesSectionPath } from './shared';
|
||||||
import { useSummary } from './useInvoiceShelfData';
|
import { useSummary } from './useInvoiceShelfData';
|
||||||
|
|
||||||
// The /invoices landing section — InvoiceShelf's own dashboard, rebuilt.
|
// The /invoices landing section — InvoiceShelf's own dashboard, rebuilt.
|
||||||
@@ -15,7 +16,6 @@ import { useSummary } from './useInvoiceShelfData';
|
|||||||
|
|
||||||
export const DashboardView = () => {
|
export const DashboardView = () => {
|
||||||
const { dashboard, currency, isLoading, error } = useSummary();
|
const { dashboard, currency, isLoading, error } = useSummary();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const chartRows = useMemo(() => {
|
const chartRows = useMemo(() => {
|
||||||
const chart = dashboard?.chart_data;
|
const chart = dashboard?.chart_data;
|
||||||
@@ -46,21 +46,21 @@ export const DashboardView = () => {
|
|||||||
label="Amount due"
|
label="Amount due"
|
||||||
value={formatMoney(dashboard.total_amount_due, currency)}
|
value={formatMoney(dashboard.total_amount_due, currency)}
|
||||||
hint={`${dashboard.total_invoice_count} invoices`}
|
hint={`${dashboard.total_invoice_count} invoices`}
|
||||||
onClick={() => navigate('/invoices/invoices?status=UNPAID')}
|
to={`${invoicesSectionPath('invoices')}?status=UNPAID`}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
tone="text-sky-500 bg-sky-500/10"
|
tone="text-sky-500 bg-sky-500/10"
|
||||||
label="Sales"
|
label="Sales"
|
||||||
value={formatMoney(dashboard.total_sales, currency)}
|
value={formatMoney(dashboard.total_sales, currency)}
|
||||||
onClick={() => navigate('/invoices/invoices')}
|
to={invoicesSectionPath('invoices')}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
icon={Wallet}
|
icon={Wallet}
|
||||||
tone="text-emerald-500 bg-emerald-500/10"
|
tone="text-emerald-500 bg-emerald-500/10"
|
||||||
label="Receipts"
|
label="Receipts"
|
||||||
value={formatMoney(dashboard.total_receipts, currency)}
|
value={formatMoney(dashboard.total_receipts, currency)}
|
||||||
onClick={() => navigate('/invoices/payments')}
|
to={invoicesSectionPath('payments')}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
icon={Receipt}
|
icon={Receipt}
|
||||||
@@ -68,7 +68,7 @@ export const DashboardView = () => {
|
|||||||
label="Net income"
|
label="Net income"
|
||||||
value={formatMoney(dashboard.total_net_income, currency)}
|
value={formatMoney(dashboard.total_net_income, currency)}
|
||||||
hint={`${formatMoney(dashboard.total_expenses, currency)} expenses`}
|
hint={`${formatMoney(dashboard.total_expenses, currency)} expenses`}
|
||||||
onClick={() => navigate('/invoices/expenses')}
|
to={invoicesSectionPath('expenses')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ export const DashboardView = () => {
|
|||||||
title="Due invoices"
|
title="Due invoices"
|
||||||
empty="Nothing outstanding."
|
empty="Nothing outstanding."
|
||||||
rows={dashboard.recent_due_invoices ?? []}
|
rows={dashboard.recent_due_invoices ?? []}
|
||||||
onOpen={(row) => navigate(`/invoices/invoices?selected=${row.id}`)}
|
href={(row) => invoicesRecordPath('invoices', row.id)}
|
||||||
render={(row: Invoice) => ({
|
render={(row: Invoice) => ({
|
||||||
primary: row.invoice_number,
|
primary: row.invoice_number,
|
||||||
secondary: row.customer?.name ?? '—',
|
secondary: row.customer?.name ?? '—',
|
||||||
@@ -156,7 +156,7 @@ export const DashboardView = () => {
|
|||||||
title="Recent estimates"
|
title="Recent estimates"
|
||||||
empty="No estimates yet."
|
empty="No estimates yet."
|
||||||
rows={dashboard.recent_estimates ?? []}
|
rows={dashboard.recent_estimates ?? []}
|
||||||
onOpen={(row) => navigate(`/invoices/estimates?selected=${row.id}`)}
|
href={(row) => invoicesRecordPath('estimates', row.id)}
|
||||||
render={(row: Estimate) => ({
|
render={(row: Estimate) => ({
|
||||||
primary: row.estimate_number,
|
primary: row.estimate_number,
|
||||||
secondary: row.customer?.name ?? '—',
|
secondary: row.customer?.name ?? '—',
|
||||||
@@ -192,24 +192,26 @@ const compact = (v: number): string => {
|
|||||||
return String(v);
|
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 = ({
|
const MetricCard = ({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
tone,
|
tone,
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
hint,
|
hint,
|
||||||
onClick,
|
to,
|
||||||
}: {
|
}: {
|
||||||
icon: typeof Wallet;
|
icon: typeof Wallet;
|
||||||
tone: string;
|
tone: string;
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
onClick?: () => void;
|
to: string;
|
||||||
}) => (
|
}) => (
|
||||||
<button
|
<Link
|
||||||
type="button"
|
to={to}
|
||||||
onClick={onClick}
|
|
||||||
className="flex flex-col gap-2 rounded-xl border border-border p-3 text-left transition-colors hover:bg-muted/40"
|
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">
|
<div className="flex items-center gap-2">
|
||||||
@@ -222,7 +224,7 @@ const MetricCard = ({
|
|||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
{hint && <div className="truncate text-[10px] text-muted-foreground">{hint}</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 };
|
type RecentRender = { primary: string; secondary: string; amount: string; status: string; note: string };
|
||||||
@@ -231,13 +233,13 @@ const RecentPanel = <T extends { id: number }>({
|
|||||||
title,
|
title,
|
||||||
rows,
|
rows,
|
||||||
render,
|
render,
|
||||||
onOpen,
|
href,
|
||||||
empty,
|
empty,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
rows: T[];
|
rows: T[];
|
||||||
render: (row: T) => RecentRender;
|
render: (row: T) => RecentRender;
|
||||||
onOpen: (row: T) => void;
|
href: (row: T) => string;
|
||||||
empty: string;
|
empty: string;
|
||||||
}) => (
|
}) => (
|
||||||
<div className="rounded-xl border border-border">
|
<div className="rounded-xl border border-border">
|
||||||
@@ -249,10 +251,9 @@ const RecentPanel = <T extends { id: number }>({
|
|||||||
{rows.map((row) => {
|
{rows.map((row) => {
|
||||||
const r = render(row);
|
const r = render(row);
|
||||||
return (
|
return (
|
||||||
<button
|
<Link
|
||||||
key={row.id}
|
key={row.id}
|
||||||
type="button"
|
to={href(row)}
|
||||||
onClick={() => onOpen(row)}
|
|
||||||
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50"
|
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="min-w-0 flex-1">
|
||||||
@@ -266,7 +267,7 @@ const RecentPanel = <T extends { id: number }>({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 text-xs tabular-nums">{r.amount}</span>
|
<span className="shrink-0 text-xs tabular-nums">{r.amount}</span>
|
||||||
</button>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ export const isInvoicesSection = (value: string | undefined): value is InvoicesS
|
|||||||
|
|
||||||
export const invoicesSectionPath = (id: InvoicesSectionId) => `/invoices/${id}`;
|
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. */
|
/** The sidecar resource slug behind each list section. `dashboard` and `reports` have none. */
|
||||||
export const SECTION_RESOURCE = {
|
export const SECTION_RESOURCE = {
|
||||||
invoices: 'invoices',
|
invoices: 'invoices',
|
||||||
|
|||||||
Reference in New Issue
Block a user