124 lines
5.3 KiB
React
124 lines
5.3 KiB
React
|
|
import { useRef, useState } from 'react';
|
||
|
|
import { toast } from 'sonner';
|
||
|
|
import { FileText, Upload, Loader2, CheckCircle2, X } from 'lucide-react';
|
||
|
|
import { api } from '@/api';
|
||
|
|
import { cn } from '@/lib/utils';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { formatCentsUSD } from '@/lib/money';
|
||
|
|
import { SectionCard, importErrorState } from './dataShared';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Import bank transactions from an OFX / QFX file. Unlike CSV, the file is
|
||
|
|
* structured, so there is no column-mapping step: upload → preview → import.
|
||
|
|
* Duplicates are skipped by the server (same dedupe scope as CSV/SimpleFIN).
|
||
|
|
*/
|
||
|
|
export default function ImportOfxSection({ onHistoryRefresh, cardProps = {} }) {
|
||
|
|
const fileRef = useRef(null);
|
||
|
|
const [preview, setPreview] = useState(null); // { import_session_id, count, sample }
|
||
|
|
const [busy, setBusy] = useState(null); // 'preview' | 'commit' | null
|
||
|
|
|
||
|
|
async function handleFile(e) {
|
||
|
|
const file = e.target.files?.[0];
|
||
|
|
if (!file) return;
|
||
|
|
setBusy('preview');
|
||
|
|
setPreview(null);
|
||
|
|
try {
|
||
|
|
setPreview(await api.previewOfxTransactionImport(file));
|
||
|
|
} catch (err) {
|
||
|
|
toast.error(importErrorState(err, 'Could not read that OFX/QFX file.').message);
|
||
|
|
} finally {
|
||
|
|
setBusy(null);
|
||
|
|
if (fileRef.current) fileRef.current.value = '';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleImport() {
|
||
|
|
if (!preview?.import_session_id) return;
|
||
|
|
setBusy('commit');
|
||
|
|
try {
|
||
|
|
const r = await api.commitOfxTransactionImport({ import_session_id: preview.import_session_id });
|
||
|
|
const parts = [`${r.imported} imported`];
|
||
|
|
if (r.skipped) parts.push(`${r.skipped} already present`);
|
||
|
|
if (r.failed) parts.push(`${r.failed} failed`);
|
||
|
|
toast.success(`OFX import complete — ${parts.join(', ')}.`);
|
||
|
|
setPreview(null);
|
||
|
|
onHistoryRefresh?.();
|
||
|
|
} catch (err) {
|
||
|
|
toast.error(err.message || 'OFX import failed.');
|
||
|
|
} finally {
|
||
|
|
setBusy(null);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<SectionCard {...cardProps}>
|
||
|
|
<div className="space-y-4 px-6 py-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 shrink-0 text-primary" />
|
||
|
|
<p className="min-w-0 text-sm text-muted-foreground">
|
||
|
|
Many banks export <span className="font-medium text-foreground">.ofx</span> or{' '}
|
||
|
|
<span className="font-medium text-foreground">.qfx</span> files. Upload one to import its
|
||
|
|
transactions — duplicates are skipped automatically.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{!preview ? (
|
||
|
|
<div>
|
||
|
|
<input
|
||
|
|
ref={fileRef}
|
||
|
|
id="ofx-upload"
|
||
|
|
type="file"
|
||
|
|
accept=".ofx,.qfx,application/x-ofx"
|
||
|
|
className="hidden"
|
||
|
|
onChange={handleFile}
|
||
|
|
disabled={busy === 'preview'}
|
||
|
|
/>
|
||
|
|
<label htmlFor="ofx-upload">
|
||
|
|
<Button asChild variant="outline" className="cursor-pointer gap-2">
|
||
|
|
<span>
|
||
|
|
{busy === 'preview' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||
|
|
{busy === 'preview' ? 'Reading…' : 'Choose .ofx / .qfx file'}
|
||
|
|
</span>
|
||
|
|
</Button>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-3">
|
||
|
|
<div className="flex items-center justify-between gap-2">
|
||
|
|
<p className="text-sm font-medium text-foreground">
|
||
|
|
<CheckCircle2 className="mr-1.5 inline h-4 w-4 text-emerald-500" />
|
||
|
|
{preview.count} transaction{preview.count === 1 ? '' : 's'} found
|
||
|
|
</p>
|
||
|
|
<Button variant="ghost" size="sm" className="gap-1 text-xs" onClick={() => setPreview(null)} disabled={busy === 'commit'}>
|
||
|
|
<X className="h-3.5 w-3.5" /> Cancel
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
<ul className="max-h-56 divide-y divide-border/50 overflow-y-auto rounded-lg border border-border/50">
|
||
|
|
{(preview.sample || []).map((tx, i) => (
|
||
|
|
<li key={i} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
|
||
|
|
<span className="min-w-0 flex-1 truncate">{tx.payee || tx.description || 'Transaction'}</span>
|
||
|
|
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{tx.posted_date}</span>
|
||
|
|
<span className={cn('shrink-0 tabular-nums', tx.amount < 0 ? 'text-foreground' : 'text-emerald-600 dark:text-emerald-400')}>
|
||
|
|
{formatCentsUSD(tx.amount, { signed: true })}
|
||
|
|
</span>
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
{preview.count > (preview.sample || []).length && (
|
||
|
|
<li className="px-3 py-2 text-center text-xs text-muted-foreground">
|
||
|
|
+ {preview.count - (preview.sample || []).length} more
|
||
|
|
</li>
|
||
|
|
)}
|
||
|
|
</ul>
|
||
|
|
<Button className="gap-2" onClick={handleImport} disabled={busy === 'commit'}>
|
||
|
|
{busy === 'commit' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||
|
|
Import {preview.count} transaction{preview.count === 1 ? '' : 's'}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</SectionCard>
|
||
|
|
);
|
||
|
|
}
|