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.
407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
import type { Column } from './components';
|
|
import type { ConfirmState } from './ConfirmDialog';
|
|
import type { SendTarget } from './SendDocumentDialog';
|
|
import type { Invoice } from './shared';
|
|
import { useState } from 'react';
|
|
import { Link } from 'react-router';
|
|
import { Copy, FileText, Filter, MoreHorizontal, Plus, Send, Trash2, Wallet } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import {
|
|
EmptyState,
|
|
ErrorState,
|
|
Pagination,
|
|
SearchBox,
|
|
SectionShell,
|
|
SimpleTable,
|
|
StatusBadge,
|
|
StatusTabs,
|
|
} from './components';
|
|
import { ConfirmDialog } from './ConfirmDialog';
|
|
import { SendDocumentDialog } from './SendDocumentDialog';
|
|
import { PdfPane } from './PdfPane';
|
|
import { formatDate, formatMoney, relativeDue } from './format';
|
|
import { INVOICE_TABS, PAGE_SIZE, type Invoice as InvoiceType } from './shared';
|
|
import { useListFilters } from './useInvoicesSection';
|
|
import {
|
|
useCurrency,
|
|
useDocumentAction,
|
|
useLookups,
|
|
useResourceMutations,
|
|
useResourceList,
|
|
} from './useInvoiceShelfData';
|
|
|
|
// The invoices list — upstream's InvoiceIndexView, with its column order, its four tabs and its filter set.
|
|
//
|
|
// Two deliberate departures from the map of the Vue app:
|
|
//
|
|
// * "Convert to Estimate" is absent. That row exists in the 3.0 source, but this instance runs 2.4.2 and
|
|
// has no invoices/{id}/convert-to-estimate route — the sidecar's allow-list reflects the live routes, so
|
|
// offering it here would render a menu item that 404s.
|
|
// * "Edit" and "Record Payment" open the editors in this app rather than routing to InvoiceShelf.
|
|
//
|
|
// `allow_edit` is honoured rather than ignored: once an invoice has payments against it, upstream locks
|
|
// editing, and letting a user open a form whose save will be rejected is worse than hiding the button.
|
|
|
|
type Props = { onEdit: (id: number | 'new') => void; recordPaymentHref: (invoiceId: number) => string };
|
|
|
|
export const InvoicesListView = ({ onEdit, recordPaymentHref }: Props) => {
|
|
const { filters, patch, select, setPage, clearFilters } = useListFilters();
|
|
const currency = useCurrency();
|
|
const { customers } = useLookups();
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const [selection, setSelection] = useState<Set<number>>(new Set());
|
|
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
|
const [sendTarget, setSendTarget] = useState<SendTarget | null>(null);
|
|
|
|
const { rows, meta, isLoading, error } = useResourceList<InvoiceType>('invoices', {
|
|
page: filters.page,
|
|
limit: PAGE_SIZE,
|
|
invoice_number: filters.search || undefined,
|
|
status: filters.status || undefined,
|
|
customer_id: filters.customerId || undefined,
|
|
from_date: filters.fromDate || undefined,
|
|
to_date: filters.toDate || undefined,
|
|
orderByField: 'created_at',
|
|
orderBy: 'desc',
|
|
});
|
|
|
|
const { removeMany, remove } = useResourceMutations('invoices');
|
|
const action = useDocumentAction('invoices');
|
|
|
|
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<Invoice>[] = [
|
|
{
|
|
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: 'invoice_date',
|
|
label: 'Date',
|
|
width: 'minmax(90px, 0.8fr)',
|
|
render: (row) => formatDate(row.invoice_date, row.formatted_invoice_date),
|
|
},
|
|
{
|
|
key: 'invoice_number',
|
|
label: 'Number',
|
|
width: 'minmax(90px, 0.8fr)',
|
|
render: (row) => <span className="font-medium">{row.invoice_number}</span>,
|
|
},
|
|
{
|
|
key: 'name',
|
|
label: 'Customer',
|
|
width: 'minmax(120px, 1.4fr)',
|
|
render: (row) => <span className="truncate">{row.customer?.name ?? '—'}</span>,
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Status',
|
|
width: 'minmax(90px, 0.9fr)',
|
|
render: (row) => <StatusBadge value={row.status} />,
|
|
},
|
|
{
|
|
key: 'due_amount',
|
|
label: 'Amount due',
|
|
width: 'minmax(120px, 1.1fr)',
|
|
align: 'right',
|
|
render: (row) => (
|
|
<div className="flex items-center justify-end gap-1.5">
|
|
{row.overdue && <StatusBadge value="OVERDUE" />}
|
|
<StatusBadge value={row.paid_status} />
|
|
<span>{formatMoney(row.due_amount, currency)}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'total',
|
|
label: 'Total',
|
|
width: 'minmax(90px, 0.9fr)',
|
|
align: 'right',
|
|
render: (row) => formatMoney(row.total, currency),
|
|
},
|
|
];
|
|
|
|
const rowMenu = (row: Invoice) => (
|
|
<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-52">
|
|
{row.allow_edit && <DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>}
|
|
<DropdownMenuItem onSelect={() => select(row.id)}>View PDF</DropdownMenuItem>
|
|
|
|
{row.status === 'DRAFT' && (
|
|
<DropdownMenuItem
|
|
onSelect={() =>
|
|
setSendTarget({
|
|
resource: 'invoices',
|
|
id: row.id,
|
|
number: row.invoice_number,
|
|
customerName: row.customer?.name ?? '',
|
|
customerEmail: row.customer?.email ?? null,
|
|
})
|
|
}
|
|
>
|
|
<Send className="mr-2 h-3.5 w-3.5" />
|
|
Send invoice
|
|
</DropdownMenuItem>
|
|
)}
|
|
{(row.status === 'SENT' || row.status === 'VIEWED') && (
|
|
<DropdownMenuItem
|
|
onSelect={() =>
|
|
setSendTarget({
|
|
resource: 'invoices',
|
|
id: row.id,
|
|
number: row.invoice_number,
|
|
customerName: row.customer?.name ?? '',
|
|
customerEmail: row.customer?.email ?? null,
|
|
resend: true,
|
|
})
|
|
}
|
|
>
|
|
<Send className="mr-2 h-3.5 w-3.5" />
|
|
Resend invoice
|
|
</DropdownMenuItem>
|
|
)}
|
|
{/* Opening the payment editor is an address (`?edit=new&pay=<id>`), not a side effect — the editor
|
|
prefills itself from those params — so it is an anchor, not a callback up to the parent and
|
|
back down through `navigate`. */}
|
|
{(row.status === 'SENT' || row.status === 'VIEWED') && (
|
|
<DropdownMenuItem asChild>
|
|
<Link to={recordPaymentHref(row.id)}>
|
|
<Wallet className="mr-2 h-3.5 w-3.5" />
|
|
Record payment
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
)}
|
|
{row.status === 'DRAFT' && (
|
|
<DropdownMenuItem
|
|
onSelect={() =>
|
|
setConfirm({
|
|
title: `Mark ${row.invoice_number} as sent?`,
|
|
// Worth spelling out — the wording is one word away from the action that does email it.
|
|
description:
|
|
'This only changes the status to Sent. Nothing is emailed to the customer. Use “Send invoice” for that.',
|
|
confirmLabel: 'Mark as sent',
|
|
onConfirm: () => action.mutate({ id: row.id, action: 'status', payload: { status: 'SENT' } }),
|
|
})
|
|
}
|
|
>
|
|
Mark as sent
|
|
</DropdownMenuItem>
|
|
)}
|
|
|
|
<DropdownMenuItem onSelect={() => action.mutate({ id: row.id, action: 'clone' })}>
|
|
<Copy className="mr-2 h-3.5 w-3.5" />
|
|
Clone invoice
|
|
</DropdownMenuItem>
|
|
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
className="text-destructive"
|
|
onSelect={() =>
|
|
setConfirm({
|
|
title: `Delete invoice ${row.invoice_number}?`,
|
|
description: 'This permanently removes the invoice and any payments recorded against it.',
|
|
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)))}
|
|
title="Select all on this page"
|
|
/>
|
|
<StatusTabs
|
|
tabs={INVOICE_TABS}
|
|
value={filters.status}
|
|
onChange={(v) => {
|
|
patch({ status: v });
|
|
setSelection(new Set());
|
|
}}
|
|
/>
|
|
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Invoice number…" />
|
|
<Button
|
|
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={() => setShowFilters((v) => !v)}
|
|
>
|
|
<Filter className="mr-1.5 h-3.5 w-3.5" />
|
|
Filter
|
|
</Button>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
{selection.size > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 text-destructive"
|
|
onClick={() =>
|
|
setConfirm({
|
|
title: `Delete ${selection.size} invoices?`,
|
|
description: 'This permanently removes them and any payments recorded against them.',
|
|
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 invoice
|
|
</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} />}>
|
|
{showFilters && (
|
|
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
|
|
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
|
Customer
|
|
<select
|
|
value={filters.customerId ?? ''}
|
|
onChange={(e) => patch({ customer: e.target.value || null })}
|
|
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
|
>
|
|
<option value="">All</option>
|
|
{customers.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
|
From
|
|
<Input
|
|
type="date"
|
|
value={filters.fromDate ?? ''}
|
|
onChange={(e) => patch({ from: e.target.value || null })}
|
|
className="h-8 w-36 text-xs"
|
|
/>
|
|
</label>
|
|
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
|
To
|
|
<Input
|
|
type="date"
|
|
value={filters.toDate ?? ''}
|
|
onChange={(e) => patch({ to: e.target.value || null })}
|
|
className="h-8 w-36 text-xs"
|
|
/>
|
|
</label>
|
|
{filters.isFiltered && (
|
|
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<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={FileText} title="Loading…" />
|
|
) : (
|
|
<EmptyState
|
|
icon={FileText}
|
|
title={filters.isFiltered ? 'No invoices match' : 'No invoices yet'}
|
|
hint={filters.isFiltered ? 'Try clearing the filters.' : 'Create the first one to get started.'}
|
|
action={
|
|
<Button size="sm" onClick={() => onEdit('new')}>
|
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
|
New invoice
|
|
</Button>
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
</SectionShell>
|
|
</div>
|
|
|
|
{selected && (
|
|
<div className="hidden w-[46%] min-w-[380px] shrink-0 lg:block">
|
|
<PdfPane
|
|
resource="invoices"
|
|
id={selected.id}
|
|
title={`Invoice ${selected.invoice_number}`}
|
|
subtitle={
|
|
<>
|
|
{selected.customer?.name}
|
|
{selected.due_date && ` · ${relativeDue(selected.due_date)}`}
|
|
</>
|
|
}
|
|
filename={`invoice-${selected.invoice_number}`}
|
|
onClose={() => select(null)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
|
|
<SendDocumentDialog target={sendTarget} currency={currency} onClose={() => setSendTarget(null)} />
|
|
</>
|
|
);
|
|
};
|