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>(new Set()); const [confirm, setConfirm] = useState(null); const { rows, meta, isLoading, error } = useResourceList('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[] = [ { key: 'check', label: '', width: '36px', render: (row) => ( e.stopPropagation()} className="flex items-center"> toggleRow(row.id)} /> ), }, { 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) => {row.expense_number ?? '—'}, }, { key: 'category', label: 'Category', width: 'minmax(100px, 1fr)', render: (row) => {row.expense_category?.name ?? '—'}, }, { key: 'customer', label: 'Customer', width: 'minmax(100px, 1fr)', render: (row) => {row.customer?.name ?? '—'}, }, { key: 'notes', label: 'Notes', width: 'minmax(120px, 1.4fr)', render: (row) => ( {row.attachment_receipt_url && ( e.stopPropagation()} title="Open receipt" className="shrink-0 text-muted-foreground hover:text-foreground" > )} {row.notes ?? '—'} ), }, { key: 'amount', label: 'Amount', width: 'minmax(100px, 1fr)', align: 'right', render: (row) => formatMoney(row.amount, currency), }, ]; const rowMenu = (row: Expense) => ( onEdit(row.id)}>Edit action.mutate({ id: row.id, action: 'duplicate' })}> Duplicate {row.attachment_receipt_url && ( window.open(row.attachment_receipt_url ?? '', '_blank', 'noopener')}> Open receipt )} setConfirm({ title: 'Delete this expense?', description: 'It disappears from the profit & loss and expense reports.', confirmLabel: 'Delete', destructive: true, onConfirm: () => remove.mutate(row.id), }) } > Delete ); if (error) return ; const toolbar = ( <> setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))} /> patch({ q: v })} placeholder="Notes…" />
{selection.size > 0 && ( )}
); return ( <> }> {showFilters && (
{filters.isFiltered && ( )}
)} r.id} onRowClick={(r) => onEdit(r.id)} rowMenu={rowMenu} empty={ isLoading ? ( ) : ( ) } />
setConfirm(null)} /> ); };