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:
@@ -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(/ /g, ' ')
|
||||
.replace(/&/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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user