Files
invoices/web/InvoicesNav.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

122 lines
4.6 KiB
TypeScript

import type { LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router';
import {
Building2,
FileText,
FileSpreadsheet,
LayoutDashboard,
Package,
PieChart,
Plug,
Receipt,
RefreshCw,
Users,
Wallet,
} from 'lucide-react';
import { INVOICES_SECTIONS, invoicesSectionPath, type InvoicesSectionId } from './shared';
import { AccountSwitcher } from './AccountSwitcher';
import { formatMoney } from './format';
import { useSummary } from './useInvoiceShelfData';
// Left panel of /invoices: the company it is pointed at, which account that is, 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.
//
// The subtitle is the account switcher rather than the amount outstanding, because with several sets of
// books on screen "whose books are these" is the question the header has to answer. Outstanding moved into
// the totals at the bottom, where it sits with the rest of the money.
const ICONS: Record<InvoicesSectionId, LucideIcon> = {
dashboard: LayoutDashboard,
invoices: FileText,
estimates: FileSpreadsheet,
recurring: RefreshCw,
payments: Wallet,
expenses: Receipt,
customers: Users,
items: Package,
reports: PieChart,
settings: Plug,
};
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 } = 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-primary/15 text-primary ring-1 ring-inset ring-border">
<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>
<AccountSwitcher />
</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="Outstanding" value={formatMoney(dashboard.total_amount_due, currency)} />
<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>
);