From 5ee56e736b80fa93901da4d9782759d5996d651b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 31 Jul 2026 06:44:02 +0000 Subject: [PATCH] add the invoices ui over the invoiceshelf sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/apps/officer-web/App.tsx | 2 + .../Dashboard/Invoices/InvoicesScreen.tsx | 58 ++ .../Dashboard/Invoices/defaultLayout.ts | 11 + .../Screens/Dashboard/Invoices/index.tsx | 1 + .../Screens/Dashboard/Layout/Dock.tsx | 2 + .../officer-web/Screens/Dashboard/index.tsx | 1 + src/apps/officer-web/state/usePageTitle.ts | 1 + .../src/AppRegistry/AppRegistry.tsx | 2 + .../src/apps/Invoices/ConfirmDialog.tsx | 50 ++ .../src/apps/Invoices/CustomersListView.tsx | 346 +++++++++ .../src/apps/Invoices/DashboardView.tsx | 277 +++++++ .../src/apps/Invoices/DocumentEditor.tsx | 732 +++++++++++++++++ .../src/apps/Invoices/EstimatesListView.tsx | 407 ++++++++++ .../src/apps/Invoices/ExpensesListView.tsx | 313 ++++++++ .../src/apps/Invoices/InvoicesListView.tsx | 400 ++++++++++ .../src/apps/Invoices/InvoicesNav.tsx | 121 +++ .../src/apps/Invoices/InvoicesView.tsx | 133 ++++ .../src/apps/Invoices/InvoicesViewHeader.tsx | 36 + .../src/apps/Invoices/ItemsListView.tsx | 262 +++++++ .../src/apps/Invoices/PaymentsListView.tsx | 302 +++++++ .../officerdev/src/apps/Invoices/PdfPane.tsx | 91 +++ .../src/apps/Invoices/RecordEditors.tsx | 734 ++++++++++++++++++ .../src/apps/Invoices/RecurringListView.tsx | 322 ++++++++ .../src/apps/Invoices/ReportsView.tsx | 211 +++++ .../src/apps/Invoices/SendDocumentDialog.tsx | 147 ++++ .../src/apps/Invoices/components.tsx | 281 +++++++ .../officerdev/src/apps/Invoices/format.ts | 152 ++++ .../officerdev/src/apps/Invoices/index.ts | 25 + .../officerdev/src/apps/Invoices/shared.ts | 433 +++++++++++ .../src/apps/Invoices/useInvoiceShelfData.ts | 413 ++++++++++ .../src/apps/Invoices/useInvoicesSection.ts | 94 +++ src/workspaces/officerdev/src/index.ts | 5 + 32 files changed, 6365 insertions(+) create mode 100644 src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Invoices/defaultLayout.ts create mode 100644 src/apps/officer-web/Screens/Dashboard/Invoices/index.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/ConfirmDialog.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/CustomersListView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/DashboardView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/DocumentEditor.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/EstimatesListView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/ExpensesListView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/InvoicesListView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/InvoicesNav.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/InvoicesView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/InvoicesViewHeader.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/ItemsListView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/PaymentsListView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/PdfPane.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/RecordEditors.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/RecurringListView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/ReportsView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/SendDocumentDialog.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/components.tsx create mode 100644 src/workspaces/officerdev/src/apps/Invoices/format.ts create mode 100644 src/workspaces/officerdev/src/apps/Invoices/index.ts create mode 100644 src/workspaces/officerdev/src/apps/Invoices/shared.ts create mode 100644 src/workspaces/officerdev/src/apps/Invoices/useInvoiceShelfData.ts create mode 100644 src/workspaces/officerdev/src/apps/Invoices/useInvoicesSection.ts diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 6fac29ea..a7ea42db 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -46,6 +46,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx new file mode 100644 index 00000000..cc65cf82 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx @@ -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(['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('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 ; + } + + return ( +
+ +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Invoices/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Invoices/defaultLayout.ts new file mode 100644 index 00000000..e2a68d91 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Invoices/defaultLayout.ts @@ -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 }, + ], +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Invoices/index.tsx b/src/apps/officer-web/Screens/Dashboard/Invoices/index.tsx new file mode 100644 index 00000000..3769a7a2 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Invoices/index.tsx @@ -0,0 +1 @@ +export * from './InvoicesScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index c58b9613..f93b3c43 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -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' }, diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 523933d2..85473265 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -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'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 69539045..3cc30b3f 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -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' }, diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index cc8d6d08..9ee80212 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -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, ]; diff --git a/src/workspaces/officerdev/src/apps/Invoices/ConfirmDialog.tsx b/src/workspaces/officerdev/src/apps/Invoices/ConfirmDialog.tsx new file mode 100644 index 00000000..c8ddbc70 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Invoices/ConfirmDialog.tsx @@ -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 ( + !open && onClose()}> + + + {state.title} + {state.description} + + + Cancel + { + state.onConfirm(); + onClose(); + }} + className={state.destructive ? 'bg-red-600 text-white hover:bg-red-700' : ''} + > + {state.confirmLabel ?? 'Confirm'} + + + + + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Invoices/CustomersListView.tsx b/src/workspaces/officerdev/src/apps/Invoices/CustomersListView.tsx new file mode 100644 index 00000000..d00dd59c --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Invoices/CustomersListView.tsx @@ -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>(new Set()); + const [confirm, setConfirm] = useState(null); + + const { rows, meta, isLoading, error } = useResourceList('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[] = [ + { + key: 'check', + label: '', + width: '36px', + render: (row) => ( + e.stopPropagation()} className="flex items-center"> + toggleRow(row.id)} /> + + ), + }, + { + key: 'name', + label: 'Name', + width: 'minmax(150px, 2fr)', + render: (row) => ( +
+
{row.name}
+ {row.contact_name &&
{row.contact_name}
} +
+ ), + }, + { + key: 'email', + label: 'Email', + width: 'minmax(120px, 1.4fr)', + render: (row) => {row.email ?? '—'}, + }, + { + 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) => ( + + + + + + onEdit(row.id)}>Edit + select(row.id)}>View + + navigate(`/invoices/invoices?customer=${row.id}`)}> + Their invoices + + navigate(`/invoices/payments?customer=${row.id}`)}> + Their payments + + + + 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); + }, + }) + } + > + + Delete + + + + ); + + if (error) return ; + + const toolbar = ( + <> + setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))} + /> + patch({ q: v })} placeholder="Display name…" /> + +
+ {selection.size > 0 && ( + + )} + +
+ + ); + + return ( + <> +
+
+ }> + {showFilters && ( +
+ + + {filters.isFiltered && ( + + )} +
+ )} + + r.id} + selectedKey={filters.selected} + onRowClick={(r) => select(r.id === filters.selected ? null : r.id)} + rowMenu={rowMenu} + empty={ + isLoading ? ( + + ) : ( + + ) + } + /> +
+
+ + {selected && ( +
+ onEdit(selected.id)} onClose={() => select(null)} /> +
+ )} +
+ + 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 ( +
+
+
+
{customer.name}
+ {customer.email &&
{customer.email}
} +
+ + +
+ +
+ {chart && ( +
+ + + + +
+ )} + +
+ {formatMoney(customer.due_amount, currency)} + {customer.contact_name && {customer.contact_name}} + {customer.phone && {customer.phone}} + {customer.website && {customer.website}} + {customer.tax_id && {customer.tax_id}} + {formatDate(customer.created_at, customer.formatted_created_at)} +
+ + + +
+
+ ); +}; + +const Totals = ({ label, value }: { label: string; value: string }) => ( +
+
{label}
+
+ {value} +
+
+); + +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 ( +
+
{title}
+
{lines.join('\n')}
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Invoices/DashboardView.tsx b/src/workspaces/officerdev/src/apps/Invoices/DashboardView.tsx new file mode 100644 index 00000000..d5d53c8c --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Invoices/DashboardView.tsx @@ -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 ; + if (isLoading) return ; + if (!dashboard) return ; + + const hasChart = chartRows.some((r) => r.sales || r.receipts || r.expenses || r.net); + + return ( +
+
+ navigate('/invoices/invoices?status=UNPAID')} + /> + navigate('/invoices/invoices')} + /> + navigate('/invoices/payments')} + /> + navigate('/invoices/expenses')} + /> +
+ + {hasChart && ( +
+
+ Last 12 months +
+ + + +
+
+
+ + + + + + + + + + compact(v)} + /> + [ + formatAmount(Math.round((value ?? 0) * 100), currency), + name ?? '', + ]} + /> + + + + + +
+
+ )} + +
+ 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), + })} + /> + 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), + })} + /> +
+
+ ); +}; + +const Gradient = ({ id, color }: { id: string; color: string }) => ( + + + + +); + +const LegendDot = ({ className, label }: { className: string; label: string }) => ( + + + {label} + +); + +/** 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; +}) => ( + +); + +type RecentRender = { primary: string; secondary: string; amount: string; status: string; note: string }; + +const RecentPanel = ({ + title, + rows, + render, + onOpen, + empty, +}: { + title: string; + rows: T[]; + render: (row: T) => RecentRender; + onOpen: (row: T) => void; + empty: string; +}) => ( +
+
{title}
+ {rows.length === 0 ? ( +
{empty}
+ ) : ( +
+ {rows.map((row) => { + const r = render(row); + return ( + + ); + })} +
+ )} +
+); + +export { ArrowDownRight, ArrowUpRight }; diff --git a/src/workspaces/officerdev/src/apps/Invoices/DocumentEditor.tsx b/src/workspaces/officerdev/src/apps/Invoices/DocumentEditor.tsx new file mode 100644 index 00000000..a4177289 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Invoices/DocumentEditor.tsx @@ -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 = { + 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( + resource, + isNew ? null : (id as number), + ); + const { create, update } = useResourceMutations(resource, isRecurring ? 'recurring invoices' : resource); + + const [customerId, setCustomerId] = useState(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([newLine(0)]); + const [discount, setDiscount] = useState(0); + const [discountType, setDiscountType] = useState<'fixed' | 'percentage'>('fixed'); + const [docTaxIds, setDocTaxIds] = useState([]); + 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(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 ; + if (!isNew && isLoading) return ; + + const patchLine = (key: string, changes: Partial) => + 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 ( +
+
+
+ {isNew ? `New ${KIND_NOUN[resource]}` : `Edit ${number || KIND_NOUN[resource]}`} +
+ + + +
+ +
+
+ + + + {!isRecurring && ( + + setNumber(ev.target.value)} className="h-8 text-xs" /> + + )} + + setPrimaryDate(ev.target.value)} + className="h-8 text-xs" + /> + + {!isRecurring && ( + + setSecondaryDate(ev.target.value)} + className="h-8 text-xs" + /> + + )} + + {isRecurring && ( + <> + + + + + + + {limitBy === 'COUNT' && ( + + setLimitCount(Number(ev.target.value))} + className="h-8 text-xs" + /> + + )} + {limitBy === 'DATE' && ( + + setLimitDate(ev.target.value)} + className="h-8 text-xs" + /> + + )} + + + + + )} + + setReference(ev.target.value)} className="h-8 text-xs" /> + + + {/* 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. */} + + + + + + + + +
+ + {isRecurring && ( + + )} + +
+
+ Item + Qty + Price + Discount + Total + +
+ + {lines.map((line) => { + const computed = computeLine(line, taxTypes, taxPerItem); + return ( +
+
+
+
+ patchLine(line.key, { name: ev.target.value, item_id: null })} + placeholder="Description of work" + className="h-8 text-xs" + /> + +
+ patchLine(line.key, { description: ev.target.value })} + placeholder="Notes (optional)" + className="h-7 text-[11px]" + /> + {taxPerItem === 'YES' && ( + patchLine(line.key, { tax_type_ids: ids })} + /> + )} +
+ + patchLine(line.key, { quantity: Number(ev.target.value) })} + className="h-8 text-right text-xs" + /> + patchLine(line.key, { price: fromMajor(ev.target.value) })} + className="h-8 text-right text-xs" + /> +
+ patchLine(line.key, { discount: Number(ev.target.value) })} + className="h-8 text-right text-xs" + /> + +
+
{formatMoney(computed.total, currency)}
+ +
+
+ ); + })} + +
+ +
+
+ +
+
+ +