BillTracker/client/components/data/ImportTransactionCsvSection...

569 lines
23 KiB
React
Raw Normal View History

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 (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{CSV_IMPORT_STEPS.map((step, index) => {
const complete = index < activeIndex;
const active = index === activeIndex;
return (
<div
key={step}
className={cn(
'flex items-center gap-2 rounded-lg border px-3 py-2 text-xs transition-colors',
complete && 'border-emerald-500/30 bg-emerald-500/5 text-emerald-600',
active && 'border-primary/40 bg-primary/5 text-foreground',
!complete && !active && 'border-border/60 bg-muted/20 text-muted-foreground',
)}
>
<span className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold tabular-nums',
complete && 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600',
active && 'border-primary/50 bg-primary/10 text-primary',
!complete && !active && 'border-border text-muted-foreground',
)}>
{complete ? <CheckCircle2 className="h-3 w-3" /> : index + 1}
</span>
<span className="truncate font-medium">{step}</span>
</div>
);
})}
</div>
);
}
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 (
<div className={cn(
'grid gap-3 border-b border-border/40 px-4 py-3 last:border-b-0 lg:grid-cols-[minmax(150px,0.8fr)_minmax(220px,1fr)_minmax(180px,1fr)]',
missingRequired && 'bg-destructive/5',
)}>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">{label}</p>
<span className={cn(
'rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase',
requirement === 'Required' || requirement === 'One required'
? 'border-destructive/30 bg-destructive/10 text-destructive'
: requirement === 'Amount source'
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'
: 'border-border/60 bg-muted/30 text-muted-foreground',
)}>
{requirement}
</span>
</div>
<p className="mt-1 font-mono text-[11px] text-muted-foreground">{field}</p>
</div>
<div className="space-y-1.5">
<select
value={current}
onChange={e => onChange(field, e.target.value)}
disabled={disabled}
className={cn(
'h-9 w-full rounded-md border bg-background px-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-60',
missingRequired ? 'border-destructive/50' : 'border-input',
)}
>
<option value="">Not mapped</option>
{headers.map(header => (
<option key={header} value={header} disabled={used.has(header)}>
{header}{used.has(header) ? ' (assigned)' : ''}
</option>
))}
</select>
<div className="flex min-h-5 flex-wrap items-center gap-1.5">
{current && current === suggested && (
<span className="text-[11px] font-medium text-emerald-600">Suggested match</span>
)}
{suggestedAvailable && !disabled && (
<button
type="button"
onClick={() => onChange(field, suggested)}
className="rounded-full border border-border/70 px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
Use {suggested}
</button>
)}
{missingRequired && (
<span className="text-[11px] text-destructive">Needs a column</span>
)}
</div>
</div>
<div className="min-w-0">
{samples.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{samples.map(value => (
<span key={value} className="max-w-full truncate rounded border border-border/50 bg-muted/25 px-2 py-1 text-[11px] text-muted-foreground">
{value}
</span>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
{current ? 'No sample values' : 'Map a column to preview values'}
</p>
)}
</div>
</div>
);
}
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 (
<div className="overflow-hidden rounded-lg border border-border/60">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/50 bg-muted/25 px-4 py-3">
<div>
<p className="text-sm font-medium">Column mapping</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{mappedCount} of {mappingFields.length} fields mapped
{missingRequired.length > 0 && ` · missing ${missingRequired.join(' and ')}`}
</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" type="button" onClick={onUseSuggested} disabled={disabled || !hasSuggestedMapping}>
Use Suggested
</Button>
<Button size="sm" variant="ghost" type="button" onClick={onClearMapping} disabled={disabled}>
Clear Mapping
</Button>
</div>
</div>
<div className="hidden border-b border-border/50 bg-muted/10 px-4 py-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground lg:grid lg:grid-cols-[minmax(150px,0.8fr)_minmax(220px,1fr)_minmax(180px,1fr)]">
<span>Field</span>
<span>CSV Column</span>
<span>Sample Values</span>
</div>
<div>
{mappingFields.map(field => (
<CsvMappingRow
key={field}
field={field}
label={fields[field]}
preview={preview}
mapping={mapping}
onChange={onMappingChange}
disabled={disabled}
/>
))}
</div>
</div>
);
}
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 <p className="py-4 text-center text-sm text-muted-foreground">No sample rows found.</p>;
}
return (
<div className="overflow-x-auto rounded-lg border border-border/60">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border/50 bg-muted/40 text-muted-foreground">
{visibleHeaders.map(header => (
<th key={header} className="px-3 py-2 text-left font-medium whitespace-nowrap">{header}</th>
))}
{hiddenCount > 0 && (
<th className="px-3 py-2 text-left font-medium whitespace-nowrap">+{hiddenCount}</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{sampleRows.map((row, index) => (
<tr key={index} className="hover:bg-muted/20">
{visibleHeaders.map(header => (
<td key={header} className="max-w-48 truncate px-3 py-2 text-muted-foreground">
{row[header] || '—'}
</td>
))}
{hiddenCount > 0 && (
<td className="px-3 py-2 text-muted-foreground">more columns</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
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 }) {
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 (
<SectionCard
title="Import Transaction CSV"
subtitle="Upload a bank or credit-card CSV, map its columns, then save normalized transactions for matching later."
>
<div className="px-6 py-5 space-y-5">
<div className="rounded-lg border border-border/60 bg-muted/25 p-4">
<div className="flex items-start gap-3">
<FileText className="mt-0.5 h-5 w-5 text-primary shrink-0" />
<div>
<p className="text-sm font-medium">Import transaction rows from CSV.</p>
<p className="mt-1 text-xs text-muted-foreground">
This importer creates shared transaction records only. It does not match transactions to bills yet.
</p>
</div>
</div>
</div>
<CsvImportStepper activeIndex={activeStep} />
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<label className="flex-1 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">CSV file</span>
<Input
ref={fileRef}
type="file"
accept=".csv,text/csv"
onChange={e => {
setFile(e.target.files?.[0] || null);
setPreview({ status: 'idle', data: null, error: null });
setMapping({});
setCommitState({ status: 'idle', result: null, error: null });
}}
/>
</label>
<div className="flex gap-2">
<Button size="sm" variant="outline" type="button" onClick={reset}>
Clear
</Button>
<Button size="sm" type="button" disabled={!file || preview.status === 'loading'} onClick={handlePreview}>
{preview.status === 'loading'
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Previewing</>
: <><Upload className="h-3.5 w-3.5 mr-1.5" />Preview</>}
</Button>
</div>
</div>
</div>
{preview.status === 'error' && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="mr-1.5 inline h-4 w-4" />
{preview.error?.message || 'CSV preview failed.'}
{preview.error?.details?.length > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs">
{preview.error.details.map((d, i) => (
<li key={i}>{d.message || JSON.stringify(d)}</li>
))}
</ul>
)}
</div>
)}
{preview.status === 'ready' && preview.data && (
<div className="space-y-5">
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">CSV Preview</p>
<p className="mt-1 text-sm font-medium">{file?.name || 'Transaction CSV'}</p>
</div>
<div className="flex flex-wrap gap-2">
<CountPill label="Rows" value={preview.data.rowCount} />
<CountPill label="Columns" value={preview.data.headers?.length || 0} />
<CountPill label="Issues" value={preview.data.errors?.length || 0} />
</div>
</div>
{preview.data.errors?.length > 0 && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3">
<p className="text-xs font-semibold uppercase tracking-widest text-amber-600 dark:text-amber-400">Review mapping</p>
<ul className="mt-2 space-y-1 text-xs text-amber-700 dark:text-amber-300">
{preview.data.errors.map((issue, i) => (
<li key={i} className="flex items-start gap-1.5">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{issue.message || JSON.stringify(issue)}</span>
</li>
))}
</ul>
</div>
)}
<CsvSampleTable preview={preview.data} />
</div>
<CsvMappingReview
preview={preview.data}
fields={fields}
mapping={mapping}
onMappingChange={handleMappingChange}
onUseSuggested={applySuggestedMapping}
onClearMapping={clearMapping}
disabled={commitState.status === 'done'}
/>
<div className="rounded-lg border border-border/60 bg-muted/25 px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
<div>
<p className="text-sm font-medium">
{canCommitCsvMapping(mapping) ? 'Ready to commit' : 'Mapping incomplete'}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Duplicates are skipped using a CSV transaction ID when available, otherwise a stable row hash.
</p>
</div>
{commitState.status === 'done' ? (
<Button size="sm" variant="outline" type="button" onClick={reset}>
<Plus className="h-3.5 w-3.5 mr-1.5" />New CSV import
</Button>
) : (
<Button size="sm" type="button" disabled={!canCommit} onClick={handleCommit}>
{commitState.status === 'loading'
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Importing</>
: <><CheckCheck className="h-3.5 w-3.5 mr-1.5" />Commit Import</>}
</Button>
)}
</div>
</div>
)}
{commitState.status === 'done' && commitState.result && (
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-4">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
<p className="text-sm font-medium text-emerald-600">CSV transaction import complete</p>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-xs">
<CountPill label="Imported" value={commitState.result.imported} />
<CountPill label="Skipped" value={commitState.result.skipped} />
<CountPill label="Failed" value={commitState.result.failed} />
</div>
{skippedRows.length > 0 && (
<div className="mt-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground">Skipped duplicates ({skippedRows.length})</p>
<ul className="mt-1 max-h-44 space-y-1 overflow-y-auto rounded-md border border-border/50 bg-background/40 p-2">
{skippedRows.map(row => (
<li key={row.row} className="break-all">
Row {row.row}: {row.provider_transaction_id}
</li>
))}
</ul>
</div>
)}
{failedRows.length > 0 && (
<div className="mt-3 text-xs text-destructive">
<p className="font-medium">Failed rows ({failedRows.length})</p>
<ul className="mt-1 max-h-64 space-y-2 overflow-y-auto rounded-md border border-destructive/20 bg-background/40 p-2">
{failedRows.map((row, index) => (
<li key={`${row.row}-${index}`} className="rounded border border-destructive/10 bg-destructive/5 px-2 py-1.5">
<p>Row {row.row}: {row.message}</p>
{row.details?.length > 0 && (
<ul className="mt-1 space-y-0.5 pl-3 text-destructive/90">
{row.details.map((detail, detailIndex) => (
<li key={detailIndex}>{formatCsvRowDetail(detail)}</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
)}
</div>
)}
{commitState.status === 'error' && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="mr-1.5 inline h-4 w-4" />
{commitState.error?.message || 'CSV import failed.'}
{commitState.error?.details?.length > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs">
{commitState.error.details.map((d, i) => (
<li key={i}>{d.message || JSON.stringify(d)}</li>
))}
</ul>
)}
</div>
)}
</div>
</SectionCard>
);
}
// ─── Section 3: Import My Data Export ────────────────────────────────────────