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.
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
import type { Column } from './components';
|
|
import type { ConfirmState } from './ConfirmDialog';
|
|
import type { Expense } from './shared';
|
|
import { useState } from 'react';
|
|
import { Copy, Filter, MoreHorizontal, Paperclip, Plus, Receipt, Trash2 } 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, Pagination, SearchBox, SectionShell, SimpleTable } from './components';
|
|
import { ConfirmDialog } from './ConfirmDialog';
|
|
import { formatDate, formatMoney } from './format';
|
|
import { PAGE_SIZE } from './shared';
|
|
import { useListFilters } from './useInvoicesSection';
|
|
import {
|
|
useCurrency,
|
|
useDocumentAction,
|
|
useLookups,
|
|
useResourceList,
|
|
useResourceMutations,
|
|
} from './useInvoiceShelfData';
|
|
|
|
// Expenses — money going the other way, and the only list with a file attached to its rows.
|
|
//
|
|
// The receipt link opens `attachment_receipt_url` directly. That URL is InvoiceShelf's own, rendered from the
|
|
// instance's APP_URL, so it is a browser-resolvable link rather than something proxied through the sidecar —
|
|
// which means it only works from a machine that can reach the InvoiceShelf host. Left as-is deliberately:
|
|
// proxying arbitrary uploaded files is a wider surface than this screen needs.
|
|
|
|
type Props = { onEdit: (id: number | 'new') => void };
|
|
|
|
export const ExpensesListView = ({ onEdit }: Props) => {
|
|
const { filters, patch, setPage, clearFilters } = useListFilters();
|
|
const currency = useCurrency();
|
|
const { customers, categories } = useLookups();
|
|
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<Expense>('expenses', {
|
|
page: filters.page,
|
|
limit: PAGE_SIZE,
|
|
search: filters.search || undefined,
|
|
customer_id: filters.customerId || undefined,
|
|
expense_category_id: filters.status || undefined,
|
|
from_date: filters.fromDate || undefined,
|
|
to_date: filters.toDate || undefined,
|
|
orderByField: 'expense_date',
|
|
orderBy: 'desc',
|
|
});
|
|
|
|
const { remove, removeMany } = useResourceMutations('expenses');
|
|
const action = useDocumentAction('expenses');
|
|
|
|
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<Expense>[] = [
|
|
{
|
|
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: 'expense_date',
|
|
label: 'Date',
|
|
width: 'minmax(90px, 0.8fr)',
|
|
render: (row) => formatDate(row.expense_date, row.formatted_expense_date),
|
|
},
|
|
{
|
|
key: 'expense_number',
|
|
label: 'Number',
|
|
width: 'minmax(90px, 0.8fr)',
|
|
render: (row) => <span className="font-medium">{row.expense_number ?? '—'}</span>,
|
|
},
|
|
{
|
|
key: 'category',
|
|
label: 'Category',
|
|
width: 'minmax(100px, 1fr)',
|
|
render: (row) => <span className="truncate">{row.expense_category?.name ?? '—'}</span>,
|
|
},
|
|
{
|
|
key: 'customer',
|
|
label: 'Customer',
|
|
width: 'minmax(100px, 1fr)',
|
|
render: (row) => <span className="truncate text-muted-foreground">{row.customer?.name ?? '—'}</span>,
|
|
},
|
|
{
|
|
key: 'notes',
|
|
label: 'Notes',
|
|
width: 'minmax(120px, 1.4fr)',
|
|
render: (row) => (
|
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
{row.attachment_receipt_url && (
|
|
<a
|
|
href={row.attachment_receipt_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
title="Open receipt"
|
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Paperclip className="h-3 w-3" />
|
|
</a>
|
|
)}
|
|
<span className="truncate text-muted-foreground">{row.notes ?? '—'}</span>
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'amount',
|
|
label: 'Amount',
|
|
width: 'minmax(100px, 1fr)',
|
|
align: 'right',
|
|
render: (row) => formatMoney(row.amount, currency),
|
|
},
|
|
];
|
|
|
|
const rowMenu = (row: Expense) => (
|
|
<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={() => action.mutate({ id: row.id, action: 'duplicate' })}>
|
|
<Copy className="mr-2 h-3.5 w-3.5" />
|
|
Duplicate
|
|
</DropdownMenuItem>
|
|
{row.attachment_receipt_url && (
|
|
<DropdownMenuItem onSelect={() => window.open(row.attachment_receipt_url ?? '', '_blank', 'noopener')}>
|
|
<Paperclip className="mr-2 h-3.5 w-3.5" />
|
|
Open receipt
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
className="text-destructive"
|
|
onSelect={() =>
|
|
setConfirm({
|
|
title: 'Delete this expense?',
|
|
description: 'It disappears from the profit & loss and expense reports.',
|
|
confirmLabel: 'Delete',
|
|
destructive: true,
|
|
onConfirm: () => remove.mutate(row.id),
|
|
})
|
|
}
|
|
>
|
|
<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="Notes…" />
|
|
<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} expenses?`,
|
|
description: 'They disappear from the profit & loss and expense reports.',
|
|
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 expense
|
|
</Button>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<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">
|
|
Category
|
|
<select
|
|
value={filters.status ?? ''}
|
|
onChange={(e) => patch({ status: e.target.value || null })}
|
|
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
|
>
|
|
<option value="">All</option>
|
|
{categories.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">
|
|
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}
|
|
onRowClick={(r) => onEdit(r.id)}
|
|
rowMenu={rowMenu}
|
|
empty={
|
|
isLoading ? (
|
|
<EmptyState icon={Receipt} title="Loading…" />
|
|
) : (
|
|
<EmptyState
|
|
icon={Receipt}
|
|
title={filters.isFiltered ? 'No expenses match' : 'No expenses yet'}
|
|
hint={
|
|
filters.isFiltered
|
|
? 'Try clearing the filters.'
|
|
: 'Recording expenses is what makes the net income figure mean anything.'
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
</SectionShell>
|
|
|
|
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
|
|
</>
|
|
);
|
|
};
|