import type { Column } from './components'; import type { ConfirmState } from './ConfirmDialog'; import type { Address, Customer } from './shared'; import { useState } from 'react'; import { Link } from 'react-router'; import { Filter, MoreHorizontal, Plus, Trash2, Users, X } 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, Field, Pagination, SearchBox, SectionShell, SimpleTable } from './components'; import { ConfirmDialog } from './ConfirmDialog'; import { formatDate, formatMoney } from './format'; import { invoicesCustomerPath, PAGE_SIZE } from './shared'; import { useListFilters } from './useInvoicesSection'; import { useCurrency, useCustomerStats, useResourceList, useResourceMutations } from './useInvoiceShelfData'; // Customers. Upstream splits the search into three separate fields rather than one box — display name, // contact name and phone are distinct API filters — so the toolbar search maps to display name and the // other two live behind the filter row, which is where upstream puts them too. type Props = { onEdit: (id: number | 'new') => void }; export const CustomersListView = ({ onEdit }: Props) => { const { filters, patch, select, setPage, clearFilters } = useListFilters(); const currency = useCurrency(); const [showFilters, setShowFilters] = useState(false); const [selection, setSelection] = useState>(new Set()); const [confirm, setConfirm] = useState(null); const { rows, meta, isLoading, error } = useResourceList('customers', { page: filters.page, limit: PAGE_SIZE, display_name: filters.search || undefined, // `status` and `from` carry contact name and phone here — one query-string slot per filter, and // customers have no status or date range to compete for them. contact_name: filters.status || undefined, phone: filters.fromDate || undefined, orderByField: 'created_at', orderBy: 'desc', }); const { remove, removeMany } = useResourceMutations('customers'); 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: 'name', label: 'Name', width: 'minmax(150px, 2fr)', render: (row) => (
{row.name}
{row.contact_name &&
{row.contact_name}
}
), }, { key: 'email', label: 'Email', width: 'minmax(120px, 1.4fr)', render: (row) => {row.email ?? '—'}, }, { key: 'phone', label: 'Phone', width: 'minmax(100px, 1fr)', render: (row) => row.phone ?? '—', }, { key: 'due_amount', label: 'Amount due', width: 'minmax(100px, 1fr)', align: 'right', render: (row) => formatMoney(row.due_amount, currency), }, { key: 'created_at', label: 'Added', width: 'minmax(90px, 0.9fr)', render: (row) => formatDate(row.created_at, row.formatted_created_at), }, ]; const rowMenu = (row: Customer) => ( onEdit(row.id)}>Edit select(row.id)}>View {/* Both go somewhere and change nothing, so they are anchors wearing a menu item — `asChild` hands Radix's item semantics to the `` rather than nesting one inside the other. Middle-click opens the filtered list in a tab, which is the point of the filter living in the URL. */} Their invoices Their payments setConfirm({ title: `Delete ${row.name}?`, description: 'InvoiceShelf refuses this while the customer still has invoices, estimates or payments — delete those first.', 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({ q: v })} placeholder="Display name…" />
{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 ? ( ) : ( ) } />
{selected && (
onEdit(selected.id)} onClose={() => select(null)} />
)}
setConfirm(null)} /> ); }; const CustomerDetail = ({ customer, onEdit, onClose, }: { customer: Customer; onEdit: () => void; onClose: () => void; }) => { const currency = useCurrency(); const { stats } = useCustomerStats(customer.id); const chart = stats?.meta?.chartData; return (
{customer.name}
{customer.email &&
{customer.email}
}
{chart && (
)}
{formatMoney(customer.due_amount, currency)} {customer.contact_name && {customer.contact_name}} {customer.phone && {customer.phone}} {customer.website && {customer.website}} {customer.tax_id && {customer.tax_id}} {formatDate(customer.created_at, customer.formatted_created_at)}
); }; const Totals = ({ label, value }: { label: string; value: string }) => (
{label}
{value}
); const AddressCard = ({ title, address }: { title: string; address?: Address | null }) => { const lines = [ address?.address_street_1, address?.address_street_2, [address?.city, address?.state, address?.zip].filter(Boolean).join(' '), address?.country?.name, ].filter((l): l is string => Boolean(l && l.trim())); if (!lines.length) return null; return (
{title}
{lines.join('\n')}
); };