BillTracker/client/components/data/ImportOfxSection.jsx

124 lines
5.3 KiB
React
Raw Normal View History

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>
);
}