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 (
{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 => (
| {header} |
))}
{hiddenCount > 0 && (
+{hiddenCount} |
)}
{sampleRows.map((row, index) => (
{visibleHeaders.map(header => (
|
{row[header] || '—'}
|
))}
{hiddenCount > 0 && (
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})
)}
)}
{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 ────────────────────────────────────────