import React, { useState, useEffect, useRef } 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 } from './dataShared'; 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) { return Object.fromEntries( Object.entries(mapping || {}).filter(([, value]) => value), ); } function canCommitCsvMapping(mapping) { return !!mapping?.posted_date && !!(mapping.amount || mapping.debit_amount || mapping.credit_amount); } const CSV_IMPORT_STEPS = ['Upload', 'Preview', 'Map', 'Commit', 'Results']; function csvImportStepIndex(preview, mapping, commitState) { 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 }) { return (
{CSV_IMPORT_STEPS.map((step, index) => { const complete = index < activeIndex; const active = index === activeIndex; return (
{complete ? : index + 1} {step}
); })}
); } function csvFieldRequirement(field, mapping) { 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, header) { if (!header) return []; const values = []; 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; } function CsvMappingRow({ field, label, preview, mapping, onChange, disabled = false }) { 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'}

)}
); } function CsvMappingReview({ preview, fields, mapping, onMappingChange, onUseSuggested, onClearMapping, disabled = false }) { 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 }) { 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] || '—'} more columns
); } function formatCsvRowDetail(detail) { if (!detail) return ''; const field = detail.field ? `${detail.field}: ` : ''; return `${field}${detail.message || detail.value || JSON.stringify(detail)}`; } export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProps = {} }) { 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, header) => { 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); 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), }); 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 && (
    {preview.error.details.map((d, i) => (
  • {d.message || JSON.stringify(d)}
  • ))}
)}
)} {preview.status === 'ready' && preview.data && (

CSV Preview

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

{preview.data.errors?.length > 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 && (
      {row.details.map((detail, detailIndex) => (
    • {formatCsvRowDetail(detail)}
    • ))}
    )}
  • ))}
)}
)} {commitState.status === 'error' && (
{commitState.error?.message || 'CSV import failed.'} {commitState.error?.details?.length > 0 && (
    {commitState.error.details.map((d, i) => (
  • {d.message || JSON.stringify(d)}
  • ))}
)}
)}
); } // ─── Section 3: Import My Data Export ────────────────────────────────────────