import * as React from "react"; import { AlertCircleIcon, CheckCircle2Icon, CircleAlertIcon, FileTextIcon, Trash2Icon, } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { cn } from "@/lib/utils"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type FilePreviewState = | "loading" | "preview" | "empty" | "error" | "importing"; export type CsvRowStatus = "pending" | "success" | "failed"; export interface CsvPreviewColumn { key: string; /** Display label shown in the column header. */ label: string; } export interface CsvPreviewRow extends Record { _status?: CsvRowStatus; /** Shown in a tooltip when status is "failed". */ _statusMessage?: string; } export interface StaffOption { id: string; name: string; } export interface FilePreviewDialogProps { open: boolean; onOpenChange: (open: boolean) => void; /** Name of the file being previewed. */ fileName?: string; state?: FilePreviewState; errorMessage?: string; /** Ordered list of columns to display. */ columns?: CsvPreviewColumn[]; /** Data rows — keys match CsvPreviewColumn.key. */ rows?: CsvPreviewRow[]; /** Called when the user edits a cell inline. */ onRowChange?: (rowIndex: number, key: string, value: string) => void; /** Called when the user clicks the delete icon on a row. */ onRowDelete?: (rowIndex: number) => void; /** Called when the user clicks "Import". */ onImport?: () => void; /** Called when the user clicks "Cancel" during import. */ onCancelImport?: () => void; /** 0–100 progress value shown in the importing state. */ importProgress?: number; /** Total rows in the file (shown in the preview header). */ totalRows?: number; /** Rows that passed validation (shown in the preview header). */ validRows?: number; /** Rows per page for the preview table. Defaults to 10. */ pageSize?: number; /** * List of staff members available for assignment. * When provided, a basic staff selector is rendered above the table. * Import is blocked until a staff member is selected. * Ignored when `staffSelector` is provided. */ staffOptions?: StaffOption[]; /** Currently selected staff ID. */ selectedStaffId?: string; /** Called when the user picks a staff member. */ onStaffSelect?: (staffId: string) => void; /** * Custom staff-selector block rendered above the table. When provided, replaces * the entire built-in "Assign staff" block (label + select + helper text). * The caller still drives `selectedStaffId` so the Import button gating works. */ staffSelector?: React.ReactNode; /** * Rendered below the staff selector while previewing. Its purpose is the batch * tag step: tags chosen here apply to every client in the import, so it belongs * beside the assignment rather than after it. */ tagStep?: React.ReactNode; className?: string; } // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- function LoadingState({ columnCount }: { columnCount: number }) { const cols = Math.max(columnCount, 3); return ( {Array.from({ length: cols }).map((_, i) => ( ))} {Array.from({ length: 4 }).map((_, rowIdx) => ( {Array.from({ length: cols }).map((_, colIdx) => ( ))} ))}
); } function EmptyState() { return (

No data found

The CSV file appears to be empty or contains only headers.

); } function ErrorState({ message }: { message?: string }) { return (

Unable to parse file

{message ?? "The file could not be read. Please check the format and try again."}

); } function ImportingState({ progress, successCount, failedCount, }: { progress: number; successCount: number; failedCount: number; }) { return (
Importing… {progress}% {(successCount > 0 || failedCount > 0) && ( {successCount} success · {failedCount} failed )}
); } function RowStatusCell({ row }: { row: CsvPreviewRow }) { const { _status, _statusMessage } = row; if (_status === "success") { return (
); } if (_status === "failed") { return ( } /> {_statusMessage && ( {_statusMessage} )} ); } return null; } // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- export function FilePreviewDialog({ open, onOpenChange, fileName, state = "preview", errorMessage, columns = [], rows = [], onRowChange, onRowDelete, onImport, onCancelImport, importProgress = 0, totalRows, validRows, pageSize = 10, staffOptions, selectedStaffId, onStaffSelect, staffSelector, tagStep, className, }: FilePreviewDialogProps) { const [page, setPage] = React.useState(0); // Reset page when rows change React.useEffect(() => { setPage(0); }, [rows.length]); const totalPages = Math.ceil(rows.length / pageSize); const pagedRows = rows.slice(page * pageSize, (page + 1) * pageSize); const pageStart = page * pageSize; // used for row index display const isImporting = state === "importing"; const hasBuiltInStaffSelector = !!staffOptions && staffOptions.length > 0; const hasStaffSelector = !!staffSelector || hasBuiltInStaffSelector; // Import is blocked when staff selection is required but none is chosen yet const canImport = state === "preview" && rows.length > 0 && (!hasStaffSelector || !!selectedStaffId); const successCount = rows.filter((r) => r._status === "success").length; const failedCount = rows.filter((r) => r._status === "failed").length; const hasStatus = rows.some((r) => r._status !== undefined); return ( Preview Import {/* File name + row counts */} {fileName && state !== "error" && (
{fileName} {state === "preview" && totalRows !== undefined && ( {validRows ?? rows.length} valid row {(validRows ?? rows.length) !== 1 ? "s" : ""} / {totalRows}{" "} total )} {hasStatus && ( {successCount} success · {failedCount} failed )}
)} {/* Content by state */} {state === "loading" && } {state === "empty" && } {state === "error" && } {state === "importing" && ( )} {state === "preview" && ( <> {/* Staff assignment — custom slot wins; otherwise fall back to built-in select */} {staffSelector ? staffSelector : hasBuiltInStaffSelector && (

All clients in this import will be assigned to the selected staff member.

)} {/* Batch tags — after the owner, since the reading is "assign, then label". */} {tagStep}
{/* Index column */} # {columns.map((col) => ( {col.label} ))} {/* Status column — only shown when any row has status */} {hasStatus && ( Status )} {/* Delete column */} {onRowDelete && } {pagedRows.map((row, pageRowIdx) => { const absoluteIdx = pageStart + pageRowIdx; return ( {/* Index cell */} {absoluteIdx + 1} {/* Data cells */} {columns.map((col) => ( onRowChange?.( absoluteIdx, col.key, e.target.value, ) } className={cn( "w-full bg-transparent px-4 py-3 text-sm text-foreground", "focus:outline-none focus:ring-1 focus:ring-inset focus:ring-primary", )} /> ))} {/* Status cell */} {hasStatus && ( )} {/* Delete cell */} {onRowDelete && ( )} ); })}
{/* Pagination */} {totalPages > 1 && (
Showing {pageStart + 1}– {Math.min(pageStart + pageSize, rows.length)} of {rows.length}
{page + 1} / {totalPages}
)} )} {isImporting ? ( ) : ( <> )}
); }