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>(new Set()); const [confirm, setConfirm] = useState(null); const [sendTarget, setSendTarget] = useState(null); const { rows, meta, isLoading, error } = useResourceList('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[] = [ { key: 'check', label: '', width: '36px', render: (row) => ( e.stopPropagation()} className="flex items-center"> toggleRow(row.id)} /> ), }, { 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) => {row.invoice_number}, }, { key: 'name', label: 'Customer', width: 'minmax(120px, 1.4fr)', render: (row) => {row.customer?.name ?? '—'}, }, { key: 'status', label: 'Status', width: 'minmax(90px, 0.9fr)', render: (row) => , }, { key: 'due_amount', label: 'Amount due', width: 'minmax(120px, 1.1fr)', align: 'right', render: (row) => (
{row.overdue && } {formatMoney(row.due_amount, currency)}
), }, { key: 'total', label: 'Total', width: 'minmax(90px, 0.9fr)', align: 'right', render: (row) => formatMoney(row.total, currency), }, ]; const rowMenu = (row: Invoice) => ( {row.allow_edit && onEdit(row.id)}>Edit} select(row.id)}>View PDF {row.status === 'DRAFT' && ( setSendTarget({ resource: 'invoices', id: row.id, number: row.invoice_number, customerName: row.customer?.name ?? '', customerEmail: row.customer?.email ?? null, }) } > Send invoice )} {(row.status === 'SENT' || row.status === 'VIEWED') && ( setSendTarget({ resource: 'invoices', id: row.id, number: row.invoice_number, customerName: row.customer?.name ?? '', customerEmail: row.customer?.email ?? null, resend: true, }) } > Resend invoice )} {/* Opening the payment editor is an address (`?edit=new&pay=`), 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') && ( Record payment )} {row.status === 'DRAFT' && ( 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 )} action.mutate({ id: row.id, action: 'clone' })}> Clone invoice 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); }, }) } > Delete ); if (error) return ; const toolbar = ( <> setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))} title="Select all on this page" /> { patch({ status: v }); setSelection(new Set()); }} /> patch({ q: v })} placeholder="Invoice number…" />
{selection.size > 0 && ( )}
); return ( <>
}> {showFilters && (
{filters.isFiltered && ( )}
)} r.id} selectedKey={filters.selected} onRowClick={(r) => select(r.id === filters.selected ? null : r.id)} rowMenu={rowMenu} empty={ isLoading ? ( ) : ( onEdit('new')}> New invoice } /> ) } />
{selected && (
{selected.customer?.name} {selected.due_date && ` · ${relativeDue(selected.due_date)}`} } filename={`invoice-${selected.invoice_number}`} onClose={() => select(null)} />
)}
setConfirm(null)} /> setSendTarget(null)} /> ); };