add the invoices ui over the invoiceshelf sidecar

a /invoices workspace screen replicating the parts of the invoiceshelf web ui
that are actually used: dashboard, invoices, estimates, recurring invoices,
payments, expenses, customers, items and reports.

one document editor serves invoices, estimates and recurring invoices — they
share the line-item table, the discounts, the taxes and the totals, and differ
only in a header strip. the arithmetic is a port of upstream's
use-document-calculations, rounding points included, because the server
re-validates the totals it is sent. money is integer minor units throughout and
is converted to major units at the edges only.

selection and editor state live in the url (`/invoices/:section`, `?selected=`,
`?edit=`), so the nav highlight is derived rather than held, back closes an
editor instead of leaving the screen, and a half-written form survives a reload.

sending a document is behind an explicit confirmation — it emails the customer.

built against the running 2.4.2 instance rather than the 3.0 checkout in
_references; the two differ materially. typechecked, not yet exercised in a
browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 06:44:02 +00:00
co-authored by Claude Opus 5
parent b6dc73915d
commit 5ee56e736b
32 changed files with 6365 additions and 0 deletions
+2
View File
@@ -46,6 +46,8 @@ export function App() {
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
<Route path="/transmission/:section" element={<Dashboard.TransmissionScreen />} />
<Route path="/invoices" element={<Dashboard.InvoicesScreen />} />
<Route path="/invoices/:section" element={<Dashboard.InvoicesScreen />} />
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
@@ -0,0 +1,58 @@
import type { LayoutNode } from 'officerdev';
import { useEffect, useMemo } from 'react';
import { Navigate, useParams } from 'react-router';
import { DEFAULT_INVOICES_SECTION, invoicesSectionPath, isInvoicesSection, WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /invoices uses the Workspace/Panel system (like /transmission and /headscale): the section nav on the
// left, the section itself on the right. Both panels talk to the officer-invoiceshelf sidecar through the
// /api/invoiceshelf auth proxy — the InvoiceShelf URL, its Sanctum token and the company header live in the
// sidecar and never reach the browser.
//
// The open section is :section in the URL; list filters, the selected record and the open editor are query
// params. So the panels read the URL rather than passing state to each other, and this screen is the single
// place that decides what an absent or bogus section means.
const ALLOWED_APP_TYPES = new Set<string | null>(['invoices-nav', 'invoices-view', null]);
function normalizeLayout(node: LayoutNode): LayoutNode {
if (node.type === 'panel') {
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'invoices-view' };
}
const children = node.children.map((c) => {
const fixed = normalizeLayout(c.node);
return fixed === c.node ? c : { ...c, node: fixed };
});
const changed = children.some((c, i) => c !== node.children[i]);
return changed ? { ...node, children } : node;
}
export const InvoicesScreen = () => {
const { section } = useParams();
const rawWorkspace = useDashboardState<LayoutNode>('screens/invoices', defaultLayout);
const workspace = useMemo(() => {
const fixed = normalizeLayout(rawWorkspace.value);
if (fixed === rawWorkspace.value) return rawWorkspace;
return { ...rawWorkspace, value: fixed };
}, [rawWorkspace]);
useEffect(() => {
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
rawWorkspace.setValue(workspace.value);
}
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
// Bare /invoices, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
// default while the address bar says something else — the nav highlight is derived from the URL.
if (!isInvoicesSection(section)) {
return <Navigate to={invoicesSectionPath(DEFAULT_INVOICES_SECTION)} replace />;
}
return (
<div className="h-full w-full pt-2">
<WorkspaceView workspace={workspace} locked />
</div>
);
};
@@ -0,0 +1,11 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'invoices-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'invoices-nav', appType: 'invoices-nav' }, size: 20 },
{ node: { type: 'panel', id: 'invoices-view', appType: 'invoices-view' }, size: 80 },
],
};
@@ -0,0 +1 @@
export * from './InvoicesScreen';
@@ -136,6 +136,7 @@ import {
Radio,
Network,
ArrowDownUp,
Receipt,
} from 'lucide-react';
export const ALL_DOCK_ITEMS: DockItem[] = [
@@ -147,6 +148,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
{ label: 'Invoices', to: '/invoices', icon: Receipt, color: '#0891b2' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
@@ -14,6 +14,7 @@ export * from './Music';
export * from './Soulseek';
export * from './Headscale';
export * from './Transmission';
export * from './Invoices';
export * from './SystemMonitor';
export * from './Activity';
export * from './CodeEditor';
@@ -19,6 +19,7 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
{ match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' },
@@ -13,6 +13,7 @@ import { appRegistryMetas as musicMetas } from '../apps/Music';
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
import { useAppRegistry } from './useAppRegistry';
import { useUserApps } from 'state/useUserApps';
@@ -35,6 +36,7 @@ const apps = [
...soulseekMetas,
...headscaleMetas,
...transmissionMetas,
...invoicesMetas,
...monitorMetas,
];
@@ -0,0 +1,50 @@
import type { ReactNode } from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
// Confirmation for the destructive and the irreversible-ish. Upstream routes every one of these through a
// single dialog store with the same "are you sure" copy; this keeps the single gate but says what will
// actually happen, because "are you sure?" on its own never told anyone anything.
export type ConfirmState = {
title: string;
description: ReactNode;
confirmLabel?: string;
destructive?: boolean;
onConfirm: () => void;
} | null;
export const ConfirmDialog = ({ state, onClose }: { state: ConfirmState; onClose: () => void }) => {
if (!state) return null;
return (
<AlertDialog open onOpenChange={(open) => !open && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{state.title}</AlertDialogTitle>
<AlertDialogDescription>{state.description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
state.onConfirm();
onClose();
}}
className={state.destructive ? 'bg-red-600 text-white hover:bg-red-700' : ''}
>
{state.confirmLabel ?? 'Confirm'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -0,0 +1,346 @@
import type { Column } from './components';
import type { ConfirmState } from './ConfirmDialog';
import type { Address, Customer } from './shared';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Filter, MoreHorizontal, Plus, Trash2, Users, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { EmptyState, ErrorState, Field, Pagination, SearchBox, SectionShell, SimpleTable } from './components';
import { ConfirmDialog } from './ConfirmDialog';
import { formatDate, formatMoney } from './format';
import { PAGE_SIZE } from './shared';
import { useListFilters } from './useInvoicesSection';
import { useCurrency, useCustomerStats, useResourceList, useResourceMutations } from './useInvoiceShelfData';
// Customers. Upstream splits the search into three separate fields rather than one box — display name,
// contact name and phone are distinct API filters — so the toolbar search maps to display name and the
// other two live behind the filter row, which is where upstream puts them too.
type Props = { onEdit: (id: number | 'new') => void };
export const CustomersListView = ({ onEdit }: Props) => {
const { filters, patch, select, setPage, clearFilters } = useListFilters();
const currency = useCurrency();
const navigate = useNavigate();
const [showFilters, setShowFilters] = useState(false);
const [selection, setSelection] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<ConfirmState>(null);
const { rows, meta, isLoading, error } = useResourceList<Customer>('customers', {
page: filters.page,
limit: PAGE_SIZE,
display_name: filters.search || undefined,
// `status` and `from` carry contact name and phone here — one query-string slot per filter, and
// customers have no status or date range to compete for them.
contact_name: filters.status || undefined,
phone: filters.fromDate || undefined,
orderByField: 'created_at',
orderBy: 'desc',
});
const { remove, removeMany } = useResourceMutations('customers');
const selected = rows.find((r) => r.id === filters.selected) ?? null;
const toggleRow = (id: number) =>
setSelection((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const allChecked = rows.length > 0 && rows.every((r) => selection.has(r.id));
const columns: Column<Customer>[] = [
{
key: 'check',
label: '',
width: '36px',
render: (row) => (
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
</span>
),
},
{
key: 'name',
label: 'Name',
width: 'minmax(150px, 2fr)',
render: (row) => (
<div className="min-w-0">
<div className="truncate font-medium">{row.name}</div>
{row.contact_name && <div className="truncate text-[10px] text-muted-foreground">{row.contact_name}</div>}
</div>
),
},
{
key: 'email',
label: 'Email',
width: 'minmax(120px, 1.4fr)',
render: (row) => <span className="truncate text-muted-foreground">{row.email ?? '—'}</span>,
},
{
key: 'phone',
label: 'Phone',
width: 'minmax(100px, 1fr)',
render: (row) => row.phone ?? '—',
},
{
key: 'due_amount',
label: 'Amount due',
width: 'minmax(100px, 1fr)',
align: 'right',
render: (row) => formatMoney(row.due_amount, currency),
},
{
key: 'created_at',
label: 'Added',
width: 'minmax(90px, 0.9fr)',
render: (row) => formatDate(row.created_at, row.formatted_created_at),
},
];
const rowMenu = (row: Customer) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
<DropdownMenuItem onSelect={() => select(row.id)}>View</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => navigate(`/invoices/invoices?customer=${row.id}`)}>
Their invoices
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => navigate(`/invoices/payments?customer=${row.id}`)}>
Their payments
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onSelect={() =>
setConfirm({
title: `Delete ${row.name}?`,
description:
'InvoiceShelf refuses this while the customer still has invoices, estimates or payments — delete those first.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
remove.mutate(row.id);
if (filters.selected === row.id) select(null);
},
})
}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
if (error) return <ErrorState error={error} />;
const toolbar = (
<>
<Checkbox
checked={allChecked}
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
/>
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Display name…" />
<Button
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
size="sm"
className="h-8"
onClick={() => setShowFilters((v) => !v)}
>
<Filter className="mr-1.5 h-3.5 w-3.5" />
Filter
</Button>
<div className="ml-auto flex items-center gap-2">
{selection.size > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-red-600"
onClick={() =>
setConfirm({
title: `Delete ${selection.size} customers?`,
description: 'Any that still have documents against them are refused.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
removeMany.mutate([...selection]);
setSelection(new Set());
},
})
}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete {selection.size}
</Button>
)}
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New customer
</Button>
</div>
</>
);
return (
<>
<div className="flex h-full min-h-0">
<div className="min-w-0 flex-1">
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
{showFilters && (
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Contact name
<Input
value={filters.status ?? ''}
onChange={(e) => patch({ status: e.target.value || null })}
className="h-8 w-40 text-xs"
/>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Phone
<Input
value={filters.fromDate ?? ''}
onChange={(e) => patch({ from: e.target.value || null })}
className="h-8 w-40 text-xs"
/>
</label>
{filters.isFiltered && (
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
Clear
</Button>
)}
</div>
)}
<SimpleTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
selectedKey={filters.selected}
onRowClick={(r) => select(r.id === filters.selected ? null : r.id)}
rowMenu={rowMenu}
empty={
isLoading ? (
<EmptyState icon={Users} title="Loading…" />
) : (
<EmptyState
icon={Users}
title={filters.isFiltered ? 'No customers match' : 'No customers yet'}
hint={filters.isFiltered ? 'Try clearing the filters.' : 'Every invoice needs one — add the first.'}
/>
)
}
/>
</SectionShell>
</div>
{selected && (
<div className="hidden w-[38%] min-w-[320px] shrink-0 lg:block">
<CustomerDetail customer={selected} onEdit={() => onEdit(selected.id)} onClose={() => select(null)} />
</div>
)}
</div>
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
</>
);
};
const CustomerDetail = ({
customer,
onEdit,
onClose,
}: {
customer: Customer;
onEdit: () => void;
onClose: () => void;
}) => {
const currency = useCurrency();
const { stats } = useCustomerStats(customer.id);
const chart = stats?.meta?.chartData;
return (
<div className="flex h-full min-h-0 w-full flex-col border-l border-border bg-background">
<div className="flex shrink-0 items-start gap-2 border-b border-border px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold">{customer.name}</div>
{customer.email && <div className="truncate text-[10px] text-muted-foreground">{customer.email}</div>}
</div>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={onEdit}>
Edit
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onClose} title="Close">
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{chart && (
<div className="grid grid-cols-2 gap-2">
<Totals label="Sales" value={formatMoney(chart.salesTotal, currency)} />
<Totals label="Receipts" value={formatMoney(chart.totalReceipts, currency)} />
<Totals label="Expenses" value={formatMoney(chart.totalExpenses, currency)} />
<Totals label="Net profit" value={formatMoney(chart.netProfit, currency)} />
</div>
)}
<div className="mt-3 rounded-lg border border-border px-3 py-1">
<Field label="Amount due">{formatMoney(customer.due_amount, currency)}</Field>
{customer.contact_name && <Field label="Contact">{customer.contact_name}</Field>}
{customer.phone && <Field label="Phone">{customer.phone}</Field>}
{customer.website && <Field label="Website">{customer.website}</Field>}
{customer.tax_id && <Field label="Tax ID">{customer.tax_id}</Field>}
<Field label="Customer since">{formatDate(customer.created_at, customer.formatted_created_at)}</Field>
</div>
<AddressCard title="Billing address" address={customer.billing} />
<AddressCard title="Shipping address" address={customer.shipping} />
</div>
</div>
);
};
const Totals = ({ label, value }: { label: string; value: string }) => (
<div className="rounded-lg border border-border p-2">
<div className="text-[10px] text-muted-foreground">{label}</div>
<div className="truncate text-sm font-semibold tabular-nums" title={value}>
{value}
</div>
</div>
);
const AddressCard = ({ title, address }: { title: string; address?: Address | null }) => {
const lines = [
address?.address_street_1,
address?.address_street_2,
[address?.city, address?.state, address?.zip].filter(Boolean).join(' '),
address?.country?.name,
].filter((l): l is string => Boolean(l && l.trim()));
if (!lines.length) return null;
return (
<div className="mt-3 rounded-lg border border-border p-3">
<div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{title}</div>
<div className="whitespace-pre-line text-xs">{lines.join('\n')}</div>
</div>
);
};
@@ -0,0 +1,277 @@
import type { Estimate, Invoice } from './shared';
import { useMemo } from 'react';
import { useNavigate } 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 { 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 navigate = useNavigate();
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="text-amber-500 bg-amber-500/10"
label="Amount due"
value={formatMoney(dashboard.total_amount_due, currency)}
hint={`${dashboard.total_invoice_count} invoices`}
onClick={() => navigate('/invoices/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')}
/>
<MetricCard
icon={Wallet}
tone="text-emerald-500 bg-emerald-500/10"
label="Receipts"
value={formatMoney(dashboard.total_receipts, currency)}
onClick={() => navigate('/invoices/payments')}
/>
<MetricCard
icon={Receipt}
tone="text-violet-500 bg-violet-500/10"
label="Net income"
value={formatMoney(dashboard.total_net_income, currency)}
hint={`${formatMoney(dashboard.total_expenses, currency)} expenses`}
onClick={() => navigate('/invoices/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-sky-500" label="Sales" />
<LegendDot className="bg-emerald-500" label="Receipts" />
<LegendDot className="bg-red-500" 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="#0ea5e9" />
<Gradient id="isReceipts" color="#10b981" />
<Gradient id="isExpenses" color="#ef4444" />
</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="#0ea5e9"
fill="url(#isSales)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="receipts"
name="Receipts"
stroke="#10b981"
fill="url(#isReceipts)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="expenses"
name="Expenses"
stroke="#ef4444"
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 ?? []}
onOpen={(row) => navigate(`/invoices/invoices?selected=${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 ?? []}
onOpen={(row) => navigate(`/invoices/estimates?selected=${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);
};
const MetricCard = ({
icon: Icon,
tone,
label,
value,
hint,
onClick,
}: {
icon: typeof Wallet;
tone: string;
label: string;
value: string;
hint?: string;
onClick?: () => void;
}) => (
<button
type="button"
onClick={onClick}
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>}
</button>
);
type RecentRender = { primary: string; secondary: string; amount: string; status: string; note: string };
const RecentPanel = <T extends { id: number }>({
title,
rows,
render,
onOpen,
empty,
}: {
title: string;
rows: T[];
render: (row: T) => RecentRender;
onOpen: (row: T) => void;
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 (
<button
key={row.id}
type="button"
onClick={() => onOpen(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>
</button>
);
})}
</div>
)}
</div>
);
export { ArrowDownRight, ArrowUpRight };
@@ -0,0 +1,732 @@
import type { DocumentItem, DocumentTax, Estimate, Invoice, RecurringInvoice, TaxType } from './shared';
import { useEffect, useMemo, useState } from 'react';
import { Plus, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { ErrorState, LoadingState } from './components';
import { formatMoney, fromMajor, toInputDate, toMajor } from './format';
import { FREQUENCY_PRESETS } from './shared';
import {
useCurrency,
useLookups,
useNextNumber,
useResourceDetail,
useResourceMutations,
useTemplates,
} from './useInvoiceShelfData';
// The invoice / estimate / recurring-invoice form. One component for all three: they share every hard part
// — the line-item table, the discounts, the taxes and the totals — and differ only in a header strip.
// An invoice has a number and two dates; an estimate the same with different labels; a recurring invoice
// has no number at all (each generated invoice takes the next one) and carries a schedule instead of dates.
//
// ── the arithmetic ───────────────────────────────────────────────────────────────────────────────
// Ported from upstream's `use-document-calculations.ts`, and the rounding points are load-bearing because
// the server re-validates the totals we send:
//
// line sub_total = round(price × quantity) (price already in minor units)
// line discount = percentage → round(|sub_total| × d / 100)
// fixed → min(round(d × 100), |sub_total|) ← d is entered in MAJOR units
// line total = sub_total discount
// document sub_total = Σ line totals
// tax base = document sub_total document discount
// compound tax base = tax base + simple taxes
// total = tax_included ? tax base : tax base + total tax
//
// Every value posted is an integer of minor units. Amounts are shown to the user in major units and
// converted at the edges only — a float never survives past a keystroke.
type Resource = 'invoices' | 'estimates' | 'recurring-invoices';
type Line = {
key: string;
item_id: number | null;
name: string;
description: string;
quantity: number;
/** Minor units. */
price: number;
discount: number;
discount_type: 'fixed' | 'percentage';
tax_type_ids: number[];
};
type Props = { resource: Resource; id: number | 'new'; onClose: () => void };
const KIND_NOUN: Record<Resource, string> = {
invoices: 'invoice',
estimates: 'estimate',
'recurring-invoices': 'recurring invoice',
};
const newLine = (index: number): Line => ({
key: `new-${index}`,
item_id: null,
name: '',
description: '',
quantity: 1,
price: 0,
discount: 0,
discount_type: 'fixed',
tax_type_ids: [],
});
export const DocumentEditor = ({ resource, id, onClose }: Props) => {
const isNew = id === 'new';
const isRecurring = resource === 'recurring-invoices';
const isInvoice = resource === 'invoices';
const currency = useCurrency();
const { customers, items, taxTypes } = useLookups();
// A recurring invoice renders through the invoice templates — it has no gallery of its own.
const templates = useTemplates(isRecurring ? 'invoices' : resource);
const nextNumber = useNextNumber(isInvoice ? 'invoice' : 'estimate', isNew && !isRecurring);
const { record, isLoading, error } = useResourceDetail<Invoice & Estimate & RecurringInvoice>(
resource,
isNew ? null : (id as number),
);
const { create, update } = useResourceMutations(resource, isRecurring ? 'recurring invoices' : resource);
const [customerId, setCustomerId] = useState<number | null>(null);
const [number, setNumber] = useState('');
const [primaryDate, setPrimaryDate] = useState(() => toInputDate(new Date().toISOString()));
const [secondaryDate, setSecondaryDate] = useState('');
const [reference, setReference] = useState('');
const [template, setTemplate] = useState('');
const [notes, setNotes] = useState('');
const [lines, setLines] = useState<Line[]>([newLine(0)]);
const [discount, setDiscount] = useState(0);
const [discountType, setDiscountType] = useState<'fixed' | 'percentage'>('fixed');
const [docTaxIds, setDocTaxIds] = useState<number[]>([]);
const [taxPerItem, setTaxPerItem] = useState<'YES' | 'NO'>('NO');
const [taxIncluded, setTaxIncluded] = useState(false);
const [loaded, setLoaded] = useState(false);
// Recurring-only. `frequency` is a raw cron expression upstream; a schedule authored outside the preset
// list keeps its own expression and is offered back as an extra option rather than silently rewritten.
const [frequency, setFrequency] = useState<string>(FREQUENCY_PRESETS[2].cron);
const [limitBy, setLimitBy] = useState<'NONE' | 'COUNT' | 'DATE'>('NONE');
const [limitCount, setLimitCount] = useState(1);
const [limitDate, setLimitDate] = useState('');
const [sendAutomatically, setSendAutomatically] = useState(false);
const [recurringStatus, setRecurringStatus] = useState<'ACTIVE' | 'ON_HOLD'>('ACTIVE');
// Seed the form once, from either the loaded record or the reserved next number. Guarded by `loaded`
// rather than by a dependency list: react-query re-delivers `record` on every background refetch, and
// without the guard a refetch mid-edit would silently discard what the user had typed.
useEffect(() => {
if (loaded) return;
if (isNew) {
// A recurring invoice has no number of its own, so it does not wait on one.
if (!isRecurring && !nextNumber) return;
if (nextNumber) setNumber(nextNumber);
setTemplate(templates[0]?.name ?? (isInvoice || isRecurring ? 'invoice1' : 'estimate1'));
setLoaded(true);
return;
}
if (!record) return;
setCustomerId(record.customer_id ?? null);
setNumber(isInvoice ? (record.invoice_number ?? '') : (record.estimate_number ?? ''));
if (isRecurring) {
setPrimaryDate(toInputDate(record.starts_at));
setFrequency(record.frequency ?? FREQUENCY_PRESETS[2].cron);
setLimitBy(record.limit_by === 'COUNT' || record.limit_by === 'DATE' ? record.limit_by : 'NONE');
setLimitCount(record.limit_count ?? 1);
setLimitDate(toInputDate(record.limit_date));
setSendAutomatically(Boolean(record.send_automatically));
setRecurringStatus(record.status === 'ON_HOLD' ? 'ON_HOLD' : 'ACTIVE');
} else {
setPrimaryDate(toInputDate(isInvoice ? record.invoice_date : record.estimate_date));
setSecondaryDate(toInputDate(isInvoice ? record.due_date : record.expiry_date));
}
setReference(record.reference_number ?? '');
setTemplate(record.template_name ?? templates[0]?.name ?? '');
setNotes(record.notes ?? '');
setTaxPerItem(record.tax_per_item === 'YES' ? 'YES' : 'NO');
setTaxIncluded(Boolean((record as { tax_included?: boolean }).tax_included));
setDiscountType(record.discount_type === 'percentage' ? 'percentage' : 'fixed');
// A fixed document discount is stored in minor units but entered in major ones; a percentage is a
// plain number either way.
setDiscount(record.discount_type === 'percentage' ? Number(record.discount) : toMajor(record.discount_val));
setDocTaxIds((record.taxes ?? []).map((t) => t.tax_type_id).filter((t): t is number => t != null));
setLines(
(record.items ?? []).map((line, i) => ({
key: `row-${line.id ?? i}`,
item_id: line.item_id ?? null,
name: line.name,
description: line.description ?? '',
quantity: Number(line.quantity) || 0,
price: Number(line.price) || 0,
discount: line.discount_type === 'percentage' ? Number(line.discount) : toMajor(line.discount_val),
discount_type: line.discount_type === 'percentage' ? 'percentage' : 'fixed',
tax_type_ids: (line.taxes ?? []).map((t) => t.tax_type_id).filter((t): t is number => t != null),
})),
);
setLoaded(true);
}, [record, isNew, nextNumber, templates, isInvoice, isRecurring, loaded]);
const totals = useMemo(
() => computeTotals({ lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes }),
[lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes],
);
if (error) return <ErrorState error={error} />;
if (!isNew && isLoading) return <LoadingState />;
const patchLine = (key: string, changes: Partial<Line>) =>
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...changes } : l)));
const pickItem = (key: string, itemId: number) => {
const source = items.find((i) => i.id === itemId);
if (!source) return;
patchLine(key, {
item_id: source.id,
name: source.name,
description: source.description ?? '',
price: Number(source.price) || 0,
});
};
const canSave =
customerId != null &&
(isRecurring || number.trim() !== '') &&
primaryDate !== '' &&
template !== '' &&
lines.some((l) => l.name.trim());
const save = () => {
// The three resources agree on everything below the header, and disagree on nothing but it.
const header = isRecurring
? {
starts_at: primaryDate,
frequency,
limit_by: limitBy,
limit_count: limitBy === 'COUNT' ? limitCount : null,
limit_date: limitBy === 'DATE' ? limitDate : null,
send_automatically: sendAutomatically,
status: recurringStatus,
}
: {
[isInvoice ? 'invoice_date' : 'estimate_date']: primaryDate,
[isInvoice ? 'due_date' : 'expiry_date']: secondaryDate || null,
[isInvoice ? 'invoice_number' : 'estimate_number']: number.trim(),
};
const payload = {
...header,
customer_id: customerId,
reference_number: reference || null,
template_name: template,
notes: notes || null,
currency_id: record?.currency_id ?? currency?.id ?? null,
exchange_rate: 1,
tax_per_item: taxPerItem,
discount_per_item: lines.some((l) => l.discount) ? 'YES' : 'NO',
// The raw figure the user typed; `discount_val` alongside it is the resolved minor-unit amount.
discount,
discount_type: discountType,
discount_val: totals.documentDiscount,
sub_total: totals.subTotal,
total: totals.total,
tax: totals.totalTax,
taxes: taxPerItem === 'NO' ? docTaxIds.map((tid) => taxLine(tid, taxTypes, totals.taxBase)) : [],
items: lines
.filter((l) => l.name.trim())
.map((l) => {
const line = computeLine(l, taxTypes, taxPerItem);
return {
item_id: l.item_id,
name: l.name.trim(),
description: l.description || null,
quantity: l.quantity,
price: l.price,
discount: l.discount,
discount_type: l.discount_type,
discount_val: line.discountVal,
tax: line.tax,
total: line.total,
taxes: taxPerItem === 'YES' ? l.tax_type_ids.map((tid) => taxLine(tid, taxTypes, line.total)) : [],
};
}),
};
if (isNew) create.mutate(payload, { onSuccess: onClose });
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
};
const busy = create.isPending || update.isPending;
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2">
<div className="min-w-0 flex-1 text-xs font-semibold">
{isNew ? `New ${KIND_NOUN[resource]}` : `Edit ${number || KIND_NOUN[resource]}`}
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button size="sm" className="h-7 text-xs" onClick={save} disabled={!canSave || busy}>
{busy ? 'Saving…' : 'Save'}
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onClose} title="Close">
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Labelled label="Customer">
<select
value={customerId ?? ''}
onChange={(ev) => setCustomerId(ev.target.value ? Number(ev.target.value) : null)}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">Select</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</Labelled>
{!isRecurring && (
<Labelled label={isInvoice ? 'Invoice number' : 'Estimate number'}>
<Input value={number} onChange={(ev) => setNumber(ev.target.value)} className="h-8 text-xs" />
</Labelled>
)}
<Labelled label={isRecurring ? 'Starts on' : isInvoice ? 'Invoice date' : 'Estimate date'}>
<Input
type="date"
value={primaryDate}
onChange={(ev) => setPrimaryDate(ev.target.value)}
className="h-8 text-xs"
/>
</Labelled>
{!isRecurring && (
<Labelled label={isInvoice ? 'Due date' : 'Expiry date'}>
<Input
type="date"
value={secondaryDate}
onChange={(ev) => setSecondaryDate(ev.target.value)}
className="h-8 text-xs"
/>
</Labelled>
)}
{isRecurring && (
<>
<Labelled label="Repeats">
<select
value={frequency}
onChange={(ev) => setFrequency(ev.target.value)}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
{FREQUENCY_PRESETS.map((f) => (
<option key={f.cron} value={f.cron}>
{f.label}
</option>
))}
{!FREQUENCY_PRESETS.some((f) => f.cron === frequency) && (
<option value={frequency}>Custom ({frequency})</option>
)}
</select>
</Labelled>
<Labelled label="Ends">
<select
value={limitBy}
onChange={(ev) => setLimitBy(ev.target.value as 'NONE' | 'COUNT' | 'DATE')}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
<option value="NONE">Never</option>
<option value="COUNT">After a number of invoices</option>
<option value="DATE">On a date</option>
</select>
</Labelled>
{limitBy === 'COUNT' && (
<Labelled label="How many">
<Input
type="number"
min={1}
value={limitCount}
onChange={(ev) => setLimitCount(Number(ev.target.value))}
className="h-8 text-xs"
/>
</Labelled>
)}
{limitBy === 'DATE' && (
<Labelled label="Until">
<Input
type="date"
value={limitDate}
onChange={(ev) => setLimitDate(ev.target.value)}
className="h-8 text-xs"
/>
</Labelled>
)}
<Labelled label="Status">
<select
value={recurringStatus}
onChange={(ev) => setRecurringStatus(ev.target.value as 'ACTIVE' | 'ON_HOLD')}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
<option value="ACTIVE">Active</option>
<option value="ON_HOLD">On hold</option>
</select>
</Labelled>
</>
)}
<Labelled label="Reference">
<Input value={reference} onChange={(ev) => setReference(ev.target.value)} className="h-8 text-xs" />
</Labelled>
<Labelled label="Template">
{/* Only the names: each template also carries a `path` that is a full base64-encoded PNG
preview, several hundred KB apiece, and nothing here needs to render one. */}
<select
value={template}
onChange={(ev) => setTemplate(ev.target.value)}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
{templates.map((t) => (
<option key={t.name} value={t.name}>
{t.name}
</option>
))}
{template && !templates.some((t) => t.name === template) && <option value={template}>{template}</option>}
</select>
</Labelled>
<Labelled label="Tax">
<select
value={taxPerItem}
onChange={(ev) => setTaxPerItem(ev.target.value as 'YES' | 'NO')}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
<option value="NO">On the whole document</option>
<option value="YES">Per line</option>
</select>
</Labelled>
<Labelled label="Prices">
<select
value={taxIncluded ? 'incl' : 'excl'}
onChange={(ev) => setTaxIncluded(ev.target.value === 'incl')}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
<option value="excl">Tax exclusive</option>
<option value="incl">Tax inclusive</option>
</select>
</Labelled>
</div>
{isRecurring && (
<label className="mt-3 flex items-center gap-2 text-xs">
<Checkbox
checked={sendAutomatically}
onCheckedChange={(checked) => setSendAutomatically(checked === true)}
/>
Email each generated invoice to the customer automatically
</label>
)}
<div className="mt-4 rounded-lg border border-border">
<div className="grid grid-cols-[1fr_70px_110px_110px_110px_32px] items-center gap-2 border-b border-border px-2 py-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
<span>Item</span>
<span className="text-right">Qty</span>
<span className="text-right">Price</span>
<span className="text-right">Discount</span>
<span className="text-right">Total</span>
<span />
</div>
{lines.map((line) => {
const computed = computeLine(line, taxTypes, taxPerItem);
return (
<div key={line.key} className="border-b border-border/40 px-2 py-2">
<div className="grid grid-cols-[1fr_70px_110px_110px_110px_32px] items-start gap-2">
<div className="min-w-0 space-y-1">
<div className="flex gap-1">
<Input
value={line.name}
onChange={(ev) => patchLine(line.key, { name: ev.target.value, item_id: null })}
placeholder="Description of work"
className="h-8 text-xs"
/>
<select
value=""
onChange={(ev) => ev.target.value && pickItem(line.key, Number(ev.target.value))}
title="Fill from the item catalogue"
className="h-8 w-8 shrink-0 rounded-md border border-input bg-background text-xs"
>
<option value=""></option>
{items.map((i) => (
<option key={i.id} value={i.id}>
{i.name}
</option>
))}
</select>
</div>
<Input
value={line.description}
onChange={(ev) => patchLine(line.key, { description: ev.target.value })}
placeholder="Notes (optional)"
className="h-7 text-[11px]"
/>
{taxPerItem === 'YES' && (
<TaxPicker
taxTypes={taxTypes}
selected={line.tax_type_ids}
onChange={(ids) => patchLine(line.key, { tax_type_ids: ids })}
/>
)}
</div>
<Input
type="number"
value={line.quantity}
onChange={(ev) => patchLine(line.key, { quantity: Number(ev.target.value) })}
className="h-8 text-right text-xs"
/>
<Input
type="number"
step="0.01"
value={toMajor(line.price)}
onChange={(ev) => patchLine(line.key, { price: fromMajor(ev.target.value) })}
className="h-8 text-right text-xs"
/>
<div className="flex gap-1">
<Input
type="number"
step="0.01"
value={line.discount}
onChange={(ev) => patchLine(line.key, { discount: Number(ev.target.value) })}
className="h-8 text-right text-xs"
/>
<select
value={line.discount_type}
onChange={(ev) =>
patchLine(line.key, { discount_type: ev.target.value as 'fixed' | 'percentage' })
}
className="h-8 w-9 shrink-0 rounded-md border border-input bg-background text-xs"
>
<option value="fixed">{currency?.symbol ?? '#'}</option>
<option value="percentage">%</option>
</select>
</div>
<div className="pt-2 text-right text-xs tabular-nums">{formatMoney(computed.total, currency)}</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
disabled={lines.length === 1}
onClick={() => setLines((prev) => prev.filter((l) => l.key !== line.key))}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
})}
<div className="px-2 py-2">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setLines((prev) => [...prev, newLine(prev.length)])}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Add line
</Button>
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-2">
<div>
<Labelled label="Notes">
<Textarea
value={notes}
onChange={(ev) => setNotes(ev.target.value)}
rows={6}
className="text-xs"
placeholder="Shown on the document."
/>
</Labelled>
</div>
<div className="rounded-lg border border-border p-3">
<TotalRow label="Subtotal" value={formatMoney(totals.subTotal, currency)} />
<div className="flex items-center justify-between gap-2 py-1 text-xs">
<span className="text-muted-foreground">Discount</span>
<div className="flex items-center gap-1">
<Input
type="number"
step="0.01"
value={discount}
onChange={(ev) => setDiscount(Number(ev.target.value))}
className="h-7 w-24 text-right text-xs"
/>
<select
value={discountType}
onChange={(ev) => setDiscountType(ev.target.value as 'fixed' | 'percentage')}
className="h-7 w-10 rounded-md border border-input bg-background text-xs"
>
<option value="fixed">{currency?.symbol ?? '#'}</option>
<option value="percentage">%</option>
</select>
</div>
</div>
{taxPerItem === 'NO' && (
<div className="py-1">
<div className="mb-1 text-[10px] text-muted-foreground">Taxes</div>
<TaxPicker taxTypes={taxTypes} selected={docTaxIds} onChange={setDocTaxIds} />
</div>
)}
<TotalRow label="Tax" value={formatMoney(totals.totalTax, currency)} />
<div className="mt-1 border-t border-border pt-1">
<TotalRow label="Total" value={formatMoney(totals.total, currency)} strong />
</div>
</div>
</div>
</div>
</div>
);
};
const Labelled = ({ label, children }: { label: string; children: React.ReactNode }) => (
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
{label}
{children}
</label>
);
const TotalRow = ({ label, value, strong }: { label: string; value: string; strong?: boolean }) => (
<div className="flex items-baseline justify-between gap-3 py-1 text-xs">
<span className="text-muted-foreground">{label}</span>
<span className={`tabular-nums ${strong ? 'text-sm font-semibold' : ''}`}>{value}</span>
</div>
);
/** Multi-select over the company's tax types, rendered as toggles because there are only ever a handful. */
const TaxPicker = ({
taxTypes,
selected,
onChange,
}: {
taxTypes: TaxType[];
selected: number[];
onChange: (ids: number[]) => void;
}) => {
if (!taxTypes.length) return <span className="text-[10px] text-muted-foreground">No tax types defined.</span>;
return (
<div className="flex flex-wrap gap-1">
{taxTypes.map((t) => {
const on = selected.includes(t.id);
return (
<button
key={t.id}
type="button"
onClick={() => onChange(on ? selected.filter((i) => i !== t.id) : [...selected, t.id])}
className={`rounded-full px-2 py-0.5 text-[10px] ring-1 ring-inset transition-colors ${
on ? 'bg-primary/10 text-primary ring-primary/30' : 'text-muted-foreground ring-border hover:bg-muted'
}`}
>
{t.name} {Number(t.percent)}%
</button>
);
})}
</div>
);
};
// ── arithmetic ───────────────────────────────────────────────────────────────────────────────────
type LineTotals = { subTotal: number; discountVal: number; total: number; tax: number };
function computeLine(line: Line, taxTypes: TaxType[], taxPerItem: 'YES' | 'NO'): LineTotals {
const subTotal = Math.round(line.price * (Number(line.quantity) || 0));
const abs = Math.abs(subTotal);
const discountVal =
line.discount_type === 'percentage'
? Math.round((abs * (Number(line.discount) || 0)) / 100)
: Math.min(fromMajor(line.discount), abs);
const total = subTotal - discountVal;
const tax =
taxPerItem === 'YES'
? line.tax_type_ids.reduce(
(sum, tid) =>
sum +
percentOf(
total,
taxTypes.find((t) => t.id === tid),
),
0,
)
: 0;
return { subTotal, discountVal, total, tax };
}
const percentOf = (base: number, taxType?: TaxType): number =>
taxType ? Math.round((base * (Number(taxType.percent) || 0)) / 100) : 0;
type TotalsInput = {
lines: Line[];
discount: number;
discountType: 'fixed' | 'percentage';
docTaxIds: number[];
taxPerItem: 'YES' | 'NO';
taxIncluded: boolean;
taxTypes: TaxType[];
};
function computeTotals(input: TotalsInput) {
const { lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes } = input;
const computed = lines.map((l) => computeLine(l, taxTypes, taxPerItem));
const subTotal = computed.reduce((sum, c) => sum + c.total, 0);
const documentDiscount =
discountType === 'percentage'
? Math.round((Math.abs(subTotal) * (Number(discount) || 0)) / 100)
: Math.min(fromMajor(discount), Math.abs(subTotal));
const taxBase = subTotal - documentDiscount;
let totalTax: number;
if (taxPerItem === 'YES') {
totalTax = computed.reduce((sum, c) => sum + c.tax, 0);
} else {
const chosen = docTaxIds.map((tid) => taxTypes.find((t) => t.id === tid)).filter((t): t is TaxType => Boolean(t));
const simple = chosen.filter((t) => !t.compound_tax).reduce((sum, t) => sum + percentOf(taxBase, t), 0);
// A compound tax is charged on the base plus the simple taxes already added — the order matters and
// reversing it silently under-charges.
const compound = chosen.filter((t) => t.compound_tax).reduce((sum, t) => sum + percentOf(taxBase + simple, t), 0);
totalTax = simple + compound;
}
return {
subTotal,
documentDiscount,
taxBase,
totalTax,
total: taxIncluded ? taxBase : taxBase + totalTax,
};
}
/** The tax row shape upstream persists alongside a document or a line. */
function taxLine(taxTypeId: number, taxTypes: TaxType[], base: number): Partial<DocumentTax> & { tax_type_id: number } {
const t = taxTypes.find((x) => x.id === taxTypeId);
return {
tax_type_id: taxTypeId,
name: t?.name,
percent: Number(t?.percent) || 0,
compound_tax: t?.compound_tax ?? false,
amount: percentOf(base, t),
};
}
@@ -0,0 +1,407 @@
import type { Column } from './components';
import type { ConfirmState } from './ConfirmDialog';
import type { SendTarget } from './SendDocumentDialog';
import type { Estimate } from './shared';
import { useState } from 'react';
import { Check, Copy, FileSpreadsheet, FileUp, Filter, MoreHorizontal, Plus, Send, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
EmptyState,
ErrorState,
Pagination,
SearchBox,
SectionShell,
SimpleTable,
StatusBadge,
StatusTabs,
} from './components';
import { ConfirmDialog } from './ConfirmDialog';
import { SendDocumentDialog } from './SendDocumentDialog';
import { PdfPane } from './PdfPane';
import { formatDate, formatMoney } from './format';
import { ESTIMATE_TABS, PAGE_SIZE } from './shared';
import { useListFilters } from './useInvoicesSection';
import {
useCurrency,
useDocumentAction,
useLookups,
useResourceList,
useResourceMutations,
} from './useInvoiceShelfData';
// Estimates — upstream's EstimateIndexView. Same skeleton as the invoices list, different verbs: an estimate
// is accepted or rejected rather than paid, and its terminal move is converting into an invoice.
//
// Convert is one-way and upstream refuses it on a REJECTED estimate, so the item is hidden in that state
// rather than offered and rejected. After a successful convert the new invoice exists alongside this
// estimate; the toast says so, because nothing on this screen would otherwise show where it went.
type Props = { onEdit: (id: number | 'new') => void; onConverted: (invoiceId: number) => void };
export const EstimatesListView = ({ onEdit, onConverted }: Props) => {
const { filters, patch, select, setPage, clearFilters } = useListFilters();
const currency = useCurrency();
const { customers } = useLookups();
const [showFilters, setShowFilters] = useState(false);
const [selection, setSelection] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [sendTarget, setSendTarget] = useState<SendTarget | null>(null);
const { rows, meta, isLoading, error } = useResourceList<Estimate>('estimates', {
page: filters.page,
limit: PAGE_SIZE,
estimate_number: filters.search || undefined,
status: filters.status || undefined,
customer_id: filters.customerId || undefined,
from_date: filters.fromDate || undefined,
to_date: filters.toDate || undefined,
orderByField: 'created_at',
orderBy: 'desc',
});
const { remove, removeMany } = useResourceMutations('estimates');
const action = useDocumentAction('estimates');
const selected = rows.find((r) => r.id === filters.selected) ?? null;
const toggleRow = (id: number) =>
setSelection((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const allChecked = rows.length > 0 && rows.every((r) => selection.has(r.id));
const columns: Column<Estimate>[] = [
{
key: 'check',
label: '',
width: '36px',
render: (row) => (
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
</span>
),
},
{
key: 'estimate_date',
label: 'Date',
width: 'minmax(90px, 0.8fr)',
render: (row) => formatDate(row.estimate_date, row.formatted_estimate_date),
},
{
key: 'estimate_number',
label: 'Number',
width: 'minmax(90px, 0.8fr)',
render: (row) => <span className="font-medium">{row.estimate_number}</span>,
},
{
key: 'name',
label: 'Customer',
width: 'minmax(120px, 1.4fr)',
render: (row) => <span className="truncate">{row.customer?.name ?? '—'}</span>,
},
{
key: 'status',
label: 'Status',
width: 'minmax(90px, 0.9fr)',
render: (row) => <StatusBadge value={row.status} />,
},
{
key: 'total',
label: 'Total',
width: 'minmax(100px, 1fr)',
align: 'right',
render: (row) => formatMoney(row.total, currency),
},
];
const setStatus = (row: Estimate, status: string, label: string) =>
setConfirm({
title: `Mark ${row.estimate_number} as ${label.toLowerCase()}?`,
description: 'This changes the status only. Nothing is emailed to the customer.',
confirmLabel: `Mark as ${label.toLowerCase()}`,
onConfirm: () => action.mutate({ id: row.id, action: 'status', payload: { status } }),
});
const rowMenu = (row: Estimate) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
<DropdownMenuItem onSelect={() => select(row.id)}>View PDF</DropdownMenuItem>
{row.status === 'DRAFT' && (
<DropdownMenuItem
onSelect={() =>
setSendTarget({
resource: 'estimates',
id: row.id,
number: row.estimate_number,
customerName: row.customer?.name ?? '',
customerEmail: row.customer?.email ?? null,
})
}
>
<Send className="mr-2 h-3.5 w-3.5" />
Send estimate
</DropdownMenuItem>
)}
{(row.status === 'SENT' || row.status === 'VIEWED') && (
<DropdownMenuItem
onSelect={() =>
setSendTarget({
resource: 'estimates',
id: row.id,
number: row.estimate_number,
customerName: row.customer?.name ?? '',
customerEmail: row.customer?.email ?? null,
resend: true,
})
}
>
<Send className="mr-2 h-3.5 w-3.5" />
Resend estimate
</DropdownMenuItem>
)}
{row.status !== 'SENT' && (
<DropdownMenuItem onSelect={() => setStatus(row, 'SENT', 'Sent')}>Mark as sent</DropdownMenuItem>
)}
{row.status !== 'ACCEPTED' && row.status !== 'REJECTED' && (
<DropdownMenuItem onSelect={() => setStatus(row, 'ACCEPTED', 'Accepted')}>
<Check className="mr-2 h-3.5 w-3.5" />
Mark as accepted
</DropdownMenuItem>
)}
{row.status !== 'REJECTED' && row.status !== 'ACCEPTED' && (
<DropdownMenuItem onSelect={() => setStatus(row, 'REJECTED', 'Rejected')}>
<X className="mr-2 h-3.5 w-3.5" />
Mark as rejected
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => action.mutate({ id: row.id, action: 'clone' })}>
<Copy className="mr-2 h-3.5 w-3.5" />
Clone estimate
</DropdownMenuItem>
{row.status !== 'REJECTED' && (
<DropdownMenuItem
onSelect={() =>
setConfirm({
title: `Convert ${row.estimate_number} into an invoice?`,
description:
'A new invoice is created from this estimate. The estimate itself stays where it is — this is not a move.',
confirmLabel: 'Convert',
onConfirm: () =>
action.mutate(
{ id: row.id, action: 'convert-to-invoice' },
{
onSuccess: (data) => {
const newId = (data as { data?: { id?: number } })?.data?.id;
if (newId) onConverted(newId);
},
},
),
})
}
>
<FileUp className="mr-2 h-3.5 w-3.5" />
Convert into invoice
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onSelect={() =>
setConfirm({
title: `Delete estimate ${row.estimate_number}?`,
description: 'This permanently removes the estimate.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
remove.mutate(row.id);
if (filters.selected === row.id) select(null);
},
})
}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
if (error) return <ErrorState error={error} />;
const toolbar = (
<>
<Checkbox
checked={allChecked}
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
/>
<StatusTabs
tabs={ESTIMATE_TABS}
value={filters.status}
onChange={(v) => {
patch({ status: v });
setSelection(new Set());
}}
/>
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Estimate number…" />
<Button
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
size="sm"
className="h-8"
onClick={() => setShowFilters((v) => !v)}
>
<Filter className="mr-1.5 h-3.5 w-3.5" />
Filter
</Button>
<div className="ml-auto flex items-center gap-2">
{selection.size > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-red-600"
onClick={() =>
setConfirm({
title: `Delete ${selection.size} estimates?`,
description: 'This permanently removes them.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
removeMany.mutate([...selection]);
setSelection(new Set());
},
})
}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete {selection.size}
</Button>
)}
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New estimate
</Button>
</div>
</>
);
return (
<>
<div className="flex h-full min-h-0">
<div className="min-w-0 flex-1">
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
{showFilters && (
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Customer
<select
value={filters.customerId ?? ''}
onChange={(e) => patch({ customer: e.target.value || null })}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">All</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
From
<Input
type="date"
value={filters.fromDate ?? ''}
onChange={(e) => patch({ from: e.target.value || null })}
className="h-8 w-36 text-xs"
/>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
To
<Input
type="date"
value={filters.toDate ?? ''}
onChange={(e) => patch({ to: e.target.value || null })}
className="h-8 w-36 text-xs"
/>
</label>
{filters.isFiltered && (
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
Clear
</Button>
)}
</div>
)}
<SimpleTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
selectedKey={filters.selected}
onRowClick={(r) => select(r.id === filters.selected ? null : r.id)}
rowMenu={rowMenu}
empty={
isLoading ? (
<EmptyState icon={FileSpreadsheet} title="Loading…" />
) : (
<EmptyState
icon={FileSpreadsheet}
title={filters.isFiltered ? 'No estimates match' : 'No estimates yet'}
hint={
filters.isFiltered
? 'Try clearing the filters.'
: 'Estimates become invoices once a customer accepts.'
}
action={
<Button size="sm" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New estimate
</Button>
}
/>
)
}
/>
</SectionShell>
</div>
{selected && (
<div className="hidden w-[46%] min-w-[380px] shrink-0 lg:block">
<PdfPane
resource="estimates"
id={selected.id}
title={`Estimate ${selected.estimate_number}`}
subtitle={selected.customer?.name}
filename={`estimate-${selected.estimate_number}`}
onClose={() => select(null)}
/>
</div>
)}
</div>
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
<SendDocumentDialog target={sendTarget} currency={currency} onClose={() => setSendTarget(null)} />
</>
);
};
@@ -0,0 +1,313 @@
import type { Column } from './components';
import type { ConfirmState } from './ConfirmDialog';
import type { Expense } from './shared';
import { useState } from 'react';
import { Copy, Filter, MoreHorizontal, Paperclip, Plus, Receipt, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { EmptyState, ErrorState, Pagination, SearchBox, SectionShell, SimpleTable } from './components';
import { ConfirmDialog } from './ConfirmDialog';
import { formatDate, formatMoney } from './format';
import { PAGE_SIZE } from './shared';
import { useListFilters } from './useInvoicesSection';
import {
useCurrency,
useDocumentAction,
useLookups,
useResourceList,
useResourceMutations,
} from './useInvoiceShelfData';
// Expenses — money going the other way, and the only list with a file attached to its rows.
//
// The receipt link opens `attachment_receipt_url` directly. That URL is InvoiceShelf's own, rendered from the
// instance's APP_URL, so it is a browser-resolvable link rather than something proxied through the sidecar —
// which means it only works from a machine that can reach the InvoiceShelf host. Left as-is deliberately:
// proxying arbitrary uploaded files is a wider surface than this screen needs.
type Props = { onEdit: (id: number | 'new') => void };
export const ExpensesListView = ({ onEdit }: Props) => {
const { filters, patch, setPage, clearFilters } = useListFilters();
const currency = useCurrency();
const { customers, categories } = useLookups();
const [showFilters, setShowFilters] = useState(false);
const [selection, setSelection] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<ConfirmState>(null);
const { rows, meta, isLoading, error } = useResourceList<Expense>('expenses', {
page: filters.page,
limit: PAGE_SIZE,
search: filters.search || undefined,
customer_id: filters.customerId || undefined,
expense_category_id: filters.status || undefined,
from_date: filters.fromDate || undefined,
to_date: filters.toDate || undefined,
orderByField: 'expense_date',
orderBy: 'desc',
});
const { remove, removeMany } = useResourceMutations('expenses');
const action = useDocumentAction('expenses');
const toggleRow = (id: number) =>
setSelection((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const allChecked = rows.length > 0 && rows.every((r) => selection.has(r.id));
const columns: Column<Expense>[] = [
{
key: 'check',
label: '',
width: '36px',
render: (row) => (
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
</span>
),
},
{
key: 'expense_date',
label: 'Date',
width: 'minmax(90px, 0.8fr)',
render: (row) => formatDate(row.expense_date, row.formatted_expense_date),
},
{
key: 'expense_number',
label: 'Number',
width: 'minmax(90px, 0.8fr)',
render: (row) => <span className="font-medium">{row.expense_number ?? '—'}</span>,
},
{
key: 'category',
label: 'Category',
width: 'minmax(100px, 1fr)',
render: (row) => <span className="truncate">{row.expense_category?.name ?? '—'}</span>,
},
{
key: 'customer',
label: 'Customer',
width: 'minmax(100px, 1fr)',
render: (row) => <span className="truncate text-muted-foreground">{row.customer?.name ?? '—'}</span>,
},
{
key: 'notes',
label: 'Notes',
width: 'minmax(120px, 1.4fr)',
render: (row) => (
<span className="flex min-w-0 items-center gap-1.5">
{row.attachment_receipt_url && (
<a
href={row.attachment_receipt_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
title="Open receipt"
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<Paperclip className="h-3 w-3" />
</a>
)}
<span className="truncate text-muted-foreground">{row.notes ?? '—'}</span>
</span>
),
},
{
key: 'amount',
label: 'Amount',
width: 'minmax(100px, 1fr)',
align: 'right',
render: (row) => formatMoney(row.amount, currency),
},
];
const rowMenu = (row: Expense) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
<DropdownMenuItem onSelect={() => action.mutate({ id: row.id, action: 'duplicate' })}>
<Copy className="mr-2 h-3.5 w-3.5" />
Duplicate
</DropdownMenuItem>
{row.attachment_receipt_url && (
<DropdownMenuItem onSelect={() => window.open(row.attachment_receipt_url ?? '', '_blank', 'noopener')}>
<Paperclip className="mr-2 h-3.5 w-3.5" />
Open receipt
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onSelect={() =>
setConfirm({
title: 'Delete this expense?',
description: 'It disappears from the profit & loss and expense reports.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => remove.mutate(row.id),
})
}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
if (error) return <ErrorState error={error} />;
const toolbar = (
<>
<Checkbox
checked={allChecked}
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
/>
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Notes…" />
<Button
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
size="sm"
className="h-8"
onClick={() => setShowFilters((v) => !v)}
>
<Filter className="mr-1.5 h-3.5 w-3.5" />
Filter
</Button>
<div className="ml-auto flex items-center gap-2">
{selection.size > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-red-600"
onClick={() =>
setConfirm({
title: `Delete ${selection.size} expenses?`,
description: 'They disappear from the profit & loss and expense reports.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
removeMany.mutate([...selection]);
setSelection(new Set());
},
})
}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete {selection.size}
</Button>
)}
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New expense
</Button>
</div>
</>
);
return (
<>
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
{showFilters && (
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Category
<select
value={filters.status ?? ''}
onChange={(e) => patch({ status: e.target.value || null })}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">All</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Customer
<select
value={filters.customerId ?? ''}
onChange={(e) => patch({ customer: e.target.value || null })}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">All</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
From
<Input
type="date"
value={filters.fromDate ?? ''}
onChange={(e) => patch({ from: e.target.value || null })}
className="h-8 w-36 text-xs"
/>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
To
<Input
type="date"
value={filters.toDate ?? ''}
onChange={(e) => patch({ to: e.target.value || null })}
className="h-8 w-36 text-xs"
/>
</label>
{filters.isFiltered && (
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
Clear
</Button>
)}
</div>
)}
<SimpleTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
onRowClick={(r) => onEdit(r.id)}
rowMenu={rowMenu}
empty={
isLoading ? (
<EmptyState icon={Receipt} title="Loading…" />
) : (
<EmptyState
icon={Receipt}
title={filters.isFiltered ? 'No expenses match' : 'No expenses yet'}
hint={
filters.isFiltered
? 'Try clearing the filters.'
: 'Recording expenses is what makes the net income figure mean anything.'
}
/>
)
}
/>
</SectionShell>
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
</>
);
};
@@ -0,0 +1,400 @@
import type { Column } from './components';
import type { ConfirmState } from './ConfirmDialog';
import type { SendTarget } from './SendDocumentDialog';
import type { Invoice } from './shared';
import { useState } from 'react';
import { Copy, FileText, Filter, MoreHorizontal, Plus, Send, Trash2, Wallet } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
EmptyState,
ErrorState,
Pagination,
SearchBox,
SectionShell,
SimpleTable,
StatusBadge,
StatusTabs,
} from './components';
import { ConfirmDialog } from './ConfirmDialog';
import { SendDocumentDialog } from './SendDocumentDialog';
import { PdfPane } from './PdfPane';
import { formatDate, formatMoney, relativeDue } from './format';
import { INVOICE_TABS, PAGE_SIZE, type Invoice as InvoiceType } from './shared';
import { useListFilters } from './useInvoicesSection';
import {
useCurrency,
useDocumentAction,
useLookups,
useResourceMutations,
useResourceList,
} from './useInvoiceShelfData';
// The invoices list — upstream's InvoiceIndexView, with its column order, its four tabs and its filter set.
//
// Two deliberate departures from the map of the Vue app:
//
// * "Convert to Estimate" is absent. That row exists in the 3.0 source, but this instance runs 2.4.2 and
// has no invoices/{id}/convert-to-estimate route — the sidecar's allow-list reflects the live routes, so
// offering it here would render a menu item that 404s.
// * "Edit" and "Record Payment" open the editors in this app rather than routing to InvoiceShelf.
//
// `allow_edit` is honoured rather than ignored: once an invoice has payments against it, upstream locks
// editing, and letting a user open a form whose save will be rejected is worse than hiding the button.
type Props = { onEdit: (id: number | 'new') => void; onRecordPayment: (invoiceId: number) => void };
export const InvoicesListView = ({ onEdit, onRecordPayment }: Props) => {
const { filters, patch, select, setPage, clearFilters } = useListFilters();
const currency = useCurrency();
const { customers } = useLookups();
const [showFilters, setShowFilters] = useState(false);
const [selection, setSelection] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [sendTarget, setSendTarget] = useState<SendTarget | null>(null);
const { rows, meta, isLoading, error } = useResourceList<InvoiceType>('invoices', {
page: filters.page,
limit: PAGE_SIZE,
invoice_number: filters.search || undefined,
status: filters.status || undefined,
customer_id: filters.customerId || undefined,
from_date: filters.fromDate || undefined,
to_date: filters.toDate || undefined,
orderByField: 'created_at',
orderBy: 'desc',
});
const { removeMany, remove } = useResourceMutations('invoices');
const action = useDocumentAction('invoices');
const selected = rows.find((r) => r.id === filters.selected) ?? null;
const toggleRow = (id: number) =>
setSelection((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const allChecked = rows.length > 0 && rows.every((r) => selection.has(r.id));
const columns: Column<Invoice>[] = [
{
key: 'check',
label: '',
width: '36px',
render: (row) => (
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
</span>
),
},
{
key: 'invoice_date',
label: 'Date',
width: 'minmax(90px, 0.8fr)',
render: (row) => formatDate(row.invoice_date, row.formatted_invoice_date),
},
{
key: 'invoice_number',
label: 'Number',
width: 'minmax(90px, 0.8fr)',
render: (row) => <span className="font-medium">{row.invoice_number}</span>,
},
{
key: 'name',
label: 'Customer',
width: 'minmax(120px, 1.4fr)',
render: (row) => <span className="truncate">{row.customer?.name ?? '—'}</span>,
},
{
key: 'status',
label: 'Status',
width: 'minmax(90px, 0.9fr)',
render: (row) => <StatusBadge value={row.status} />,
},
{
key: 'due_amount',
label: 'Amount due',
width: 'minmax(120px, 1.1fr)',
align: 'right',
render: (row) => (
<div className="flex items-center justify-end gap-1.5">
{row.overdue && <StatusBadge value="OVERDUE" />}
<StatusBadge value={row.paid_status} />
<span>{formatMoney(row.due_amount, currency)}</span>
</div>
),
},
{
key: 'total',
label: 'Total',
width: 'minmax(90px, 0.9fr)',
align: 'right',
render: (row) => formatMoney(row.total, currency),
},
];
const rowMenu = (row: Invoice) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
{row.allow_edit && <DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>}
<DropdownMenuItem onSelect={() => select(row.id)}>View PDF</DropdownMenuItem>
{row.status === 'DRAFT' && (
<DropdownMenuItem
onSelect={() =>
setSendTarget({
resource: 'invoices',
id: row.id,
number: row.invoice_number,
customerName: row.customer?.name ?? '',
customerEmail: row.customer?.email ?? null,
})
}
>
<Send className="mr-2 h-3.5 w-3.5" />
Send invoice
</DropdownMenuItem>
)}
{(row.status === 'SENT' || row.status === 'VIEWED') && (
<DropdownMenuItem
onSelect={() =>
setSendTarget({
resource: 'invoices',
id: row.id,
number: row.invoice_number,
customerName: row.customer?.name ?? '',
customerEmail: row.customer?.email ?? null,
resend: true,
})
}
>
<Send className="mr-2 h-3.5 w-3.5" />
Resend invoice
</DropdownMenuItem>
)}
{(row.status === 'SENT' || row.status === 'VIEWED') && (
<DropdownMenuItem onSelect={() => onRecordPayment(row.id)}>
<Wallet className="mr-2 h-3.5 w-3.5" />
Record payment
</DropdownMenuItem>
)}
{row.status === 'DRAFT' && (
<DropdownMenuItem
onSelect={() =>
setConfirm({
title: `Mark ${row.invoice_number} as sent?`,
// Worth spelling out — the wording is one word away from the action that does email it.
description:
'This only changes the status to Sent. Nothing is emailed to the customer. Use “Send invoice” for that.',
confirmLabel: 'Mark as sent',
onConfirm: () => action.mutate({ id: row.id, action: 'status', payload: { status: 'SENT' } }),
})
}
>
Mark as sent
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => action.mutate({ id: row.id, action: 'clone' })}>
<Copy className="mr-2 h-3.5 w-3.5" />
Clone invoice
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onSelect={() =>
setConfirm({
title: `Delete invoice ${row.invoice_number}?`,
description: 'This permanently removes the invoice and any payments recorded against it.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
remove.mutate(row.id);
if (filters.selected === row.id) select(null);
},
})
}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
if (error) return <ErrorState error={error} />;
const toolbar = (
<>
<Checkbox
checked={allChecked}
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
title="Select all on this page"
/>
<StatusTabs
tabs={INVOICE_TABS}
value={filters.status}
onChange={(v) => {
patch({ status: v });
setSelection(new Set());
}}
/>
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Invoice number…" />
<Button
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
size="sm"
className="h-8"
onClick={() => setShowFilters((v) => !v)}
>
<Filter className="mr-1.5 h-3.5 w-3.5" />
Filter
</Button>
<div className="ml-auto flex items-center gap-2">
{selection.size > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-red-600"
onClick={() =>
setConfirm({
title: `Delete ${selection.size} invoices?`,
description: 'This permanently removes them and any payments recorded against them.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
removeMany.mutate([...selection]);
setSelection(new Set());
},
})
}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete {selection.size}
</Button>
)}
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New invoice
</Button>
</div>
</>
);
return (
<>
<div className="flex h-full min-h-0">
<div className="min-w-0 flex-1">
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
{showFilters && (
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Customer
<select
value={filters.customerId ?? ''}
onChange={(e) => patch({ customer: e.target.value || null })}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">All</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
From
<Input
type="date"
value={filters.fromDate ?? ''}
onChange={(e) => patch({ from: e.target.value || null })}
className="h-8 w-36 text-xs"
/>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
To
<Input
type="date"
value={filters.toDate ?? ''}
onChange={(e) => patch({ to: e.target.value || null })}
className="h-8 w-36 text-xs"
/>
</label>
{filters.isFiltered && (
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
Clear
</Button>
)}
</div>
)}
<SimpleTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
selectedKey={filters.selected}
onRowClick={(r) => select(r.id === filters.selected ? null : r.id)}
rowMenu={rowMenu}
empty={
isLoading ? (
<EmptyState icon={FileText} title="Loading…" />
) : (
<EmptyState
icon={FileText}
title={filters.isFiltered ? 'No invoices match' : 'No invoices yet'}
hint={filters.isFiltered ? 'Try clearing the filters.' : 'Create the first one to get started.'}
action={
<Button size="sm" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New invoice
</Button>
}
/>
)
}
/>
</SectionShell>
</div>
{selected && (
<div className="hidden w-[46%] min-w-[380px] shrink-0 lg:block">
<PdfPane
resource="invoices"
id={selected.id}
title={`Invoice ${selected.invoice_number}`}
subtitle={
<>
{selected.customer?.name}
{selected.due_date && ` · ${relativeDue(selected.due_date)}`}
</>
}
filename={`invoice-${selected.invoice_number}`}
onClose={() => select(null)}
/>
</div>
)}
</div>
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
<SendDocumentDialog target={sendTarget} currency={currency} onClose={() => setSendTarget(null)} />
</>
);
};
@@ -0,0 +1,121 @@
import type { LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router';
import {
Building2,
FileText,
FileSpreadsheet,
LayoutDashboard,
Package,
PieChart,
Receipt,
RefreshCw,
Users,
Wallet,
} from 'lucide-react';
import { INVOICES_SECTIONS, invoicesSectionPath, type InvoicesSectionId } from './shared';
import { formatMoney } from './format';
import { useSummary } from './useInvoiceShelfData';
// Left panel of /invoices: the company it is pointed at, the amount outstanding, then the sections.
//
// Sections are real links so cmd-click, back and reload behave. Counts come from the dashboard totals that
// useSummary already holds — no section fetches a count of its own just to render a badge.
const ICONS: Record<InvoicesSectionId, LucideIcon> = {
dashboard: LayoutDashboard,
invoices: FileText,
estimates: FileSpreadsheet,
recurring: RefreshCw,
payments: Wallet,
expenses: Receipt,
customers: Users,
items: Package,
reports: PieChart,
};
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
export const InvoicesNav = () => {
const { company, currency, dashboard, isLoading, error } = useSummary();
const counts: Partial<Record<InvoicesSectionId, number>> = {
invoices: dashboard?.total_invoice_count,
estimates: dashboard?.total_estimate_count,
customers: dashboard?.total_customer_count,
};
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-sky-500/15 text-sky-500 ring-1 ring-black/5">
<Building2 className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight" title={company?.name ?? undefined}>
{company?.name ?? (isLoading ? 'Loading…' : 'InvoiceShelf')}
</div>
<div className="truncate text-xs text-muted-foreground">
{error ? (
<span className="text-red-500">unreachable</span>
) : dashboard ? (
<>{formatMoney(dashboard.total_amount_due, currency)} outstanding</>
) : (
'—'
)}
</div>
</div>
</div>
<nav className="flex flex-col gap-0.5 px-2 pb-3">
{INVOICES_SECTIONS.map(({ id, label }) => {
const Icon = ICONS[id];
const count = counts[id];
return (
<NavLink
key={id}
to={invoicesSectionPath(id)}
className={({ isActive }) =>
`${ROW} ${
isActive
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`
}
>
{({ isActive }) => (
<>
{isActive && (
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
)}
<Icon
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
<span className="flex-1">{label}</span>
{count != null && count > 0 && (
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
)}
</>
)}
</NavLink>
);
})}
</nav>
{dashboard && (
<div className="mt-auto space-y-1.5 border-t border-border/60 px-4 py-3 text-[11px]">
<NavRowStat label="Sales" value={formatMoney(dashboard.total_sales, currency)} />
<NavRowStat label="Received" value={formatMoney(dashboard.total_receipts, currency)} />
<NavRowStat label="Expenses" value={formatMoney(dashboard.total_expenses, currency)} />
<NavRowStat label="Net" value={formatMoney(dashboard.total_net_income, currency)} strong />
</div>
)}
</div>
);
};
const NavRowStat = ({ label, value, strong }: { label: string; value: string; strong?: boolean }) => (
<div className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground">{label}</span>
<span className={`tabular-nums ${strong ? 'font-semibold text-foreground' : 'text-foreground/80'}`}>{value}</span>
</div>
);
@@ -0,0 +1,133 @@
import { useCallback } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { CustomersListView } from './CustomersListView';
import { DashboardView } from './DashboardView';
import { DocumentEditor } from './DocumentEditor';
import { EstimatesListView } from './EstimatesListView';
import { ExpensesListView } from './ExpensesListView';
import { InvoicesListView } from './InvoicesListView';
import { ItemsListView } from './ItemsListView';
import { PaymentsListView } from './PaymentsListView';
import { RecurringListView } from './RecurringListView';
import { ReportsView } from './ReportsView';
import { CustomerEditor, ExpenseEditor, ItemEditor, PaymentEditor } from './RecordEditors';
import { useInvoicesSection } from './useInvoicesSection';
// Right panel of the /invoices workspace: renders the section named by the URL, and owns the one piece of
// state the sections share — which record is being edited.
//
// That lives in the query string (`?edit=42`, `?edit=new`) rather than in component state, for the same
// reason selection does: it is addressable. Back closes the editor instead of leaving the screen, a
// half-written form survives a reload of the tab, and "record a payment against invoice 12" is a link
// (`?edit=new&pay=12`) rather than a click path.
type EditTarget = number | 'new' | null;
const parseEdit = (raw: string | null): EditTarget => {
if (raw === 'new') return 'new';
const n = Number(raw);
return Number.isInteger(n) && n > 0 ? n : null;
};
export const InvoicesView = () => {
const section = useInvoicesSection();
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const edit = parseEdit(params.get('edit'));
const payFor = Number(params.get('pay')) || null;
const openEditor = useCallback(
(id: number | 'new', extra?: Record<string, string>) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set('edit', String(id));
for (const [key, value] of Object.entries(extra ?? {})) next.set(key, value);
return next;
},
{ replace: false },
),
[setParams],
);
const closeEditor = useCallback(
() =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete('edit');
next.delete('pay');
return next;
},
{ replace: true },
),
[setParams],
);
// Documents get a full-height editor rather than a dialog — a line-item table does not fit in one, and
// upstream gives them a whole page too.
if (edit != null && (section === 'invoices' || section === 'estimates' || section === 'recurring')) {
const resource = section === 'recurring' ? 'recurring-invoices' : section;
return <DocumentEditor resource={resource} id={edit} onClose={closeEditor} />;
}
switch (section) {
case 'invoices':
return (
<InvoicesListView
onEdit={openEditor}
onRecordPayment={(invoiceId) => navigate(`/invoices/payments?edit=new&pay=${invoiceId}`)}
/>
);
case 'estimates':
return (
<EstimatesListView
onEdit={openEditor}
onConverted={(invoiceId) => navigate(`/invoices/invoices?selected=${invoiceId}`)}
/>
);
case 'recurring':
return <RecurringListView onEdit={openEditor} />;
case 'payments':
return (
<>
<PaymentsListView onEdit={openEditor} />
{edit != null && <PaymentEditor id={edit} forInvoiceId={payFor ?? undefined} onClose={closeEditor} />}
</>
);
case 'expenses':
return (
<>
<ExpensesListView onEdit={openEditor} />
{edit != null && <ExpenseEditor id={edit} onClose={closeEditor} />}
</>
);
case 'customers':
return (
<>
<CustomersListView onEdit={openEditor} />
{edit != null && <CustomerEditor id={edit} onClose={closeEditor} />}
</>
);
case 'items':
return (
<>
<ItemsListView onEdit={openEditor} />
{edit != null && <ItemEditor id={edit} onClose={closeEditor} />}
</>
);
case 'reports':
return <ReportsView />;
default:
return <DashboardView />;
}
};
@@ -0,0 +1,36 @@
import { AlertTriangle, Receipt } from 'lucide-react';
import { formatMoney } from './format';
import { INVOICES_SECTIONS } from './shared';
import { useInvoicesSection } from './useInvoicesSection';
import { useSummary } from './useInvoiceShelfData';
// Panel header for the right (invoices-view) panel: which section, which company, and the one figure that
// is worth seeing from every section — how much is still owed.
export const InvoicesViewHeader = () => {
const section = useInvoicesSection();
const { company, currency, dashboard, error } = useSummary();
const label = INVOICES_SECTIONS.find((s) => s.id === section)?.label ?? 'Invoices';
return (
<>
<Receipt className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-xs font-medium">
{label}
{company?.name && <span className="ml-1.5 font-normal text-black/50">· {company.name}</span>}
</span>
{error ? (
<span className="flex shrink-0 items-center gap-1 text-[10px] text-red-600">
<AlertTriangle className="h-3 w-3" />
unreachable
</span>
) : (
dashboard && (
<span className="shrink-0 text-[10px] tabular-nums text-black/60">
{formatMoney(dashboard.total_amount_due, currency)} due
</span>
)
)}
</>
);
};
@@ -0,0 +1,262 @@
import type { Column } from './components';
import type { ConfirmState } from './ConfirmDialog';
import type { Item } from './shared';
import { useState } from 'react';
import { Filter, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { EmptyState, ErrorState, Pagination, SearchBox, SectionShell, SimpleTable } from './components';
import { ConfirmDialog } from './ConfirmDialog';
import { formatDate, formatMoney, parseMoneyMinor } from './format';
import { PAGE_SIZE } from './shared';
import { useListFilters } from './useInvoicesSection';
import { useCurrency, useLookups, useResourceList, useResourceMutations } from './useInvoiceShelfData';
// Items — the reusable line-item catalogue. The shortest screen here, and the only list with no detail pane:
// an item is four fields, so clicking one opens the editor directly rather than a preview of itself.
type Props = { onEdit: (id: number | 'new') => void };
export const ItemsListView = ({ onEdit }: Props) => {
const { filters, patch, setPage, clearFilters } = useListFilters();
const currency = useCurrency();
const { units } = useLookups();
const [showFilters, setShowFilters] = useState(false);
const [priceInput, setPriceInput] = useState('');
const [selection, setSelection] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<ConfirmState>(null);
const { rows, meta, isLoading, error } = useResourceList<Item>('items', {
page: filters.page,
limit: PAGE_SIZE,
search: filters.search || undefined,
unit_id: filters.customerId || undefined,
// Upstream matches price EXACTLY, in minor units — it is a lookup, not a range. `1000` finds items
// priced at 10.00 and nothing else.
price: filters.status || undefined,
orderByField: 'created_at',
orderBy: 'desc',
});
const { remove, removeMany } = useResourceMutations('items');
const toggleRow = (id: number) =>
setSelection((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const allChecked = rows.length > 0 && rows.every((r) => selection.has(r.id));
const columns: Column<Item>[] = [
{
key: 'check',
label: '',
width: '36px',
render: (row) => (
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
</span>
),
},
{
key: 'name',
label: 'Name',
width: 'minmax(150px, 2fr)',
render: (row) => (
<div className="min-w-0">
<div className="truncate font-medium">{row.name}</div>
{row.description && <div className="truncate text-[10px] text-muted-foreground">{row.description}</div>}
</div>
),
},
{
key: 'unit',
label: 'Unit',
width: 'minmax(80px, 0.8fr)',
render: (row) => row.unit?.name ?? '—',
},
{
key: 'price',
label: 'Price',
width: 'minmax(100px, 1fr)',
align: 'right',
render: (row) => formatMoney(row.price, currency),
},
{
key: 'created_at',
label: 'Added',
width: 'minmax(90px, 0.9fr)',
render: (row) => formatDate(null, row.formatted_created_at),
},
];
const rowMenu = (row: Item) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onSelect={() =>
setConfirm({
title: `Delete ${row.name}?`,
description:
'Lines already written from this item stay as they are — an invoice keeps its own copy of the name and price.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => remove.mutate(row.id),
})
}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
if (error) return <ErrorState error={error} />;
const applyPrice = () => {
const trimmed = priceInput.trim();
patch({ status: trimmed ? String(parseMoneyMinor(trimmed, currency)) : null });
};
const toolbar = (
<>
<Checkbox
checked={allChecked}
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
/>
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Item name…" />
<Button
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
size="sm"
className="h-8"
onClick={() => setShowFilters((v) => !v)}
>
<Filter className="mr-1.5 h-3.5 w-3.5" />
Filter
</Button>
<div className="ml-auto flex items-center gap-2">
{selection.size > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-red-600"
onClick={() =>
setConfirm({
title: `Delete ${selection.size} items?`,
description: 'Lines already written from them are unaffected.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
removeMany.mutate([...selection]);
setSelection(new Set());
},
})
}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete {selection.size}
</Button>
)}
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New item
</Button>
</div>
</>
);
return (
<>
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
{showFilters && (
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Unit
<select
value={filters.customerId ?? ''}
onChange={(e) => patch({ customer: e.target.value || null })}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">All</option>
{units.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Exact price
<Input
value={priceInput}
onChange={(e) => setPriceInput(e.target.value)}
onBlur={applyPrice}
onKeyDown={(e) => e.key === 'Enter' && applyPrice()}
placeholder="10.00"
className="h-8 w-28 text-xs"
/>
</label>
{filters.isFiltered && (
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => {
setPriceInput('');
clearFilters();
}}
>
Clear
</Button>
)}
</div>
)}
<SimpleTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
onRowClick={(r) => onEdit(r.id)}
rowMenu={rowMenu}
empty={
isLoading ? (
<EmptyState icon={Package} title="Loading…" />
) : (
<EmptyState
icon={Package}
title={filters.isFiltered ? 'No items match' : 'No items yet'}
hint={
filters.isFiltered
? 'Try clearing the filters.'
: 'Items are optional — they just save retyping the same line.'
}
/>
)
}
/>
</SectionShell>
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
</>
);
};
@@ -0,0 +1,302 @@
import type { Column } from './components';
import type { ConfirmState } from './ConfirmDialog';
import type { SendTarget } from './SendDocumentDialog';
import type { Payment } from './shared';
import { useState } from 'react';
import { Filter, MoreHorizontal, Plus, Send, Trash2, Wallet } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { EmptyState, ErrorState, Pagination, SearchBox, SectionShell, SimpleTable } from './components';
import { ConfirmDialog } from './ConfirmDialog';
import { SendDocumentDialog } from './SendDocumentDialog';
import { PdfPane } from './PdfPane';
import { formatDate, formatMoney } from './format';
import { PAGE_SIZE } from './shared';
import { useListFilters } from './useInvoicesSection';
import { useCurrency, useLookups, useResourceList, useResourceMutations } from './useInvoiceShelfData';
// Payments — upstream's PaymentIndexView. A payment is a receipt against an invoice, so the interesting
// columns are which invoice and by what method; the PDF is the receipt itself.
//
// There is no status here and so no tabs: a payment either exists or it does not.
type Props = { onEdit: (id: number | 'new') => void };
export const PaymentsListView = ({ onEdit }: Props) => {
const { filters, patch, select, setPage, clearFilters } = useListFilters();
const currency = useCurrency();
const { customers, paymentMethods } = useLookups();
const [showFilters, setShowFilters] = useState(false);
const [selection, setSelection] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [sendTarget, setSendTarget] = useState<SendTarget | null>(null);
const { rows, meta, isLoading, error } = useResourceList<Payment>('payments', {
page: filters.page,
limit: PAGE_SIZE,
payment_number: filters.search || undefined,
customer_id: filters.customerId || undefined,
payment_method_id: filters.status || undefined,
orderByField: 'created_at',
orderBy: 'desc',
});
const { remove, removeMany } = useResourceMutations('payments');
const selected = rows.find((r) => r.id === filters.selected) ?? null;
const toggleRow = (id: number) =>
setSelection((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const allChecked = rows.length > 0 && rows.every((r) => selection.has(r.id));
const columns: Column<Payment>[] = [
{
key: 'check',
label: '',
width: '36px',
render: (row) => (
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
</span>
),
},
{
key: 'payment_date',
label: 'Date',
width: 'minmax(90px, 0.8fr)',
render: (row) => formatDate(row.payment_date, row.formatted_payment_date),
},
{
key: 'payment_number',
label: 'Number',
width: 'minmax(100px, 0.9fr)',
render: (row) => <span className="font-medium">{row.payment_number}</span>,
},
{
key: 'name',
label: 'Customer',
width: 'minmax(120px, 1.3fr)',
render: (row) => <span className="truncate">{row.customer?.name ?? '—'}</span>,
},
{
key: 'payment_mode',
label: 'Mode',
width: 'minmax(90px, 0.9fr)',
render: (row) => row.payment_method?.name ?? '—',
},
{
key: 'invoice_number',
label: 'Invoice',
width: 'minmax(90px, 0.9fr)',
render: (row) => row.invoice?.invoice_number ?? '—',
},
{
key: 'amount',
label: 'Amount',
width: 'minmax(100px, 1fr)',
align: 'right',
render: (row) => formatMoney(row.amount, currency),
},
];
const rowMenu = (row: Payment) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
<DropdownMenuItem onSelect={() => select(row.id)}>View receipt</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
setSendTarget({
resource: 'payments',
id: row.id,
number: row.payment_number,
customerName: row.customer?.name ?? '',
customerEmail: row.customer?.email ?? null,
})
}
>
<Send className="mr-2 h-3.5 w-3.5" />
Send receipt
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onSelect={() =>
setConfirm({
title: `Delete payment ${row.payment_number}?`,
description:
'The amount goes back onto the linked invoice as due. This does not refund anything — it only removes the record.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
remove.mutate(row.id);
if (filters.selected === row.id) select(null);
},
})
}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
if (error) return <ErrorState error={error} />;
const toolbar = (
<>
<Checkbox
checked={allChecked}
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
/>
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Payment number…" />
<Button
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
size="sm"
className="h-8"
onClick={() => setShowFilters((v) => !v)}
>
<Filter className="mr-1.5 h-3.5 w-3.5" />
Filter
</Button>
<div className="ml-auto flex items-center gap-2">
{selection.size > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-red-600"
onClick={() =>
setConfirm({
title: `Delete ${selection.size} payments?`,
description: 'Their amounts go back onto the linked invoices as due.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
removeMany.mutate([...selection]);
setSelection(new Set());
},
})
}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete {selection.size}
</Button>
)}
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New payment
</Button>
</div>
</>
);
return (
<>
<div className="flex h-full min-h-0">
<div className="min-w-0 flex-1">
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
{showFilters && (
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Customer
<select
value={filters.customerId ?? ''}
onChange={(e) => patch({ customer: e.target.value || null })}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">All</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Payment mode
{/* Reuses the `status` slot in the query string — one filter key per list, and payments
have no status of their own to compete for it. */}
<select
value={filters.status ?? ''}
onChange={(e) => patch({ status: e.target.value || null })}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">All</option>
{paymentMethods.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</label>
{filters.isFiltered && (
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
Clear
</Button>
)}
</div>
)}
<SimpleTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
selectedKey={filters.selected}
onRowClick={(r) => select(r.id === filters.selected ? null : r.id)}
rowMenu={rowMenu}
empty={
isLoading ? (
<EmptyState icon={Wallet} title="Loading…" />
) : (
<EmptyState
icon={Wallet}
title={filters.isFiltered ? 'No payments match' : 'No payments yet'}
hint={
filters.isFiltered
? 'Try clearing the filters.'
: 'Record one against a sent invoice to mark it paid.'
}
/>
)
}
/>
</SectionShell>
</div>
{selected && (
<div className="hidden w-[46%] min-w-[380px] shrink-0 lg:block">
<PdfPane
resource="payments"
id={selected.id}
title={`Payment ${selected.payment_number}`}
subtitle={selected.customer?.name}
filename={`payment-${selected.payment_number}`}
onClose={() => select(null)}
/>
</div>
)}
</div>
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
<SendDocumentDialog target={sendTarget} currency={currency} onClose={() => setSendTarget(null)} />
</>
);
};
@@ -0,0 +1,91 @@
import type { ReactNode } from 'react';
import { Download, ExternalLink, Loader2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { downloadPdf, usePdfUrl } from './useInvoiceShelfData';
// The right-hand detail pane for invoices, estimates and payments — upstream's document view, which is
// itself just a PDF iframe with an action bar over it.
//
// The PDF is fetched as a blob and handed to the iframe as an object URL rather than being pointed at the
// API path directly. It has to be: the sidecar sits behind Officer's bearer auth and an <iframe src> sends
// no Authorization header, so a direct src would render a 401 body. usePdfUrl owns that fetch and revokes
// the object URL when the document changes, so clicking down a list does not leak a PDF per click.
//
// These PDFs are also the reason the sidecar repairs magic bytes: InvoiceShelf 2.4.2 prepends ~200 bytes of
// a serialized HTTP response to its own PDF body, which every renderer refuses. By the time it arrives here
// it is a clean %PDF.
export const PdfPane = ({
resource,
id,
title,
subtitle,
filename,
actions,
onClose,
}: {
resource: string;
id: number | null;
title: string;
subtitle?: ReactNode;
filename: string;
actions?: ReactNode;
onClose: () => void;
}) => {
const { url, isLoading, error } = usePdfUrl(resource, id);
if (id == null) return null;
return (
<div className="flex h-full min-h-0 w-full flex-col border-l border-border bg-background">
<div className="flex shrink-0 items-start gap-2 border-b border-border px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold">{title}</div>
{subtitle && <div className="truncate text-[10px] text-muted-foreground">{subtitle}</div>}
</div>
<div className="flex shrink-0 items-center gap-1">
{actions}
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
disabled={!url}
title="Download PDF"
onClick={() => url && downloadPdf(url, filename)}
>
<Download className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
disabled={!url}
title="Open in new tab"
onClick={() => url && window.open(url, '_blank', 'noopener')}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onClose} title="Close">
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 bg-muted/30">
{isLoading && (
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Rendering PDF
</div>
)}
{error && !isLoading && (
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
<div className="text-xs font-medium text-red-500">Could not load PDF</div>
<div className="max-w-xs text-[10px] text-muted-foreground">{error}</div>
</div>
)}
{url && !isLoading && <iframe src={url} title={title} className="h-full w-full border-0" />}
</div>
</div>
);
};
@@ -0,0 +1,734 @@
import type { ReactNode } from 'react';
import type { Address, Customer, Expense, Invoice, Item, Payment } from './shared';
import { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { formatMoney, fromMajor, toInputDate, toMajor } from './format';
import {
useCurrency,
useLookups,
useNextNumber,
useResourceDetail,
useResourceList,
useResourceMutations,
} from './useInvoiceShelfData';
// The four small record forms: customer, item, payment, expense. Dialogs rather than full screens —
// each is a handful of fields, and unlike a document none of them needs a line-item table.
//
// One shape for all four: `id === 'new'` creates, a number edits, and the form seeds itself once from the
// loaded record. The seed is guarded by a `loaded` flag rather than a dependency list because react-query
// re-delivers the record on every background refetch, and re-seeding mid-edit would silently discard what
// the user had typed.
//
// Money is entered in MAJOR units and written back in minor ones (`fromMajor`), the same convention as the
// document editor. Reading goes the other way through `toMajor`.
type EditorProps = { id: number | 'new'; onClose: () => void };
// ── shared bits ──────────────────────────────────────────────────────────────────────────────────
const Row = ({ children }: { children: ReactNode }) => <div className="grid gap-3 sm:grid-cols-2">{children}</div>;
const Cell = ({ label, children }: { label: string; children: ReactNode }) => (
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">{label}</Label>
{children}
</div>
);
const Select = ({
value,
onChange,
children,
}: {
value: string | number;
onChange: (value: string) => void;
children: ReactNode;
}) => (
<select
value={value}
onChange={(ev) => onChange(ev.target.value)}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
{children}
</select>
);
const EditorShell = ({
title,
description,
canSave,
busy,
onSave,
onClose,
children,
wide,
}: {
title: string;
description?: string;
canSave: boolean;
busy: boolean;
onSave: () => void;
onClose: () => void;
children: ReactNode;
wide?: boolean;
}) => (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className={wide ? 'sm:max-w-2xl' : 'sm:max-w-lg'}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="max-h-[65vh] space-y-3 overflow-y-auto pr-1">{children}</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button size="sm" onClick={onSave} disabled={!canSave || busy}>
{busy ? 'Saving…' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
// ── customer ─────────────────────────────────────────────────────────────────────────────────────
const EMPTY_ADDRESS = {
name: '',
address_street_1: '',
address_street_2: '',
city: '',
state: '',
zip: '',
phone: '',
country_id: '' as number | '',
};
type AddressForm = typeof EMPTY_ADDRESS;
const fromAddress = (a: Address | null | undefined): AddressForm =>
a
? {
name: a.name ?? '',
address_street_1: a.address_street_1 ?? '',
address_street_2: a.address_street_2 ?? '',
city: a.city ?? '',
state: a.state ?? '',
zip: a.zip ?? '',
phone: a.phone ?? '',
country_id: a.country_id ?? '',
}
: { ...EMPTY_ADDRESS };
export const CustomerEditor = ({ id, onClose }: EditorProps) => {
const isNew = id === 'new';
const { currencies } = useLookups();
const currency = useCurrency();
const { record } = useResourceDetail<Customer>('customers', isNew ? null : (id as number));
const { create, update } = useResourceMutations('customers');
// Read-only upstream; the address selects are unusable without it.
const { rows: countries } = useResourceList<{ id: number; name: string }>('countries');
const [form, setForm] = useState({
name: '',
email: '',
phone: '',
contact_name: '',
company_name: '',
website: '',
prefix: '',
tax_id: '',
currency_id: '' as number | '',
enable_portal: false,
});
const [billing, setBilling] = useState<AddressForm>({ ...EMPTY_ADDRESS });
const [shipping, setShipping] = useState<AddressForm>({ ...EMPTY_ADDRESS });
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (loaded || isNew || !record) return;
setForm({
name: record.name ?? '',
email: record.email ?? '',
phone: record.phone ?? '',
contact_name: record.contact_name ?? '',
company_name: record.company_name ?? '',
website: record.website ?? '',
prefix: record.prefix ?? '',
tax_id: record.tax_id ?? '',
currency_id: record.currency_id ?? '',
enable_portal: Boolean(record.enable_portal),
});
setBilling(fromAddress(record.billing));
setShipping(fromAddress(record.shipping));
setLoaded(true);
}, [record, isNew, loaded]);
const patch = (changes: Partial<typeof form>) => setForm((prev) => ({ ...prev, ...changes }));
const save = () => {
const payload = {
...form,
email: form.email || null,
currency_id: form.currency_id === '' ? (currency?.id ?? null) : form.currency_id,
billing: { ...billing, country_id: billing.country_id === '' ? null : billing.country_id },
shipping: { ...shipping, country_id: shipping.country_id === '' ? null : shipping.country_id },
};
if (isNew) create.mutate(payload, { onSuccess: onClose });
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
};
return (
<EditorShell
title={isNew ? 'New customer' : `Edit ${form.name || 'customer'}`}
canSave={form.name.trim() !== ''}
busy={create.isPending || update.isPending}
onSave={save}
onClose={onClose}
wide
>
<Row>
<Cell label="Display name">
<Input value={form.name} onChange={(ev) => patch({ name: ev.target.value })} className="h-8 text-xs" />
</Cell>
<Cell label="Contact name">
<Input
value={form.contact_name}
onChange={(ev) => patch({ contact_name: ev.target.value })}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Email">
<Input
type="email"
value={form.email}
onChange={(ev) => patch({ email: ev.target.value })}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Phone">
<Input value={form.phone} onChange={(ev) => patch({ phone: ev.target.value })} className="h-8 text-xs" />
</Cell>
<Cell label="Company">
<Input
value={form.company_name}
onChange={(ev) => patch({ company_name: ev.target.value })}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Website">
<Input value={form.website} onChange={(ev) => patch({ website: ev.target.value })} className="h-8 text-xs" />
</Cell>
<Cell label="Tax ID">
<Input value={form.tax_id} onChange={(ev) => patch({ tax_id: ev.target.value })} className="h-8 text-xs" />
</Cell>
<Cell label="Currency">
<Select value={form.currency_id} onChange={(v) => patch({ currency_id: v ? Number(v) : '' })}>
<option value="">Company default</option>
{currencies.map((c) => (
<option key={c.id} value={c.id}>
{c.code} {c.name}
</option>
))}
</Select>
</Cell>
</Row>
<label className="flex items-center gap-2 text-xs">
<Checkbox
checked={form.enable_portal}
onCheckedChange={(checked) => patch({ enable_portal: checked === true })}
/>
Give this customer a portal login
</label>
<AddressFields label="Billing address" value={billing} onChange={setBilling} countries={countries} />
<AddressFields
label="Shipping address"
value={shipping}
onChange={setShipping}
countries={countries}
onCopy={() => setShipping({ ...billing })}
/>
</EditorShell>
);
};
const AddressFields = ({
label,
value,
onChange,
countries,
onCopy,
}: {
label: string;
value: AddressForm;
onChange: (next: AddressForm) => void;
countries: { id: number; name: string }[];
onCopy?: () => void;
}) => {
const patch = (changes: Partial<AddressForm>) => onChange({ ...value, ...changes });
return (
<div className="rounded-lg border border-border p-2">
<div className="mb-2 flex items-center justify-between">
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
{onCopy && (
<Button variant="ghost" size="sm" className="h-6 text-[10px]" onClick={onCopy}>
Same as billing
</Button>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<Input
placeholder="Name"
value={value.name}
onChange={(ev) => patch({ name: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="Phone"
value={value.phone}
onChange={(ev) => patch({ phone: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="Street"
value={value.address_street_1}
onChange={(ev) => patch({ address_street_1: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="Street 2"
value={value.address_street_2}
onChange={(ev) => patch({ address_street_2: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="City"
value={value.city}
onChange={(ev) => patch({ city: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="State"
value={value.state}
onChange={(ev) => patch({ state: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="ZIP"
value={value.zip}
onChange={(ev) => patch({ zip: ev.target.value })}
className="h-8 text-xs"
/>
<Select value={value.country_id} onChange={(v) => patch({ country_id: v ? Number(v) : '' })}>
<option value="">Country</option>
{countries.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
</div>
</div>
);
};
// ── item ─────────────────────────────────────────────────────────────────────────────────────────
export const ItemEditor = ({ id, onClose }: EditorProps) => {
const isNew = id === 'new';
const currency = useCurrency();
const { units } = useLookups();
const { record } = useResourceDetail<Item>('items', isNew ? null : (id as number));
const { create, update } = useResourceMutations('items');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [price, setPrice] = useState(0);
const [unitId, setUnitId] = useState<number | ''>('');
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (loaded || isNew || !record) return;
setName(record.name ?? '');
setDescription(record.description ?? '');
setPrice(toMajor(record.price));
setUnitId(record.unit_id ?? '');
setLoaded(true);
}, [record, isNew, loaded]);
const save = () => {
const payload = {
name: name.trim(),
description: description || null,
price: fromMajor(price),
unit_id: unitId === '' ? null : unitId,
currency_id: record?.currency_id ?? currency?.id ?? null,
};
if (isNew) create.mutate(payload, { onSuccess: onClose });
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
};
return (
<EditorShell
title={isNew ? 'New item' : `Edit ${name || 'item'}`}
description="A saved line you can drop onto any invoice or estimate."
canSave={name.trim() !== ''}
busy={create.isPending || update.isPending}
onSave={save}
onClose={onClose}
>
<Cell label="Name">
<Input value={name} onChange={(ev) => setName(ev.target.value)} className="h-8 text-xs" />
</Cell>
<Row>
<Cell label={`Price (${currency?.code ?? ''})`}>
<Input
type="number"
step="0.01"
value={price}
onChange={(ev) => setPrice(Number(ev.target.value))}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Unit">
<Select value={unitId} onChange={(v) => setUnitId(v ? Number(v) : '')}>
<option value="">None</option>
{units.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</Select>
</Cell>
</Row>
<Cell label="Description">
<Textarea value={description} onChange={(ev) => setDescription(ev.target.value)} rows={3} className="text-xs" />
</Cell>
</EditorShell>
);
};
// ── payment ──────────────────────────────────────────────────────────────────────────────────────
/**
* `forInvoiceId` is the "Record payment" path from the invoices list: the invoice is known, its customer
* and outstanding amount are not, so they are fetched and filled in rather than made the user's problem.
*/
export const PaymentEditor = ({ id, forInvoiceId, onClose }: EditorProps & { forInvoiceId?: number }) => {
const isNew = id === 'new';
const currency = useCurrency();
const { customers, paymentMethods } = useLookups();
const { record } = useResourceDetail<Payment>('payments', isNew ? null : (id as number));
const { record: seedInvoice } = useResourceDetail<Invoice>('invoices', isNew ? (forInvoiceId ?? null) : null);
const { create, update } = useResourceMutations('payments');
const nextNumber = useNextNumber('payment', isNew);
const [customerId, setCustomerId] = useState<number | ''>('');
const [invoiceId, setInvoiceId] = useState<number | ''>('');
const [number, setNumber] = useState('');
const [date, setDate] = useState(() => toInputDate(new Date().toISOString()));
const [amount, setAmount] = useState(0);
const [methodId, setMethodId] = useState<number | ''>('');
const [notes, setNotes] = useState('');
const [loaded, setLoaded] = useState(false);
// `status: 'DUE'` is upstream's own token for "paid_status in (UNPAID, PARTIALLY_PAID)" — the only
// invoices a payment can sensibly be attached to. Fetched only once a customer is chosen.
const { rows: openInvoices } = useResourceList<Invoice>(
'invoices',
{ customer_id: customerId || undefined, status: 'DUE', limit: 100 },
customerId !== '',
);
useEffect(() => {
if (loaded) return;
if (isNew) {
if (!nextNumber) return;
// Wait for the seed invoice too, or the form would open blank and then jump under the user's cursor.
if (forInvoiceId != null && !seedInvoice) return;
setNumber(nextNumber);
if (seedInvoice) {
setCustomerId(seedInvoice.customer_id);
setInvoiceId(seedInvoice.id);
setAmount(toMajor(seedInvoice.due_amount));
}
setLoaded(true);
return;
}
if (!record) return;
setCustomerId(record.customer_id ?? '');
setInvoiceId(record.invoice_id ?? '');
setNumber(record.payment_number ?? '');
setDate(toInputDate(record.payment_date));
setAmount(toMajor(record.amount));
setMethodId(record.payment_method_id ?? '');
setNotes(record.notes ?? '');
setLoaded(true);
}, [record, isNew, nextNumber, seedInvoice, forInvoiceId, loaded]);
// The pre-selected invoice may not be in the customer's open list yet (that query runs a beat later), so
// fall back to the seed for the over-payment warning.
const selected =
openInvoices.find((inv) => inv.id === invoiceId) ??
(seedInvoice && seedInvoice.id === invoiceId ? seedInvoice : undefined);
const save = () => {
const payload = {
payment_date: date,
payment_number: number.trim(),
customer_id: customerId === '' ? null : customerId,
invoice_id: invoiceId === '' ? null : invoiceId,
payment_method_id: methodId === '' ? null : methodId,
amount: fromMajor(amount),
notes: notes || null,
currency_id: record?.currency_id ?? currency?.id ?? null,
exchange_rate: 1,
};
if (isNew) create.mutate(payload, { onSuccess: onClose });
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
};
return (
<EditorShell
title={isNew ? 'Record a payment' : `Edit payment ${number}`}
description="Recording a payment reduces the linked invoice's due amount. It does not email anyone."
canSave={customerId !== '' && number.trim() !== '' && date !== '' && amount > 0}
busy={create.isPending || update.isPending}
onSave={save}
onClose={onClose}
>
<Row>
<Cell label="Date">
<Input type="date" value={date} onChange={(ev) => setDate(ev.target.value)} className="h-8 text-xs" />
</Cell>
<Cell label="Payment number">
<Input value={number} onChange={(ev) => setNumber(ev.target.value)} className="h-8 text-xs" />
</Cell>
<Cell label="Customer">
<Select
value={customerId}
onChange={(v) => {
setCustomerId(v ? Number(v) : '');
// The old invoice belongs to the old customer; keeping it would post the payment to a
// document the new customer does not own.
setInvoiceId('');
}}
>
<option value="">Select</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
</Cell>
<Cell label="Invoice">
<Select
value={invoiceId}
onChange={(v) => {
const next = v ? Number(v) : '';
setInvoiceId(next);
// Default the amount to whatever is still outstanding — the overwhelmingly common case, and
// still editable for a part payment.
const inv = openInvoices.find((i) => i.id === next);
if (inv) setAmount(toMajor(inv.due_amount));
}}
>
<option value="">{customerId === '' ? 'Pick a customer first' : 'Unlinked'}</option>
{/* The pre-selected invoice is listed explicitly in case the open-invoice query has not landed
yet — otherwise the select would show a blank for a value it does hold. */}
{seedInvoice && !openInvoices.some((inv) => inv.id === seedInvoice.id) && (
<option value={seedInvoice.id}>
{seedInvoice.invoice_number} {formatMoney(seedInvoice.due_amount, currency)} due
</option>
)}
{openInvoices.map((inv) => (
<option key={inv.id} value={inv.id}>
{inv.invoice_number} {formatMoney(inv.due_amount, currency)} due
</option>
))}
</Select>
</Cell>
<Cell label={`Amount (${currency?.code ?? ''})`}>
<Input
type="number"
step="0.01"
value={amount}
onChange={(ev) => setAmount(Number(ev.target.value))}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Method">
<Select value={methodId} onChange={(v) => setMethodId(v ? Number(v) : '')}>
<option value="">None</option>
{paymentMethods.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</Select>
</Cell>
</Row>
{selected && fromMajor(amount) > Number(selected.due_amount ?? 0) && (
<p className="text-[10px] text-amber-600">
More than the {formatMoney(selected.due_amount, currency)} outstanding on {selected.invoice_number}.
InvoiceShelf will refuse this.
</p>
)}
<Cell label="Notes">
<Textarea value={notes} onChange={(ev) => setNotes(ev.target.value)} rows={3} className="text-xs" />
</Cell>
</EditorShell>
);
};
// ── expense ──────────────────────────────────────────────────────────────────────────────────────
export const ExpenseEditor = ({ id, onClose }: EditorProps) => {
const isNew = id === 'new';
const currency = useCurrency();
const { customers, categories, paymentMethods } = useLookups();
const { record } = useResourceDetail<Expense>('expenses', isNew ? null : (id as number));
const { create, update } = useResourceMutations('expenses');
const [date, setDate] = useState(() => toInputDate(new Date().toISOString()));
const [number, setNumber] = useState('');
const [categoryId, setCategoryId] = useState<number | ''>('');
const [customerId, setCustomerId] = useState<number | ''>('');
const [methodId, setMethodId] = useState<number | ''>('');
const [amount, setAmount] = useState(0);
const [notes, setNotes] = useState('');
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (loaded || isNew || !record) return;
setDate(toInputDate(record.expense_date));
setNumber(record.expense_number ?? '');
setCategoryId(record.expense_category_id ?? '');
setCustomerId(record.customer_id ?? '');
setMethodId(record.payment_method_id ?? '');
setAmount(toMajor(record.amount));
setNotes(record.notes ?? '');
setLoaded(true);
}, [record, isNew, loaded]);
const save = () => {
const payload = {
expense_date: date,
expense_number: number || null,
expense_category_id: categoryId === '' ? null : categoryId,
customer_id: customerId === '' ? null : customerId,
payment_method_id: methodId === '' ? null : methodId,
amount: fromMajor(amount),
notes: notes || null,
currency_id: record?.currency_id ?? currency?.id ?? null,
exchange_rate: 1,
};
if (isNew) create.mutate(payload, { onSuccess: onClose });
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
};
return (
<EditorShell
title={isNew ? 'New expense' : 'Edit expense'}
canSave={categoryId !== '' && date !== '' && amount !== 0}
busy={create.isPending || update.isPending}
onSave={save}
onClose={onClose}
>
<Row>
<Cell label="Date">
<Input type="date" value={date} onChange={(ev) => setDate(ev.target.value)} className="h-8 text-xs" />
</Cell>
<Cell label="Number">
<Input
value={number}
onChange={(ev) => setNumber(ev.target.value)}
placeholder="Optional"
className="h-8 text-xs"
/>
</Cell>
<Cell label="Category">
<Select value={categoryId} onChange={(v) => setCategoryId(v ? Number(v) : '')}>
<option value="">Select</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
</Cell>
<Cell label={`Amount (${currency?.code ?? ''})`}>
<Input
type="number"
step="0.01"
value={amount}
onChange={(ev) => setAmount(Number(ev.target.value))}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Customer">
<Select value={customerId} onChange={(v) => setCustomerId(v ? Number(v) : '')}>
<option value="">None</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
</Cell>
<Cell label="Paid with">
<Select value={methodId} onChange={(v) => setMethodId(v ? Number(v) : '')}>
<option value="">None</option>
{paymentMethods.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</Select>
</Cell>
</Row>
<Cell label="Notes">
<Textarea value={notes} onChange={(ev) => setNotes(ev.target.value)} rows={3} className="text-xs" />
</Cell>
{/* Receipts are read-only here. Attaching one is a multipart upload to a separate endpoint that the
sidecar's JSON allow-list does not carry; an existing receipt is linked from the expenses list. */}
{record?.attachment_receipt_url && (
<p className="text-[10px] text-muted-foreground">
This expense has a receipt attached.{' '}
<a
href={record.attachment_receipt_url}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
Open it
</a>
. Replacing it has to be done in InvoiceShelf.
</p>
)}
</EditorShell>
);
};
@@ -0,0 +1,322 @@
import type { Column } from './components';
import type { ConfirmState } from './ConfirmDialog';
import type { Invoice, RecurringInvoice, RecurringStatus } from './shared';
import { useState } from 'react';
import { MoreHorizontal, Plus, Repeat, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
EmptyState,
ErrorState,
Field,
Pagination,
SearchBox,
SectionShell,
SimpleTable,
StatusBadge,
} from './components';
import { ConfirmDialog } from './ConfirmDialog';
import { formatDate, formatMoney } from './format';
import { PAGE_SIZE, RECURRING_TABS, frequencyLabel } from './shared';
import { StatusTabs } from './components';
import { useListFilters } from './useInvoicesSection';
import { useCurrency, useResourceDetail, useResourceList, useResourceMutations } from './useInvoiceShelfData';
// Recurring invoices — a schedule, not a document.
//
// This is the one section whose detail pane is NOT a PDF, and it is not an omission: a recurring invoice has
// no unique_hash and no PDF route upstream, because there is nothing to render. What it has is a schedule and
// a list of the invoices that schedule has already generated, so that is what the pane shows.
//
// The schedule's `frequency` is a cron expression; frequencyLabel turns the ones upstream's own form can
// produce back into words and leaves anything else alone.
type Props = { onEdit: (id: number | 'new') => void };
export const RecurringListView = ({ onEdit }: Props) => {
const { filters, patch, select, setPage, clearFilters } = useListFilters();
const currency = useCurrency();
const [selection, setSelection] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<ConfirmState>(null);
const { rows, meta, isLoading, error } = useResourceList<RecurringInvoice>('recurring-invoices', {
page: filters.page,
limit: PAGE_SIZE,
search: filters.search || undefined,
status: filters.status || undefined,
customer_id: filters.customerId || undefined,
orderByField: 'created_at',
orderBy: 'desc',
});
const { remove, removeMany } = useResourceMutations('recurring-invoices', 'recurring invoices');
const toggleRow = (id: number) =>
setSelection((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const allChecked = rows.length > 0 && rows.every((r) => selection.has(r.id));
const columns: Column<RecurringInvoice>[] = [
{
key: 'check',
label: '',
width: '36px',
render: (row) => (
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
</span>
),
},
{
key: 'starts_at',
label: 'Starts at',
width: 'minmax(90px, 0.9fr)',
render: (row) => formatDate(row.starts_at, row.formatted_starts_at),
},
{
key: 'customer',
label: 'Customer',
width: 'minmax(140px, 1.6fr)',
render: (row) => (
<div className="min-w-0">
<div className="truncate">{row.customer?.name ?? '—'}</div>
{row.customer?.contact_name && (
<div className="truncate text-[10px] text-muted-foreground">{row.customer.contact_name}</div>
)}
</div>
),
},
{
key: 'frequency',
label: 'Frequency',
width: 'minmax(100px, 1.1fr)',
render: (row) => <span className="truncate">{frequencyLabel(row.frequency)}</span>,
},
{
key: 'status',
label: 'Status',
width: 'minmax(90px, 0.9fr)',
render: (row) => <StatusBadge value={row.status} />,
},
{
key: 'total',
label: 'Total',
width: 'minmax(100px, 1fr)',
align: 'right',
render: (row) => formatMoney(row.total, currency),
},
];
const rowMenu = (row: RecurringInvoice) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
<DropdownMenuItem onSelect={() => select(row.id)}>View schedule</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onSelect={() =>
setConfirm({
title: 'Delete this schedule?',
description:
'Invoices it has already generated are kept — only the schedule stops. Nothing further will be issued.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
remove.mutate(row.id);
if (filters.selected === row.id) select(null);
},
})
}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
if (error) return <ErrorState error={error} />;
const toolbar = (
<>
<Checkbox
checked={allChecked}
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
/>
<StatusTabs
tabs={RECURRING_TABS}
value={(filters.status as RecurringStatus | null) ?? null}
onChange={(v) => patch({ status: v })}
/>
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Customer…" />
<div className="ml-auto flex items-center gap-2">
{filters.isFiltered && (
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
Clear
</Button>
)}
{selection.size > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-red-600"
onClick={() =>
setConfirm({
title: `Delete ${selection.size} schedules?`,
description: 'Invoices they have already generated are kept.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
removeMany.mutate([...selection]);
setSelection(new Set());
},
})
}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete {selection.size}
</Button>
)}
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New schedule
</Button>
</div>
</>
);
return (
<>
<div className="flex h-full min-h-0">
<div className="min-w-0 flex-1">
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
<SimpleTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
selectedKey={filters.selected}
onRowClick={(r) => select(r.id === filters.selected ? null : r.id)}
rowMenu={rowMenu}
empty={
isLoading ? (
<EmptyState icon={Repeat} title="Loading…" />
) : (
<EmptyState
icon={Repeat}
title={filters.isFiltered ? 'No schedules match' : 'No recurring invoices'}
hint={
filters.isFiltered
? 'Try clearing the filters.'
: 'A schedule issues the same invoice to a customer on a fixed cadence.'
}
/>
)
}
/>
</SectionShell>
</div>
{filters.selected != null && (
<div className="hidden w-[42%] min-w-[340px] shrink-0 lg:block">
<RecurringDetail id={filters.selected} onClose={() => select(null)} />
</div>
)}
</div>
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
</>
);
};
/**
* The schedule pane. Fetched by id rather than taken from the list row because `invoices` is only present on
* the singular read — the index resource omits the relation.
*/
const RecurringDetail = ({ id, onClose }: { id: number; onClose: () => void }) => {
const { record, isLoading } = useResourceDetail<RecurringInvoice>('recurring-invoices', id);
const currency = useCurrency();
const generated: Invoice[] = record?.invoices ?? [];
return (
<div className="flex h-full min-h-0 w-full flex-col border-l border-border bg-background">
<div className="flex shrink-0 items-start gap-2 border-b border-border px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold">{record?.customer?.name ?? 'Schedule'}</div>
<div className="truncate text-[10px] text-muted-foreground">{frequencyLabel(record?.frequency)}</div>
</div>
<StatusBadge value={record?.status} />
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onClose} title="Close">
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{isLoading && <div className="text-xs text-muted-foreground">Loading</div>}
{record && (
<>
<div className="rounded-lg border border-border px-3 py-1">
<Field label="Starts at">{formatDate(record.starts_at, record.formatted_starts_at)}</Field>
<Field label="Next invoice">{formatDate(record.next_invoice_at, record.formatted_next_invoice_at)}</Field>
<Field label="Frequency">{frequencyLabel(record.frequency)}</Field>
<Field label="Limit">{limitLabel(record)}</Field>
<Field label="Sends automatically">{record.send_automatically ? 'Yes' : 'No'}</Field>
<Field label="Total">{formatMoney(record.total, currency)}</Field>
</div>
<div className="mt-3">
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Generated invoices
</div>
{generated.length === 0 ? (
<div className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-xs text-muted-foreground">
Nothing issued yet.
</div>
) : (
<div className="divide-y divide-border/50 rounded-lg border border-border">
{generated.map((inv) => (
<div key={inv.id} className="flex items-center gap-2 px-3 py-2 text-xs">
<span className="font-medium">{inv.invoice_number}</span>
<StatusBadge value={inv.status} />
<span className="ml-auto tabular-nums">{formatMoney(inv.total, currency)}</span>
</div>
))}
</div>
)}
</div>
{record.notes && (
<div className="mt-3 rounded-lg border border-border p-3 text-xs text-muted-foreground">
{record.notes}
</div>
)}
</>
)}
</div>
</div>
);
};
const limitLabel = (row: RecurringInvoice): string => {
if (row.limit_by === 'COUNT') return `${row.limit_count ?? 0} invoices`;
if (row.limit_by === 'DATE') return formatDate(row.limit_date, row.formatted_limit_date);
return 'None';
};
@@ -0,0 +1,211 @@
import type { ReportKind } from './shared';
import { useMemo, useState } from 'react';
import { BarChart3, Download, ExternalLink, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { EmptyState } from './components';
import { REPORT_KINDS } from './shared';
import { downloadPdf, useReportUrl } from './useInvoiceShelfData';
// Reports. Every one of these is a server-rendered PDF — InvoiceShelf builds them in Blade and there is no
// JSON equivalent, so this screen is a range picker and a viewer, which is exactly what upstream's is.
//
// from_date and to_date are MANDATORY, not optional filters: each controller does
// `Carbon::createFromFormat('Y-m-d', $request->from_date)` unguarded, so omitting either is a 500 rather than
// an unbounded report. The range therefore always has a value here and the presets always resolve to a pair.
type PresetId =
| 'today'
| 'this-week'
| 'this-month'
| 'this-quarter'
| 'this-year'
| 'previous-week'
| 'previous-month'
| 'previous-quarter'
| 'previous-year'
| 'custom';
const PRESETS: { id: PresetId; label: string }[] = [
{ id: 'today', label: 'Today' },
{ id: 'this-week', label: 'This week' },
{ id: 'this-month', label: 'This month' },
{ id: 'this-quarter', label: 'This quarter' },
{ id: 'this-year', label: 'This year' },
{ id: 'previous-week', label: 'Previous week' },
{ id: 'previous-month', label: 'Previous month' },
{ id: 'previous-quarter', label: 'Previous quarter' },
{ id: 'previous-year', label: 'Previous year' },
{ id: 'custom', label: 'Custom' },
];
const iso = (d: Date): string => {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
};
/** Monday-start, matching Carbon's default `startOfWeek()` on this instance. */
const startOfWeek = (d: Date): Date => {
const out = new Date(d);
const offset = (out.getDay() + 6) % 7;
out.setDate(out.getDate() - offset);
return out;
};
function resolvePreset(id: PresetId): { from: string; to: string } | null {
if (id === 'custom') return null;
const now = new Date();
const y = now.getFullYear();
const q = Math.floor(now.getMonth() / 3);
switch (id) {
case 'today':
return { from: iso(now), to: iso(now) };
case 'this-week': {
const start = startOfWeek(now);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return { from: iso(start), to: iso(end) };
}
case 'previous-week': {
const start = startOfWeek(now);
start.setDate(start.getDate() - 7);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return { from: iso(start), to: iso(end) };
}
case 'this-month':
return { from: iso(new Date(y, now.getMonth(), 1)), to: iso(new Date(y, now.getMonth() + 1, 0)) };
case 'previous-month':
return { from: iso(new Date(y, now.getMonth() - 1, 1)), to: iso(new Date(y, now.getMonth(), 0)) };
case 'this-quarter':
return { from: iso(new Date(y, q * 3, 1)), to: iso(new Date(y, q * 3 + 3, 0)) };
case 'previous-quarter':
return { from: iso(new Date(y, q * 3 - 3, 1)), to: iso(new Date(y, q * 3, 0)) };
case 'this-year':
return { from: iso(new Date(y, 0, 1)), to: iso(new Date(y, 11, 31)) };
case 'previous-year':
return { from: iso(new Date(y - 1, 0, 1)), to: iso(new Date(y - 1, 11, 31)) };
default:
return null;
}
}
export const ReportsView = () => {
const [kind, setKind] = useState<ReportKind>('sales-customers');
const [preset, setPreset] = useState<PresetId>('this-month');
// Seeded from the default preset so the first render already has a valid range to request.
const [custom, setCustom] = useState(() => resolvePreset('this-month') ?? { from: '', to: '' });
const range = useMemo(() => resolvePreset(preset) ?? custom, [preset, custom]);
const ready = Boolean(range.from && range.to);
const { url, isLoading, error } = useReportUrl(kind, { from_date: range.from, to_date: range.to }, ready);
const label = REPORT_KINDS.find((r) => r.id === kind)?.label ?? 'Report';
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-1 border-b border-border px-3 py-2">
{REPORT_KINDS.map((r) => (
<button
key={r.id}
type="button"
onClick={() => setKind(r.id)}
className={`shrink-0 rounded-md px-2.5 py-1 text-xs transition-colors ${
kind === r.id ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/60'
}`}
>
{r.label}
</button>
))}
</div>
<div className="flex shrink-0 flex-wrap items-end gap-3 border-b border-border px-3 py-2">
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Range
<select
value={preset}
onChange={(e) => {
const next = e.target.value as PresetId;
// Carry the resolved dates into the custom fields so switching to Custom starts from what is
// already on screen instead of blanking the report.
const resolved = resolvePreset(next);
if (resolved) setCustom(resolved);
setPreset(next);
}}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
{PRESETS.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
From
<Input
type="date"
value={range.from}
disabled={preset !== 'custom'}
onChange={(e) => setCustom((c) => ({ ...c, from: e.target.value }))}
className="h-8 w-36 text-xs"
/>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
To
<Input
type="date"
value={range.to}
disabled={preset !== 'custom'}
onChange={(e) => setCustom((c) => ({ ...c, to: e.target.value }))}
className="h-8 w-36 text-xs"
/>
</label>
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-8"
disabled={!url}
onClick={() => url && downloadPdf(url, `${kind}-${range.from}_${range.to}`)}
>
<Download className="mr-1.5 h-3.5 w-3.5" />
Download
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
disabled={!url}
title="Open in new tab"
onClick={() => url && window.open(url, '_blank', 'noopener')}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 bg-muted/30">
{!ready && (
<EmptyState icon={BarChart3} title="Pick a date range" hint="Reports need both a start and an end." />
)}
{ready && isLoading && (
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Building {label.toLowerCase()}
</div>
)}
{ready && error && !isLoading && (
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
<div className="text-xs font-medium text-red-500">Could not build the report</div>
<div className="max-w-md text-[10px] text-muted-foreground">{error}</div>
</div>
)}
{ready && url && !isLoading && <iframe src={url} title={label} className="h-full w-full border-0" />}
</div>
</div>
);
};
@@ -0,0 +1,147 @@
import type { Currency } from './shared';
import { useEffect, useState } from 'react';
import { Loader2, Send } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useDocumentAction, useSummary } from './useInvoiceShelfData';
// The one dialog in /invoices that causes something irreversible to leave the building: `send` hands the
// document to InvoiceShelf's mailer, which posts it to the customer. There is no unsend.
//
// So it is deliberately not a menu item that fires on click. The recipient is shown, editable, and
// pre-filled from the customer record rather than assumed; the button says who it is emailing; and nothing
// else in this app calls the send action. Everything that *looks* like sending but isn't — "mark as sent",
// which only moves the status — goes through a plain confirm instead, and says so.
export type SendTarget = {
/** Sidecar resource slug: invoices | estimates | payments. */
resource: string;
id: number;
/** Shown in the title, e.g. "000145". */
number: string;
customerName: string;
customerEmail: string | null;
/** Whether this is a first send or a resend, purely for wording. */
resend?: boolean;
};
const DEFAULT_BODY = [
'<p>Hi {CUSTOMER_NAME},</p>',
'<p>Please find the attached document.</p>',
'<p>Thanks,<br/>{COMPANY_NAME}</p>',
].join('\n');
const KIND_LABEL: Record<string, string> = {
invoices: 'invoice',
estimates: 'estimate',
payments: 'payment receipt',
};
export const SendDocumentDialog = ({
target,
currency: _currency,
onClose,
}: {
target: SendTarget | null;
currency?: Currency | null;
onClose: () => void;
}) => {
const { company, summary } = useSummary();
const action = useDocumentAction(target?.resource ?? 'invoices');
const kind = KIND_LABEL[target?.resource ?? ''] ?? 'document';
const [to, setTo] = useState('');
const [from, setFrom] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
// Re-seed whenever a different document is opened. Keyed on id so reopening the same one keeps nothing
// stale from a previous edit of another document's mail.
useEffect(() => {
if (!target) return;
setTo(target.customerEmail ?? '');
setFrom(summary?.me?.email ?? '');
setSubject(`${company?.name ?? 'Invoice'}${kind} ${target.number}`);
setBody(
DEFAULT_BODY.replace('{CUSTOMER_NAME}', target.customerName || 'there').replace(
'{COMPANY_NAME}',
company?.name ?? '',
),
);
}, [target?.id, target?.resource, company?.name, summary?.me?.email, kind, target]);
if (!target) return null;
const missingRecipient = !to.trim();
const submit = () => {
action.mutate({ id: target.id, action: 'send', payload: { subject, body, from, to } }, { onSuccess: onClose });
};
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{target.resend ? 'Resend' : 'Send'} {kind} {target.number}
</DialogTitle>
<DialogDescription>
This emails {target.customerName} through InvoiceShelf. It cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">To</Label>
<Input value={to} onChange={(e) => setTo(e.target.value)} className="h-8 text-xs" type="email" />
</div>
<div className="space-y-1">
<Label className="text-xs">From</Label>
<Input value={from} onChange={(e) => setFrom(e.target.value)} className="h-8 text-xs" type="email" />
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Subject</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="h-8 text-xs" />
</div>
<div className="space-y-1">
<Label className="text-xs">Message</Label>
<Textarea value={body} onChange={(e) => setBody(e.target.value)} rows={7} className="text-xs" />
<p className="text-[10px] text-muted-foreground">
HTML. InvoiceShelf attaches the PDF itself do not link it here.
</p>
</div>
{missingRecipient && (
<p className="text-[10px] text-amber-600">This customer has no email address on file. Enter one to send.</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={onClose} disabled={action.isPending}>
Cancel
</Button>
<Button size="sm" onClick={submit} disabled={action.isPending || missingRecipient}>
{action.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
Send to {to || '—'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,281 @@
import type { ReactNode } from 'react';
import type { LucideIcon } from 'lucide-react';
import type { PageMeta } from './shared';
import { ChevronLeft, ChevronRight, Inbox, Loader2, Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { STATUS_TONE, humanStatus } from './shared';
// Presentational furniture shared by every /invoices section. Nothing here fetches or mutates — these are
// the pieces the section views arrange.
/** Status pill. Unknown tokens fall through to the neutral tone rather than rendering unstyled. */
export const StatusBadge = ({ value, className = '' }: { value: string | null | undefined; className?: string }) => {
if (!value) return null;
const tone = STATUS_TONE[value] ?? 'bg-muted text-muted-foreground ring-border';
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ring-1 ring-inset ${tone} ${className}`}
>
{humanStatus(value)}
</span>
);
};
export const EmptyState = ({
icon: Icon = Inbox,
title,
hint,
action,
}: {
icon?: LucideIcon;
title: string;
hint?: string;
action?: ReactNode;
}) => (
<div className="flex h-full flex-col items-center justify-center gap-3 p-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Icon className="h-6 w-6" />
</div>
<div>
<div className="text-sm font-medium">{title}</div>
{hint && <div className="mt-1 max-w-sm text-xs text-muted-foreground">{hint}</div>}
</div>
{action}
</div>
);
export const LoadingState = ({ label = 'Loading…' }: { label?: string }) => (
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{label}
</div>
);
export const ErrorState = ({ error, hint }: { error: unknown; hint?: string }) => (
<div className="flex h-full flex-col items-center justify-center gap-2 p-10 text-center">
<div className="text-sm font-medium text-red-500">Could not reach InvoiceShelf</div>
<div className="max-w-md text-xs text-muted-foreground">
{hint ?? 'Check that officer-invoiceshelf is running and INVOICESHELF_URL / INVOICESHELF_TOKEN are set.'}
</div>
{error != null && (
<pre className="max-w-md overflow-hidden text-ellipsis text-[10px] text-muted-foreground/70">
{String((error as Error)?.message ?? error).slice(0, 300)}
</pre>
)}
</div>
);
/**
* Search box that writes straight through on every keystroke.
*
* Not debounced, deliberately: the query string is the source of truth, and react-query keys each distinct
* search as its own cache entry, so backspacing through a term replays cached responses rather than
* refiring requests. A debounce would only add lag to the first character.
*/
export const SearchBox = ({
value,
onChange,
placeholder = 'Search…',
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) => (
<div className="relative min-w-0 flex-1 sm:max-w-xs">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="h-8 pl-8 text-xs"
/>
</div>
);
/** Horizontal status tabs above a list, each carrying the count when one is known. */
export const StatusTabs = <T extends string>({
tabs,
value,
onChange,
}: {
tabs: readonly { id: T | null; label: string; count?: number }[];
value: T | null;
onChange: (v: T | null) => void;
}) => (
<div className="flex items-center gap-1 overflow-x-auto">
{tabs.map((tab) => {
const active = value === tab.id;
return (
<button
key={tab.id ?? 'all'}
type="button"
onClick={() => onChange(tab.id)}
className={`shrink-0 rounded-md px-2.5 py-1 text-xs transition-colors ${
active ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/60'
}`}
>
{tab.label}
{tab.count != null && <span className="ml-1.5 tabular-nums opacity-60">{tab.count}</span>}
</button>
);
})}
</div>
);
/**
* Pager for a Laravel-paginated list. Renders nothing on a single page — a lone disabled "1 of 1" is noise.
*/
export const Pagination = ({ meta, onPage }: { meta: PageMeta | null; onPage: (page: number) => void }) => {
if (!meta || meta.last_page <= 1) return null;
const { current_page, last_page, from, to, total } = meta;
return (
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted-foreground">
<span className="tabular-nums">
{from ?? 0}{to ?? 0} of {total}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
disabled={current_page <= 1}
onClick={() => onPage(current_page - 1)}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<span className="tabular-nums">
{current_page} / {last_page}
</span>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
disabled={current_page >= last_page}
onClick={() => onPage(current_page + 1)}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
};
/** Column contract for SimpleTable. `width` is a grid track ('1fr', '120px', 'minmax(0,2fr)'). */
export type Column<T> = {
key: string;
label: string;
width: string;
align?: 'left' | 'right' | 'center';
render: (row: T) => ReactNode;
};
/**
* The list table.
*
* A CSS grid rather than a <table>: every list here shares a sticky header and a single scroller, and grid
* tracks keep the two aligned without the colgroup gymnastics a real table needs. Not virtualised — these
* lists are server-paginated, so the row count on screen is bounded by `limit`.
*/
export const SimpleTable = <T,>({
rows,
columns,
rowKey,
selectedKey,
onRowClick,
rowMenu,
empty,
}: {
rows: T[];
columns: Column<T>[];
rowKey: (row: T) => number | string;
selectedKey?: number | string | null;
onRowClick?: (row: T) => void;
rowMenu?: (row: T) => ReactNode;
empty?: ReactNode;
}) => {
const template = columns.map((c) => c.width).join(' ') + (rowMenu ? ' 40px' : '');
if (!rows.length && empty) return <>{empty}</>;
return (
<div className="h-full overflow-auto">
<div
className="sticky top-0 z-10 grid border-b border-border bg-background/95 backdrop-blur"
style={{ gridTemplateColumns: template }}
>
{columns.map((col) => (
<div
key={col.key}
className={`overflow-hidden px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground ${
col.align === 'right' ? 'text-right' : col.align === 'center' ? 'text-center' : ''
}`}
>
<span className="truncate">{col.label}</span>
</div>
))}
{rowMenu && <div />}
</div>
<div>
{rows.map((row) => {
const key = rowKey(row);
const isSelected = selectedKey != null && key === selectedKey;
return (
<div
key={key}
onClick={() => onRowClick?.(row)}
className={`grid items-center border-b border-border/40 text-xs transition-colors ${
onRowClick ? 'cursor-pointer' : ''
} ${isSelected ? 'bg-primary/10' : 'hover:bg-muted/50'}`}
style={{ gridTemplateColumns: template }}
>
{columns.map((col) => (
<div
key={col.key}
className={`min-w-0 overflow-hidden px-3 py-2 ${
col.align === 'right' ? 'text-right tabular-nums' : col.align === 'center' ? 'text-center' : ''
}`}
>
{col.render(row)}
</div>
))}
{rowMenu && (
<div className="flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
{rowMenu(row)}
</div>
)}
</div>
);
})}
</div>
</div>
);
};
/** Label/value row for the detail panes. */
export const Field = ({ label, children }: { label: string; children: ReactNode }) => (
<div className="flex items-baseline justify-between gap-3 py-1 text-xs">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="min-w-0 truncate text-right">{children}</span>
</div>
);
export const SectionShell = ({
toolbar,
children,
footer,
}: {
toolbar?: ReactNode;
children: ReactNode;
footer?: ReactNode;
}) => (
<div className="flex h-full min-h-0 flex-col">
{toolbar && (
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2">{toolbar}</div>
)}
<div className="min-h-0 flex-1">{children}</div>
{footer}
</div>
);
@@ -0,0 +1,152 @@
import type { Currency, Money } from './shared';
// Presentation helpers. The money one is the load-bearing piece: InvoiceShelf stores minor units as
// integers and describes how to render them in the company's `current_company_currency` record, so the
// separators, the precision and the symbol side all come from data rather than from the browser locale.
// Intl.NumberFormat is deliberately NOT used — it would apply the *viewer's* locale to the *company's*
// currency and quietly render "1,234.56 €" for an install configured the other way round.
/**
* Coerce an API money field to a number of minor units.
*
* Upstream is genuinely inconsistent about the JSON type here — `invoice.total` arrives as the number
* 450000 while `customer.due_amount` arrives as the string "1830000" — so every read goes through this
* rather than trusting either. Non-finite input collapses to 0 so a missing total renders as "0,00 €"
* instead of "NaN".
*/
export const toMinor = (value: Money): number => {
if (value == null) return 0;
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : 0;
};
const groupDigits = (digits: string, separator: string): string => digits.replace(/\B(?=(\d{3})+(?!\d))/g, separator);
/**
* The minor-unit divisor.
*
* Deliberately the constant 100 and NOT 10**precision, because that is what upstream does — its
* `utils/format-money.ts` opens with `amount = amountInCents / 100` and then uses `precision` only to
* decide how many decimal digits to *print*. On a 3-decimal currency the two rules disagree, and matching
* InvoiceShelf's own arithmetic matters more here than being independently right: a figure that differs
* from the one its web UI shows for the same invoice would read as a bug in this screen.
*/
const MINOR_PER_MAJOR = 100;
/**
* Render minor units the way InvoiceShelf renders them.
*
* Ported from `resources/scripts/utils/format-money.ts` and matched on the details that are visible:
* there is always a single space between symbol and number, the minus sign sits inside the number rather
* than before the symbol (so "€ -1.234,56", not "-€ 1.234,56"), and `precision: 0` prints no decimal part
* at all rather than a bare separator.
*/
export const formatMoney = (value: Money, currency?: Currency | null): string => {
let precision = Math.abs(Number(currency?.precision ?? 2));
if (!Number.isFinite(precision)) precision = 2;
const amount = toMinor(value) / MINOR_PER_MAJOR;
const negativeSign = amount < 0 ? '-' : '';
const abs = Math.abs(amount);
const thousands = currency?.thousand_separator ?? ',';
const decimal = currency?.decimal_separator ?? '.';
const symbol = currency?.symbol ?? '';
const fixed = abs.toFixed(precision);
const [whole = '0', frac = ''] = fixed.split('.');
const combined = negativeSign + groupDigits(whole, thousands) + (precision ? decimal + frac : '');
if (!symbol) return combined;
// `swap_currency_symbol` arrives as a boolean from the API and as 0/1 from a settings write.
return currency?.swap_currency_symbol ? `${combined} ${symbol}` : `${symbol} ${combined}`;
};
/** Bare number, no symbol — for chart axes and form inputs where the symbol lives elsewhere. */
export const formatAmount = (value: Money, currency?: Currency | null): string =>
formatMoney(value, currency ? { ...currency, symbol: '' } : null);
/** Minor units → the major-unit number an editable input holds (450000 → 4500). */
export const toMajor = (value: Money): number => toMinor(value) / MINOR_PER_MAJOR;
/** The inverse, for writes. Rounds because floats reaching here have been through an <input>. */
export const fromMajor = (value: number | string): number => {
const n = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
return Number.isFinite(n) ? Math.round(n * MINOR_PER_MAJOR) : 0;
};
/** Strips symbol and separators back to minor units — the paste-into-a-money-field path. */
export const parseMoneyMinor = (text: string, currency?: Currency | null): number => {
const thousands = currency?.thousand_separator ?? ',';
const decimal = currency?.decimal_separator ?? '.';
const cleaned = text
.replace(currency?.symbol ?? '', '')
.split(thousands)
.join('')
.replace(decimal, '.')
.replace(/[^\d.-]/g, '');
const n = Number(cleaned);
return Number.isFinite(n) ? Math.round(n * MINOR_PER_MAJOR) : 0;
};
// ── dates ────────────────────────────────────────────────────────────────────────────────────────
/**
* Parse the several date shapes the API mixes: "2026-06-21", "2026-06-21 00:00:00" and full ISO.
* The space form is not valid ISO-8601, so it is normalised before Date sees it.
*/
const parseDate = (value: string | null | undefined): Date | null => {
if (!value) return null;
const normalised = value.includes('T') ? value : value.replace(' ', 'T');
const d = new Date(normalised);
return Number.isNaN(d.getTime()) ? null : d;
};
/**
* Prefer upstream's own `formatted_*` field when the caller has one: it already reflects the company's
* configured date format (dd/mm/yyyy here), which we cannot reconstruct from the raw value alone.
*/
export const formatDate = (raw: string | null | undefined, preformatted?: string | null): string => {
if (preformatted) return preformatted;
const d = parseDate(raw);
if (!d) return '—';
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()}`;
};
/** ISO yyyy-mm-dd, the form every write endpoint expects and `<input type="date">` speaks. */
export const toInputDate = (raw: string | null | undefined): string => {
const d = parseDate(raw);
if (!d) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
};
/** Whole days until `raw`; negative once past. Used for the overdue hint next to a due date. */
export const daysUntil = (raw: string | null | undefined): number | null => {
const d = parseDate(raw);
if (!d) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
d.setHours(0, 0, 0, 0);
return Math.round((d.getTime() - today.getTime()) / 86_400_000);
};
export const relativeDue = (raw: string | null | undefined): string => {
const days = daysUntil(raw);
if (days == null) return '';
if (days === 0) return 'due today';
if (days > 0) return days === 1 ? 'due tomorrow' : `due in ${days} days`;
const overdue = Math.abs(days);
return overdue === 1 ? '1 day overdue' : `${overdue} days overdue`;
};
/** Strip the HTML upstream stores in note fields, for one-line previews. */
export const stripHtml = (html: string | null | undefined): string =>
(html ?? '')
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/\s+/g, ' ')
.trim();
@@ -0,0 +1,25 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { LayoutGrid, PanelLeft } from 'lucide-react';
import { InvoicesNav } from './InvoicesNav';
import { InvoicesView } from './InvoicesView';
import { InvoicesViewHeader } from './InvoicesViewHeader';
export { InvoicesNav, InvoicesView };
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'invoices-nav',
name: 'Invoices',
icon: PanelLeft,
component: InvoicesNav,
availableOnPanel: false,
},
{
key: 'invoices-view',
name: 'Invoices',
icon: LayoutGrid,
component: InvoicesView,
header: InvoicesViewHeader,
availableOnPanel: false,
},
];
@@ -0,0 +1,433 @@
// Domain vocabulary for /invoices — the Officer front end over the officer-invoiceshelf sidecar.
//
// Types are written against the LIVE 2.4.2 instance's actual responses, not the 3.0.0-alpha.1 checkout in
// _references. Two things about those responses drive most of the odd choices here:
//
// 1. Money is an integer count of minor units (cents), and the API is inconsistent about whether it hands
// it over as a number or a decimal string — `invoice.total` is 450000 but `customer.due_amount` is
// "1830000". Hence the `Money` alias and `toMinor()` in format.ts. Never do arithmetic on these
// without going through it.
// 2. Every record embeds its whole company, including the role/ability list — a single invoice row carries
// ~40KB of super-admin abilities nobody asked for. That is upstream's shape and we cannot change it,
// so the types below deliberately declare `company?: unknown`: naming those fields would invite code
// that reads them, and the interesting data is never in there.
export const INVOICES_SECTIONS = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'invoices', label: 'Invoices' },
{ id: 'estimates', label: 'Estimates' },
{ id: 'recurring', label: 'Recurring' },
{ id: 'payments', label: 'Payments' },
{ id: 'expenses', label: 'Expenses' },
{ id: 'customers', label: 'Customers' },
{ id: 'items', label: 'Items' },
{ id: 'reports', label: 'Reports' },
] as const;
export type InvoicesSectionId = (typeof INVOICES_SECTIONS)[number]['id'];
export const DEFAULT_INVOICES_SECTION: InvoicesSectionId = 'dashboard';
export const isInvoicesSection = (value: string | undefined): value is InvoicesSectionId =>
INVOICES_SECTIONS.some((s) => s.id === value);
export const invoicesSectionPath = (id: InvoicesSectionId) => `/invoices/${id}`;
/** The sidecar resource slug behind each list section. `dashboard` and `reports` have none. */
export const SECTION_RESOURCE = {
invoices: 'invoices',
estimates: 'estimates',
recurring: 'recurring-invoices',
payments: 'payments',
expenses: 'expenses',
customers: 'customers',
items: 'items',
} as const satisfies Partial<Record<InvoicesSectionId, string>>;
export type ListSectionId = keyof typeof SECTION_RESOURCE;
export const isListSection = (id: InvoicesSectionId): id is ListSectionId => id in SECTION_RESOURCE;
// ── money ────────────────────────────────────────────────────────────────────────────────────────
/** An integer count of minor units, which upstream may hand over as either a number or a decimal string. */
export type Money = number | string | null | undefined;
export type Currency = {
id: number;
name: string;
code: string;
symbol: string;
precision: number | string;
thousand_separator: string;
decimal_separator: string;
/** true → "1.234,56 €"; false → "€1.234,56". */
swap_currency_symbol: boolean | number;
};
// ── status vocabulary ────────────────────────────────────────────────────────────────────────────
// The real persisted statuses. DUE and OVERDUE are NOT among them — upstream computes those for filters
// and badges (`overdue` is a derived boolean on the invoice, `DUE` is a filter-only token), so they appear
// in the tone map and the filter vocabulary but never as a value you can write back.
export type InvoiceStatus = 'DRAFT' | 'SENT' | 'VIEWED' | 'COMPLETED';
export type PaidStatus = 'UNPAID' | 'PARTIALLY_PAID' | 'PAID';
export type EstimateStatus = 'DRAFT' | 'SENT' | 'VIEWED' | 'EXPIRED' | 'ACCEPTED' | 'REJECTED';
export type RecurringStatus = 'ACTIVE' | 'ON_HOLD' | 'COMPLETED';
/**
* Tailwind classes per status token, matched hue-for-hue to upstream's badge components
* (InvoiceStatusBadge / EstimateStatusBadge / PaidStatusBadge / RecurringInvoiceStatusBadge).
*
* The distinctions are finer than they look and are worth keeping: VIEWED is indigo where SENT is blue,
* UNPAID is orange where OVERDUE is red, and PARTIALLY_PAID is cyan — so a glance down the column tells
* you which of two similar states a row is in without reading the text.
*/
export const STATUS_TONE: Record<string, string> = {
DRAFT: 'bg-muted text-muted-foreground ring-border',
SENT: 'bg-blue-500/10 text-blue-600 ring-blue-500/20 dark:text-blue-400',
VIEWED: 'bg-indigo-500/10 text-indigo-600 ring-indigo-500/20 dark:text-indigo-400',
COMPLETED: 'bg-green-500/10 text-green-600 ring-green-500/20 dark:text-green-400',
DUE: 'bg-amber-500/10 text-amber-600 ring-amber-500/20 dark:text-amber-400',
OVERDUE: 'bg-red-500/10 text-red-600 ring-red-500/20 dark:text-red-400',
UNPAID: 'bg-orange-500/10 text-orange-600 ring-orange-500/20 dark:text-orange-400',
PARTIALLY_PAID: 'bg-cyan-500/10 text-cyan-600 ring-cyan-500/20 dark:text-cyan-400',
PAID: 'bg-emerald-500/10 text-emerald-600 ring-emerald-500/20 dark:text-emerald-400',
EXPIRED: 'bg-red-500/10 text-red-600 ring-red-500/20 dark:text-red-400',
ACCEPTED: 'bg-emerald-500/10 text-emerald-600 ring-emerald-500/20 dark:text-emerald-400',
REJECTED: 'bg-rose-500/10 text-rose-600 ring-rose-500/20 dark:text-rose-400',
ACTIVE: 'bg-blue-500/10 text-blue-600 ring-blue-500/20 dark:text-blue-400',
ON_HOLD: 'bg-amber-500/10 text-amber-600 ring-amber-500/20 dark:text-amber-400',
};
/** Status filter vocabulary per list, in upstream's own order. */
export const INVOICE_STATUS_FILTERS = ['DRAFT', 'DUE', 'SENT', 'VIEWED', 'COMPLETED'] as const;
export const INVOICE_PAID_FILTERS = ['UNPAID', 'PAID', 'PARTIALLY_PAID'] as const;
export const ESTIMATE_STATUS_FILTERS = ['DRAFT', 'SENT', 'VIEWED', 'EXPIRED', 'ACCEPTED', 'REJECTED'] as const;
export const RECURRING_STATUS_FILTERS = ['ACTIVE', 'ON_HOLD', 'COMPLETED'] as const;
/** Tabs above each list — `null` is "All". Mirrors upstream's tab sets exactly. */
export const INVOICE_TABS = [
{ id: null, label: 'All' },
{ id: 'DRAFT', label: 'Draft' },
{ id: 'SENT', label: 'Sent' },
{ id: 'DUE', label: 'Due' },
] as const;
export const ESTIMATE_TABS = [
{ id: null, label: 'All' },
{ id: 'DRAFT', label: 'Draft' },
{ id: 'SENT', label: 'Sent' },
] as const;
export const RECURRING_TABS = [
{ id: null, label: 'All' },
{ id: 'ACTIVE', label: 'Active' },
{ id: 'ON_HOLD', label: 'On hold' },
] as const;
/** Upstream paginates every list at 10. Matched so page numbers line up between the two UIs. */
export const PAGE_SIZE = 10;
export const humanStatus = (value: string | null | undefined): string =>
(value ?? '')
.replace(/_/g, ' ')
.toLowerCase()
.replace(/^./, (c) => c.toUpperCase());
// ── records ──────────────────────────────────────────────────────────────────────────────────────
export type Address = {
id: number;
name: string | null;
address_street_1: string | null;
address_street_2: string | null;
city: string | null;
state: string | null;
zip: string | null;
phone: string | null;
country_id: number | null;
country?: { id: number; code: string; name: string } | null;
};
export type Customer = {
id: number;
name: string;
email: string | null;
phone: string | null;
contact_name: string | null;
company_name: string | null;
website: string | null;
prefix: string | null;
tax_id: string | null;
currency_id: number | null;
enable_portal: boolean;
due_amount: Money;
formatted_created_at: string | null;
created_at: string | null;
billing?: Address | null;
shipping?: Address | null;
// Deliberately unnamed — see the header note about embedded company payloads.
company?: unknown;
};
export type Unit = { id: number; name: string };
export type TaxType = { id: number; name: string; percent: number | string; compound_tax: boolean | number };
export type Category = { id: number; name: string };
export type PaymentMethod = { id: number; name: string };
export type Item = {
id: number;
name: string;
description: string | null;
price: Money;
unit_id: number | null;
currency_id: number | null;
tax_per_item: boolean;
formatted_created_at: string | null;
unit?: Unit | null;
};
/** A line on an invoice or estimate. Same shape either way. */
export type DocumentItem = {
id: number;
item_id: number | null;
name: string;
description: string | null;
quantity: number | string;
price: Money;
discount_type: 'fixed' | 'percentage';
discount: number | string;
discount_val: Money;
tax: Money;
total: Money;
unit_name: string | null;
taxes?: DocumentTax[];
};
export type DocumentTax = {
id?: number;
tax_type_id: number | null;
name?: string;
percent: number | string;
amount: Money;
compound_tax?: boolean | number;
};
/** Shared spine of invoices, estimates and recurring invoices — they differ only at the edges. */
type DocumentBase = {
id: number;
status: string;
notes: string | null;
reference_number: string | null;
customer_id: number;
currency_id: number;
unique_hash: string;
template_name: string | null;
sub_total: Money;
total: Money;
tax: Money;
discount: number | string;
discount_type: 'fixed' | 'percentage';
discount_val: Money;
tax_per_item: 'YES' | 'NO';
discount_per_item: 'YES' | 'NO';
sent: boolean;
viewed: boolean;
items: DocumentItem[];
taxes?: DocumentTax[];
customer?: Customer | null;
formatted_created_at: string | null;
};
export type Invoice = DocumentBase & {
invoice_number: string;
invoice_date: string;
due_date: string | null;
paid_status: PaidStatus;
due_amount: Money;
overdue: boolean;
allow_edit: boolean;
invoice_pdf_url: string | null;
formatted_invoice_date: string | null;
formatted_due_date: string | null;
recurring_invoice_id: number | null;
};
export type Estimate = DocumentBase & {
estimate_number: string;
estimate_date: string;
expiry_date: string | null;
estimate_pdf_url: string | null;
formatted_estimate_date: string | null;
formatted_expiry_date: string | null;
/** Set once converted; the convert action is refused a second time. */
invoice_id?: number | null;
};
export type RecurringInvoice = DocumentBase & {
starts_at: string | null;
next_invoice_at: string | null;
/** A five-field cron expression, not a keyword — see FREQUENCY_PRESETS. */
frequency: string | null;
limit_by: 'NONE' | 'COUNT' | 'DATE' | string | null;
limit_count: number | null;
limit_date: string | null;
send_automatically: boolean;
due_amount: Money;
formatted_starts_at: string | null;
formatted_next_invoice_at: string | null;
formatted_limit_date: string | null;
/** Present only once the schedule has generated something. */
invoices?: Invoice[];
};
/**
* A recurring invoice's `frequency` is a raw cron expression, which is unreadable in a table column.
*
* These are upstream's own presets, cron string for cron string (`recurring-invoices/store.ts` →
* `initFrequencies`), in its order — common business intervals first, then the short ones. A schedule
* created outside these presets keeps a cron expression we do not recognise, and `frequencyLabel` shows
* it verbatim rather than guessing.
*/
export const FREQUENCY_PRESETS = [
{ cron: '0 0 * * 0', label: 'Every week' },
{ cron: '0 0 */14 * *', label: 'Every 2 weeks' },
{ cron: '0 0 1 * *', label: 'Every month' },
{ cron: '0 0 1 */2 *', label: 'Every 2 months' },
{ cron: '0 0 1 */3 *', label: 'Every quarter' },
{ cron: '0 0 1 */6 *', label: 'Every 6 months' },
{ cron: '0 0 1 1 *', label: 'Every year' },
{ cron: '0 0 * * *', label: 'Every day' },
{ cron: '0 5 */15 * *', label: 'Every 15 days' },
{ cron: '0 * * * *', label: 'Every hour' },
{ cron: '* * * * *', label: 'Every minute' },
] as const;
export const frequencyLabel = (cron: string | null | undefined): string =>
FREQUENCY_PRESETS.find((f) => f.cron === cron)?.label ?? cron ?? '—';
export type Payment = {
id: number;
payment_number: string;
payment_date: string;
amount: Money;
notes: string | null;
unique_hash: string;
invoice_id: number | null;
customer_id: number;
payment_method_id: number | null;
currency_id: number;
transaction_id: string | null;
payment_pdf_url: string | null;
formatted_payment_date: string | null;
formatted_created_at: string | null;
customer?: Customer | null;
invoice?: Invoice | null;
payment_method?: PaymentMethod | null;
};
export type Expense = {
id: number;
expense_date: string;
expense_number: string | null;
amount: Money;
notes: string | null;
expense_category_id: number | null;
payment_method_id: number | null;
customer_id: number | null;
currency_id: number;
/** Absolute, rendered from the instance's APP_URL — usable in a browser, not from this process. */
attachment_receipt_url: string | null;
formatted_expense_date: string | null;
formatted_created_at: string | null;
/** `expense_category`, not `category` — the column is `expense_category_id` but the relation keeps the prefix. */
expense_category?: Category | null;
customer?: Customer | null;
payment_method?: PaymentMethod | null;
};
// ── envelopes ────────────────────────────────────────────────────────────────────────────────────
export type PageMeta = {
current_page: number;
last_page: number;
per_page: number;
from: number | null;
to: number | null;
total: number;
};
export type Paginated<T> = { data: T[]; meta?: PageMeta };
export type Summary = {
me: { id: number; name: string; email: string } | null;
company: { id: number; name: string; unique_hash: string; address?: Address | null; logo?: string | null } | null;
currency: Currency | null;
settings: Record<string, string> | null;
dashboard: Dashboard | null;
};
export type Dashboard = {
total_amount_due: Money;
total_customer_count: number;
total_invoice_count: number;
total_estimate_count: number;
total_sales: Money;
total_receipts: Money;
total_expenses: Money;
total_net_income: Money;
recent_due_invoices: Invoice[];
recent_estimates: Estimate[];
chart_data: {
months: string[];
invoice_totals: Money[];
expense_totals: Money[];
receipt_totals: Money[];
net_income_totals: Money[];
} | null;
};
/**
* `GET /customers/{id}/stats` — the customer plus a twelve-month chart.
*
* The chart keys are camelCase here (`invoiceTotals`) where the company dashboard's are snake_case
* (`invoice_totals`). That is upstream's inconsistency, not a transcription slip; the two shapes cannot share
* a type.
*/
export type CustomerStats = {
data?: Customer;
meta?: {
chartData?: {
months: string[];
invoiceTotals: Money[];
expenseTotals: Money[];
receiptTotals: Money[];
netProfits: Money[];
netProfit: Money;
salesTotal: Money;
totalReceipts: Money;
totalExpenses: Money;
};
};
};
export type Lookups = {
customers: Customer[];
items: Item[];
units: Unit[];
taxTypes: TaxType[];
categories: Category[];
paymentMethods: PaymentMethod[];
currencies: Currency[];
};
export const REPORT_KINDS = [
{ id: 'sales-customers', label: 'Sales by customer' },
{ id: 'sales-items', label: 'Sales by item' },
{ id: 'tax-summary', label: 'Tax summary' },
{ id: 'profit-loss', label: 'Profit & loss' },
{ id: 'expenses', label: 'Expenses' },
] as const;
export type ReportKind = (typeof REPORT_KINDS)[number]['id'];
@@ -0,0 +1,413 @@
import type { Currency, CustomerStats, Dashboard, Lookups, Paginated, ReportKind, Summary } from './shared';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
// Data layer for the /invoices panels. Everything goes through the /api/invoiceshelf auth proxy to the
// officer-invoiceshelf sidecar, which holds the InvoiceShelf URL, the Sanctum token and the company header.
//
// Unlike the Transmission panels this does NOT poll. InvoiceShelf is a bookkeeping app whose records only
// change when someone in this UI changes them — a background refetch would spend requests to re-render
// identical rows. Freshness comes from invalidating after writes instead, plus a refetch when the window
// regains focus in case the change was made in InvoiceShelf's own UI.
//
// Mutations invalidate the whole ['invoiceshelf'] prefix rather than patching caches. The totals are deeply
// entangled — recording a payment moves the invoice's due_amount AND its paid_status AND the customer's
// due_amount AND four dashboard figures — so a targeted patch would be subtly wrong far more often than it
// would be cheap.
const ROOT = 'invoiceshelf';
const BASE = '/invoiceshelf/_officer';
/** Records are stable between writes; this only bounds how long a remount reuses the cache. */
const STALE_MS = 30_000;
const EMPTY: never[] = [];
const qs = (params?: Record<string, string | number | undefined | null>): string => {
if (!params) return '';
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '') continue;
search.set(key, String(value));
}
const out = search.toString();
return out ? `?${out}` : '';
};
/**
* Pull a human message out of a thrown client error.
*
* useClient throws `{status, message}` where message is the raw response body, so the useful text is
* usually one JSON level down. Laravel reports validation failures as `{message, errors: {field: [msg]}}`
* and the field-level message is the specific one — "The due date must be a date after invoice date" beats
* "The given data was invalid."
*/
function errorMessage(err: unknown, fallback: string): string {
const raw = typeof err === 'object' && err !== null && 'message' in err ? String((err as Error).message) : '';
if (!raw) return fallback;
try {
const parsed = JSON.parse(raw) as { error?: string; message?: string; errors?: Record<string, string[]> };
const firstField = parsed.errors && Object.values(parsed.errors)[0]?.[0];
return firstField || parsed.error || parsed.message || fallback;
} catch {
return raw || fallback;
}
}
// ── company-wide context ─────────────────────────────────────────────────────────────────────────
/**
* The one call every panel needs: who we are, the company, its currency and the dashboard totals.
*
* The currency matters beyond the dashboard — it carries precision, separators and symbol placement, and
* every money value in the UI is unrenderable without it. Panels take it from here rather than each
* fetching it, so there is a single cache entry keeping them consistent.
*/
export function useSummary() {
const { get } = useClient();
const query = useQuery({
queryKey: [ROOT, 'summary'] as const,
queryFn: () => get<Summary>(`${BASE}/summary`),
staleTime: STALE_MS,
});
return {
summary: query.data ?? null,
currency: query.data?.currency ?? null,
company: query.data?.company ?? null,
dashboard: query.data?.dashboard ?? null,
isLoading: query.isLoading,
error: query.error,
};
}
/** Convenience for the many components that need only the currency to render money. */
export function useCurrency(): Currency | null {
return useSummary().currency;
}
/**
* Every select-box option in one call: customers, items, units, tax types, categories, payment methods,
* currencies. Held long because these are small, near-static reference lists that half the forms need.
*/
export function useLookups() {
const { get } = useClient();
const query = useQuery({
queryKey: [ROOT, 'lookups'] as const,
queryFn: () => get<Lookups>(`${BASE}/lookups`),
staleTime: 5 * 60_000,
});
const data = query.data;
return {
customers: data?.customers ?? EMPTY,
items: data?.items ?? EMPTY,
units: data?.units ?? EMPTY,
taxTypes: data?.taxTypes ?? EMPTY,
categories: data?.categories ?? EMPTY,
paymentMethods: data?.paymentMethods ?? EMPTY,
currencies: data?.currencies ?? EMPTY,
isLoading: query.isLoading,
};
}
export function useDashboard(): { dashboard: Dashboard | null; isLoading: boolean } {
const { dashboard, isLoading } = useSummary();
return { dashboard, isLoading };
}
// ── generic resource access ──────────────────────────────────────────────────────────────────────
export type ListParams = Record<string, string | number | undefined | null>;
/**
* A page of any allow-listed resource. `params` is passed to upstream verbatim, which is what makes one
* hook enough for seven list screens — InvoiceShelf's index endpoints share a filter vocabulary
* (`page`, `limit`, `search`, `customer_id`, `status`, `from_date`, `to_date`, `orderByField`, `orderBy`).
*
* `params` is spread into the query key, so changing a filter is a new cache entry rather than a refetch
* of the old one — paging back and forth is then free.
*/
export function useResourceList<T>(resource: string, params?: ListParams, enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [ROOT, resource, 'list', params ?? {}] as const,
queryFn: () => get<Paginated<T>>(`${BASE}/${resource}${qs(params)}`),
enabled,
staleTime: STALE_MS,
// Keeps the previous page on screen while the next one loads, instead of flashing an empty table.
placeholderData: (prev) => prev,
});
return {
rows: query.data?.data ?? (EMPTY as T[]),
meta: query.data?.meta ?? null,
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
};
}
/**
* One record by id. Upstream wraps singular reads in a `data` key for most resources but returns some bare,
* so the response is unwrapped defensively rather than assuming either.
*/
export function useResourceDetail<T>(resource: string, id: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [ROOT, resource, 'detail', id] as const,
queryFn: async () => {
const res = await get<{ data?: T } | T>(`${BASE}/${resource}/${id}`);
return (res && typeof res === 'object' && 'data' in res ? (res as { data: T }).data : (res as T)) ?? null;
},
enabled: id != null,
staleTime: STALE_MS,
});
return { record: query.data ?? null, isLoading: query.isLoading, error: query.error };
}
/**
* Create / update / delete / bulk-delete for one resource.
*
* All four invalidate the entire ['invoiceshelf'] prefix on success — see the header note on why targeted
* cache patching is the wrong trade here.
*/
export function useResourceMutations(resource: string, label = resource) {
const { post, put, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: [ROOT] });
const create = useMutation({
mutationFn: (payload: unknown) => post<{ data?: unknown }>(`${BASE}/${resource}`, payload),
onSuccess: () => {
invalidate();
toast.success(`${singular(label)} created`);
},
onError: (err) => toast.error(errorMessage(err, `Could not create ${singular(label).toLowerCase()}`)),
});
const update = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: unknown }) =>
put<{ data?: unknown }>(`${BASE}/${resource}/${id}`, payload),
onSuccess: () => {
invalidate();
toast.success(`${singular(label)} saved`);
},
onError: (err) => toast.error(errorMessage(err, `Could not save ${singular(label).toLowerCase()}`)),
});
const remove = useMutation({
mutationFn: (id: number) => del<unknown>(`${BASE}/${resource}/${id}`),
onSuccess: () => {
invalidate();
toast.success(`${singular(label)} deleted`);
},
onError: (err) => toast.error(errorMessage(err, `Could not delete ${singular(label).toLowerCase()}`)),
});
const removeMany = useMutation({
mutationFn: (ids: number[]) => post<unknown>(`${BASE}/${resource}/delete`, { ids }),
onSuccess: (_data, ids) => {
invalidate();
toast.success(`${ids.length} ${ids.length === 1 ? singular(label).toLowerCase() : label} deleted`);
},
onError: (err) => toast.error(errorMessage(err, `Could not delete ${label}`)),
});
return { create, update, remove, removeMany };
}
const singular = (label: string): string => {
const base = label.endsWith('ies') ? `${label.slice(0, -3)}y` : label.endsWith('s') ? label.slice(0, -1) : label;
return base.charAt(0).toUpperCase() + base.slice(1);
};
/**
* The per-document verbs: status, clone, send, convert-to-invoice, duplicate.
*
* `send` genuinely emails the customer. It is reachable from here because the UI needs it, but nothing in
* this module calls it on its own — it must always be behind an explicit confirm step, never a side effect
* of saving or of any other action.
*/
export function useDocumentAction(resource: string) {
const { post } = useClient();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, action, payload }: { id: number; action: string; payload?: unknown }) =>
post<unknown>(`${BASE}/${resource}/${id}/${action}`, payload ?? {}),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: [ROOT] });
toast.success(ACTION_TOAST[vars.action] ?? 'Done');
},
onError: (err) => toast.error(errorMessage(err, 'Action failed')),
});
}
const ACTION_TOAST: Record<string, string> = {
status: 'Status updated',
clone: 'Copy created',
send: 'Sent',
'convert-to-invoice': 'Converted to invoice',
duplicate: 'Duplicated',
};
/** The number the next document of this kind would take, for the create form's placeholder. */
export function useNextNumber(key: 'invoice' | 'estimate' | 'payment', enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [ROOT, 'next-number', key] as const,
queryFn: () => get<{ nextNumber?: string; nextSequenceNumber?: number }>(`${BASE}/next-number?key=${key}`),
enabled,
// Never cached: two create forms opened in sequence must not both claim the same number.
staleTime: 0,
gcTime: 0,
});
return query.data?.nextNumber ?? null;
}
/**
* The PDF templates a document can be rendered with. `template_name` is a *required* field on every
* invoice/estimate write, so the create form needs this before it can save anything — it is not decoration.
*/
export function useTemplates(resource: 'invoices' | 'estimates') {
const { get } = useClient();
const query = useQuery({
queryKey: [ROOT, resource, 'templates'] as const,
queryFn: () => get<{ [key: string]: unknown }>(`${BASE}/${resource}/templates`),
staleTime: 60 * 60_000,
});
// Upstream keys the payload by resource — `{invoiceTemplates: […]}` / `{estimateTemplates: […]}` — so it
// is read positionally rather than by a name that differs per endpoint.
const list = Object.values(query.data ?? {}).find(Array.isArray) as { name: string; path?: string }[] | undefined;
return list ?? (EMPTY as { name: string; path?: string }[]);
}
export function useCustomerStats(id: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [ROOT, 'customers', 'stats', id] as const,
queryFn: () => get<CustomerStats>(`${BASE}/customers/${id}/stats`),
enabled: id != null,
staleTime: STALE_MS,
});
return { stats: query.data ?? null, isLoading: query.isLoading };
}
// ── PDFs ─────────────────────────────────────────────────────────────────────────────────────────
type PdfState = { url: string | null; isLoading: boolean; error: string | null };
/**
* Fetch a PDF and expose it as an object URL an <iframe> can render.
*
* It has to be done this way round: the sidecar sits behind Officer's bearer auth, and a naked
* `<iframe src="/api/...">` cannot carry an Authorization header — it would fetch unauthenticated and
* render a 401 body. So the bytes are pulled with the authenticated client and handed to the iframe as a
* blob: URL instead.
*
* The previous URL is revoked whenever the target changes and on unmount; without that, every document a
* user clicks through leaks its PDF into memory until the tab closes.
*/
export function usePdfUrl(resource: string, id: number | null, enabled = true) {
const { getBlob } = useClient();
const [state, setState] = useState<PdfState>({ url: null, isLoading: false, error: null });
useEffect(() => {
if (id == null || !enabled) {
setState({ url: null, isLoading: false, error: null });
return;
}
let objectUrl: string | null = null;
// Guards against an out-of-order response overwriting a newer one when the user clicks quickly.
let cancelled = false;
setState({ url: null, isLoading: true, error: null });
getBlob(`${BASE}/${resource}/${id}/pdf`)
.then((blob) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setState({ url: objectUrl, isLoading: false, error: null });
})
.catch((err: unknown) => {
if (cancelled) return;
setState({ url: null, isLoading: false, error: errorMessage(err, 'Could not load PDF') });
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
// getBlob is recreated on every useClient() call, so depending on it would refetch forever.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resource, id, enabled]);
return state;
}
/** Same trick for the report PDFs, which are parameterised by date range rather than by id. */
export function useReportUrl(kind: ReportKind | null, params: ListParams, enabled = true) {
const { getBlob } = useClient();
const [state, setState] = useState<PdfState>({ url: null, isLoading: false, error: null });
const key = `${kind ?? ''}${qs(params)}`;
useEffect(() => {
if (!kind || !enabled) {
setState({ url: null, isLoading: false, error: null });
return;
}
let objectUrl: string | null = null;
let cancelled = false;
setState({ url: null, isLoading: true, error: null });
getBlob(`${BASE}/reports/${kind}${qs(params)}`)
.then((blob) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setState({ url: objectUrl, isLoading: false, error: null });
})
.catch((err: unknown) => {
if (cancelled) return;
setState({ url: null, isLoading: false, error: errorMessage(err, 'Could not build report') });
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
// `key` collapses kind+params into one comparable string; see the note in usePdfUrl about getBlob.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, enabled]);
return state;
}
/** Save a fetched PDF to disk under a readable name. */
export function downloadPdf(url: string, filename: string) {
const a = document.createElement('a');
a.href = url;
a.download = filename.endsWith('.pdf') ? filename : `${filename}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
}
export { errorMessage };
@@ -0,0 +1,94 @@
import { useCallback, useMemo } from 'react';
import { useParams, useSearchParams } from 'react-router';
import { DEFAULT_INVOICES_SECTION, isInvoicesSection, type InvoicesSectionId } from './shared';
// The URL names the open section and the selected record — not a panel channel. See docs/navigation-audit.md.
// InvoicesScreen redirects anything unrecognised, so the fallback here only covers the instant before that
// lands.
export function useInvoicesSection(): InvoicesSectionId {
const { section } = useParams();
return isInvoicesSection(section) ? section : DEFAULT_INVOICES_SECTION;
}
export type ListFilters = {
/** The record whose detail pane is open. `?selected=` because the list stays on screen beside it. */
selected: number | null;
search: string;
status: string | null;
customerId: number | null;
fromDate: string | null;
toDate: string | null;
page: number;
isFiltered: boolean;
};
/**
* Read/write the list query string.
*
* Every setter drops `page` back to 1 — changing a filter while on page 4 of the old result set otherwise
* lands you on a page that may not exist, and upstream answers that with an empty array rather than an
* error, which reads as "no results" for a filter that has plenty.
*
* Selection is `replace: true` so clicking through a list does not bury the previous screen under a dozen
* history entries; filter changes push normally, because backing out of a filter is a thing people want.
*/
export function useListFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const filters = useMemo<ListFilters>(() => {
const num = (key: string) => {
const raw = searchParams.get(key);
const n = raw == null ? NaN : Number(raw);
return Number.isInteger(n) && n > 0 ? n : null;
};
const status = searchParams.get('status');
const search = searchParams.get('q') ?? '';
const customerId = num('customer');
const fromDate = searchParams.get('from');
const toDate = searchParams.get('to');
return {
selected: num('selected'),
search,
status,
customerId,
fromDate,
toDate,
page: num('page') ?? 1,
isFiltered: Boolean(search || status || customerId || fromDate || toDate),
};
}, [searchParams]);
const patch = useCallback(
(changes: Record<string, string | number | null>, opts?: { replace?: boolean; keepPage?: boolean }) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === '') next.delete(key);
else next.set(key, String(value));
}
if (!opts?.keepPage && !('page' in changes)) next.delete('page');
return next;
},
{ replace: opts?.replace ?? false },
);
},
[setSearchParams],
);
const select = useCallback(
(id: number | null) => patch({ selected: id }, { replace: true, keepPage: true }),
[patch],
);
const setPage = useCallback((page: number) => patch({ page: page <= 1 ? null : page }, { keepPage: true }), [patch]);
const clearFilters = useCallback(
() => patch({ q: null, status: null, customer: null, from: null, to: null }),
[patch],
);
return { filters, patch, select, setPage, clearFilters };
}
+5
View File
@@ -38,6 +38,11 @@ export {
isTransmissionSection,
} from './apps/Transmission/shared';
export type { TransmissionSectionId } from './apps/Transmission/shared';
// Same for /invoices.
export { DEFAULT_INVOICES_SECTION, invoicesSectionPath, isInvoicesSection } from './apps/Invoices/shared';
export type { InvoicesSectionId } from './apps/Invoices/shared';
export {
useFilesAPI,
useTasks,