{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "csv-importer",
  "title": "CSV Importer",
  "description": "Multi-step CSV import workflow with column mapping, data validation, and row correction.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "badge",
    "button",
    "card",
    "input",
    "label",
    "@drift-ui/file-dropzone",
    "@drift-ui/stepper",
    "@drift-ui/virtual-list",
    "@drift-ui/use-controllable-state",
    "@drift-ui/shape"
  ],
  "files": [
    {
      "path": "registry/default/ui/csv-importer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  AlertCircleIcon,\n  CheckCircle2Icon,\n  ChevronLeftIcon,\n  ChevronRightIcon,\n  FileSpreadsheetIcon,\n  Trash2Icon,\n  UploadIcon,\n} from \"lucide-react\"\n\nimport { useControllableState } from \"@/registry/default/hooks/use-controllable-state\"\nimport {\n  buildRowRecords,\n  cleanImportRows,\n  parseCsv,\n  suggestColumnMapping,\n  updateRowValue,\n  type CsvField,\n  type CsvRowRecord,\n} from \"@/registry/default/lib/csv-importer.ts\"\nimport { cn } from \"@/registry/default/lib/utils\"\nimport { Badge } from \"@/registry/default/ui/badge\"\nimport { Button } from \"@/registry/default/ui/button\"\nimport {\n  Card,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/registry/default/ui/card\"\nimport { FileDropzone } from \"@/registry/default/ui/file-dropzone\"\nimport { Input } from \"@/registry/default/ui/input\"\nimport { Label } from \"@/registry/default/ui/label\"\nimport {\n  Stepper,\n  StepperContent,\n  StepperIndicator,\n  StepperItem,\n  StepperList,\n  StepperSeparator,\n  StepperTitle,\n  StepperTrigger,\n} from \"@/registry/default/ui/stepper\"\nimport { VirtualList } from \"@/registry/default/ui/virtual-list\"\n\nexport type { CsvField, CsvRowRecord }\n\nexport interface CsvImporterProps extends Omit<\n  React.ComponentProps<typeof Card>,\n  \"onComplete\"\n> {\n  fields: CsvField[]\n  onComplete: (rows: Record<string, unknown>[]) => void\n  onCancel?: () => void\n  maxRows?: number\n  maxFileSize?: number\n  sampleRowsCount?: number\n  autoMap?: boolean\n  step?: number\n  defaultStep?: number\n  onStepChange?: (step: number) => void\n  defaultFile?: File\n}\n\nconst SELECT_CLASS_NAME =\n  \"h-control w-full rounded-control border border-input bg-background px-3 py-1 text-sm shadow-control transition-colors outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 [&>option]:bg-popover [&>option]:text-popover-foreground\"\n\nfunction formatBytes(bytes: number): string {\n  if (bytes === 0) return \"0 B\"\n  const k = 1024\n  const sizes = [\"B\", \"KB\", \"MB\", \"GB\"]\n  const i = Math.min(\n    Math.floor(Math.log(bytes) / Math.log(k)),\n    sizes.length - 1\n  )\n  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`\n}\n\nconst STEP_TITLES = [\"Upload\", \"Map Columns\", \"Review Data\", \"Import\"]\n\nexport function CsvImporter({\n  fields,\n  onComplete,\n  onCancel,\n  maxRows = 10000,\n  maxFileSize = 10 * 1024 * 1024,\n  sampleRowsCount = 3,\n  autoMap = true,\n  step: controlledStep,\n  defaultStep = 0,\n  onStepChange,\n  defaultFile,\n  className,\n  onKeyDown,\n  ...props\n}: CsvImporterProps) {\n  const { value: activeStep, setValue: setActiveStep } =\n    useControllableState<number>({\n      value: controlledStep,\n      defaultValue: defaultStep,\n      onChange: onStepChange,\n    })\n\n  // Uploaded file data state\n  const [file, setFile] = React.useState<File | null>(null)\n  const [parseError, setParseError] = React.useState<string | null>(null)\n  const [isParsing, setIsParsing] = React.useState(false)\n  const [headers, setHeaders] = React.useState<string[]>([])\n  const [rawRows, setRawRows] = React.useState<string[][]>([])\n  const [isTruncated, setIsTruncated] = React.useState(false)\n\n  // Mapping state: fieldId -> CSV header name or col-X index\n  const [mapping, setMapping] = React.useState<Record<string, string>>({})\n  const [lastBuiltMappingKey, setLastBuiltMappingKey] = React.useState<\n    string | null\n  >(null)\n\n  // Row records state for review step\n  const [rowRecords, setRowRecords] = React.useState<CsvRowRecord[]>([])\n  const [reviewFilter, setReviewFilter] = React.useState<\n    \"all\" | \"invalid\" | \"valid\"\n  >(\"all\")\n\n  const defaultFileInitializedRef = React.useRef(false)\n\n  // Handle file acceptance and CSV parsing\n  const handleFilesAccepted = React.useCallback(\n    async (files: File[]) => {\n      const selected = files[0]\n      if (!selected) return\n\n      setIsParsing(true)\n      setParseError(null)\n\n      try {\n        const text = await selected.text()\n        const result = parseCsv(text, { maxRows })\n\n        if (result.headers.length === 0) {\n          setParseError(\n            \"The CSV file is empty or does not contain a header row.\"\n          )\n          setIsParsing(false)\n          return\n        }\n\n        setFile(selected)\n        setHeaders(result.headers)\n        setRawRows(result.rows)\n        setIsTruncated(result.truncated)\n        setRowRecords([])\n        setLastBuiltMappingKey(null)\n        setReviewFilter(\"all\")\n\n        if (autoMap) {\n          const suggested = suggestColumnMapping(result.headers, fields)\n          setMapping(suggested)\n        } else {\n          setMapping({})\n        }\n\n        setActiveStep(1)\n      } catch (err) {\n        setParseError(\n          err instanceof Error ? err.message : \"Failed to parse CSV file.\"\n        )\n      } finally {\n        setIsParsing(false)\n      }\n    },\n    [autoMap, fields, maxRows, setActiveStep]\n  )\n\n  React.useEffect(() => {\n    if (!defaultFile || defaultFileInitializedRef.current) return\n    defaultFileInitializedRef.current = true\n\n    let active = true\n\n    void Promise.resolve().then(() => {\n      if (!active) return\n\n      if (maxFileSize !== undefined && defaultFile.size > maxFileSize) {\n        setParseError(\n          `File exceeds maximum allowed size of ${formatBytes(maxFileSize)}.`\n        )\n        return\n      }\n\n      return defaultFile.text().then((text) => {\n        if (!active) return\n        try {\n          const result = parseCsv(text, { maxRows })\n          if (result.headers.length === 0) {\n            setParseError(\n              \"The CSV file is empty or does not contain a header row.\"\n            )\n            return\n          }\n          setFile(defaultFile)\n          setHeaders(result.headers)\n          setRawRows(result.rows)\n          setIsTruncated(result.truncated)\n          setRowRecords([])\n          setLastBuiltMappingKey(null)\n          setReviewFilter(\"all\")\n          if (autoMap) {\n            setMapping(suggestColumnMapping(result.headers, fields))\n          } else {\n            setMapping({})\n          }\n          setActiveStep(1)\n        } catch (err) {\n          setParseError(\n            err instanceof Error ? err.message : \"Failed to parse CSV file.\"\n          )\n        }\n      })\n    })\n\n    return () => {\n      active = false\n    }\n  }, [defaultFile, maxFileSize, maxRows, autoMap, fields, setActiveStep])\n\n  // Check required fields mapping\n  const unmappedRequiredFields = React.useMemo(() => {\n    return fields.filter((f) => f.required && !mapping[f.id])\n  }, [fields, mapping])\n\n  const canProceedToReview = unmappedRequiredFields.length === 0\n\n  // Advance from Mapping to Review\n  const handleProceedToReview = React.useCallback(() => {\n    const currentKey = JSON.stringify(mapping)\n    if (rowRecords.length === 0 || lastBuiltMappingKey !== currentKey) {\n      const summary = buildRowRecords(rawRows, headers, mapping, fields)\n      setRowRecords(summary.rows)\n      setLastBuiltMappingKey(currentKey)\n    }\n    setActiveStep(2)\n  }, [\n    fields,\n    headers,\n    lastBuiltMappingKey,\n    mapping,\n    rawRows,\n    rowRecords.length,\n    setActiveStep,\n  ])\n\n  // Cell edit in Review step\n  const handleCellChange = React.useCallback(\n    (rowId: string, fieldId: string, newValue: string) => {\n      setRowRecords((prev) =>\n        prev.map((row) => {\n          if (row.id === rowId) {\n            return updateRowValue(row, fieldId, newValue, fields)\n          }\n          return row\n        })\n      )\n    },\n    [fields]\n  )\n\n  // Delete row in Review step\n  const handleDeleteRow = React.useCallback((rowId: string) => {\n    setRowRecords((prev) => prev.filter((r) => r.id !== rowId))\n  }, [])\n\n  // Remove all invalid rows in Review step\n  const handleRemoveInvalidRows = React.useCallback(() => {\n    setRowRecords((prev) => prev.filter((r) => r.isValid))\n    setReviewFilter(\"all\")\n  }, [])\n\n  // Calculate review stats\n  const { validCount, invalidCount, totalCount } = React.useMemo(() => {\n    let valid = 0\n    let invalid = 0\n    for (const r of rowRecords) {\n      if (r.isValid) valid++\n      else invalid++\n    }\n    return {\n      validCount: valid,\n      invalidCount: invalid,\n      totalCount: rowRecords.length,\n    }\n  }, [rowRecords])\n\n  // Filtered rows for VirtualList\n  const filteredRows = React.useMemo(() => {\n    if (reviewFilter === \"valid\") return rowRecords.filter((r) => r.isValid)\n    if (reviewFilter === \"invalid\") return rowRecords.filter((r) => !r.isValid)\n    return rowRecords\n  }, [reviewFilter, rowRecords])\n\n  // Active mapped fields for table columns\n  const mappedFields = React.useMemo(() => {\n    return fields.filter((f) => Boolean(mapping[f.id]))\n  }, [fields, mapping])\n\n  // Reset / start over\n  const handleReset = React.useCallback(() => {\n    setFile(null)\n    setHeaders([])\n    setRawRows([])\n    setMapping({})\n    setRowRecords([])\n    setLastBuiltMappingKey(null)\n    setReviewFilter(\"all\")\n    setParseError(null)\n    setActiveStep(0)\n    onCancel?.()\n  }, [onCancel, setActiveStep])\n\n  // Keyboard shortcut listener: Escape resets or cancels active step\n  const handleKeyDown = React.useCallback(\n    (e: React.KeyboardEvent<HTMLDivElement>) => {\n      onKeyDown?.(e)\n      if (e.defaultPrevented) return\n\n      if (e.key === \"Escape\") {\n        const target = e.target as HTMLElement\n        if (\n          target &&\n          (target.tagName === \"INPUT\" || target.tagName === \"SELECT\")\n        ) {\n          target.blur()\n          return\n        }\n        if (activeStep > 0) {\n          e.preventDefault()\n          handleReset()\n        }\n      }\n    },\n    [activeStep, handleReset, onKeyDown]\n  )\n\n  // Final Complete Import\n  const handleCompleteImport = React.useCallback(() => {\n    const cleanData = cleanImportRows(rowRecords, fields)\n    onComplete(cleanData)\n  }, [fields, onComplete, rowRecords])\n\n  return (\n    <Card\n      data-slot=\"csv-importer\"\n      className={cn(\"w-full overflow-hidden shadow-xs\", className)}\n      onKeyDown={handleKeyDown}\n      {...props}\n    >\n      <CardHeader className=\"border-b pb-4\">\n        <Stepper\n          value={activeStep}\n          onValueChange={setActiveStep}\n          stepCount={STEP_TITLES.length}\n          linear\n        >\n          <StepperList\n            aria-label=\"CSV Import Progress\"\n            className=\"w-full justify-between\"\n          >\n            {STEP_TITLES.map((title, idx) => (\n              <StepperItem key={title} step={idx} className=\"flex-1\">\n                <StepperTrigger\n                  disabled={\n                    (idx === 1 && !file) ||\n                    (idx === 2 &&\n                      (rowRecords.length === 0 || !canProceedToReview)) ||\n                    (idx === 3 && (rowRecords.length === 0 || validCount === 0))\n                  }\n                >\n                  <StepperIndicator />\n                  <StepperTitle>{title}</StepperTitle>\n                </StepperTrigger>\n                {idx < STEP_TITLES.length - 1 && <StepperSeparator />}\n              </StepperItem>\n            ))}\n          </StepperList>\n\n          {/* Step 0: Upload */}\n          <StepperContent step={0}>\n            <div className=\"space-y-4 pt-4\">\n              <div>\n                <CardTitle className=\"text-lg\">Upload CSV File</CardTitle>\n                <CardDescription>\n                  Upload your UTF-8 encoded .csv file to begin importing data.\n                </CardDescription>\n              </div>\n\n              <FileDropzone\n                accept=\".csv,text/csv\"\n                maxSize={maxFileSize}\n                multiple={false}\n                maxFiles={1}\n                disabled={isParsing}\n                onFilesAccepted={handleFilesAccepted}\n                onFilesRejected={(rejections) => {\n                  const first = rejections[0]\n                  if (first.reason === \"size\") {\n                    setParseError(\n                      `File exceeds maximum allowed size of ${formatBytes(maxFileSize)}.`\n                    )\n                  } else if (first.reason === \"type\") {\n                    setParseError(\"Only .csv files are supported.\")\n                  } else {\n                    setParseError(\"Invalid file upload.\")\n                  }\n                }}\n                className=\"py-12\"\n              >\n                <div className=\"flex flex-col items-center gap-2 text-center\">\n                  <div className=\"flex size-12 items-center justify-center rounded-full border bg-muted/40\">\n                    <UploadIcon className=\"size-6 text-muted-foreground\" />\n                  </div>\n                  <span className=\"text-sm font-medium\">\n                    Drag and drop your CSV file here, or{\" \"}\n                    <span className=\"text-primary underline underline-offset-4\">\n                      browse\n                    </span>\n                  </span>\n                  <span className=\"text-xs text-muted-foreground\">\n                    Up to {formatBytes(maxFileSize)} (max{\" \"}\n                    {maxRows.toLocaleString()} rows)\n                  </span>\n                </div>\n              </FileDropzone>\n\n              {parseError && (\n                <div\n                  role=\"alert\"\n                  className=\"flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive\"\n                >\n                  <AlertCircleIcon className=\"size-4 shrink-0\" />\n                  <span>{parseError}</span>\n                </div>\n              )}\n            </div>\n          </StepperContent>\n\n          {/* Step 1: Map Columns */}\n          <StepperContent step={1}>\n            <div className=\"space-y-4 pt-4\">\n              <div className=\"flex flex-wrap items-center justify-between gap-2\">\n                <div>\n                  <CardTitle className=\"text-lg\">Map Columns</CardTitle>\n                  <CardDescription>\n                    Match columns from{\" \"}\n                    <span className=\"font-medium text-foreground\">\n                      {file?.name}\n                    </span>{\" \"}\n                    ({rawRows.length} rows detected) to application fields.\n                  </CardDescription>\n                </div>\n                {isTruncated && (\n                  <Badge\n                    variant=\"outline\"\n                    className=\"border-amber-500/50 text-amber-600 dark:text-amber-400\"\n                  >\n                    Showing first {maxRows} rows\n                  </Badge>\n                )}\n              </div>\n\n              {unmappedRequiredFields.length > 0 && (\n                <div className=\"flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400\">\n                  <AlertCircleIcon className=\"size-4 shrink-0\" />\n                  <span>\n                    Please map all required fields:{\" \"}\n                    <strong>\n                      {unmappedRequiredFields.map((f) => f.label).join(\", \")}\n                    </strong>\n                  </span>\n                </div>\n              )}\n\n              <div className=\"space-y-3 divide-y rounded-lg border bg-card p-4\">\n                {fields.map((field) => {\n                  const mappedVal = mapping[field.id] ?? \"\"\n                  let colIdx = -1\n                  if (\n                    typeof mappedVal === \"string\" &&\n                    mappedVal.startsWith(\"col-\")\n                  ) {\n                    colIdx = parseInt(mappedVal.slice(4), 10)\n                  } else if (mappedVal) {\n                    colIdx = headers.indexOf(mappedVal)\n                  }\n\n                  const selectedSelectValue =\n                    colIdx >= 0 && colIdx < headers.length\n                      ? `col-${colIdx}`\n                      : \"\"\n\n                  const sampleValues =\n                    colIdx >= 0 && colIdx < headers.length\n                      ? rawRows\n                          .slice(0, sampleRowsCount)\n                          .map((r) => r[colIdx] ?? \"\")\n                          .filter((v) => v !== \"\")\n                      : []\n\n                  return (\n                    <div\n                      key={field.id}\n                      className=\"grid grid-cols-1 items-start gap-3 pt-3 first:pt-0 sm:grid-cols-2\"\n                    >\n                      <div className=\"space-y-1\">\n                        <div className=\"flex items-center gap-2\">\n                          <Label\n                            htmlFor={`map-${field.id}`}\n                            className=\"font-medium\"\n                          >\n                            {field.label}\n                          </Label>\n                          {field.required ? (\n                            <Badge variant=\"secondary\" className=\"text-[10px]\">\n                              Required\n                            </Badge>\n                          ) : (\n                            <span className=\"text-xs text-muted-foreground\">\n                              Optional\n                            </span>\n                          )}\n                        </div>\n                        {field.description && (\n                          <p className=\"text-xs text-muted-foreground\">\n                            {field.description}\n                          </p>\n                        )}\n                      </div>\n\n                      <div className=\"space-y-1.5\">\n                        <select\n                          id={`map-${field.id}`}\n                          aria-label={`Map ${field.label}`}\n                          value={selectedSelectValue}\n                          onChange={(e) => {\n                            const val = e.target.value\n                            setMapping((prev) => {\n                              if (prev[field.id] === val) return prev\n                              return {\n                                ...prev,\n                                [field.id]: val,\n                              }\n                            })\n                            setRowRecords([])\n                            setLastBuiltMappingKey(null)\n                          }}\n                          className={SELECT_CLASS_NAME}\n                        >\n                          <option value=\"\">-- Do not import --</option>\n                          {headers.map((h, idx) => {\n                            const isDuplicate =\n                              headers.filter((header) => header === h).length >\n                              1\n                            const label = isDuplicate\n                              ? `${h} (Column ${idx + 1})`\n                              : h\n                            const colKey = `col-${idx}`\n                            return (\n                              <option key={colKey} value={colKey}>\n                                {label}\n                              </option>\n                            )\n                          })}\n                        </select>\n\n                        {selectedSelectValue && sampleValues.length > 0 && (\n                          <p className=\"truncate text-xs text-muted-foreground\">\n                            Preview:{\" \"}\n                            <span className=\"font-mono text-[11px] text-foreground\">\n                              {sampleValues.join(\", \")}\n                            </span>\n                          </p>\n                        )}\n                      </div>\n                    </div>\n                  )\n                })}\n              </div>\n\n              <div className=\"flex items-center justify-between pt-2\">\n                <Button\n                  type=\"button\"\n                  variant=\"outline\"\n                  onClick={() => setActiveStep(0)}\n                >\n                  <ChevronLeftIcon className=\"size-4\" />\n                  Back\n                </Button>\n                <Button\n                  type=\"button\"\n                  disabled={!canProceedToReview}\n                  onClick={handleProceedToReview}\n                >\n                  Continue to Review\n                  <ChevronRightIcon className=\"size-4\" />\n                </Button>\n              </div>\n            </div>\n          </StepperContent>\n\n          {/* Step 2: Review Data */}\n          <StepperContent step={2}>\n            <div className=\"space-y-4 pt-4\">\n              <div className=\"flex flex-wrap items-center justify-between gap-3\">\n                <div>\n                  <CardTitle className=\"text-lg\">Review & Fix Data</CardTitle>\n                  <CardDescription>\n                    Review parsed data. Edit invalid cells directly or remove\n                    rows.\n                  </CardDescription>\n                </div>\n\n                <div className=\"flex flex-wrap items-center gap-2\">\n                  <div\n                    role=\"tablist\"\n                    aria-label=\"Filter rows\"\n                    className=\"flex rounded-lg border bg-muted/40 p-0.5 text-xs font-medium\"\n                  >\n                    <button\n                      type=\"button\"\n                      role=\"tab\"\n                      aria-selected={reviewFilter === \"all\"}\n                      onClick={() => setReviewFilter(\"all\")}\n                      className={cn(\n                        \"rounded-md px-2.5 py-1 transition-colors\",\n                        reviewFilter === \"all\"\n                          ? \"bg-background text-foreground shadow-xs\"\n                          : \"text-muted-foreground hover:text-foreground\"\n                      )}\n                    >\n                      All ({totalCount})\n                    </button>\n                    <button\n                      type=\"button\"\n                      role=\"tab\"\n                      aria-selected={reviewFilter === \"invalid\"}\n                      onClick={() => setReviewFilter(\"invalid\")}\n                      className={cn(\n                        \"rounded-md px-2.5 py-1 transition-colors\",\n                        reviewFilter === \"invalid\"\n                          ? \"bg-background text-destructive shadow-xs\"\n                          : \"text-muted-foreground hover:text-foreground\"\n                      )}\n                    >\n                      Invalid ({invalidCount})\n                    </button>\n                    <button\n                      type=\"button\"\n                      role=\"tab\"\n                      aria-selected={reviewFilter === \"valid\"}\n                      onClick={() => setReviewFilter(\"valid\")}\n                      className={cn(\n                        \"rounded-md px-2.5 py-1 transition-colors\",\n                        reviewFilter === \"valid\"\n                          ? \"bg-background text-foreground shadow-xs\"\n                          : \"text-muted-foreground hover:text-foreground\"\n                      )}\n                    >\n                      Valid ({validCount})\n                    </button>\n                  </div>\n\n                  {invalidCount > 0 && (\n                    <Button\n                      type=\"button\"\n                      variant=\"outline\"\n                      size=\"sm\"\n                      onClick={handleRemoveInvalidRows}\n                      className=\"text-xs\"\n                    >\n                      <Trash2Icon className=\"size-3.5 text-muted-foreground\" />\n                      Discard invalid ({invalidCount})\n                    </Button>\n                  )}\n                </div>\n              </div>\n\n              {invalidCount > 0 && (\n                <div className=\"flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-xs text-destructive\">\n                  <AlertCircleIcon className=\"size-4 shrink-0\" />\n                  <span>\n                    {invalidCount} row\n                    {invalidCount === 1 ? \" contains\" : \"s contain\"} validation\n                    errors. Fix values in the table below or discard them.\n                  </span>\n                </div>\n              )}\n\n              {/* Virtualized Table */}\n              <div className=\"rounded-lg border bg-card\">\n                {/* Header */}\n                <div className=\"flex border-b bg-muted/50 px-3 py-2 text-xs font-semibold text-muted-foreground\">\n                  <div className=\"w-12 shrink-0\">#</div>\n                  {mappedFields.map((field) => (\n                    <div key={field.id} className=\"min-w-36 flex-1 px-2\">\n                      <span className=\"text-foreground\">{field.label}</span>\n                      {field.required && (\n                        <span className=\"ml-0.5 text-destructive\">*</span>\n                      )}\n                    </div>\n                  ))}\n                  <div className=\"w-12 shrink-0 text-right\">Action</div>\n                </div>\n\n                {filteredRows.length === 0 ? (\n                  <div className=\"p-8 text-center text-sm text-muted-foreground\">\n                    No rows match the current filter.\n                  </div>\n                ) : (\n                  <VirtualList\n                    aria-label=\"CSV records table\"\n                    items={filteredRows}\n                    estimateSize={52}\n                    overscan={6}\n                    getItemKey={(row) => row.id}\n                    className=\"max-h-80 divide-y overflow-y-auto\"\n                  >\n                    {(row) => {\n                      return (\n                        <div\n                          key={row.id}\n                          className={cn(\n                            \"flex items-center px-3 py-1.5 text-xs transition-colors\",\n                            !row.isValid && \"bg-destructive/5\"\n                          )}\n                        >\n                          <div className=\"w-12 shrink-0 font-mono text-[11px] text-muted-foreground\">\n                            {row.rowIndex + 1}\n                          </div>\n\n                          {mappedFields.map((field) => {\n                            const error = row.errors[field.id]\n                            const value = row.values[field.id] ?? \"\"\n\n                            return (\n                              <div\n                                key={field.id}\n                                className=\"min-w-36 flex-1 px-2 py-0.5\"\n                              >\n                                <Input\n                                  aria-label={`${field.label} row ${row.rowIndex + 1}`}\n                                  aria-invalid={Boolean(error)}\n                                  value={value}\n                                  onChange={(e) =>\n                                    handleCellChange(\n                                      row.id,\n                                      field.id,\n                                      e.target.value\n                                    )\n                                  }\n                                  className={cn(\n                                    \"h-control-sm py-0 text-xs\",\n                                    Boolean(error) &&\n                                      \"border-destructive focus-visible:ring-destructive/30\"\n                                  )}\n                                />\n                                {error && (\n                                  <span className=\"mt-0.5 block truncate text-[10px] text-destructive\">\n                                    {error}\n                                  </span>\n                                )}\n                              </div>\n                            )\n                          })}\n\n                          <div className=\"w-12 shrink-0 text-right\">\n                            <Button\n                              type=\"button\"\n                              variant=\"ghost\"\n                              size=\"icon\"\n                              onClick={() => handleDeleteRow(row.id)}\n                              aria-label={`Delete row ${row.rowIndex + 1}`}\n                              className=\"size-7 text-muted-foreground hover:text-destructive\"\n                            >\n                              <Trash2Icon className=\"size-3.5\" />\n                            </Button>\n                          </div>\n                        </div>\n                      )\n                    }}\n                  </VirtualList>\n                )}\n              </div>\n\n              <div className=\"flex items-center justify-between pt-2\">\n                <Button\n                  type=\"button\"\n                  variant=\"outline\"\n                  onClick={() => setActiveStep(1)}\n                >\n                  <ChevronLeftIcon className=\"size-4\" />\n                  Back\n                </Button>\n                <Button\n                  type=\"button\"\n                  disabled={validCount === 0}\n                  onClick={() => setActiveStep(3)}\n                >\n                  {invalidCount === 0\n                    ? \"Continue to Import\"\n                    : `Import ${validCount} valid row${validCount === 1 ? \"\" : \"s\"}`}\n                  <ChevronRightIcon className=\"size-4\" />\n                </Button>\n              </div>\n            </div>\n          </StepperContent>\n\n          {/* Step 3: Summary / Import */}\n          <StepperContent step={3}>\n            <div className=\"space-y-4 pt-4\">\n              <div>\n                <CardTitle className=\"text-lg\">Confirm Import</CardTitle>\n                <CardDescription>\n                  Review the import summary before committing records to the\n                  application.\n                </CardDescription>\n              </div>\n\n              <div className=\"grid grid-cols-1 gap-3 sm:grid-cols-2\">\n                <div className=\"space-y-1 rounded-lg border bg-muted/20 p-4\">\n                  <span className=\"text-xs text-muted-foreground\">\n                    Source file\n                  </span>\n                  <div className=\"flex items-center gap-2 font-medium\">\n                    <FileSpreadsheetIcon className=\"size-4 text-primary\" />\n                    <span className=\"truncate\">\n                      {file?.name ?? \"Imported file\"}\n                    </span>\n                  </div>\n                  {file && (\n                    <span className=\"text-xs text-muted-foreground\">\n                      Size: {formatBytes(file.size)}\n                    </span>\n                  )}\n                </div>\n\n                <div className=\"space-y-1 rounded-lg border bg-muted/20 p-4\">\n                  <span className=\"text-xs text-muted-foreground\">\n                    Import readiness\n                  </span>\n                  <div className=\"flex items-center gap-1.5 font-medium text-emerald-600 dark:text-emerald-400\">\n                    <CheckCircle2Icon className=\"size-4 shrink-0\" />\n                    <span>\n                      {validCount} record{validCount === 1 ? \"\" : \"s\"} ready\n                    </span>\n                  </div>\n                  {invalidCount > 0 && (\n                    <span className=\"text-xs text-muted-foreground\">\n                      ({invalidCount} invalid row{invalidCount === 1 ? \"\" : \"s\"}{\" \"}\n                      will be skipped)\n                    </span>\n                  )}\n                </div>\n              </div>\n\n              <div className=\"space-y-2 rounded-lg border p-4\">\n                <span className=\"text-xs font-medium text-muted-foreground\">\n                  Mapped Fields ({mappedFields.length})\n                </span>\n                <div className=\"flex flex-wrap gap-1.5\">\n                  {mappedFields.map((field) => (\n                    <Badge\n                      key={field.id}\n                      variant=\"secondary\"\n                      className=\"text-xs\"\n                    >\n                      {field.label} &larr; {mapping[field.id]}\n                    </Badge>\n                  ))}\n                </div>\n              </div>\n\n              <div className=\"flex items-center justify-between pt-2\">\n                <Button\n                  type=\"button\"\n                  variant=\"outline\"\n                  onClick={() => setActiveStep(2)}\n                >\n                  <ChevronLeftIcon className=\"size-4\" />\n                  Back\n                </Button>\n\n                <div className=\"flex items-center gap-2\">\n                  <Button type=\"button\" variant=\"ghost\" onClick={handleReset}>\n                    Start Over\n                  </Button>\n                  <Button\n                    type=\"button\"\n                    onClick={handleCompleteImport}\n                    disabled={validCount === 0}\n                  >\n                    Complete Import ({validCount})\n                  </Button>\n                </div>\n              </div>\n            </div>\n          </StepperContent>\n        </Stepper>\n      </CardHeader>\n    </Card>\n  )\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/csv-importer.ts",
      "content": "export type CsvField = {\n  id: string\n  label: string\n  required?: boolean\n  description?: string\n  validate?: (value: unknown, row: Record<string, unknown>) => boolean | string\n  transform?: (value: string) => unknown\n}\n\nexport type CsvParseResult = {\n  headers: string[]\n  rows: string[][]\n  totalRows: number\n  truncated: boolean\n}\n\nexport type CsvRowRecord = {\n  id: string\n  rowIndex: number\n  values: Record<string, string>\n  errors: Record<string, string>\n  isValid: boolean\n}\n\nexport type ValidationSummary = {\n  rows: CsvRowRecord[]\n  validCount: number\n  invalidCount: number\n  totalCount: number\n}\n\n/**\n * Strips leading UTF-8 Byte Order Mark (\\uFEFF) if present.\n */\nexport function stripBom(text: string): string {\n  if (text.charCodeAt(0) === 0xfeff) {\n    return text.slice(1)\n  }\n  return text\n}\n\n/**\n * Normalizes text to lowercase alphanumeric for fuzzy matching.\n */\nexport function normalizeKey(text: string): string {\n  return text.toLowerCase().replace(/[^a-z0-9]/g, \"\")\n}\n\n/**\n * Pure RFC 4180 compliant CSV parser.\n * Handles quoted fields, commas/newlines inside quotes, \"\" escapes, CRLF, and UTF-8 BOM.\n * Supports maxRows protection for main-thread parsing.\n */\nexport function parseCsv(\n  csv: string,\n  options?: { maxRows?: number }\n): CsvParseResult {\n  const text = stripBom(csv)\n  if (!text.trim()) {\n    return {\n      headers: [],\n      rows: [],\n      totalRows: 0,\n      truncated: false,\n    }\n  }\n\n  const maxRows = options?.maxRows ?? Infinity\n  const records: string[][] = []\n  let currentRecord: string[] = []\n  let currentField = \"\"\n  let inQuotes = false\n  let i = 0\n  const len = text.length\n\n  while (i < len) {\n    const char = text[i]\n\n    if (inQuotes) {\n      if (char === '\"') {\n        if (i + 1 < len && text[i + 1] === '\"') {\n          // Escaped quote: \"\"\n          currentField += '\"'\n          i += 2\n          continue\n        } else {\n          // Closing quote\n          inQuotes = false\n          i++\n          continue\n        }\n      } else {\n        currentField += char\n        i++\n        continue\n      }\n    } else {\n      if (char === '\"') {\n        inQuotes = true\n        i++\n        continue\n      } else if (char === \",\") {\n        currentRecord.push(currentField)\n        currentField = \"\"\n        i++\n        continue\n      } else if (char === \"\\r\") {\n        if (i + 1 < len && text[i + 1] === \"\\n\") {\n          i++\n        }\n        currentRecord.push(currentField)\n        currentField = \"\"\n        if (currentRecord.length > 1 || currentRecord[0] !== \"\") {\n          records.push(currentRecord)\n          if (records.length - 1 > maxRows) {\n            const rawHeaders = records[0]\n            const headers = rawHeaders.map((h) => h.trim())\n            const rows = records.slice(1, maxRows + 1).map((row) => {\n              if (row.length < headers.length) {\n                return [...row, ...Array(headers.length - row.length).fill(\"\")]\n              }\n              return row.slice(0, headers.length)\n            })\n            return {\n              headers,\n              rows,\n              totalRows: maxRows,\n              truncated: true,\n            }\n          }\n        }\n        currentRecord = []\n        i++\n        continue\n      } else if (char === \"\\n\") {\n        currentRecord.push(currentField)\n        currentField = \"\"\n        if (currentRecord.length > 1 || currentRecord[0] !== \"\") {\n          records.push(currentRecord)\n          if (records.length - 1 > maxRows) {\n            const rawHeaders = records[0]\n            const headers = rawHeaders.map((h) => h.trim())\n            const rows = records.slice(1, maxRows + 1).map((row) => {\n              if (row.length < headers.length) {\n                return [...row, ...Array(headers.length - row.length).fill(\"\")]\n              }\n              return row.slice(0, headers.length)\n            })\n            return {\n              headers,\n              rows,\n              totalRows: maxRows,\n              truncated: true,\n            }\n          }\n        }\n        currentRecord = []\n        i++\n        continue\n      } else {\n        currentField += char\n        i++\n        continue\n      }\n    }\n  }\n\n  if (inQuotes) {\n    throw new Error(\"Unterminated quoted field in CSV\")\n  }\n\n  // Handle final field and record\n  currentRecord.push(currentField)\n  if (currentRecord.length > 1 || currentRecord[0] !== \"\") {\n    records.push(currentRecord)\n  }\n\n  if (records.length === 0) {\n    return {\n      headers: [],\n      rows: [],\n      totalRows: 0,\n      truncated: false,\n    }\n  }\n\n  const rawHeaders = records[0]\n  const headers = rawHeaders.map((h) => h.trim())\n  const rawRows = records.slice(1)\n  const isTruncated = rawRows.length > maxRows\n  const rows = isTruncated ? rawRows.slice(0, maxRows) : rawRows\n\n  const normalizedRows = rows.map((row) => {\n    if (row.length < headers.length) {\n      return [...row, ...Array(headers.length - row.length).fill(\"\")]\n    }\n    return row.slice(0, headers.length)\n  })\n\n  return {\n    headers,\n    rows: normalizedRows,\n    totalRows: rawRows.length,\n    truncated: isTruncated,\n  }\n}\n\n/**\n * Suggests column mappings by matching CSV headers against field ids and labels,\n * ignoring case and standard separators (spaces, underscores, hyphens).\n */\nexport function suggestColumnMapping(\n  headers: string[],\n  fields: CsvField[]\n): Record<string, string> {\n  const mapping: Record<string, string> = {}\n  const usedHeaders = new Set<string>()\n\n  // 1. Exact case-insensitive match on id or label\n  for (const field of fields) {\n    const idLower = field.id.toLowerCase()\n    const labelLower = field.label.toLowerCase()\n\n    const match = headers.find(\n      (h) =>\n        !usedHeaders.has(h) &&\n        (h.toLowerCase() === idLower || h.toLowerCase() === labelLower)\n    )\n\n    if (match) {\n      mapping[field.id] = match\n      usedHeaders.add(match)\n    }\n  }\n\n  // 2. Normalized alphanumeric match\n  for (const field of fields) {\n    if (mapping[field.id]) continue\n\n    const idNorm = normalizeKey(field.id)\n    const labelNorm = normalizeKey(field.label)\n\n    const match = headers.find(\n      (h) =>\n        !usedHeaders.has(h) &&\n        (normalizeKey(h) === idNorm || normalizeKey(h) === labelNorm)\n    )\n\n    if (match) {\n      mapping[field.id] = match\n      usedHeaders.add(match)\n    }\n  }\n\n  return mapping\n}\n\n/**\n * Validates an individual row against field requirements and custom validators.\n */\nexport function validateRow(\n  fields: CsvField[],\n  values: Record<string, string>\n): { errors: Record<string, string>; isValid: boolean } {\n  const errors: Record<string, string> = {}\n\n  for (const field of fields) {\n    const val = values[field.id] ?? \"\"\n    const trimmed = typeof val === \"string\" ? val.trim() : \"\"\n\n    if (field.required && trimmed === \"\") {\n      errors[field.id] = `${field.label} is required`\n      continue\n    }\n\n    if (trimmed !== \"\" && field.validate) {\n      const result = field.validate(val, values)\n      if (typeof result === \"string\" && result.length > 0) {\n        errors[field.id] = result\n      } else if (result === false) {\n        errors[field.id] = `${field.label} is invalid`\n      }\n    }\n  }\n\n  return {\n    errors,\n    isValid: Object.keys(errors).length === 0,\n  }\n}\n\n/**\n * Builds validated row records from raw CSV rows and column mapping.\n */\nexport function buildRowRecords(\n  rows: string[][],\n  headers: string[],\n  mapping: Record<string, string>,\n  fields: CsvField[]\n): ValidationSummary {\n  const fieldHeaderIndices: Record<string, number> = {}\n  for (const field of fields) {\n    const val = mapping[field.id]\n    if (val !== undefined && val !== \"\") {\n      if (typeof val === \"string\" && val.startsWith(\"col-\")) {\n        const colIdx = parseInt(val.slice(4), 10)\n        if (!isNaN(colIdx) && colIdx >= 0 && colIdx < headers.length) {\n          fieldHeaderIndices[field.id] = colIdx\n          continue\n        }\n      }\n      const idx = headers.indexOf(val)\n      if (idx !== -1) fieldHeaderIndices[field.id] = idx\n    }\n  }\n\n  let validCount = 0\n  let invalidCount = 0\n\n  const rowRecords: CsvRowRecord[] = rows.map((row, rowIndex) => {\n    const values: Record<string, string> = {}\n    for (const field of fields) {\n      const colIdx = fieldHeaderIndices[field.id]\n      values[field.id] = colIdx !== undefined ? (row[colIdx] ?? \"\") : \"\"\n    }\n\n    const { errors, isValid } = validateRow(fields, values)\n    if (isValid) validCount++\n    else invalidCount++\n\n    return {\n      id: `row-${rowIndex}`,\n      rowIndex,\n      values,\n      errors,\n      isValid,\n    }\n  })\n\n  return {\n    rows: rowRecords,\n    validCount,\n    invalidCount,\n    totalCount: rowRecords.length,\n  }\n}\n\n/**\n * Re-validates a single row record after a cell value is modified.\n */\nexport function updateRowValue(\n  rowRecord: CsvRowRecord,\n  fieldId: string,\n  newValue: string,\n  fields: CsvField[]\n): CsvRowRecord {\n  const nextValues = { ...rowRecord.values, [fieldId]: newValue }\n  const { errors, isValid } = validateRow(fields, nextValues)\n  return {\n    ...rowRecord,\n    values: nextValues,\n    errors,\n    isValid,\n  }\n}\n\n/**\n * Converts valid row records into clean application data objects, applying transforms.\n */\nexport function cleanImportRows(\n  rows: CsvRowRecord[],\n  fields: CsvField[]\n): Record<string, unknown>[] {\n  return rows\n    .filter((r) => r.isValid)\n    .map((r) => {\n      const cleanData: Record<string, unknown> = {}\n      for (const field of fields) {\n        const raw = r.values[field.id] ?? \"\"\n        if (field.transform) {\n          cleanData[field.id] = field.transform(raw)\n        } else {\n          cleanData[field.id] = raw\n        }\n      }\n      return cleanData\n    })\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}