Files
invoices/web/InvoicesView.tsx
T
officer 6c70eb8eb3 invoices, extracted from the platform as a plugin
Ninth plugin out, third resurrection: switched off since 2026-08-13 with the
permission reduced to api: [], so the server booted clean and every panel
request 404'd. invoiceshelf/schema was also commented out of the drizzle
barrel.

Named `invoices`, not `invoiceshelf`. The appName drives the API prefix, the
frontend route, the table prefix and the install key all at once, and before
extraction those had drifted: the API was /api/invoiceshelf while the screen
was /invoices. The URL is what a person sees and should name what the plugin
shows rather than the daemon behind it — the permission key and label were
already `invoices`, so the API prefix was the odd one out.

Cost: three call sites, and renaming invoiceshelf_accounts to invoices_accounts
to satisfy the table-prefix rule. That rename was free ONLY because the table
existed on no server, which was checked before deciding. With rows in it,
keeping the vendor name would have been the better trade: db:push sees a rename
as a drop plus a create and this project has no migration mechanism.

The sidecar keeps its Invoiceshelf-named functions deliberately — they name the
upstream API it actually speaks.

91 palette classes converted, 52 of them in STATUS_TONE, which was already the
single source of status colour but held raw palette strings. SENT and VIEWED
now share `info`: the closed six-tone set has no second shade of "in flight,
not yet actioned". Recorded in PLUGIN.md as a real loss.
2026-08-19 03:47:44 +00:00

155 lines
5.5 KiB
TypeScript

import { useCallback } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { ConnectionSection } from './ConnectionSection';
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 { invoicesNewPaymentPath, invoicesSectionPath } from './shared';
import { useInvoiceShelfHealth } from './useInvoiceShelfData';
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 { data: health, isLoading: healthLoading } = useInvoiceShelfHealth();
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],
);
// Health gates every section, and its two failure modes are answered differently: nothing configured yet is
// the setup form, whatever section was asked for, because there is nothing else useful to show; a stored
// instance that is failing keeps its own message and a way back, since replacing a working token by
// accident is worse than a wall of text.
if (section === 'settings') return <ConnectionSection />;
if (!healthLoading && health && !health.ok) {
if (health.configured === false) return <ConnectionSection />;
return (
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
<p className="text-sm font-medium">InvoiceShelf is not answering</p>
<p className="max-w-sm text-xs text-muted-foreground">
{health.error ?? 'The invoiceshelf sidecar could not reach the configured instance.'}
</p>
<Link to={invoicesSectionPath('settings')} className="mt-2 text-xs font-medium text-primary hover:underline">
Check the connection
</Link>
</div>
);
}
// 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} recordPaymentHref={invoicesNewPaymentPath} />;
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 />;
}
};