import { useState, useRef, type ReactNode } from 'react'; import { toast } from 'sonner'; import { Upload, Loader2, CheckCircle2, CheckCheck, AlertTriangle, Plus, FileText, } from 'lucide-react'; import { api } from '@/api'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { SectionCard, CountPill, importErrorState, type SectionCardProps, type ImportErrorState } from './dataShared'; type CsvMapping = Record; interface CsvPreviewData { import_session_id?: string; headers?: string[]; sampleRows?: Record[]; suggestedMapping?: Record; fields?: Record; rowCount?: number; errors?: { message?: string }[]; } interface CsvRowDetail { field?: string; message?: string; value?: unknown; } interface CsvCommitRow { row: number; result?: string; provider_transaction_id?: string; message?: string; details?: CsvRowDetail[]; } interface CsvCommitResult { imported?: number; skipped?: number; failed?: number; details?: CsvCommitRow[]; } interface CsvPreviewState { status: 'idle' | 'loading' | 'ready' | 'error'; data: CsvPreviewData | null; error: ImportErrorState | null; } interface CsvCommitState { status: 'idle' | 'loading' | 'done' | 'error'; result: CsvCommitResult | null; error: ImportErrorState | null; } const CSV_MAPPING_FIELDS = [ 'posted_date', 'amount', 'debit_amount', 'credit_amount', 'description', 'payee', 'memo', 'category', 'account', 'transaction_id', 'transaction_type', 'currency', 'transacted_at', ]; function compactMapping(mapping: CsvMapping | null | undefined): CsvMapping { return Object.fromEntries( Object.entries(mapping || {}).filter(([, value]) => value), ) as CsvMapping; } function canCommitCsvMapping(mapping: CsvMapping | null | undefined): boolean { return !!mapping?.posted_date && !!(mapping?.amount || mapping?.debit_amount || mapping?.credit_amount); } const CSV_IMPORT_STEPS = ['Upload', 'Preview', 'Map', 'Commit', 'Results']; function csvImportStepIndex(preview: CsvPreviewState, mapping: CsvMapping, commitState: CsvCommitState): number { if (commitState.status === 'done') return 4; if (commitState.status === 'loading') return 3; if (preview.status === 'ready') return canCommitCsvMapping(mapping) ? 3 : 2; if (preview.status === 'loading' || preview.status === 'error') return 1; return 0; } function CsvImportStepper({ activeIndex }: { activeIndex: number }) { return (
{CSV_IMPORT_STEPS.map((step, index) => { const complete = index < activeIndex; const active = index === activeIndex; return (
{complete ? : index + 1} {step}
); })}
); } function csvFieldRequirement(field: string, mapping: CsvMapping): string { if (field === 'posted_date') return 'Required'; if (['amount', 'debit_amount', 'credit_amount'].includes(field)) { return canCommitCsvMapping({ ...mapping, posted_date: mapping?.posted_date || '__date__' }) ? 'Amount source' : 'One required'; } return 'Optional'; } function csvFieldSamples(preview: CsvPreviewData | null | undefined, header: string): string[] { if (!header) return []; const values: string[] = []; for (const row of preview?.sampleRows || []) { const value = String(row?.[header] || '').trim(); if (value && !values.includes(value)) values.push(value); if (values.length >= 3) break; } return values; } interface CsvMappingRowProps { field: string; label: string; preview: CsvPreviewData | null | undefined; mapping: CsvMapping; onChange: (field: string, header: string) => void; disabled?: boolean; } function CsvMappingRow({ field, label, preview, mapping, onChange, disabled = false }: CsvMappingRowProps) { const headers = preview?.headers || []; const suggested = preview?.suggestedMapping?.[field] || ''; const current = mapping[field] || ''; const used = new Set(Object.entries(mapping) .filter(([key, value]) => key !== field && value) .map(([, value]) => value)); const requirement = csvFieldRequirement(field, mapping); const missingRequired = (requirement === 'Required' || requirement === 'One required') && !current; const samples = csvFieldSamples(preview, current); const suggestedAvailable = suggested && suggested !== current && !used.has(suggested); return (

{label}

{requirement}

{field}

{current && current === suggested && ( Suggested match )} {suggestedAvailable && !disabled && ( )} {missingRequired && ( Needs a column )}
{samples.length > 0 ? (
{samples.map(value => ( {value} ))}
) : (

{current ? 'No sample values' : 'Map a column to preview values'}

)}
); } interface CsvMappingReviewProps { preview: CsvPreviewData | null | undefined; fields: Record; mapping: CsvMapping; onMappingChange: (field: string, header: string) => void; onUseSuggested: () => void; onClearMapping: () => void; disabled?: boolean; } function CsvMappingReview({ preview, fields, mapping, onMappingChange, onUseSuggested, onClearMapping, disabled = false }: CsvMappingReviewProps) { const mappingFields = CSV_MAPPING_FIELDS.filter(field => fields[field]); const mappedCount = mappingFields.filter(field => mapping[field]).length; const hasSuggestedMapping = Object.values(preview?.suggestedMapping || {}).some(Boolean); const missingRequired = [ !mapping.posted_date ? 'Posted date' : null, !(mapping.amount || mapping.debit_amount || mapping.credit_amount) ? 'Amount' : null, ].filter(Boolean); return (

Column mapping

{mappedCount} of {mappingFields.length} fields mapped {missingRequired.length > 0 && ` · missing ${missingRequired.join(' and ')}`}

Field CSV Column Sample Values
{mappingFields.map(field => ( ))}
); } function CsvSampleTable({ preview }: { preview: CsvPreviewData | null | undefined }) { const headers = preview?.headers || []; const sampleRows = preview?.sampleRows || []; const visibleHeaders = headers.slice(0, 8); const hiddenCount = Math.max(0, headers.length - visibleHeaders.length); if (sampleRows.length === 0) { return

No sample rows found.

; } return (
{visibleHeaders.map(header => ( ))} {hiddenCount > 0 && ( )} {sampleRows.map((row, index) => ( {visibleHeaders.map(header => ( ))} {hiddenCount > 0 && ( )} ))}
{header}+{hiddenCount}
{(row[header] as ReactNode) || '—'} more columns
); } function formatCsvRowDetail(detail: CsvRowDetail | null | undefined): string { if (!detail) return ''; const field = detail.field ? `${detail.field}: ` : ''; return `${field}${detail.message || detail.value || JSON.stringify(detail)}`; } export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProps = {} }: { onHistoryRefresh?: () => void; cardProps?: Partial; }) { const fileRef = useRef(null); const [file, setFile] = useState(null); const [preview, setPreview] = useState({ status: 'idle', data: null, error: null }); const [mapping, setMapping] = useState({}); const [commitState, setCommitState] = useState({ status: 'idle', result: null, error: null }); const reset = () => { setFile(null); setPreview({ status: 'idle', data: null, error: null }); setMapping({}); setCommitState({ status: 'idle', result: null, error: null }); if (fileRef.current) fileRef.current.value = ''; }; const handleMappingChange = (field: string, header: string) => { if (commitState.status === 'done') return; setMapping(prev => { const next = { ...prev }; if (header) next[field] = header; else delete next[field]; return next; }); setCommitState({ status: 'idle', result: null, error: null }); }; const handlePreview = async () => { if (!file) { toast.error('Choose a CSV file first.'); return; } setPreview({ status: 'loading', data: null, error: null }); setMapping({}); setCommitState({ status: 'idle', result: null, error: null }); try { const data = await api.previewCsvTransactionImport(file) as CsvPreviewData; setPreview({ status: 'ready', data, error: null }); setMapping(compactMapping(data.suggestedMapping || {})); toast.success('CSV preview ready.'); } catch (err) { const errorState = importErrorState(err, 'CSV preview failed.'); setPreview({ status: 'error', data: null, error: errorState }); toast.error(errorState.message || 'CSV preview failed.'); } }; const handleCommit = async () => { if (!preview.data?.import_session_id || !canCommitCsvMapping(mapping)) return; setCommitState({ status: 'loading', result: null, error: null }); try { const result = await api.commitCsvTransactionImport({ import_session_id: preview.data.import_session_id, mapping: compactMapping(mapping), }) as CsvCommitResult; setCommitState({ status: 'done', result, error: null }); toast.success(`CSV imported — ${result.imported} imported, ${result.skipped} skipped.`); onHistoryRefresh?.(); } catch (err) { const errorState = importErrorState(err, 'CSV import failed.'); setCommitState({ status: 'error', result: null, error: errorState }); toast.error(errorState.message || 'CSV import failed.'); } }; const applySuggestedMapping = () => { if (commitState.status === 'done') return; setMapping(compactMapping(preview.data?.suggestedMapping || {})); setCommitState({ status: 'idle', result: null, error: null }); }; const clearMapping = () => { if (commitState.status === 'done') return; setMapping({}); setCommitState({ status: 'idle', result: null, error: null }); }; const fields = preview.data?.fields || {}; const canCommit = preview.status === 'ready' && !!preview.data?.import_session_id && canCommitCsvMapping(mapping) && commitState.status !== 'loading' && commitState.status !== 'done'; const activeStep = csvImportStepIndex(preview, mapping, commitState); const failedRows = (commitState.result?.details || []).filter(d => d.result === 'failed'); const skippedRows = (commitState.result?.details || []).filter(d => d.result === 'skipped_duplicate'); return (

Import transaction rows from CSV.

This importer creates shared transaction records only. It does not match transactions to bills yet.

{preview.status === 'error' && (
{preview.error?.message || 'CSV preview failed.'} {(preview.error?.details?.length ?? 0) > 0 && (
    {preview.error?.details.map((d, i) => { const dd = d as { message?: string }; return
  • {dd.message || JSON.stringify(d)}
  • ; })}
)}
)} {preview.status === 'ready' && preview.data && (

CSV Preview

{file?.name || 'Transaction CSV'}

{(preview.data.errors?.length ?? 0) > 0 && (

Review mapping

    {preview.data.errors?.map((issue, i) => (
  • {issue.message || JSON.stringify(issue)}
  • ))}
)}

{canCommitCsvMapping(mapping) ? 'Ready to commit' : 'Mapping incomplete'}

Duplicates are skipped using a CSV transaction ID when available, otherwise a stable row hash.

{commitState.status === 'done' ? ( ) : ( )}
)} {commitState.status === 'done' && commitState.result && (

CSV transaction import complete

{skippedRows.length > 0 && (

Skipped duplicates ({skippedRows.length})

    {skippedRows.map(row => (
  • Row {row.row}: {row.provider_transaction_id}
  • ))}
)} {failedRows.length > 0 && (

Failed rows ({failedRows.length})

    {failedRows.map((row, index) => (
  • Row {row.row}: {row.message}

    {(row.details?.length ?? 0) > 0 && (
      {row.details?.map((detail, detailIndex) => (
    • {formatCsvRowDetail(detail)}
    • ))}
    )}
  • ))}
)}
)} {commitState.status === 'error' && (
{commitState.error?.message || 'CSV import failed.'} {(commitState.error?.details?.length ?? 0) > 0 && (
    {commitState.error?.details.map((d, i) => { const dd = d as { message?: string }; return
  • {dd.message || JSON.stringify(d)}
  • ; })}
)}
)}
); }