Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions web/src/components/common/EntityTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { ReactNode } from 'react';

/**
* Column definition for {@link EntityTable}.
*
* @typeParam T - The row data type.
*/
export interface EntityTableColumn<T> {
/** 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 `<td>` for this column. */
cellClassName?: string;
/** Renders the cell content for a given row. */
render: (item: T) => ReactNode;
}

interface EntityTableProps<T> {
items: T[];
columns: EntityTableColumn<T>[];
/** Resolves the stable id for a row (used for selection + keys). */
getRowId: (item: T) => string;
selectedIds: Set<string>;
onSelectionChange: (ids: Set<string>) => 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<T>({
items,
columns,
getRowId,
selectedIds,
onSelectionChange,
onRowClick,
getRowSelectLabel,
emptyMessage = 'No items found.',
}: EntityTableProps<T>) {
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 (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--bg-card)] border-b border-[var(--border)]">
<th className="px-4 py-3 text-left w-10">
<input
type="checkbox"
checked={allSelected}
onChange={(e) => handleSelectAll(e.target.checked)}
className="rounded border-[var(--border)] accent-[var(--accent)]"
aria-label="Select all"
/>
</th>
{columns.map((col) => (
<th
key={col.key}
className={`px-4 py-3 font-medium text-[var(--text-secondary)] uppercase tracking-wide text-xs ${
col.align === 'right' ? 'text-right' : 'text-left'
}`}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td
colSpan={colSpan}
className="px-4 py-8 text-center text-[var(--text-secondary)]"
>
{emptyMessage}
</td>
</tr>
) : (
items.map((item) => {
const id = getRowId(item);
const isSelected = selectedIds.has(id);

return (
<tr
key={id}
className={`border-b border-[var(--border)] last:border-b-0 transition-colors cursor-pointer ${
isSelected
? 'bg-[var(--accent)]/5'
: 'bg-[var(--bg-page)] hover:bg-[var(--bg-card)]'
}`}
onClick={() => onRowClick?.(item)}
>
<td
className="px-4 py-3"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => handleRowSelect(id, e.target.checked)}
className="rounded border-[var(--border)] accent-[var(--accent)]"
aria-label={getRowSelectLabel?.(item) ?? 'Select row'}
/>
</td>
{columns.map((col) => (
<td
key={col.key}
className={`px-4 py-3 ${
col.align === 'right' ? 'text-right' : ''
} ${col.cellClassName ?? ''}`}
>
{col.render(item)}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
</div>
);
}
208 changes: 80 additions & 128 deletions web/src/components/finance/InvoiceTable.tsx
Original file line number Diff line number Diff line change
@@ -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[];
Expand All @@ -21,135 +22,86 @@ const STATUS_COLORS: Record<string, string> = {
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<WorkItem>[] = [
{
key: 'subject',
header: 'Work Order / Client',
render: (item) => {
const clientName = clientMap.get(item.clientId) ?? item.clientId;
return (
<>
<div className="font-medium text-[var(--text-primary)] truncate max-w-xs">
{item.subject || '(No subject)'}
</div>
<div className="text-xs text-[var(--text-secondary)] mt-0.5">
{clientName}
{item.isRetainerInvoice && (
<span className="ml-1.5 text-[10px] font-semibold text-[var(--color-green)] bg-[var(--color-green)]/10 px-1.5 py-0.5 rounded">
Retainer
</span>
)}
</div>
</>
);
},
},
{
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 (
<div className="flex flex-col gap-1">
<span
className="inline-block px-2 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: STATUS_COLORS[status] ?? STATUS_COLORS.draft }}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
{risk && (
<InsightBadge label={risk.risk} level={risk.risk} tooltip={risk.reason} />
)}
{risk?.predictedPayDate && (
<span className="text-xs text-[var(--text-secondary)]">
~{new Date(risk.predictedPayDate).toLocaleDateString()}
</span>
)}
</div>
);
},
},
];

return (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--bg-card)] border-b border-[var(--border)]">
<th className="px-4 py-3 text-left w-10">
<input
type="checkbox"
checked={allSelected}
onChange={e => handleSelectAll(e.target.checked)}
className="rounded border-[var(--border)] accent-[var(--accent)]"
aria-label="Select all"
/>
</th>
<th className="px-4 py-3 text-left font-medium text-[var(--text-secondary)] uppercase tracking-wide text-xs">
Work Order / Client
</th>
<th className="px-4 py-3 text-right font-medium text-[var(--text-secondary)] uppercase tracking-wide text-xs">
Amount
</th>
<th className="px-4 py-3 text-left font-medium text-[var(--text-secondary)] uppercase tracking-wide text-xs">
Sent Date
</th>
<th className="px-4 py-3 text-left font-medium text-[var(--text-secondary)] uppercase tracking-wide text-xs">
Due Date
</th>
<th className="px-4 py-3 text-left font-medium text-[var(--text-secondary)] uppercase tracking-wide text-xs">
Status
</th>
</tr>
</thead>
<tbody>
{workItems.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--text-secondary)]">
No invoices found.
</td>
</tr>
) : (
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 (
<tr
key={id}
className={`border-b border-[var(--border)] last:border-b-0 transition-colors cursor-pointer ${
isSelected ? 'bg-[var(--accent)]/5' : 'bg-[var(--bg-page)] hover:bg-[var(--bg-card)]'
}`}
onClick={() => onRowClick?.(item)}
>
<td className="px-4 py-3" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={e => handleRowSelect(id, e.target.checked)}
className="rounded border-[var(--border)] accent-[var(--accent)]"
aria-label={`Select ${item.subject}`}
/>
</td>
<td className="px-4 py-3">
<div className="font-medium text-[var(--text-primary)] truncate max-w-xs">
{item.subject || '(No subject)'}
</div>
<div className="text-xs text-[var(--text-secondary)] mt-0.5">
{clientName}
{item.isRetainerInvoice && (
<span className="ml-1.5 text-[10px] font-semibold text-[var(--color-green)] bg-[var(--color-green)]/10 px-1.5 py-0.5 rounded">
Retainer
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-right font-mono text-[var(--text-primary)]">
{formatCurrency(item.totalCost)}
</td>
<td className="px-4 py-3 text-[var(--text-secondary)]">
{item.invoiceSentDate ? formatDate(item.invoiceSentDate) : '—'}
</td>
<td className="px-4 py-3 text-[var(--text-secondary)]">
{item.invoiceDueDate ? formatDate(item.invoiceDueDate) : '—'}
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
<span
className="inline-block px-2 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: STATUS_COLORS[status] ?? STATUS_COLORS.draft }}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
{risk && (
<InsightBadge label={risk.risk} level={risk.risk} tooltip={risk.reason} />
)}
{risk?.predictedPayDate && (
<span className="text-xs text-[var(--text-secondary)]">
~{new Date(risk.predictedPayDate).toLocaleDateString()}
</span>
)}
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<EntityTable
items={workItems}
columns={columns}
getRowId={(item) => item.id ?? ''}
selectedIds={selectedIds}
onSelectionChange={onSelectionChange}
onRowClick={onRowClick}
getRowSelectLabel={(item) => `Select ${item.subject}`}
emptyMessage="No invoices found."
/>
);
}
Loading
Loading