diff --git a/web/src/components/common/EntityTable.tsx b/web/src/components/common/EntityTable.tsx new file mode 100644 index 0000000..1d9b412 --- /dev/null +++ b/web/src/components/common/EntityTable.tsx @@ -0,0 +1,156 @@ +import type { ReactNode } from 'react'; + +/** + * Column definition for {@link EntityTable}. + * + * @typeParam T - The row data type. + */ +export interface EntityTableColumn { + /** Stable key for the column. */ + key: string; + /** Header label (rendered in the uppercase header style). */ + header: string; + /** Horizontal alignment for header + cell. Defaults to `'left'`. */ + align?: 'left' | 'right'; + /** Optional extra class names applied to each `` for this column. */ + cellClassName?: string; + /** Renders the cell content for a given row. */ + render: (item: T) => ReactNode; +} + +interface EntityTableProps { + items: T[]; + columns: EntityTableColumn[]; + /** Resolves the stable id for a row (used for selection + keys). */ + getRowId: (item: T) => string; + selectedIds: Set; + onSelectionChange: (ids: Set) => void; + /** Invoked when a row body (not the checkbox) is clicked. */ + onRowClick?: (item: T) => void; + /** Accessible label for a row's selection checkbox. */ + getRowSelectLabel?: (item: T) => string; + /** Message shown when there are no rows. */ + emptyMessage?: string; +} + +/** + * Shared, column-driven data table used by both the Invoices list and the + * Work Orders list so the two stay visually identical. The markup and styling + * mirror the original `InvoiceTable` exactly (bordered container, uppercase + * header row, select-all checkbox, hover/selected row states). + */ +export function EntityTable({ + items, + columns, + getRowId, + selectedIds, + onSelectionChange, + onRowClick, + getRowSelectLabel, + emptyMessage = 'No items found.', +}: EntityTableProps) { + const allSelected = + items.length > 0 && items.every((item) => selectedIds.has(getRowId(item))); + + function handleSelectAll(checked: boolean) { + if (checked) { + onSelectionChange(new Set(items.map((item) => getRowId(item)))); + } else { + onSelectionChange(new Set()); + } + } + + function handleRowSelect(id: string, checked: boolean) { + const next = new Set(selectedIds); + if (checked) { + next.add(id); + } else { + next.delete(id); + } + onSelectionChange(next); + } + + const colSpan = columns.length + 1; + + return ( +
+ + + + + {columns.map((col) => ( + + ))} + + + + {items.length === 0 ? ( + + + + ) : ( + items.map((item) => { + const id = getRowId(item); + const isSelected = selectedIds.has(id); + + return ( + onRowClick?.(item)} + > + + {columns.map((col) => ( + + ))} + + ); + }) + )} + +
+ handleSelectAll(e.target.checked)} + className="rounded border-[var(--border)] accent-[var(--accent)]" + aria-label="Select all" + /> + + {col.header} +
+ {emptyMessage} +
e.stopPropagation()} + > + handleRowSelect(id, e.target.checked)} + className="rounded border-[var(--border)] accent-[var(--accent)]" + aria-label={getRowSelectLabel?.(item) ?? 'Select row'} + /> + + {col.render(item)} +
+
+ ); +} diff --git a/web/src/components/finance/InvoiceTable.tsx b/web/src/components/finance/InvoiceTable.tsx index 3c957f2..b09047a 100644 --- a/web/src/components/finance/InvoiceTable.tsx +++ b/web/src/components/finance/InvoiceTable.tsx @@ -1,6 +1,7 @@ import type { WorkItem, Client, InvoiceRisk } from '../../lib/types'; import { formatCurrency, formatDate } from '../../lib/utils'; import { InsightBadge } from '../insights/InsightBadge'; +import { EntityTable, type EntityTableColumn } from '../common/EntityTable'; interface InvoiceTableProps { workItems: WorkItem[]; @@ -21,135 +22,86 @@ const STATUS_COLORS: Record = { export function InvoiceTable({ workItems, clients, selectedIds, onSelectionChange, onRowClick, invoiceRisks }: InvoiceTableProps) { const clientMap = new Map(clients.map(c => [c.id, c.name])); - const allSelected = workItems.length > 0 && workItems.every(item => selectedIds.has(item.id ?? '')); - - function handleSelectAll(checked: boolean) { - if (checked) { - onSelectionChange(new Set(workItems.map(item => item.id ?? ''))); - } else { - onSelectionChange(new Set()); - } - } - - function handleRowSelect(id: string, checked: boolean) { - const next = new Set(selectedIds); - if (checked) { - next.add(id); - } else { - next.delete(id); - } - onSelectionChange(next); - } + const columns: EntityTableColumn[] = [ + { + key: 'subject', + header: 'Work Order / Client', + render: (item) => { + const clientName = clientMap.get(item.clientId) ?? item.clientId; + return ( + <> +
+ {item.subject || '(No subject)'} +
+
+ {clientName} + {item.isRetainerInvoice && ( + + Retainer + + )} +
+ + ); + }, + }, + { + key: 'amount', + header: 'Amount', + align: 'right', + cellClassName: 'font-mono text-[var(--text-primary)]', + render: (item) => formatCurrency(item.totalCost), + }, + { + key: 'sentDate', + header: 'Sent Date', + cellClassName: 'text-[var(--text-secondary)]', + render: (item) => (item.invoiceSentDate ? formatDate(item.invoiceSentDate) : '—'), + }, + { + key: 'dueDate', + header: 'Due Date', + cellClassName: 'text-[var(--text-secondary)]', + render: (item) => (item.invoiceDueDate ? formatDate(item.invoiceDueDate) : '—'), + }, + { + key: 'status', + header: 'Status', + render: (item) => { + const status = item.invoiceStatus ?? 'draft'; + const risk = invoiceRisks?.find((r) => r.workItemId === (item.id ?? '')); + return ( +
+ + {status.charAt(0).toUpperCase() + status.slice(1)} + + {risk && ( + + )} + {risk?.predictedPayDate && ( + + ~{new Date(risk.predictedPayDate).toLocaleDateString()} + + )} +
+ ); + }, + }, + ]; return ( -
- - - - - - - - - - - - - {workItems.length === 0 ? ( - - - - ) : ( - workItems.map(item => { - const id = item.id ?? ''; - const isSelected = selectedIds.has(id); - const status = item.invoiceStatus ?? 'draft'; - const clientName = clientMap.get(item.clientId) ?? item.clientId; - const risk = invoiceRisks?.find((r) => r.workItemId === id); - - return ( - onRowClick?.(item)} - > - - - - - - - - ); - }) - )} - -
- handleSelectAll(e.target.checked)} - className="rounded border-[var(--border)] accent-[var(--accent)]" - aria-label="Select all" - /> - - Work Order / Client - - Amount - - Sent Date - - Due Date - - Status -
- No invoices found. -
e.stopPropagation()}> - handleRowSelect(id, e.target.checked)} - className="rounded border-[var(--border)] accent-[var(--accent)]" - aria-label={`Select ${item.subject}`} - /> - -
- {item.subject || '(No subject)'} -
-
- {clientName} - {item.isRetainerInvoice && ( - - Retainer - - )} -
-
- {formatCurrency(item.totalCost)} - - {item.invoiceSentDate ? formatDate(item.invoiceSentDate) : '—'} - - {item.invoiceDueDate ? formatDate(item.invoiceDueDate) : '—'} - -
- - {status.charAt(0).toUpperCase() + status.slice(1)} - - {risk && ( - - )} - {risk?.predictedPayDate && ( - - ~{new Date(risk.predictedPayDate).toLocaleDateString()} - - )} -
-
-
+ item.id ?? ''} + selectedIds={selectedIds} + onSelectionChange={onSelectionChange} + onRowClick={onRowClick} + getRowSelectLabel={(item) => `Select ${item.subject}`} + emptyMessage="No invoices found." + /> ); } diff --git a/web/src/components/workitems/WorkItemTable.tsx b/web/src/components/workitems/WorkItemTable.tsx new file mode 100644 index 0000000..753fbca --- /dev/null +++ b/web/src/components/workitems/WorkItemTable.tsx @@ -0,0 +1,148 @@ +import type { WorkItem, Client } from '../../lib/types'; +import { formatCurrency, formatHours, formatDate } from '../../lib/utils'; +import { StatusBadge } from './StatusBadge'; +import { TypeTag } from './TypeTag'; +import { InsightBadge } from '../insights/InsightBadge'; +import { EntityTable, type EntityTableColumn } from '../common/EntityTable'; +import type { CompletionEstimate, ScopeCreepAlert } from '../../lib/types'; + +interface WorkItemTableProps { + workItems: WorkItem[]; + clients: Client[]; + appMap: Record; + selectedIds: Set; + onSelectionChange: (ids: Set) => void; + onRowClick?: (workItem: WorkItem) => void; + completionEstimates?: CompletionEstimate[]; + scopeCreep?: ScopeCreepAlert[]; +} + +/** + * Work Orders list rendered with the shared {@link EntityTable} so it is + * visually identical to the Invoices list. Columns are adapted to + * work-order-relevant fields (subject/client, type, hours, cost, created, + * status) mirroring how the invoice table shows its analogous fields. + */ +export function WorkItemTable({ + workItems, + clients, + appMap, + selectedIds, + onSelectionChange, + onRowClick, + completionEstimates, + scopeCreep, +}: WorkItemTableProps) { + const clientMap = new Map(clients.map((c) => [c.id, c.name])); + + const columns: EntityTableColumn[] = [ + { + key: 'subject', + header: 'Work Order / Client', + render: (item) => { + const clientName = clientMap.get(item.clientId) ?? item.clientId; + const appName = item.appId ? appMap[item.appId] : undefined; + return ( + <> +
+ {item.subject || '(No subject)'} +
+
+ {clientName} + {appName && ( + + {appName} + + )} + {!item.isBillable && ( + + Non-Billable + + )} +
+ + ); + }, + }, + { + key: 'type', + header: 'Type', + render: (item) => , + }, + { + key: 'hours', + header: 'Hours', + align: 'right', + cellClassName: 'font-mono text-[var(--text-secondary)] tabular-nums', + render: (item) => formatHours(item.totalHours), + }, + { + key: 'cost', + header: 'Cost', + align: 'right', + cellClassName: 'font-mono text-[var(--text-primary)] tabular-nums', + render: (item) => formatCurrency(item.totalCost), + }, + { + key: 'created', + header: 'Created', + cellClassName: 'text-[var(--text-secondary)]', + render: (item) => formatDate(item.createdAt), + }, + { + key: 'status', + header: 'Status', + render: (item) => { + const estimate = completionEstimates?.find((e) => e.workItemId === item.id); + const creep = scopeCreep?.find((s) => s.workItemId === item.id); + return ( +
+ + {item.clientApproval && ( + + {item.clientApproval === 'approved' + ? 'Approved' + : item.clientApproval === 'rejected' + ? 'Rejected' + : 'Pending'} + + )} + {estimate && ( + + )} + {creep && ( + + )} +
+ ); + }, + }, + ]; + + return ( + item.id ?? ''} + selectedIds={selectedIds} + onSelectionChange={onSelectionChange} + onRowClick={onRowClick} + getRowSelectLabel={(item) => `Select ${item.subject}`} + emptyMessage="No work orders found." + /> + ); +} diff --git a/web/src/routes/contractor/WorkItems.tsx b/web/src/routes/contractor/WorkItems.tsx index 574397f..fdb281c 100644 --- a/web/src/routes/contractor/WorkItems.tsx +++ b/web/src/routes/contractor/WorkItems.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { WorkItemCard } from '../../components/workitems/WorkItemCard'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { WorkItemTable } from '../../components/workitems/WorkItemTable'; import { FilterTabs } from '../../components/workitems/FilterTabs'; import { NewWorkOrderModal } from '../../components/workitems/NewWorkOrderModal'; import type { WorkItem, Client, AppSettings, App } from '../../lib/types'; @@ -10,7 +10,6 @@ import { isWorkOrder } from '../../lib/workItem'; import { bulkUpdateStatus, convertToInvoice } from '../../services/firestore'; import { IconDocument } from '../../components/icons'; import { useInsights } from '../../hooks/useFirestore'; -import { InsightBadge } from '../../components/insights/InsightBadge'; interface WorkItemsProps { workItems: WorkItem[]; @@ -24,6 +23,7 @@ const statusTabs = ['Open', 'All', 'Draft', 'In Review', 'Approved', 'Completed' export default function WorkItems({ workItems, clients, apps, settings }: WorkItemsProps) { const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); const { insights } = useInsights(); const [search, setSearch] = useState(''); @@ -132,13 +132,6 @@ export default function WorkItems({ workItems, clients, apps, settings }: WorkIt }); }, [workItems, selectedType, selectedStatus, search, clientMap, selectedClients, selectedApps, dateRange]); - function toggleSelect(id: string) { - const next = new Set(selectedIds); - if (next.has(id)) next.delete(id); - else next.add(id); - setSelectedIds(next); - } - async function handleBulkApprove() { const ids = [...selectedIds].filter((id) => { const item = workItems.find((i) => i.id === id); @@ -385,42 +378,18 @@ export default function WorkItems({ workItems, clients, apps, settings }: WorkIt )} {/* List */} -
- {filtered.map((item, i) => { - const estimate = insights?.projects?.completionEstimates?.find((e) => e.workItemId === item.id); - const creep = insights?.projects?.scopeCreep?.find((s) => s.workItemId === item.id); - return ( -
- - {(estimate || creep) && ( -
- {estimate && ( - - )} - {creep && ( - - )} -
- )} -
- ); - })} -
+ {filtered.length > 0 && ( + navigate(`/dashboard/work-items/${item.id}`)} + completionEstimates={insights?.projects?.completionEstimates} + scopeCreep={insights?.projects?.scopeCreep} + /> + )} {filtered.length === 0 && (