570 lines
23 KiB
JavaScript
570 lines
23 KiB
JavaScript
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, 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 (
|
|
<SectionCard
|
|
title="Import Transaction CSV"
|
|
subtitle="Upload a bank or credit-card CSV, map its columns, then save normalized transactions for matching later."
|
|
{...cardProps}
|
|
>
|
|
<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 ────────────────────────────────────────
|