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

408 lines
14 KiB
TypeScript

import type { Column } from './components';
import type { ConfirmState } from './ConfirmDialog';
import type { SendTarget } from './SendDocumentDialog';
import type { Estimate } from './shared';
import { useState } from 'react';
import { Check, Copy, FileSpreadsheet, FileUp, Filter, MoreHorizontal, Plus, Send, Trash2, X } 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 } from './format';
import { ESTIMATE_TABS, PAGE_SIZE } from './shared';
import { useListFilters } from './useInvoicesSection';
import {
useCurrency,
useDocumentAction,
useLookups,
useResourceList,
useResourceMutations,
} from './useInvoiceShelfData';
// Estimates — upstream's EstimateIndexView. Same skeleton as the invoices list, different verbs: an estimate
// is accepted or rejected rather than paid, and its terminal move is converting into an invoice.
//
// Convert is one-way and upstream refuses it on a REJECTED estimate, so the item is hidden in that state
// rather than offered and rejected. After a successful convert the new invoice exists alongside this
// estimate; the toast says so, because nothing on this screen would otherwise show where it went.
type Props = { onEdit: (id: number | 'new') => void; onConverted: (invoiceId: number) => void };
export const EstimatesListView = ({ onEdit, onConverted }: 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<Estimate>('estimates', {
page: filters.page,
limit: PAGE_SIZE,
estimate_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 { remove, removeMany } = useResourceMutations('estimates');
const action = useDocumentAction('estimates');
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<Estimate>[] = [
{
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: 'estimate_date',
label: 'Date',
width: 'minmax(90px, 0.8fr)',
render: (row) => formatDate(row.estimate_date, row.formatted_estimate_date),
},
{
key: 'estimate_number',
label: 'Number',
width: 'minmax(90px, 0.8fr)',
render: (row) => <span className="font-medium">{row.estimate_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: 'total',
label: 'Total',
width: 'minmax(100px, 1fr)',
align: 'right',
render: (row) => formatMoney(row.total, currency),
},
];
const setStatus = (row: Estimate, status: string, label: string) =>
setConfirm({
title: `Mark ${row.estimate_number} as ${label.toLowerCase()}?`,
description: 'This changes the status only. Nothing is emailed to the customer.',
confirmLabel: `Mark as ${label.toLowerCase()}`,
onConfirm: () => action.mutate({ id: row.id, action: 'status', payload: { status } }),
});
const rowMenu = (row: Estimate) => (
<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-56">
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
<DropdownMenuItem onSelect={() => select(row.id)}>View PDF</DropdownMenuItem>
{row.status === 'DRAFT' && (
<DropdownMenuItem
onSelect={() =>
setSendTarget({
resource: 'estimates',
id: row.id,
number: row.estimate_number,
customerName: row.customer?.name ?? '',
customerEmail: row.customer?.email ?? null,
})
}
>
<Send className="mr-2 h-3.5 w-3.5" />
Send estimate
</DropdownMenuItem>
)}
{(row.status === 'SENT' || row.status === 'VIEWED') && (
<DropdownMenuItem
onSelect={() =>
setSendTarget({
resource: 'estimates',
id: row.id,
number: row.estimate_number,
customerName: row.customer?.name ?? '',
customerEmail: row.customer?.email ?? null,
resend: true,
})
}
>
<Send className="mr-2 h-3.5 w-3.5" />
Resend estimate
</DropdownMenuItem>
)}
{row.status !== 'SENT' && (
<DropdownMenuItem onSelect={() => setStatus(row, 'SENT', 'Sent')}>Mark as sent</DropdownMenuItem>
)}
{row.status !== 'ACCEPTED' && row.status !== 'REJECTED' && (
<DropdownMenuItem onSelect={() => setStatus(row, 'ACCEPTED', 'Accepted')}>
<Check className="mr-2 h-3.5 w-3.5" />
Mark as accepted
</DropdownMenuItem>
)}
{row.status !== 'REJECTED' && row.status !== 'ACCEPTED' && (
<DropdownMenuItem onSelect={() => setStatus(row, 'REJECTED', 'Rejected')}>
<X className="mr-2 h-3.5 w-3.5" />
Mark as rejected
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => action.mutate({ id: row.id, action: 'clone' })}>
<Copy className="mr-2 h-3.5 w-3.5" />
Clone estimate
</DropdownMenuItem>
{row.status !== 'REJECTED' && (
<DropdownMenuItem
onSelect={() =>
setConfirm({
title: `Convert ${row.estimate_number} into an invoice?`,
description:
'A new invoice is created from this estimate. The estimate itself stays where it is — this is not a move.',
confirmLabel: 'Convert',
onConfirm: () =>
action.mutate(
{ id: row.id, action: 'convert-to-invoice' },
{
onSuccess: (data) => {
const newId = (data as { data?: { id?: number } })?.data?.id;
if (newId) onConverted(newId);
},
},
),
})
}
>
<FileUp className="mr-2 h-3.5 w-3.5" />
Convert into invoice
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onSelect={() =>
setConfirm({
title: `Delete estimate ${row.estimate_number}?`,
description: 'This permanently removes the estimate.',
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={ESTIMATE_TABS}
value={filters.status}
onChange={(v) => {
patch({ status: v });
setSelection(new Set());
}}
/>
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Estimate 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} estimates?`,
description: 'This permanently removes 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 estimate
</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={FileSpreadsheet} title="Loading…" />
) : (
<EmptyState
icon={FileSpreadsheet}
title={filters.isFiltered ? 'No estimates match' : 'No estimates yet'}
hint={
filters.isFiltered
? 'Try clearing the filters.'
: 'Estimates become invoices once a customer accepts.'
}
action={
<Button size="sm" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New estimate
</Button>
}
/>
)
}
/>
</SectionShell>
</div>
{selected && (
<div className="hidden w-[46%] min-w-[380px] shrink-0 lg:block">
<PdfPane
resource="estimates"
id={selected.id}
title={`Estimate ${selected.estimate_number}`}
subtitle={selected.customer?.name}
filename={`estimate-${selected.estimate_number}`}
onClose={() => select(null)}
/>
</div>
)}
</div>
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
<SendDocumentDialog target={sendTarget} currency={currency} onClose={() => setSendTarget(null)} />
</>
);
};