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.
349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
import type { Column } from './components';
|
|
import type { ConfirmState } from './ConfirmDialog';
|
|
import type { Address, Customer } from './shared';
|
|
import { useState } from 'react';
|
|
import { Link } from 'react-router';
|
|
import { Filter, MoreHorizontal, Plus, Trash2, Users, X } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Input } from '@/components/ui/input';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { EmptyState, ErrorState, Field, Pagination, SearchBox, SectionShell, SimpleTable } from './components';
|
|
import { ConfirmDialog } from './ConfirmDialog';
|
|
import { formatDate, formatMoney } from './format';
|
|
import { invoicesCustomerPath, PAGE_SIZE } from './shared';
|
|
import { useListFilters } from './useInvoicesSection';
|
|
import { useCurrency, useCustomerStats, useResourceList, useResourceMutations } from './useInvoiceShelfData';
|
|
|
|
// Customers. Upstream splits the search into three separate fields rather than one box — display name,
|
|
// contact name and phone are distinct API filters — so the toolbar search maps to display name and the
|
|
// other two live behind the filter row, which is where upstream puts them too.
|
|
|
|
type Props = { onEdit: (id: number | 'new') => void };
|
|
|
|
export const CustomersListView = ({ onEdit }: Props) => {
|
|
const { filters, patch, select, setPage, clearFilters } = useListFilters();
|
|
const currency = useCurrency();
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const [selection, setSelection] = useState<Set<number>>(new Set());
|
|
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
|
|
|
const { rows, meta, isLoading, error } = useResourceList<Customer>('customers', {
|
|
page: filters.page,
|
|
limit: PAGE_SIZE,
|
|
display_name: filters.search || undefined,
|
|
// `status` and `from` carry contact name and phone here — one query-string slot per filter, and
|
|
// customers have no status or date range to compete for them.
|
|
contact_name: filters.status || undefined,
|
|
phone: filters.fromDate || undefined,
|
|
orderByField: 'created_at',
|
|
orderBy: 'desc',
|
|
});
|
|
|
|
const { remove, removeMany } = useResourceMutations('customers');
|
|
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<Customer>[] = [
|
|
{
|
|
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: 'name',
|
|
label: 'Name',
|
|
width: 'minmax(150px, 2fr)',
|
|
render: (row) => (
|
|
<div className="min-w-0">
|
|
<div className="truncate font-medium">{row.name}</div>
|
|
{row.contact_name && <div className="truncate text-[10px] text-muted-foreground">{row.contact_name}</div>}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'email',
|
|
label: 'Email',
|
|
width: 'minmax(120px, 1.4fr)',
|
|
render: (row) => <span className="truncate text-muted-foreground">{row.email ?? '—'}</span>,
|
|
},
|
|
{
|
|
key: 'phone',
|
|
label: 'Phone',
|
|
width: 'minmax(100px, 1fr)',
|
|
render: (row) => row.phone ?? '—',
|
|
},
|
|
{
|
|
key: 'due_amount',
|
|
label: 'Amount due',
|
|
width: 'minmax(100px, 1fr)',
|
|
align: 'right',
|
|
render: (row) => formatMoney(row.due_amount, currency),
|
|
},
|
|
{
|
|
key: 'created_at',
|
|
label: 'Added',
|
|
width: 'minmax(90px, 0.9fr)',
|
|
render: (row) => formatDate(row.created_at, row.formatted_created_at),
|
|
},
|
|
];
|
|
|
|
const rowMenu = (row: Customer) => (
|
|
<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">
|
|
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => select(row.id)}>View</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
{/* Both go somewhere and change nothing, so they are anchors wearing a menu item — `asChild` hands
|
|
Radix's item semantics to the `<Link>` rather than nesting one inside the other. Middle-click
|
|
opens the filtered list in a tab, which is the point of the filter living in the URL. */}
|
|
<DropdownMenuItem asChild>
|
|
<Link to={invoicesCustomerPath('invoices', row.id)}>Their invoices</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem asChild>
|
|
<Link to={invoicesCustomerPath('payments', row.id)}>Their payments</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
className="text-destructive"
|
|
onSelect={() =>
|
|
setConfirm({
|
|
title: `Delete ${row.name}?`,
|
|
description:
|
|
'InvoiceShelf refuses this while the customer still has invoices, estimates or payments — delete those first.',
|
|
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)))}
|
|
/>
|
|
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Display name…" />
|
|
<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} customers?`,
|
|
description: 'Any that still have documents against them are refused.',
|
|
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 customer
|
|
</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">
|
|
Contact name
|
|
<Input
|
|
value={filters.status ?? ''}
|
|
onChange={(e) => patch({ status: e.target.value || null })}
|
|
className="h-8 w-40 text-xs"
|
|
/>
|
|
</label>
|
|
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
|
Phone
|
|
<Input
|
|
value={filters.fromDate ?? ''}
|
|
onChange={(e) => patch({ from: e.target.value || null })}
|
|
className="h-8 w-40 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={Users} title="Loading…" />
|
|
) : (
|
|
<EmptyState
|
|
icon={Users}
|
|
title={filters.isFiltered ? 'No customers match' : 'No customers yet'}
|
|
hint={filters.isFiltered ? 'Try clearing the filters.' : 'Every invoice needs one — add the first.'}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
</SectionShell>
|
|
</div>
|
|
|
|
{selected && (
|
|
<div className="hidden w-[38%] min-w-[320px] shrink-0 lg:block">
|
|
<CustomerDetail customer={selected} onEdit={() => onEdit(selected.id)} onClose={() => select(null)} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
|
|
</>
|
|
);
|
|
};
|
|
|
|
const CustomerDetail = ({
|
|
customer,
|
|
onEdit,
|
|
onClose,
|
|
}: {
|
|
customer: Customer;
|
|
onEdit: () => void;
|
|
onClose: () => void;
|
|
}) => {
|
|
const currency = useCurrency();
|
|
const { stats } = useCustomerStats(customer.id);
|
|
const chart = stats?.meta?.chartData;
|
|
|
|
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">{customer.name}</div>
|
|
{customer.email && <div className="truncate text-[10px] text-muted-foreground">{customer.email}</div>}
|
|
</div>
|
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={onEdit}>
|
|
Edit
|
|
</Button>
|
|
<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">
|
|
{chart && (
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Totals label="Sales" value={formatMoney(chart.salesTotal, currency)} />
|
|
<Totals label="Receipts" value={formatMoney(chart.totalReceipts, currency)} />
|
|
<Totals label="Expenses" value={formatMoney(chart.totalExpenses, currency)} />
|
|
<Totals label="Net profit" value={formatMoney(chart.netProfit, currency)} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-3 rounded-lg border border-border px-3 py-1">
|
|
<Field label="Amount due">{formatMoney(customer.due_amount, currency)}</Field>
|
|
{customer.contact_name && <Field label="Contact">{customer.contact_name}</Field>}
|
|
{customer.phone && <Field label="Phone">{customer.phone}</Field>}
|
|
{customer.website && <Field label="Website">{customer.website}</Field>}
|
|
{customer.tax_id && <Field label="Tax ID">{customer.tax_id}</Field>}
|
|
<Field label="Customer since">{formatDate(customer.created_at, customer.formatted_created_at)}</Field>
|
|
</div>
|
|
|
|
<AddressCard title="Billing address" address={customer.billing} />
|
|
<AddressCard title="Shipping address" address={customer.shipping} />
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Totals = ({ label, value }: { label: string; value: string }) => (
|
|
<div className="rounded-lg border border-border p-2">
|
|
<div className="text-[10px] text-muted-foreground">{label}</div>
|
|
<div className="truncate text-sm font-semibold tabular-nums" title={value}>
|
|
{value}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const AddressCard = ({ title, address }: { title: string; address?: Address | null }) => {
|
|
const lines = [
|
|
address?.address_street_1,
|
|
address?.address_street_2,
|
|
[address?.city, address?.state, address?.zip].filter(Boolean).join(' '),
|
|
address?.country?.name,
|
|
].filter((l): l is string => Boolean(l && l.trim()));
|
|
|
|
if (!lines.length) return null;
|
|
|
|
return (
|
|
<div className="mt-3 rounded-lg border border-border p-3">
|
|
<div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{title}</div>
|
|
<div className="whitespace-pre-line text-xs">{lines.join('\n')}</div>
|
|
</div>
|
|
);
|
|
};
|