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) => (
+
+
+ setConfirm(null)} />
+ setSendTarget(null)} />
+ >
+ );
+};
diff --git a/src/workspaces/officerdev/src/apps/Invoices/PdfPane.tsx b/src/workspaces/officerdev/src/apps/Invoices/PdfPane.tsx
new file mode 100644
index 00000000..0bf74d9e
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Invoices/PdfPane.tsx
@@ -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