feat: DynamicTable adapter for Untitled UI Table + import preview upgrade

- DynamicTable component: wraps Table for dynamic/unknown columns with headerRenderer support
- Import wizard preview now uses DynamicTable instead of plain HTML table
- Fixed modal height (80vh) to prevent jitter between wizard steps
- Campaign card shows actual linked lead count, not marketing metric

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 13:53:46 +05:30
parent f0ed4ad32b
commit b8ae561d0f
2 changed files with 106 additions and 58 deletions

View File

@@ -0,0 +1,61 @@
import type { ReactNode } from 'react';
import { TableBody as AriaTableBody } from 'react-aria-components';
import { Table } from './table';
export type DynamicColumn = {
id: string;
label: string;
headerRenderer?: () => ReactNode;
width?: string;
};
export type DynamicRow = {
id: string;
[key: string]: any;
};
interface DynamicTableProps<T extends DynamicRow> {
columns: DynamicColumn[];
rows: T[];
renderCell: (row: T, columnId: string) => ReactNode;
rowClassName?: (row: T) => string;
size?: 'sm' | 'md';
maxRows?: number;
}
export const DynamicTable = <T extends DynamicRow>({
columns,
rows,
renderCell,
rowClassName,
size = 'sm',
maxRows,
}: DynamicTableProps<T>) => {
const displayRows = maxRows ? rows.slice(0, maxRows) : rows;
return (
<Table size={size} aria-label="Dynamic table">
<Table.Header>
{columns.map(col => (
<Table.Head key={col.id} id={col.id} label={col.headerRenderer ? '' : col.label}>
{col.headerRenderer?.()}
</Table.Head>
))}
</Table.Header>
<AriaTableBody items={displayRows}>
{(row) => (
<Table.Row
id={row.id}
className={rowClassName?.(row)}
>
{columns.map(col => (
<Table.Cell key={col.id}>
{renderCell(row, col.id)}
</Table.Cell>
))}
</Table.Row>
)}
</AriaTableBody>
</Table>
);
};

View File

@@ -4,6 +4,8 @@ import { faFileImport, faCheck, faSpinner, faTriangleExclamation, faCloudArrowUp
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal'; import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { DynamicTable } from '@/components/application/table/dynamic-table';
import type { DynamicColumn, DynamicRow } from '@/components/application/table/dynamic-table';
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon'; import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
@@ -202,7 +204,7 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
<Modal className="sm:max-w-5xl"> <Modal className="sm:max-w-5xl">
<Dialog> <Dialog>
{() => ( {() => (
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden max-h-[85vh]"> <div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden" style={{ height: '80vh', minHeight: '500px' }}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-secondary shrink-0"> <div className="flex items-center justify-between px-6 py-4 border-b border-secondary shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -247,7 +249,7 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
{campaign.campaignStatus} {campaign.campaignStatus}
</Badge> </Badge>
</div> </div>
<span className="mt-2 text-xs text-tertiary">{campaign.leadCount ?? 0} leads</span> <span className="mt-2 text-xs text-tertiary">{leads.filter(l => l.campaignId === campaign.id).length} leads</span>
</button> </button>
)) ))
)} )}
@@ -281,63 +283,48 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
{noPhoneCount > 0 && <span className="text-error-primary">{noPhoneCount} no phone</span>} {noPhoneCount > 0 && <span className="text-error-primary">{noPhoneCount} no phone</span>}
</div> </div>
<div className="overflow-x-auto rounded-lg border border-secondary"> <div className="overflow-hidden rounded-lg ring-1 ring-secondary">
<table className="w-full text-xs"> <DynamicTable<DynamicRow>
<thead> columns={[
<tr className="bg-secondary"> ...mapping.map(m => ({
{mapping.map(m => ( id: m.csvHeader,
<th key={m.csvHeader} className="px-3 py-2 text-left font-normal"> label: m.csvHeader,
<div className="space-y-1"> headerRenderer: () => (
<span className="text-[10px] text-quaternary uppercase">{m.csvHeader}</span> <div className="space-y-1">
<select <span className="text-[10px] text-quaternary uppercase block">{m.csvHeader}</span>
value={m.leadField ?? ''} <select
onChange={e => handleMappingChange(m.csvHeader, e.target.value || null)} value={m.leadField ?? ''}
className="w-full rounded border border-secondary bg-primary px-2 py-1 text-xs text-primary" onChange={e => handleMappingChange(m.csvHeader, e.target.value || null)}
> className="w-full rounded border border-secondary bg-primary px-2 py-1 text-xs text-primary"
<option value="">Skip</option> >
{LEAD_FIELDS.map(f => ( <option value="">Skip</option>
<option key={f.field} value={f.field}>{f.label}</option> {LEAD_FIELDS.map(f => (
))} <option key={f.field} value={f.field}>{f.label}</option>
</select> ))}
</div> </select>
</th> </div>
))} ),
<th className="px-3 py-2 text-left font-normal"> }) as DynamicColumn),
<span className="text-[10px] text-quaternary uppercase">Patient Match</span> { id: '__match__', label: 'Patient Match' },
</th> ]}
</tr> rows={rowsWithMatch.map((item, i) => ({ id: `row-${i}`, ...item }))}
</thead> maxRows={20}
<tbody> renderCell={(row, columnId) => {
{rowsWithMatch.slice(0, 20).map((item, i) => ( if (columnId === '__match__') {
<tr key={i} className={cx( if (row.matchedPatient) return <Badge size="sm" color="success" type="pill-color">{row.matchedPatient.fullName?.firstName ?? 'Patient'}</Badge>;
'border-t border-secondary', if (row.isDuplicate) return <Badge size="sm" color="warning" type="pill-color">Duplicate</Badge>;
item.isDuplicate && 'bg-warning-primary opacity-60', if (!row.hasPhone) return <Badge size="sm" color="error" type="pill-color">No phone</Badge>;
!item.hasPhone && 'bg-error-primary opacity-40', return <Badge size="sm" color="gray" type="pill-color">New</Badge>;
)}> }
{mapping.map(m => ( return <span className="text-tertiary truncate block max-w-[150px]">{row.row?.[columnId] ?? ''}</span>;
<td key={m.csvHeader} className="px-3 py-2 text-tertiary truncate max-w-[150px]"> }}
{item.row[m.csvHeader] ?? ''} rowClassName={(row) => cx(
</td> row.isDuplicate && 'bg-warning-primary opacity-60',
))} !row.hasPhone && 'bg-error-primary opacity-40',
<td className="px-3 py-2"> )}
{item.matchedPatient ? ( />
<Badge size="sm" color="success" type="pill-color">
{item.matchedPatient.fullName?.firstName ?? 'Patient'}
</Badge>
) : item.isDuplicate ? (
<Badge size="sm" color="warning" type="pill-color">Duplicate</Badge>
) : !item.hasPhone ? (
<Badge size="sm" color="error" type="pill-color">No phone</Badge>
) : (
<Badge size="sm" color="gray" type="pill-color">New</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
{csvRows.length > 20 && ( {csvRows.length > 20 && (
<div className="bg-secondary px-3 py-2 text-center text-xs text-tertiary"> <div className="bg-secondary px-3 py-2 text-center text-xs text-tertiary border-t border-secondary">
Showing 20 of {csvRows.length} rows Showing 20 of {csvRows.length} rows
</div> </div>
)} )}