import type { Column } from './components'; import type { ConfirmState } from './ConfirmDialog'; import type { Invoice, RecurringInvoice, RecurringStatus } from './shared'; import { useState } from 'react'; import { MoreHorizontal, Plus, Repeat, Trash2, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { EmptyState, ErrorState, Field, Pagination, SearchBox, SectionShell, SimpleTable, StatusBadge, } from './components'; import { ConfirmDialog } from './ConfirmDialog'; import { formatDate, formatMoney } from './format'; import { PAGE_SIZE, RECURRING_TABS, frequencyLabel } from './shared'; import { StatusTabs } from './components'; import { useListFilters } from './useInvoicesSection'; import { useCurrency, useResourceDetail, useResourceList, useResourceMutations } from './useInvoiceShelfData'; // Recurring invoices — a schedule, not a document. // // This is the one section whose detail pane is NOT a PDF, and it is not an omission: a recurring invoice has // no unique_hash and no PDF route upstream, because there is nothing to render. What it has is a schedule and // a list of the invoices that schedule has already generated, so that is what the pane shows. // // The schedule's `frequency` is a cron expression; frequencyLabel turns the ones upstream's own form can // produce back into words and leaves anything else alone. type Props = { onEdit: (id: number | 'new') => void }; export const RecurringListView = ({ onEdit }: Props) => { const { filters, patch, select, setPage, clearFilters } = useListFilters(); const currency = useCurrency(); const [selection, setSelection] = useState>(new Set()); const [confirm, setConfirm] = useState(null); const { rows, meta, isLoading, error } = useResourceList('recurring-invoices', { page: filters.page, limit: PAGE_SIZE, search: filters.search || undefined, status: filters.status || undefined, customer_id: filters.customerId || undefined, orderByField: 'created_at', orderBy: 'desc', }); const { remove, removeMany } = useResourceMutations('recurring-invoices', 'recurring invoices'); 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: 'starts_at', label: 'Starts at', width: 'minmax(90px, 0.9fr)', render: (row) => formatDate(row.starts_at, row.formatted_starts_at), }, { key: 'customer', label: 'Customer', width: 'minmax(140px, 1.6fr)', render: (row) => (
{row.customer?.name ?? '—'}
{row.customer?.contact_name && (
{row.customer.contact_name}
)}
), }, { key: 'frequency', label: 'Frequency', width: 'minmax(100px, 1.1fr)', render: (row) => {frequencyLabel(row.frequency)}, }, { key: 'status', label: 'Status', width: 'minmax(90px, 0.9fr)', render: (row) => , }, { key: 'total', label: 'Total', width: 'minmax(100px, 1fr)', align: 'right', render: (row) => formatMoney(row.total, currency), }, ]; const rowMenu = (row: RecurringInvoice) => ( onEdit(row.id)}>Edit select(row.id)}>View schedule setConfirm({ title: 'Delete this schedule?', description: 'Invoices it has already generated are kept — only the schedule stops. Nothing further will be issued.', 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)))} /> patch({ status: v })} /> patch({ q: v })} placeholder="Customer…" />
{filters.isFiltered && ( )} {selection.size > 0 && ( )}
); return ( <>
}> r.id} selectedKey={filters.selected} onRowClick={(r) => select(r.id === filters.selected ? null : r.id)} rowMenu={rowMenu} empty={ isLoading ? ( ) : ( ) } />
{filters.selected != null && (
select(null)} />
)}
setConfirm(null)} /> ); }; /** * The schedule pane. Fetched by id rather than taken from the list row because `invoices` is only present on * the singular read — the index resource omits the relation. */ const RecurringDetail = ({ id, onClose }: { id: number; onClose: () => void }) => { const { record, isLoading } = useResourceDetail('recurring-invoices', id); const currency = useCurrency(); const generated: Invoice[] = record?.invoices ?? []; return (
{record?.customer?.name ?? 'Schedule'}
{frequencyLabel(record?.frequency)}
{isLoading &&
Loading…
} {record && ( <>
{formatDate(record.starts_at, record.formatted_starts_at)} {formatDate(record.next_invoice_at, record.formatted_next_invoice_at)} {frequencyLabel(record.frequency)} {limitLabel(record)} {record.send_automatically ? 'Yes' : 'No'} {formatMoney(record.total, currency)}
Generated invoices
{generated.length === 0 ? (
Nothing issued yet.
) : (
{generated.map((inv) => (
{inv.invoice_number} {formatMoney(inv.total, currency)}
))}
)}
{record.notes && (
{record.notes}
)} )}
); }; const limitLabel = (row: RecurringInvoice): string => { if (row.limit_by === 'COUNT') return `${row.limit_count ?? 0} invoices`; if (row.limit_by === 'DATE') return formatDate(row.limit_date, row.formatted_limit_date); return 'None'; };