import type { Column } from './components'; import type { ConfirmState } from './ConfirmDialog'; import type { SendTarget } from './SendDocumentDialog'; import type { Estimate } from './shared'; import { useState } from 'react'; import { Check, Copy, FileSpreadsheet, FileUp, Filter, MoreHorizontal, Plus, Send, Trash2, X } 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 } from './format'; import { ESTIMATE_TABS, PAGE_SIZE } from './shared'; import { useListFilters } from './useInvoicesSection'; import { useCurrency, useDocumentAction, useLookups, useResourceList, useResourceMutations, } from './useInvoiceShelfData'; // Estimates — upstream's EstimateIndexView. Same skeleton as the invoices list, different verbs: an estimate // is accepted or rejected rather than paid, and its terminal move is converting into an invoice. // // Convert is one-way and upstream refuses it on a REJECTED estimate, so the item is hidden in that state // rather than offered and rejected. After a successful convert the new invoice exists alongside this // estimate; the toast says so, because nothing on this screen would otherwise show where it went. type Props = { onEdit: (id: number | 'new') => void; onConverted: (invoiceId: number) => void }; export const EstimatesListView = ({ onEdit, onConverted }: 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('estimates', { page: filters.page, limit: PAGE_SIZE, estimate_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 { remove, removeMany } = useResourceMutations('estimates'); const action = useDocumentAction('estimates'); 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: 'estimate_date', label: 'Date', width: 'minmax(90px, 0.8fr)', render: (row) => formatDate(row.estimate_date, row.formatted_estimate_date), }, { key: 'estimate_number', label: 'Number', width: 'minmax(90px, 0.8fr)', render: (row) => {row.estimate_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: 'total', label: 'Total', width: 'minmax(100px, 1fr)', align: 'right', render: (row) => formatMoney(row.total, currency), }, ]; const setStatus = (row: Estimate, status: string, label: string) => setConfirm({ title: `Mark ${row.estimate_number} as ${label.toLowerCase()}?`, description: 'This changes the status only. Nothing is emailed to the customer.', confirmLabel: `Mark as ${label.toLowerCase()}`, onConfirm: () => action.mutate({ id: row.id, action: 'status', payload: { status } }), }); const rowMenu = (row: Estimate) => ( onEdit(row.id)}>Edit select(row.id)}>View PDF {row.status === 'DRAFT' && ( setSendTarget({ resource: 'estimates', id: row.id, number: row.estimate_number, customerName: row.customer?.name ?? '', customerEmail: row.customer?.email ?? null, }) } > Send estimate )} {(row.status === 'SENT' || row.status === 'VIEWED') && ( setSendTarget({ resource: 'estimates', id: row.id, number: row.estimate_number, customerName: row.customer?.name ?? '', customerEmail: row.customer?.email ?? null, resend: true, }) } > Resend estimate )} {row.status !== 'SENT' && ( setStatus(row, 'SENT', 'Sent')}>Mark as sent )} {row.status !== 'ACCEPTED' && row.status !== 'REJECTED' && ( setStatus(row, 'ACCEPTED', 'Accepted')}> Mark as accepted )} {row.status !== 'REJECTED' && row.status !== 'ACCEPTED' && ( setStatus(row, 'REJECTED', 'Rejected')}> Mark as rejected )} action.mutate({ id: row.id, action: 'clone' })}> Clone estimate {row.status !== 'REJECTED' && ( setConfirm({ title: `Convert ${row.estimate_number} into an invoice?`, description: 'A new invoice is created from this estimate. The estimate itself stays where it is — this is not a move.', confirmLabel: 'Convert', onConfirm: () => action.mutate( { id: row.id, action: 'convert-to-invoice' }, { onSuccess: (data) => { const newId = (data as { data?: { id?: number } })?.data?.id; if (newId) onConverted(newId); }, }, ), }) } > Convert into invoice )} setConfirm({ title: `Delete estimate ${row.estimate_number}?`, description: 'This permanently removes the estimate.', 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 }); setSelection(new Set()); }} /> patch({ q: v })} placeholder="Estimate 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 estimate } /> ) } />
{selected && (
select(null)} />
)}
setConfirm(null)} /> setSendTarget(null)} /> ); };