mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
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:
61
src/components/application/table/dynamic-table.tsx
Normal file
61
src/components/application/table/dynamic-table.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,8 @@ import { faFileImport, faCheck, faSpinner, faTriangleExclamation, faCloudArrowUp
|
||||
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
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 { useData } from '@/providers/data-provider';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
@@ -202,7 +204,7 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
|
||||
<Modal className="sm:max-w-5xl">
|
||||
<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 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-secondary shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -247,7 +249,7 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
|
||||
{campaign.campaignStatus}
|
||||
</Badge>
|
||||
</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>
|
||||
))
|
||||
)}
|
||||
@@ -281,14 +283,15 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
|
||||
{noPhoneCount > 0 && <span className="text-error-primary">{noPhoneCount} no phone</span>}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-secondary">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-secondary">
|
||||
{mapping.map(m => (
|
||||
<th key={m.csvHeader} className="px-3 py-2 text-left font-normal">
|
||||
<div className="overflow-hidden rounded-lg ring-1 ring-secondary">
|
||||
<DynamicTable<DynamicRow>
|
||||
columns={[
|
||||
...mapping.map(m => ({
|
||||
id: m.csvHeader,
|
||||
label: m.csvHeader,
|
||||
headerRenderer: () => (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-quaternary uppercase">{m.csvHeader}</span>
|
||||
<span className="text-[10px] text-quaternary uppercase block">{m.csvHeader}</span>
|
||||
<select
|
||||
value={m.leadField ?? ''}
|
||||
onChange={e => handleMappingChange(m.csvHeader, e.target.value || null)}
|
||||
@@ -300,44 +303,28 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2 text-left font-normal">
|
||||
<span className="text-[10px] text-quaternary uppercase">Patient Match</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rowsWithMatch.slice(0, 20).map((item, i) => (
|
||||
<tr key={i} className={cx(
|
||||
'border-t border-secondary',
|
||||
item.isDuplicate && 'bg-warning-primary opacity-60',
|
||||
!item.hasPhone && 'bg-error-primary opacity-40',
|
||||
)}>
|
||||
{mapping.map(m => (
|
||||
<td key={m.csvHeader} className="px-3 py-2 text-tertiary truncate max-w-[150px]">
|
||||
{item.row[m.csvHeader] ?? ''}
|
||||
</td>
|
||||
))}
|
||||
<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>
|
||||
),
|
||||
}) as DynamicColumn),
|
||||
{ id: '__match__', label: 'Patient Match' },
|
||||
]}
|
||||
rows={rowsWithMatch.map((item, i) => ({ id: `row-${i}`, ...item }))}
|
||||
maxRows={20}
|
||||
renderCell={(row, columnId) => {
|
||||
if (columnId === '__match__') {
|
||||
if (row.matchedPatient) return <Badge size="sm" color="success" type="pill-color">{row.matchedPatient.fullName?.firstName ?? 'Patient'}</Badge>;
|
||||
if (row.isDuplicate) return <Badge size="sm" color="warning" type="pill-color">Duplicate</Badge>;
|
||||
if (!row.hasPhone) return <Badge size="sm" color="error" type="pill-color">No phone</Badge>;
|
||||
return <Badge size="sm" color="gray" type="pill-color">New</Badge>;
|
||||
}
|
||||
return <span className="text-tertiary truncate block max-w-[150px]">{row.row?.[columnId] ?? ''}</span>;
|
||||
}}
|
||||
rowClassName={(row) => cx(
|
||||
row.isDuplicate && 'bg-warning-primary opacity-60',
|
||||
!row.hasPhone && 'bg-error-primary opacity-40',
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
/>
|
||||
{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
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user