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

323 lines
12 KiB
TypeScript

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-destructive"
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-destructive"
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';
};