2026-05-15 01:36:56 -05:00
|
|
|
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle,
|
|
|
|
|
|
AlertTriangle, Loader2, RefreshCw, Clock, ChevronDown,
|
2026-05-09 13:03:36 -05:00
|
|
|
|
ChevronUp, SkipForward, Plus, CheckCheck, Sparkles,
|
2026-05-16 21:36:04 -05:00
|
|
|
|
List, Building2, ChevronLeft, FileText, Link2, Link2Off,
|
|
|
|
|
|
EyeOff, Eye, Search,
|
2026-05-03 19:51:57 -05:00
|
|
|
|
} 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 { Switch } from '@/components/ui/switch';
|
2026-05-09 13:03:36 -05:00
|
|
|
|
import {
|
|
|
|
|
|
AlertDialog,
|
2026-05-10 14:36:59 -05:00
|
|
|
|
AlertDialogAction,
|
2026-05-09 13:03:36 -05:00
|
|
|
|
AlertDialogCancel,
|
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
|
AlertDialogFooter,
|
|
|
|
|
|
AlertDialogHeader,
|
|
|
|
|
|
AlertDialogTitle,
|
|
|
|
|
|
AlertDialogTrigger,
|
|
|
|
|
|
} from '@/components/ui/alert-dialog';
|
2026-05-16 21:36:04 -05:00
|
|
|
|
import {
|
|
|
|
|
|
Dialog,
|
|
|
|
|
|
DialogContent,
|
|
|
|
|
|
DialogDescription,
|
|
|
|
|
|
DialogFooter,
|
|
|
|
|
|
DialogHeader,
|
|
|
|
|
|
DialogTitle,
|
|
|
|
|
|
} from '@/components/ui/dialog';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
|
|
// ─── User export availability flag ───────────────────────────────────────────
|
|
|
|
|
|
// Flip to true when GET /api/export/user-db and GET /api/export/user-excel exist.
|
|
|
|
|
|
const USER_EXPORTS_AVAILABLE = true;
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function fmt(isoStr) {
|
|
|
|
|
|
if (!isoStr) return '—';
|
|
|
|
|
|
const d = new Date(isoStr);
|
|
|
|
|
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
|
|
|
|
|
+ ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function groupRowsBySheet(rows) {
|
|
|
|
|
|
const map = new Map();
|
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
|
const key = row.sheet_name || '(unknown sheet)';
|
|
|
|
|
|
if (!map.has(key)) map.set(key, []);
|
|
|
|
|
|
map.get(key).push(row);
|
|
|
|
|
|
}
|
|
|
|
|
|
return Array.from(map.entries()).map(([name, rows]) => ({ name, rows }));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function initialDecisionFromRecommendation(row) {
|
|
|
|
|
|
const rec = row.recommendation || {};
|
|
|
|
|
|
const action = rec.action === 'ambiguous' ? null : (rec.action || row.proposed_action || null);
|
|
|
|
|
|
|
|
|
|
|
|
if (!action || row.requires_user_decision) return { action: null };
|
|
|
|
|
|
if (action === 'skip_row') return { action: 'skip_row' };
|
|
|
|
|
|
if (action === 'match_existing_bill') {
|
|
|
|
|
|
return {
|
|
|
|
|
|
action,
|
|
|
|
|
|
bill_id: rec.bill_id ?? row.possible_bill_matches?.[0]?.bill_id ?? null,
|
|
|
|
|
|
bill_name: null,
|
|
|
|
|
|
due_day: rec.due_day ?? null,
|
|
|
|
|
|
actual_amount: rec.actual_amount ?? row.detected_amount ?? null,
|
|
|
|
|
|
payment_amount: rec.payment_amount ?? row.detected_payment_amount ?? null,
|
|
|
|
|
|
payment_date: rec.payment_date ?? row.detected_paid_date ?? null,
|
|
|
|
|
|
notes: row.detected_notes ?? null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (action === 'create_new_bill') {
|
|
|
|
|
|
return {
|
|
|
|
|
|
action,
|
|
|
|
|
|
bill_id: null,
|
|
|
|
|
|
bill_name: rec.bill_name || row.detected_bill_name || '',
|
|
|
|
|
|
category_id: rec.category_id ?? null,
|
|
|
|
|
|
due_day: rec.due_day ?? null,
|
|
|
|
|
|
expected_amount: rec.expected_amount ?? row.detected_amount ?? null,
|
|
|
|
|
|
actual_amount: rec.actual_amount ?? row.detected_amount ?? null,
|
|
|
|
|
|
payment_amount: rec.payment_amount ?? row.detected_payment_amount ?? null,
|
|
|
|
|
|
payment_date: rec.payment_date ?? row.detected_paid_date ?? null,
|
|
|
|
|
|
notes: row.detected_notes ?? null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return { action };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function safeRawBillName(row) {
|
|
|
|
|
|
const raw = row.raw_values?.find((v) => {
|
|
|
|
|
|
const text = String(v || '').trim();
|
|
|
|
|
|
if (!text || text.length > 80) return false;
|
|
|
|
|
|
if (/^(?:total|subtotal|sum|grand\s*total)$/i.test(text)) return false;
|
|
|
|
|
|
if (/^\$?\(?\d[\d,]*(?:\.\d{1,2})?\)?$/.test(text)) return false;
|
|
|
|
|
|
if (/^\d{1,2}\/\d{1,2}(?:\/\d{2,4})?$/.test(text)) return false;
|
|
|
|
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return false;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
});
|
|
|
|
|
|
return raw ? String(raw).trim() : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildCreateNewDecision(row, currentDecision = {}) {
|
|
|
|
|
|
const rec = row.recommendation || {};
|
|
|
|
|
|
const billName = currentDecision.bill_name
|
|
|
|
|
|
|| row.detected_bill_name
|
|
|
|
|
|
|| rec.bill_name
|
|
|
|
|
|
|| safeRawBillName(row);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
...currentDecision,
|
|
|
|
|
|
action: 'create_new_bill',
|
|
|
|
|
|
previous_match_bill_id: currentDecision.bill_id ?? currentDecision.previous_match_bill_id ?? rec.bill_id ?? row.possible_bill_matches?.[0]?.bill_id ?? null,
|
|
|
|
|
|
bill_id: null,
|
|
|
|
|
|
bill_name: billName,
|
|
|
|
|
|
category_id: currentDecision.category_id ?? rec.category_id ?? null,
|
|
|
|
|
|
due_day: currentDecision.due_day ?? rec.due_day ?? null,
|
|
|
|
|
|
expected_amount: currentDecision.expected_amount ?? rec.expected_amount ?? row.detected_amount ?? null,
|
|
|
|
|
|
actual_amount: currentDecision.actual_amount ?? rec.actual_amount ?? row.detected_amount ?? null,
|
|
|
|
|
|
payment_amount: currentDecision.payment_amount ?? rec.payment_amount ?? row.detected_payment_amount ?? null,
|
|
|
|
|
|
payment_date: currentDecision.payment_date ?? rec.payment_date ?? row.detected_paid_date ?? null,
|
|
|
|
|
|
notes: currentDecision.notes ?? row.detected_notes ?? null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildInitialDecisions(rows) {
|
|
|
|
|
|
const d = {};
|
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
|
const hasError = row.errors?.length > 0;
|
|
|
|
|
|
if (hasError || row.proposed_action === 'skip_row') {
|
|
|
|
|
|
d[row.row_id] = { action: 'skip_row' };
|
|
|
|
|
|
} else {
|
|
|
|
|
|
d[row.row_id] = initialDecisionFromRecommendation(row);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return d;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isDecisionComplete(action, decision) {
|
|
|
|
|
|
if (!action) return false;
|
|
|
|
|
|
if (action === 'skip_row') return true;
|
|
|
|
|
|
if (action === 'create_new_bill') return !!(decision?.bill_name?.trim());
|
|
|
|
|
|
if (['match_existing_bill', 'update_monthly_state', 'add_monthly_note', 'create_payment'].includes(action)) {
|
|
|
|
|
|
return !!decision?.bill_id;
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Badges ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function SourceBadge({ source }) {
|
|
|
|
|
|
const MAP = {
|
|
|
|
|
|
row_date: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
|
|
|
|
|
|
sheet_name: 'bg-blue-500/15 text-blue-600 dark:text-blue-400',
|
|
|
|
|
|
default: 'bg-amber-500/15 text-amber-600 dark:text-amber-500',
|
|
|
|
|
|
ambiguous: 'bg-red-500/15 text-red-600 dark:text-red-400',
|
|
|
|
|
|
};
|
|
|
|
|
|
const LABELS = { row_date: 'date cell', sheet_name: 'tab name', default: 'default', ambiguous: 'ambiguous' };
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium', MAP[source] ?? MAP.ambiguous)}>
|
|
|
|
|
|
{LABELS[source] ?? source}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ConfidenceBadge({ confidence }) {
|
|
|
|
|
|
const MAP = { high: 'text-emerald-600 dark:text-emerald-400', medium: 'text-amber-600 dark:text-amber-500', low: 'text-red-500' };
|
|
|
|
|
|
return <span className={cn('text-[10px] font-semibold uppercase', MAP[confidence] ?? '')}>{confidence}</span>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function actionLabel(action) {
|
|
|
|
|
|
const MAP = {
|
|
|
|
|
|
match_existing_bill: 'Match existing bill',
|
|
|
|
|
|
create_new_bill: 'Create new bill',
|
|
|
|
|
|
skip_row: 'Skip row',
|
|
|
|
|
|
ambiguous: 'Needs decision',
|
|
|
|
|
|
update_monthly_state: 'Update monthly record',
|
|
|
|
|
|
add_monthly_note: 'Add monthly note',
|
|
|
|
|
|
create_payment: 'Record as payment',
|
|
|
|
|
|
};
|
|
|
|
|
|
return MAP[action] || (action ? action.replace(/_/g, ' ') : 'Needs decision');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function importErrorState(err, fallback) {
|
|
|
|
|
|
const data = err?.data || {};
|
|
|
|
|
|
return {
|
|
|
|
|
|
message: err?.message || data.message || data.error || fallback,
|
|
|
|
|
|
error: data.error || fallback,
|
|
|
|
|
|
code: data.code || err?.code || null,
|
|
|
|
|
|
details: Array.isArray(data.details) ? data.details : (Array.isArray(err?.details) ? err.details : []),
|
|
|
|
|
|
error_id: data.error_id || null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SheetStatusBadge({ status }) {
|
|
|
|
|
|
const MAP = {
|
|
|
|
|
|
parsed: 'bg-emerald-500/15 text-emerald-600',
|
|
|
|
|
|
parsed_month_only: 'bg-amber-500/15 text-amber-600',
|
|
|
|
|
|
ambiguous: 'bg-orange-500/15 text-orange-600',
|
|
|
|
|
|
skipped: 'bg-muted text-muted-foreground',
|
|
|
|
|
|
};
|
|
|
|
|
|
const LABELS = {
|
|
|
|
|
|
parsed: 'parsed', parsed_month_only: 'month only', ambiguous: 'ambiguous', skipped: 'skipped',
|
|
|
|
|
|
};
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium', MAP[status] ?? MAP.ambiguous)}>
|
|
|
|
|
|
{LABELS[status] ?? status}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Shared SectionCard ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function SectionCard({ title, subtitle, children, className }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={cn('table-surface mb-6', className)}>
|
|
|
|
|
|
<div className="px-6 py-4 border-b border-border/50">
|
|
|
|
|
|
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">{title}</h2>
|
|
|
|
|
|
{subtitle && <p className="text-sm text-muted-foreground mt-1">{subtitle}</p>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="divide-y divide-border/50">{children}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Section 2: Download My Data ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function ExportCard({ icon: Icon, title, description, filename, endpoint }) {
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
const handleDownload = async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(endpoint, { credentials: 'include' });
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
let data = {};
|
|
|
|
|
|
try { data = await res.json(); } catch {}
|
|
|
|
|
|
throw new Error(data.error || `HTTP ${res.status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
const disposition = res.headers.get('Content-Disposition');
|
|
|
|
|
|
const match = disposition?.match(/filename="?([^"]+)"?/i);
|
|
|
|
|
|
const name = match ? match[1] : filename;
|
|
|
|
|
|
const blob = await res.blob();
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
|
a.href = url; a.download = name; a.click();
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
toast.success(`${title} downloaded.`);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Download failed.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const disabled = !USER_EXPORTS_AVAILABLE || loading;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="px-6 py-5 flex items-start justify-between gap-6">
|
|
|
|
|
|
<div className="flex items-start gap-4 flex-1 min-w-0">
|
|
|
|
|
|
<div className="mt-0.5 h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
|
|
|
|
|
<Icon className="h-5 w-5 text-primary" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
|
|
|
|
|
|
<p className="text-sm font-medium">{title}</p>
|
|
|
|
|
|
{!USER_EXPORTS_AVAILABLE && (
|
|
|
|
|
|
<span className="inline-flex items-center rounded-full bg-muted border border-border px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
|
|
|
|
|
Coming soon
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">{description}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="shrink-0 pt-0.5">
|
|
|
|
|
|
<Button size="sm" variant="outline" disabled={disabled}
|
|
|
|
|
|
onClick={USER_EXPORTS_AVAILABLE ? handleDownload : undefined}>
|
|
|
|
|
|
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Downloading…</>
|
|
|
|
|
|
: <><Download className="h-3.5 w-3.5 mr-1.5" />{USER_EXPORTS_AVAILABLE ? 'Download' : 'Not Available Yet'}</>}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function DownloadMyDataSection() {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
title="Download My Data"
|
|
|
|
|
|
subtitle="Export your bill tracker data for your own records. These exports include only your data — not a full system backup."
|
|
|
|
|
|
>
|
|
|
|
|
|
<ExportCard icon={Database} title="SQLite Data Export"
|
|
|
|
|
|
description="Download a portable SQLite database containing your bill tracker data."
|
|
|
|
|
|
filename="bill-tracker-user-export.sqlite" endpoint="/api/export/user-db" />
|
|
|
|
|
|
<ExportCard icon={FileSpreadsheet} title="Excel Databook"
|
|
|
|
|
|
description="Download an Excel workbook with sheets for bills, payments, categories, monthly state, and summary data."
|
|
|
|
|
|
filename="bill-tracker-databook.xlsx" endpoint="/api/export/user-excel" />
|
2026-05-10 15:29:35 -05:00
|
|
|
|
<div className="px-6 py-3 rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/40 flex items-start gap-2.5 mx-6 mt-2">
|
|
|
|
|
|
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" />
|
|
|
|
|
|
<p className="text-xs text-amber-700 dark:text-amber-300">Exports may contain sensitive account metadata (website URLs, usernames, account info). Store exported files securely and avoid sharing them unencrypted.</p>
|
|
|
|
|
|
</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
<div className="px-6 py-5 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
|
|
|
|
<div className="rounded-lg bg-muted/40 border border-border/60 p-4">
|
|
|
|
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2.5">What's included</p>
|
|
|
|
|
|
<ul className="space-y-1.5">
|
2026-05-10 15:29:35 -05:00
|
|
|
|
{['Bills','Payments','Categories','Monthly bill state','Monthly starting amounts','History ranges','Notes','Export metadata'].map(i => (
|
2026-05-03 19:51:57 -05:00
|
|
|
|
<li key={i} className="flex items-center gap-2 text-xs text-foreground/80">
|
|
|
|
|
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 shrink-0" />{i}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="rounded-lg bg-muted/40 border border-border/60 p-4">
|
|
|
|
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2.5">What's not included</p>
|
|
|
|
|
|
<ul className="space-y-1.5">
|
|
|
|
|
|
{['Passwords','Sessions','Admin settings','Server configuration',"Other users' data",'Full system backup files'].map(i => (
|
|
|
|
|
|
<li key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
|
|
|
|
<XCircle className="h-3.5 w-3.5 text-muted-foreground/40 shrink-0" />{i}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function CountPill({ label, value }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="rounded-lg border border-border/60 bg-muted/25 px-3 py-2">
|
|
|
|
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{label}</p>
|
|
|
|
|
|
<p className="mt-1 text-sm font-semibold tabular-nums">{value ?? 0}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 21:36:04 -05:00
|
|
|
|
// ─── Section 2: Transaction Matching ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const TRANSACTION_FILTERS = [
|
|
|
|
|
|
{ id: 'open', label: 'Open', params: { ignored: 'false' } },
|
|
|
|
|
|
{ id: 'unmatched', label: 'Unmatched', params: { match_status: 'unmatched', ignored: 'false' } },
|
|
|
|
|
|
{ id: 'matched', label: 'Matched', params: { match_status: 'matched', ignored: 'false' } },
|
|
|
|
|
|
{ id: 'ignored', label: 'Ignored', params: { match_status: 'ignored', ignored: 'true' } },
|
|
|
|
|
|
{ id: 'all', label: 'All', params: { ignored: 'all' } },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
function transactionStatus(tx) {
|
|
|
|
|
|
if (tx?.ignored) return 'ignored';
|
|
|
|
|
|
return tx?.match_status || 'unmatched';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function TransactionStatusBadge({ tx }) {
|
|
|
|
|
|
const status = transactionStatus(tx);
|
|
|
|
|
|
const styles = {
|
|
|
|
|
|
matched: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600',
|
|
|
|
|
|
ignored: 'border-muted-foreground/30 bg-muted/40 text-muted-foreground',
|
|
|
|
|
|
unmatched: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className={cn(
|
|
|
|
|
|
'inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase',
|
|
|
|
|
|
styles[status] || styles.unmatched,
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{status}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatTransactionAmount(amount, currency = 'USD') {
|
|
|
|
|
|
const value = Math.abs(Number(amount || 0)) / 100;
|
|
|
|
|
|
const sign = Number(amount || 0) < 0 ? '-' : '+';
|
|
|
|
|
|
return `${sign}${new Intl.NumberFormat(undefined, {
|
|
|
|
|
|
style: 'currency',
|
|
|
|
|
|
currency: currency || 'USD',
|
|
|
|
|
|
}).format(value)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function transactionDate(tx) {
|
|
|
|
|
|
return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || '—';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function transactionTitle(tx) {
|
|
|
|
|
|
return tx?.payee || tx?.description || tx?.memo || 'Transaction';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 09:44:16 -05:00
|
|
|
|
function matchScoreTone(score) {
|
|
|
|
|
|
const value = Number(score) || 0;
|
|
|
|
|
|
if (value >= 80) return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
|
|
|
|
|
|
if (value >= 55) return 'border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400';
|
|
|
|
|
|
return 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onReject }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="rounded-lg border border-sky-500/20 bg-sky-500/[0.035]">
|
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-sky-500/10 px-4 py-3">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-sky-500/20 bg-sky-500/10 text-sky-600 dark:text-sky-400">
|
|
|
|
|
|
<Sparkles className="h-4 w-4" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-sm font-semibold">Suggested matches</p>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">{loading ? 'Checking transactions' : `${suggestions.length} ready for review`}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<div className="px-4 py-6 text-sm text-muted-foreground">
|
|
|
|
|
|
<Loader2 className="mr-2 inline h-4 w-4 animate-spin" />
|
|
|
|
|
|
Finding likely bill matches...
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : suggestions.length === 0 ? (
|
|
|
|
|
|
<div className="px-4 py-6 text-sm text-muted-foreground">
|
|
|
|
|
|
No suggested matches right now.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="grid gap-2 p-3 xl:grid-cols-2">
|
|
|
|
|
|
{suggestions.map(suggestion => {
|
|
|
|
|
|
const tx = suggestion.transaction || {};
|
|
|
|
|
|
const bill = suggestion.bill || {};
|
|
|
|
|
|
const acceptBusy = actionId === `suggestion-match:${suggestion.id}`;
|
|
|
|
|
|
const rejectBusy = actionId === `suggestion-reject:${suggestion.id}`;
|
|
|
|
|
|
const busy = acceptBusy || rejectBusy;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={suggestion.id}
|
|
|
|
|
|
className="rounded-lg border border-border/60 bg-background/80 p-3 shadow-sm transition-colors hover:border-sky-500/30"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-start justify-between gap-3">
|
|
|
|
|
|
<div className="min-w-0 space-y-1">
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
|
<span className={cn('rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase', matchScoreTone(suggestion.score))}>
|
|
|
|
|
|
{suggestion.score}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<p className="truncate text-sm font-medium">{transactionTitle(tx)}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
|
|
|
|
{transactionDate(tx)} · {tx.source_label || tx.source_type_label || 'Transaction'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className={cn(
|
|
|
|
|
|
'shrink-0 text-sm font-semibold tabular-nums',
|
|
|
|
|
|
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{formatTransactionAmount(tx.amount, tx.currency)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="mt-3 flex items-center gap-2 rounded-md border border-emerald-500/15 bg-emerald-500/[0.045] px-2.5 py-2">
|
|
|
|
|
|
<Link2 className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<p className="truncate text-sm font-medium">{bill.name || `Bill ${suggestion.billId}`}</p>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
|
Expected ${Number(bill.expected_amount || 0).toFixed(2)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{suggestion.reasons?.length > 0 && (
|
|
|
|
|
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
|
|
|
|
{suggestion.reasons.slice(0, 4).map(reason => (
|
|
|
|
|
|
<span key={reason} className="rounded-full border border-border/60 bg-muted/30 px-2 py-0.5 text-[10px] text-muted-foreground">
|
|
|
|
|
|
{reason}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="mt-3 flex justify-end gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
onClick={() => onReject(suggestion)}
|
|
|
|
|
|
className="h-8 text-xs text-muted-foreground hover:text-destructive"
|
|
|
|
|
|
>
|
|
|
|
|
|
{rejectBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <XCircle className="h-3.5 w-3.5" />}
|
|
|
|
|
|
<span className="ml-1.5">Reject</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
onClick={() => onAccept(suggestion)}
|
|
|
|
|
|
className="h-8 gap-1.5 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
{acceptBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
|
|
|
|
|
|
Match
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 21:36:04 -05:00
|
|
|
|
function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading }) {
|
|
|
|
|
|
const [query, setQuery] = useState('');
|
|
|
|
|
|
const [selectedBillId, setSelectedBillId] = useState('');
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (open) {
|
|
|
|
|
|
setQuery('');
|
|
|
|
|
|
setSelectedBillId(transaction?.matched_bill_id ? String(transaction.matched_bill_id) : '');
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [open, transaction?.id, transaction?.matched_bill_id]);
|
|
|
|
|
|
|
|
|
|
|
|
const filteredBills = useMemo(() => {
|
|
|
|
|
|
const q = query.trim().toLowerCase();
|
|
|
|
|
|
if (!q) return bills.slice(0, 40);
|
|
|
|
|
|
return bills
|
|
|
|
|
|
.filter(bill => String(bill.name || '').toLowerCase().includes(q))
|
|
|
|
|
|
.slice(0, 40);
|
|
|
|
|
|
}, [bills, query]);
|
|
|
|
|
|
|
|
|
|
|
|
const selectedBill = bills.find(bill => String(bill.id) === String(selectedBillId));
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
|
<DialogContent className="sm:max-w-2xl">
|
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
|
<DialogTitle>Match Transaction</DialogTitle>
|
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
|
Choose the bill this transaction paid. Nothing changes until you confirm.
|
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
|
|
{transaction && (
|
|
|
|
|
|
<div className="rounded-lg border border-border/60 bg-muted/25 p-3">
|
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<p className="truncate text-sm font-medium">{transactionTitle(transaction)}</p>
|
|
|
|
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
|
|
|
|
{transactionDate(transaction)} · {transaction.source_label || transaction.source_type_label || 'Transaction'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className={cn(
|
|
|
|
|
|
'text-sm font-semibold tabular-nums',
|
|
|
|
|
|
Number(transaction.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{formatTransactionAmount(transaction.amount, transaction.currency)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{transaction.description && transaction.description !== transactionTitle(transaction) && (
|
|
|
|
|
|
<p className="mt-2 text-xs text-muted-foreground">{transaction.description}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<label className="space-y-1.5">
|
|
|
|
|
|
<span className="text-xs font-medium text-muted-foreground">Find bill</span>
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={query}
|
|
|
|
|
|
onChange={e => setQuery(e.target.value)}
|
|
|
|
|
|
placeholder="Search bills"
|
|
|
|
|
|
className="pl-8"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="max-h-72 overflow-y-auto rounded-lg border border-border/60">
|
|
|
|
|
|
{filteredBills.length === 0 ? (
|
|
|
|
|
|
<p className="px-4 py-8 text-center text-sm text-muted-foreground">No bills found.</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="divide-y divide-border/40">
|
|
|
|
|
|
{filteredBills.map(bill => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={bill.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setSelectedBillId(String(bill.id))}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/30',
|
|
|
|
|
|
String(selectedBillId) === String(bill.id) && 'bg-primary/5',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<p className="truncate text-sm font-medium">{bill.name}</p>
|
|
|
|
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
|
|
|
|
Due day {bill.due_day ?? '—'} · expected ${Number(bill.expected_amount || 0).toFixed(2)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{String(selectedBillId) === String(bill.id) && (
|
|
|
|
|
|
<CheckCircle2 className="h-4 w-4 shrink-0 text-primary" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<DialogFooter className="gap-2">
|
|
|
|
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
|
|
|
|
Cancel
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
disabled={!selectedBill || loading}
|
|
|
|
|
|
onClick={() => selectedBill && onConfirm(selectedBill.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Matching…</> : <><Link2 className="h-3.5 w-3.5 mr-1.5" />Confirm Match</>}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function TransactionMatchingSection({ refreshKey }) {
|
|
|
|
|
|
const [transactions, setTransactions] = useState([]);
|
2026-05-18 09:44:16 -05:00
|
|
|
|
const [suggestions, setSuggestions] = useState([]);
|
2026-05-16 21:36:04 -05:00
|
|
|
|
const [bills, setBills] = useState([]);
|
|
|
|
|
|
const [filter, setFilter] = useState('open');
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
2026-05-18 09:44:16 -05:00
|
|
|
|
const [suggestionsLoading, setSuggestionsLoading] = useState(true);
|
2026-05-16 21:36:04 -05:00
|
|
|
|
const [billsLoading, setBillsLoading] = useState(true);
|
|
|
|
|
|
const [actionId, setActionId] = useState(null);
|
|
|
|
|
|
const [matchOpen, setMatchOpen] = useState(false);
|
|
|
|
|
|
const [matchTransaction, setMatchTransaction] = useState(null);
|
|
|
|
|
|
|
|
|
|
|
|
const currentFilter = TRANSACTION_FILTERS.find(item => item.id === filter) || TRANSACTION_FILTERS[0];
|
|
|
|
|
|
|
|
|
|
|
|
const loadTransactions = async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await api.transactions({ limit: 100, ...currentFilter.params });
|
|
|
|
|
|
setTransactions(data || []);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to load transactions.');
|
|
|
|
|
|
setTransactions([]);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-18 09:44:16 -05:00
|
|
|
|
const loadSuggestions = async () => {
|
|
|
|
|
|
setSuggestionsLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await api.matchSuggestions({ limit: 8 });
|
|
|
|
|
|
setSuggestions(data || []);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to load match suggestions.');
|
|
|
|
|
|
setSuggestions([]);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSuggestionsLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const refreshTransactionWorkbench = async () => {
|
|
|
|
|
|
await Promise.all([loadTransactions(), loadSuggestions()]);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-16 21:36:04 -05:00
|
|
|
|
const loadBills = async () => {
|
|
|
|
|
|
setBillsLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await api.bills();
|
|
|
|
|
|
setBills(data || []);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setBills([]);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBillsLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { loadBills(); }, []);
|
|
|
|
|
|
useEffect(() => { loadTransactions(); }, [filter, refreshKey]);
|
2026-05-18 09:44:16 -05:00
|
|
|
|
useEffect(() => { loadSuggestions(); }, [refreshKey]);
|
2026-05-16 21:36:04 -05:00
|
|
|
|
|
|
|
|
|
|
const openMatchDialog = (tx) => {
|
|
|
|
|
|
setMatchTransaction(tx);
|
|
|
|
|
|
setMatchOpen(true);
|
|
|
|
|
|
if (!bills.length && !billsLoading) loadBills();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const runTransactionAction = async (tx, action) => {
|
|
|
|
|
|
setActionId(`${action}:${tx.id}`);
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (action === 'unmatch') {
|
|
|
|
|
|
await api.unmatchTransaction(tx.id);
|
|
|
|
|
|
toast.success('Transaction unmatched.');
|
|
|
|
|
|
} else if (action === 'ignore') {
|
|
|
|
|
|
await api.ignoreTransaction(tx.id);
|
|
|
|
|
|
toast.success('Transaction ignored.');
|
|
|
|
|
|
} else if (action === 'unignore') {
|
|
|
|
|
|
await api.unignoreTransaction(tx.id);
|
|
|
|
|
|
toast.success('Transaction restored.');
|
|
|
|
|
|
}
|
2026-05-18 09:44:16 -05:00
|
|
|
|
await refreshTransactionWorkbench();
|
2026-05-16 21:36:04 -05:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Transaction action failed.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setActionId(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const confirmMatch = async (billId) => {
|
|
|
|
|
|
if (!matchTransaction) return;
|
|
|
|
|
|
setActionId(`match:${matchTransaction.id}`);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.matchTransaction(matchTransaction.id, billId);
|
|
|
|
|
|
toast.success('Transaction matched to bill.');
|
|
|
|
|
|
setMatchOpen(false);
|
|
|
|
|
|
setMatchTransaction(null);
|
2026-05-18 09:44:16 -05:00
|
|
|
|
await refreshTransactionWorkbench();
|
2026-05-16 21:36:04 -05:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Transaction match failed.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setActionId(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-18 09:44:16 -05:00
|
|
|
|
const acceptSuggestion = async (suggestion) => {
|
|
|
|
|
|
setActionId(`suggestion-match:${suggestion.id}`);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.matchTransaction(suggestion.transactionId, suggestion.billId);
|
|
|
|
|
|
toast.success('Suggested match confirmed.');
|
|
|
|
|
|
await refreshTransactionWorkbench();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Suggested match failed.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setActionId(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const rejectSuggestion = async (suggestion) => {
|
|
|
|
|
|
setActionId(`suggestion-reject:${suggestion.id}`);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.rejectMatchSuggestion(suggestion.id);
|
|
|
|
|
|
toast.success('Suggestion rejected.');
|
|
|
|
|
|
await loadSuggestions();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Suggestion could not be rejected.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setActionId(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-16 21:36:04 -05:00
|
|
|
|
return (
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
title="Transactions"
|
|
|
|
|
|
subtitle="Review imported or manual transactions and confirm matches to bills."
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="px-6 py-5 space-y-4">
|
|
|
|
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
|
{TRANSACTION_FILTERS.map(item => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setFilter(item.id)}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
|
|
|
|
|
|
filter === item.id
|
|
|
|
|
|
? 'border-primary/40 bg-primary/10 text-primary'
|
|
|
|
|
|
: 'border-border/60 bg-muted/20 text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{item.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-05-18 09:44:16 -05:00
|
|
|
|
<Button size="sm" variant="outline" type="button" onClick={refreshTransactionWorkbench} disabled={loading || suggestionsLoading}>
|
2026-05-16 21:36:04 -05:00
|
|
|
|
{loading ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-1.5" />}
|
|
|
|
|
|
Refresh
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-18 09:44:16 -05:00
|
|
|
|
<SuggestedMatchesPanel
|
|
|
|
|
|
suggestions={suggestions}
|
|
|
|
|
|
loading={suggestionsLoading}
|
|
|
|
|
|
actionId={actionId}
|
|
|
|
|
|
onAccept={acceptSuggestion}
|
|
|
|
|
|
onReject={rejectSuggestion}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-05-16 21:36:04 -05:00
|
|
|
|
<div className="overflow-x-auto rounded-lg border border-border/60">
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<div className="px-4 py-8 text-center text-sm text-muted-foreground">Loading transactions…</div>
|
|
|
|
|
|
) : transactions.length === 0 ? (
|
|
|
|
|
|
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
|
|
|
|
|
No transactions found for this filter.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr className="border-b border-border/50 bg-muted/30 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
|
|
|
|
<th className="px-4 py-2 text-left">Date</th>
|
|
|
|
|
|
<th className="px-4 py-2 text-left">Transaction</th>
|
|
|
|
|
|
<th className="px-4 py-2 text-left">Match</th>
|
|
|
|
|
|
<th className="px-4 py-2 text-right">Amount</th>
|
|
|
|
|
|
<th className="px-4 py-2 text-right">Actions</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody className="divide-y divide-border/30">
|
|
|
|
|
|
{transactions.map(tx => {
|
|
|
|
|
|
const status = transactionStatus(tx);
|
|
|
|
|
|
const busy = actionId?.endsWith(`:${tx.id}`);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<tr key={tx.id} className="hover:bg-muted/20">
|
|
|
|
|
|
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
|
|
|
|
|
|
{transactionDate(tx)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-4 py-3 min-w-[240px]">
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<p className="truncate text-sm font-medium">{transactionTitle(tx)}</p>
|
|
|
|
|
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
|
|
|
|
{[tx.description, tx.account_name, tx.source_label].filter(Boolean).join(' · ') || '—'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-4 py-3 min-w-[180px]">
|
|
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
|
|
|
|
<TransactionStatusBadge tx={tx} />
|
|
|
|
|
|
{tx.matched_bill_name ? (
|
|
|
|
|
|
<span className="text-xs text-foreground">{tx.matched_bill_name}</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">No bill linked</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className={cn(
|
|
|
|
|
|
'px-4 py-3 text-right font-semibold tabular-nums whitespace-nowrap',
|
|
|
|
|
|
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{formatTransactionAmount(tx.amount, tx.currency)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-4 py-3">
|
|
|
|
|
|
<div className="flex justify-end gap-1.5">
|
|
|
|
|
|
{status === 'ignored' ? (
|
|
|
|
|
|
<Button size="sm" variant="outline" type="button" disabled={busy} onClick={() => runTransactionAction(tx, 'unignore')}>
|
|
|
|
|
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Eye className="h-3.5 w-3.5" />}
|
|
|
|
|
|
<span className="ml-1.5 hidden xl:inline">Unignore</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{status === 'matched' ? (
|
|
|
|
|
|
<Button size="sm" variant="outline" type="button" disabled={busy} onClick={() => runTransactionAction(tx, 'unmatch')}>
|
|
|
|
|
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link2Off className="h-3.5 w-3.5" />}
|
|
|
|
|
|
<span className="ml-1.5 hidden xl:inline">Unmatch</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button size="sm" type="button" disabled={busy || billsLoading} onClick={() => openMatchDialog(tx)}>
|
|
|
|
|
|
<Link2 className="h-3.5 w-3.5" />
|
|
|
|
|
|
<span className="ml-1.5 hidden xl:inline">Match</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Button size="sm" variant="ghost" type="button" disabled={busy} onClick={() => runTransactionAction(tx, 'ignore')}>
|
|
|
|
|
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
|
|
|
|
|
|
<span className="ml-1.5 hidden xl:inline">Ignore</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<MatchBillDialog
|
|
|
|
|
|
open={matchOpen}
|
|
|
|
|
|
onOpenChange={setMatchOpen}
|
|
|
|
|
|
transaction={matchTransaction}
|
|
|
|
|
|
bills={bills}
|
|
|
|
|
|
loading={actionId === `match:${matchTransaction?.id}`}
|
|
|
|
|
|
onConfirm={confirmMatch}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 20:26:09 -05:00
|
|
|
|
// ─── Section 1: Import Transaction CSV ───────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 21:36:04 -05:00
|
|
|
|
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] || '';
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const current = mapping[field] || '';
|
|
|
|
|
|
const used = new Set(Object.entries(mapping)
|
|
|
|
|
|
.filter(([key, value]) => key !== field && value)
|
|
|
|
|
|
.map(([, value]) => value));
|
2026-05-16 21:36:04 -05:00
|
|
|
|
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);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-16 21:36:04 -05:00
|
|
|
|
<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>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
)}
|
2026-05-16 21:36:04 -05:00
|
|
|
|
</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}
|
|
|
|
|
|
/>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
))}
|
2026-05-16 21:36:04 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 21:36:04 -05:00
|
|
|
|
function formatCsvRowDetail(detail) {
|
|
|
|
|
|
if (!detail) return '';
|
|
|
|
|
|
const field = detail.field ? `${detail.field}: ` : '';
|
|
|
|
|
|
return `${field}${detail.message || detail.value || JSON.stringify(detail)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 20:26:09 -05:00
|
|
|
|
export 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) => {
|
2026-05-16 21:36:04 -05:00
|
|
|
|
if (commitState.status === 'done') return;
|
2026-05-16 20:26:09 -05:00
|
|
|
|
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.');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-16 21:36:04 -05:00
|
|
|
|
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 });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const fields = preview.data?.fields || {};
|
|
|
|
|
|
const canCommit = preview.status === 'ready'
|
|
|
|
|
|
&& preview.data?.import_session_id
|
|
|
|
|
|
&& canCommitCsvMapping(mapping)
|
|
|
|
|
|
&& commitState.status !== 'loading'
|
|
|
|
|
|
&& commitState.status !== 'done';
|
2026-05-16 21:36:04 -05:00
|
|
|
|
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');
|
2026-05-16 20:26:09 -05:00
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
|
2026-05-16 21:36:04 -05:00
|
|
|
|
<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>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
</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">
|
2026-05-16 21:36:04 -05:00
|
|
|
|
<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>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-16 21:36:04 -05:00
|
|
|
|
{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>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<CsvSampleTable preview={preview.data} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-16 21:36:04 -05:00
|
|
|
|
<CsvMappingReview
|
|
|
|
|
|
preview={preview.data}
|
|
|
|
|
|
fields={fields}
|
|
|
|
|
|
mapping={mapping}
|
|
|
|
|
|
onMappingChange={handleMappingChange}
|
|
|
|
|
|
onUseSuggested={applySuggestedMapping}
|
|
|
|
|
|
onClearMapping={clearMapping}
|
|
|
|
|
|
disabled={commitState.status === 'done'}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-05-16 20:26:09 -05:00
|
|
|
|
<div className="rounded-lg border border-border/60 bg-muted/25 px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
|
2026-05-16 21:36:04 -05:00
|
|
|
|
<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>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
{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">
|
2026-05-16 21:36:04 -05:00
|
|
|
|
<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">
|
2026-05-16 20:26:09 -05:00
|
|
|
|
{skippedRows.map(row => (
|
2026-05-16 21:36:04 -05:00
|
|
|
|
<li key={row.row} className="break-all">
|
|
|
|
|
|
Row {row.row}: {row.provider_transaction_id}
|
|
|
|
|
|
</li>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{failedRows.length > 0 && (
|
|
|
|
|
|
<div className="mt-3 text-xs text-destructive">
|
2026-05-16 21:36:04 -05:00
|
|
|
|
<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>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
))}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
// ─── Section 3: Import My Data Export ────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export function ImportMyDataSection({ onHistoryRefresh }) {
|
|
|
|
|
|
const fileRef = useRef(null);
|
|
|
|
|
|
const [file, setFile] = useState(null);
|
|
|
|
|
|
const [preview, setPreview] = useState({ status: 'idle', data: null, error: null });
|
|
|
|
|
|
const [applyState, setApplyState] = useState({ status: 'idle', result: null, error: null });
|
2026-05-10 14:36:59 -05:00
|
|
|
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
|
|
const reset = () => {
|
|
|
|
|
|
setFile(null);
|
|
|
|
|
|
setPreview({ status: 'idle', data: null, error: null });
|
|
|
|
|
|
setApplyState({ status: 'idle', result: null, error: null });
|
|
|
|
|
|
if (fileRef.current) fileRef.current.value = '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handlePreview = async () => {
|
|
|
|
|
|
if (!file) {
|
|
|
|
|
|
toast.error('Choose a SQLite data export first.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setPreview({ status: 'loading', data: null, error: null });
|
|
|
|
|
|
setApplyState({ status: 'idle', result: null, error: null });
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await api.previewUserDbImport(file);
|
|
|
|
|
|
setPreview({ status: 'ready', data, error: null });
|
|
|
|
|
|
toast.success('SQLite export preview ready.');
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setPreview({ status: 'error', data: null, error: importErrorState(err, 'SQLite import preview failed.') });
|
|
|
|
|
|
toast.error(err.message || 'SQLite import preview failed.');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-10 14:36:59 -05:00
|
|
|
|
const handleApply = () => {
|
2026-05-03 19:51:57 -05:00
|
|
|
|
if (!preview.data?.import_session_id) return;
|
2026-05-10 14:36:59 -05:00
|
|
|
|
setConfirmOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleConfirmImport = async () => {
|
|
|
|
|
|
setConfirmOpen(false);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
setApplyState({ status: 'loading', result: null, error: null });
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await api.applyUserDbImport({
|
|
|
|
|
|
import_session_id: preview.data.import_session_id,
|
|
|
|
|
|
options: { overwrite: false },
|
|
|
|
|
|
});
|
|
|
|
|
|
setApplyState({ status: 'done', result, error: null });
|
|
|
|
|
|
toast.success('SQLite data import applied.');
|
|
|
|
|
|
onHistoryRefresh?.();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setApplyState({ status: 'error', result: null, error: importErrorState(err, 'SQLite import apply failed.') });
|
|
|
|
|
|
toast.error(err.message || 'SQLite import apply failed.');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const counts = preview.data?.counts || {};
|
|
|
|
|
|
const summary = preview.data?.summary || {};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-10 14:36:59 -05:00
|
|
|
|
<>
|
|
|
|
|
|
<SectionCard title="Import My Data Export"
|
|
|
|
|
|
subtitle="Restore data from a SQLite export created by this app for your account.">
|
2026-05-03 19:51:57 -05:00
|
|
|
|
<div className="px-6 py-5">
|
|
|
|
|
|
<div className="rounded-lg border border-border/60 bg-muted/25 p-4">
|
|
|
|
|
|
<div className="flex items-start gap-3">
|
|
|
|
|
|
<Database className="mt-0.5 h-5 w-5 text-primary shrink-0" />
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-sm font-medium">Import a SQLite data export created by this app.</p>
|
|
|
|
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
|
|
|
|
This is not a full system restore. Existing records are skipped by default, and admin/system data is never imported.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="mt-4 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">SQLite export file</span>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
ref={fileRef}
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
accept=".sqlite,.db,application/octet-stream,application/x-sqlite3,application/vnd.sqlite3"
|
|
|
|
|
|
onChange={e => {
|
|
|
|
|
|
setFile(e.target.files?.[0] || null);
|
|
|
|
|
|
setPreview({ status: 'idle', data: null, error: null });
|
|
|
|
|
|
setApplyState({ 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>
|
|
|
|
|
|
|
|
|
|
|
|
{preview.status === 'error' && (
|
|
|
|
|
|
<div className="mt-4 rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
|
|
|
|
|
<AlertTriangle className="inline h-4 w-4 mr-1.5" />
|
|
|
|
|
|
{preview.error?.message || 'SQLite import 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 || d.table || JSON.stringify(d)}</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{preview.status === 'ready' && preview.data && (
|
|
|
|
|
|
<div className="mt-4 space-y-4">
|
|
|
|
|
|
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
|
|
|
|
|
|
<div className="flex items-start justify-between gap-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-sm font-medium">Preview ready</p>
|
|
|
|
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
|
|
|
|
Exported {fmt(preview.data.metadata?.exported_at)} · {preview.data.source_filename || 'SQLite export'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase text-muted-foreground">
|
|
|
|
|
|
User data only
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
|
|
|
|
|
<CountPill label="Bills" value={counts.bills} />
|
|
|
|
|
|
<CountPill label="Categories" value={counts.categories} />
|
|
|
|
|
|
<CountPill label="Payments" value={counts.payments} />
|
|
|
|
|
|
<CountPill label="Monthly" value={counts.monthly_bill_state} />
|
|
|
|
|
|
<CountPill label="Notes" value={counts.notes} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
|
|
|
|
|
{Object.entries(summary).filter(([, v]) => v && typeof v === 'object').map(([key, value]) => (
|
|
|
|
|
|
<div key={key} className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2 text-xs">
|
|
|
|
|
|
<p className="font-semibold capitalize">{key.replace(/_/g, ' ')}</p>
|
|
|
|
|
|
<p className="mt-1 text-muted-foreground">
|
|
|
|
|
|
create {value.create || 0} · skip {value.skip || 0} · conflict {value.conflict || 0}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{preview.data.warnings?.length > 0 && (
|
|
|
|
|
|
<div className="mt-4 space-y-1">
|
|
|
|
|
|
{preview.data.warnings.map((warning, i) => (
|
|
|
|
|
|
<p key={i} className="text-xs text-amber-600 dark:text-amber-400">
|
|
|
|
|
|
<AlertTriangle className="mr-1 inline h-3.5 w-3.5" />{warning}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">Review the preview before applying. Nothing is imported until you confirm.</p>
|
|
|
|
|
|
<Button size="sm" type="button" disabled={applyState.status === 'loading'} onClick={handleApply}>
|
|
|
|
|
|
{applyState.status === 'loading'
|
|
|
|
|
|
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Applying…</>
|
|
|
|
|
|
: 'Apply Import'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{applyState.status === 'done' && applyState.result && (
|
|
|
|
|
|
<div className="mt-4 rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-4">
|
|
|
|
|
|
<p className="text-sm font-medium text-emerald-600">SQLite import applied</p>
|
|
|
|
|
|
<div className="mt-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
|
|
|
|
|
|
<CountPill label="Created" value={applyState.result.rows_created} />
|
|
|
|
|
|
<CountPill label="Skipped" value={applyState.result.rows_skipped} />
|
|
|
|
|
|
<CountPill label="Conflicts" value={applyState.result.rows_conflicted} />
|
|
|
|
|
|
<CountPill label="Errors" value={applyState.result.rows_errored} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{applyState.status === 'error' && (
|
|
|
|
|
|
<div className="mt-4 rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
|
|
|
|
|
<AlertTriangle className="inline h-4 w-4 mr-1.5" />{applyState.error?.message || 'SQLite import apply failed.'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SectionCard>
|
2026-05-10 14:36:59 -05:00
|
|
|
|
{/* Import confirmation dialog */}
|
|
|
|
|
|
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogTitle>Import SQLite data export?</AlertDialogTitle>
|
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
|
Import this SQLite data export into your account? Existing records will be skipped by default.
|
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
|
|
|
|
<AlertDialogAction onClick={handleConfirmImport}>
|
|
|
|
|
|
Confirm Import
|
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
</>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Section 4: Import History ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export function ImportHistorySection({ history, loading, onRefresh }) {
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<SectionCard title="Import History">
|
|
|
|
|
|
<div className="px-6 py-6 text-sm text-muted-foreground">Loading…</div>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const rows = history ?? [];
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<SectionCard title="Import History">
|
|
|
|
|
|
<div className="px-6 py-4 flex items-center justify-between">
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
|
{rows.length === 0 ? 'No imports yet.' : `${rows.length} import${rows.length === 1 ? '' : 's'}`}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<Button size="sm" variant="ghost" onClick={onRefresh} className="h-7 text-xs gap-1.5">
|
|
|
|
|
|
<RefreshCw className="h-3 w-3" />Refresh
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{rows.length > 0 && (
|
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
|
<table className="w-full text-xs">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr className="border-b border-border/50 text-muted-foreground">
|
|
|
|
|
|
{['Date','File','Sheet','Parsed','Created','Updated','Skipped','Errors'].map(h => (
|
|
|
|
|
|
<th key={h} className="px-4 py-2 text-left font-medium whitespace-nowrap">{h}</th>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody className="divide-y divide-border/30">
|
|
|
|
|
|
{rows.map(r => (
|
|
|
|
|
|
<tr key={r.id} className="hover:bg-muted/20 transition-colors">
|
|
|
|
|
|
<td className="px-4 py-2 whitespace-nowrap text-muted-foreground">
|
|
|
|
|
|
<span className="flex items-center gap-1"><Clock className="h-3 w-3" />{fmt(r.imported_at)}</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-4 py-2 text-muted-foreground">{r.source_filename || '—'}</td>
|
|
|
|
|
|
<td className="px-4 py-2 text-muted-foreground">{r.sheet_name || '—'}</td>
|
|
|
|
|
|
<td className="px-4 py-2 tabular-nums">{r.rows_parsed}</td>
|
|
|
|
|
|
<td className="px-4 py-2 tabular-nums text-emerald-600">{r.rows_created}</td>
|
|
|
|
|
|
<td className="px-4 py-2 tabular-nums text-blue-600">{r.rows_updated}</td>
|
|
|
|
|
|
<td className="px-4 py-2 tabular-nums text-muted-foreground">{r.rows_skipped}</td>
|
|
|
|
|
|
<td className="px-4 py-2 tabular-nums text-red-500">{r.rows_errored}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── XLSX Import: Workbook Summary ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function WorkbookSummaryCard({ workbook }) {
|
|
|
|
|
|
const isMulti = workbook.parse_mode === 'all_sheets';
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="rounded-lg border border-border bg-muted/30 p-4 mb-4 space-y-3">
|
|
|
|
|
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
|
|
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Workbook Summary</p>
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
|
{isMulti ? `${workbook.total_row_count} rows across ${workbook.sheet_names.length} tabs` : `${workbook.row_count} rows · ${workbook.selected_sheet}`}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{isMulti && workbook.sheets?.length > 0 && (
|
|
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
|
|
|
|
|
|
{workbook.sheets.map(s => (
|
|
|
|
|
|
<div key={s.name} className={cn(
|
|
|
|
|
|
'flex items-center justify-between rounded px-3 py-1.5 text-xs',
|
|
|
|
|
|
s.is_non_month_sheet || s.status === 'skipped' ? 'bg-muted/50 text-muted-foreground/60' : 'bg-background border border-border/60'
|
|
|
|
|
|
)}>
|
|
|
|
|
|
<span className="font-medium truncate mr-2">{s.name}</span>
|
|
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
|
|
|
|
{s.detected_year && s.detected_month && (
|
|
|
|
|
|
<span className="tabular-nums text-muted-foreground">
|
|
|
|
|
|
{String(s.detected_month).padStart(2,'0')}/{s.detected_year}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<SheetStatusBadge status={s.status} />
|
|
|
|
|
|
{s.status !== 'skipped' && <span className="text-muted-foreground">{s.row_count} rows</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── XLSX Import: Row Decision Controls ──────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const ACTIONS_NEEDING_BILL = new Set(['match_existing_bill','update_monthly_state','add_monthly_note','create_payment']);
|
|
|
|
|
|
|
|
|
|
|
|
function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories, selected, onSelectedChange }) {
|
|
|
|
|
|
const [expanded, setExpanded] = useState(row.requires_user_decision || !decision?.action);
|
|
|
|
|
|
|
|
|
|
|
|
const action = decision?.action ?? null;
|
|
|
|
|
|
const isSkip = action === 'skip_row';
|
|
|
|
|
|
const hasError = row.errors?.length > 0;
|
|
|
|
|
|
const complete = isDecisionComplete(action, decision);
|
|
|
|
|
|
const rec = row.recommendation || {};
|
|
|
|
|
|
|
|
|
|
|
|
const suggestedBills = row.possible_bill_matches ?? [];
|
|
|
|
|
|
const suggestedIds = new Set(suggestedBills.map(b => b.bill_id));
|
|
|
|
|
|
const otherBills = allBills.filter(b => !suggestedIds.has(b.id));
|
|
|
|
|
|
|
|
|
|
|
|
const handleAction = (val) => {
|
|
|
|
|
|
const next = { ...decision, action: val };
|
|
|
|
|
|
if (val === 'create_new_bill') {
|
|
|
|
|
|
Object.assign(next, buildCreateNewDecision(row, decision));
|
|
|
|
|
|
} else if (ACTIONS_NEEDING_BILL.has(val)) {
|
|
|
|
|
|
next.bill_name = null;
|
|
|
|
|
|
next.bill_id = decision?.bill_id ?? decision?.previous_match_bill_id ?? rec.bill_id ?? suggestedBills[0]?.bill_id ?? null;
|
|
|
|
|
|
next.actual_amount = decision?.actual_amount ?? rec.actual_amount ?? row.detected_amount ?? null;
|
|
|
|
|
|
next.payment_amount = decision?.payment_amount ?? rec.payment_amount ?? row.detected_payment_amount ?? null;
|
|
|
|
|
|
next.payment_date = decision?.payment_date ?? rec.payment_date ?? row.detected_paid_date ?? null;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
next.bill_id = null;
|
|
|
|
|
|
next.bill_name = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
onDecisionChange(row.row_id, next);
|
|
|
|
|
|
if (val === 'skip_row') setExpanded(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleBill = (e) => {
|
|
|
|
|
|
onDecisionChange(row.row_id, { ...decision, bill_id: parseInt(e.target.value, 10) || null });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleBillName = (e) => {
|
|
|
|
|
|
onDecisionChange(row.row_id, { ...decision, bill_name: e.target.value });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDecisionField = (field, value) => {
|
|
|
|
|
|
onDecisionChange(row.row_id, { ...decision, [field]: value });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={cn(
|
|
|
|
|
|
'border-b border-border/40 last:border-0',
|
|
|
|
|
|
isSkip && 'opacity-50',
|
|
|
|
|
|
!complete && action !== null && 'bg-amber-500/5',
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{/* Main row */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="flex items-start gap-3 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors"
|
|
|
|
|
|
onClick={() => setExpanded(e => !e)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* Selection */}
|
|
|
|
|
|
<div className="mt-0.5 shrink-0" onClick={e => e.stopPropagation()}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={selected}
|
|
|
|
|
|
onChange={e => onSelectedChange(row.row_id, e.target.checked)}
|
|
|
|
|
|
aria-label={`Select row ${row.source_row_number}`}
|
|
|
|
|
|
className="h-4 w-4 rounded border-border accent-primary"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Status icon */}
|
|
|
|
|
|
<div className="mt-0.5 shrink-0">
|
|
|
|
|
|
{hasError ? <XCircle className="h-4 w-4 text-muted-foreground/40" /> :
|
|
|
|
|
|
isSkip ? <SkipForward className="h-4 w-4 text-muted-foreground/40" /> :
|
|
|
|
|
|
complete ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> :
|
|
|
|
|
|
action !== null ? <AlertTriangle className="h-4 w-4 text-amber-500" /> :
|
|
|
|
|
|
<AlertTriangle className="h-4 w-4 text-orange-500" />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Content */}
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
<span className="text-xs text-muted-foreground tabular-nums">#{row.source_row_number}</span>
|
|
|
|
|
|
{row.sheet_name && <span className="text-xs text-muted-foreground">{row.sheet_name}</span>}
|
|
|
|
|
|
{row.detected_year && row.detected_month && (
|
|
|
|
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
|
|
|
|
{String(row.detected_month).padStart(2,'0')}/{row.detected_year}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{row.year_month_source && <SourceBadge source={row.year_month_source} />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-baseline gap-3 mt-0.5 flex-wrap">
|
|
|
|
|
|
<span className={cn('text-sm font-medium', !row.detected_bill_name && 'italic text-muted-foreground/60')}>
|
|
|
|
|
|
{row.detected_bill_name || '(no bill name)'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{row.detected_amount != null && (
|
|
|
|
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
|
|
|
|
${row.detected_amount.toFixed(2)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{row.detected_paid_date && (
|
|
|
|
|
|
<span className="text-xs text-emerald-500 tabular-nums">
|
|
|
|
|
|
paid {row.detected_paid_date}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{row.detected_labels?.length > 0 && (
|
|
|
|
|
|
<span className="text-xs text-muted-foreground/70">{row.detected_labels.join(', ')}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{row.detected_notes && (
|
|
|
|
|
|
<span className="text-xs text-muted-foreground/70 italic truncate max-w-[200px]">{row.detected_notes}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Right: action status + expand */}
|
|
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
|
|
|
|
{action === null ? (
|
|
|
|
|
|
<span className="text-xs font-medium text-orange-500">Needs decision</span>
|
|
|
|
|
|
) : isSkip ? (
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">Skipped</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="text-xs text-muted-foreground capitalize">{action.replace(/_/g,' ')}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{action !== 'skip_row' && (
|
|
|
|
|
|
expanded ? <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Expanded decision controls */}
|
|
|
|
|
|
{expanded && !hasError && (
|
|
|
|
|
|
<div className="px-11 pb-4 space-y-3">
|
|
|
|
|
|
{/* Recommendation */}
|
|
|
|
|
|
{rec.action && (
|
|
|
|
|
|
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 space-y-1.5">
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
<span className="text-xs font-medium">Recommended: {actionLabel(rec.action)}</span>
|
|
|
|
|
|
{rec.bill_name && rec.action === 'match_existing_bill' && (
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">→ {rec.bill_name}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{rec.category_name && (
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">Category: {rec.category_name}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{rec.due_day && <span className="text-xs text-muted-foreground">Due day: {rec.due_day}</span>}
|
|
|
|
|
|
{rec.actual_amount != null && <span className="text-xs text-muted-foreground">${Number(rec.actual_amount).toFixed(2)}</span>}
|
|
|
|
|
|
<ConfidenceBadge confidence={rec.confidence} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{rec.reason && <p className="text-xs text-muted-foreground">Reason: {rec.reason}</p>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Warnings */}
|
|
|
|
|
|
{(rec.warnings?.length > 0 || row.warnings?.length > 0) && (
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
{Array.from(new Set([...(rec.warnings || []), ...(row.warnings || [])])).map((w, i) => (
|
|
|
|
|
|
<p key={i} className="text-xs text-amber-600 flex items-start gap-1.5">
|
|
|
|
|
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />{w}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Possible matches hint */}
|
|
|
|
|
|
{suggestedBills.length > 0 && (
|
|
|
|
|
|
<div className="flex flex-wrap gap-1.5">
|
|
|
|
|
|
<span className="text-xs text-muted-foreground self-center">Suggested:</span>
|
|
|
|
|
|
{suggestedBills.slice(0, 3).map(b => (
|
|
|
|
|
|
<button key={b.bill_id} type="button"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
onDecisionChange(row.row_id, {
|
|
|
|
|
|
...decision,
|
|
|
|
|
|
action: 'match_existing_bill',
|
|
|
|
|
|
bill_id: b.bill_id,
|
|
|
|
|
|
previous_match_bill_id: b.bill_id,
|
|
|
|
|
|
bill_name: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
setExpanded(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-xs px-2 py-0.5 rounded-full border border-border bg-background hover:bg-muted transition-colors">
|
|
|
|
|
|
{b.bill_name}
|
|
|
|
|
|
{b.match_confidence === 'high' && <CheckCircle2 className="inline h-2.5 w-2.5 ml-1 text-emerald-500" />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Action selector */}
|
|
|
|
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
|
|
|
|
<label className="text-xs text-muted-foreground w-14 shrink-0">Action</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={action ?? ''}
|
|
|
|
|
|
onChange={e => handleAction(e.target.value || null)}
|
|
|
|
|
|
className="text-sm border border-input bg-background rounded-md px-2 py-1 focus:outline-none focus:ring-1 focus:ring-ring"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">— choose action —</option>
|
|
|
|
|
|
<option value="match_existing_bill">Match existing bill</option>
|
|
|
|
|
|
<option value="create_new_bill">Create new bill</option>
|
|
|
|
|
|
<option value="update_monthly_state">Update monthly record</option>
|
|
|
|
|
|
<option value="add_monthly_note">Add monthly note</option>
|
|
|
|
|
|
<option value="create_payment">Record as payment</option>
|
|
|
|
|
|
<option value="skip_row">Skip this row</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Bill selector (for actions that need a bill) */}
|
|
|
|
|
|
{ACTIONS_NEEDING_BILL.has(action) && (
|
|
|
|
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
|
|
|
|
<label className="text-xs text-muted-foreground w-14 shrink-0">Bill</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={decision?.bill_id ?? ''}
|
|
|
|
|
|
onChange={handleBill}
|
|
|
|
|
|
className="text-sm border border-input bg-background rounded-md px-2 py-1 flex-1 min-w-[200px] focus:outline-none focus:ring-1 focus:ring-ring"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">— select bill —</option>
|
|
|
|
|
|
{suggestedBills.length > 0 && (
|
|
|
|
|
|
<optgroup label="Suggested matches">
|
|
|
|
|
|
{suggestedBills.map(b => (
|
|
|
|
|
|
<option key={b.bill_id} value={b.bill_id}>
|
|
|
|
|
|
{b.bill_name}{b.expected_amount ? ` ($${b.expected_amount})` : ''} · {b.match_confidence}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</optgroup>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{otherBills.length > 0 && (
|
|
|
|
|
|
<optgroup label={suggestedBills.length ? 'All other bills' : 'All bills'}>
|
|
|
|
|
|
{otherBills.map(b => (
|
|
|
|
|
|
<option key={b.id} value={b.id}>{b.name}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</optgroup>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Bill name input for create_new_bill */}
|
|
|
|
|
|
{action === 'create_new_bill' && (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<label className="text-xs text-muted-foreground w-14 shrink-0">Name</label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={decision?.bill_name ?? ''}
|
|
|
|
|
|
onChange={handleBillName}
|
|
|
|
|
|
placeholder="Bill name"
|
|
|
|
|
|
className="h-8 text-sm flex-1 max-w-sm"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
|
|
|
|
<label className="text-xs text-muted-foreground w-14 shrink-0">Category</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={decision?.category_id ?? ''}
|
|
|
|
|
|
onChange={e => handleDecisionField('category_id', parseInt(e.target.value, 10) || null)}
|
|
|
|
|
|
className="text-sm border border-input bg-background rounded-md px-2 py-1 min-w-[180px] focus:outline-none focus:ring-1 focus:ring-ring"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">No category</option>
|
|
|
|
|
|
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
{rec.category_name && <span className="text-xs text-muted-foreground">Suggested: {rec.category_name}</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
|
|
|
|
<label className="text-xs text-muted-foreground w-14 shrink-0">Details</label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="1"
|
|
|
|
|
|
max="31"
|
|
|
|
|
|
value={decision?.due_day ?? ''}
|
|
|
|
|
|
onChange={e => handleDecisionField('due_day', e.target.value ? parseInt(e.target.value, 10) : null)}
|
|
|
|
|
|
placeholder="Due day"
|
|
|
|
|
|
className="h-8 text-sm w-24"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
step="0.01"
|
|
|
|
|
|
value={decision?.expected_amount ?? ''}
|
|
|
|
|
|
onChange={e => handleDecisionField('expected_amount', e.target.value === '' ? null : parseFloat(e.target.value))}
|
|
|
|
|
|
placeholder="Expected amount"
|
|
|
|
|
|
className="h-8 text-sm w-40"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{action && action !== 'skip_row' && (
|
|
|
|
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
|
|
|
|
<label className="text-xs text-muted-foreground w-14 shrink-0">Paid</label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="date"
|
|
|
|
|
|
value={decision?.payment_date ?? ''}
|
|
|
|
|
|
onChange={e => handleDecisionField('payment_date', e.target.value || null)}
|
|
|
|
|
|
className="h-8 text-sm w-40"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
step="0.01"
|
|
|
|
|
|
value={decision?.payment_amount ?? ''}
|
|
|
|
|
|
onChange={e => handleDecisionField('payment_amount', e.target.value === '' ? null : parseFloat(e.target.value))}
|
|
|
|
|
|
placeholder="Paid amount"
|
|
|
|
|
|
className="h-8 text-sm w-36"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Quick skip */}
|
|
|
|
|
|
{action !== 'skip_row' && (
|
|
|
|
|
|
<button type="button"
|
|
|
|
|
|
onClick={() => { handleAction('skip_row'); setExpanded(false); }}
|
|
|
|
|
|
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors">
|
|
|
|
|
|
<SkipForward className="h-3 w-3" />Skip this row
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── XLSX Import: Preview Table ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function PreviewTable({ rows, decisions, onDecisionChange, allBills, categories, selectedRows, onSelectedChange }) {
|
|
|
|
|
|
const groups = groupRowsBySheet(rows);
|
|
|
|
|
|
const multiTab = groups.length > 1;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
{groups.map(({ name, rows: groupRows }) => (
|
|
|
|
|
|
<div key={name}>
|
|
|
|
|
|
{multiTab && (
|
|
|
|
|
|
<div className="px-4 py-2 bg-muted/40 border-b border-border/60 flex items-center gap-2">
|
|
|
|
|
|
<FileSpreadsheet className="h-3.5 w-3.5 text-muted-foreground" />
|
|
|
|
|
|
<span className="text-xs font-semibold text-muted-foreground">{name}</span>
|
|
|
|
|
|
<span className="text-xs text-muted-foreground/60">· {groupRows.length} rows</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{groupRows.map(row => (
|
|
|
|
|
|
<RowDecisionRow
|
|
|
|
|
|
key={row.row_id}
|
|
|
|
|
|
row={row}
|
|
|
|
|
|
decision={decisions[row.row_id]}
|
|
|
|
|
|
onDecisionChange={onDecisionChange}
|
|
|
|
|
|
allBills={allBills}
|
|
|
|
|
|
categories={categories}
|
|
|
|
|
|
selected={selectedRows.has(row.row_id)}
|
|
|
|
|
|
onSelectedChange={onSelectedChange}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function BulkActionBar({
|
|
|
|
|
|
rows,
|
|
|
|
|
|
selectedRows,
|
|
|
|
|
|
onSelectAll,
|
|
|
|
|
|
onClearSelection,
|
|
|
|
|
|
onBulkSkip,
|
|
|
|
|
|
onBulkCreateNew,
|
|
|
|
|
|
onBulkReset,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const allSelected = rows.length > 0 && rows.every(r => selectedRows.has(r.row_id));
|
|
|
|
|
|
const selectedCount = selectedRows.size;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="border-b border-border bg-muted/25 px-4 py-3">
|
|
|
|
|
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
|
|
|
|
|
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={allSelected}
|
|
|
|
|
|
onChange={e => e.target.checked ? onSelectAll() : onClearSelection()}
|
|
|
|
|
|
className="h-4 w-4 rounded border-border accent-primary"
|
|
|
|
|
|
/>
|
|
|
|
|
|
Select all visible
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
{selectedCount > 0 && (
|
|
|
|
|
|
<span className="text-xs font-medium text-foreground">{selectedCount} row{selectedCount === 1 ? '' : 's'} selected</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{selectedCount > 0 && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Button size="sm" variant="outline" onClick={onBulkSkip} className="h-8 text-xs">
|
|
|
|
|
|
Skip selected
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button size="sm" variant="outline" onClick={onBulkCreateNew} className="h-8 text-xs">
|
|
|
|
|
|
Create new bills
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button size="sm" variant="ghost" onClick={onBulkReset} className="h-8 text-xs">
|
|
|
|
|
|
Reset to recommendation
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button size="sm" variant="ghost" onClick={onClearSelection} className="h-8 text-xs text-muted-foreground">
|
|
|
|
|
|
Clear selection
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Section 1: Import Spreadsheet History ────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const INITIAL_OPTIONS = {
|
|
|
|
|
|
parseAllSheets: true,
|
|
|
|
|
|
defaultYear: new Date().getFullYear(),
|
|
|
|
|
|
defaultMonth: '',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-15 01:36:56 -05:00
|
|
|
|
// ─── Bill History Import helpers ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function ConfidenceDot({ level }) {
|
|
|
|
|
|
const cls = level === 'high' ? 'bg-emerald-500'
|
|
|
|
|
|
: level === 'medium' ? 'bg-amber-500'
|
|
|
|
|
|
: 'bg-muted-foreground/30';
|
|
|
|
|
|
return <span className={cn('h-2 w-2 rounded-full shrink-0 inline-block', cls)} />;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function useBillGroups(previewRows, allBills) {
|
|
|
|
|
|
return useMemo(() => {
|
|
|
|
|
|
const billMap = new Map(allBills.map(b => [b.id, b]));
|
|
|
|
|
|
const groups = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
for (const row of previewRows) {
|
|
|
|
|
|
for (const match of (row.possible_bill_matches ?? [])) {
|
|
|
|
|
|
if (!billMap.has(match.bill_id)) continue;
|
|
|
|
|
|
if (!groups.has(match.bill_id)) {
|
|
|
|
|
|
groups.set(match.bill_id, {
|
|
|
|
|
|
bill: billMap.get(match.bill_id),
|
|
|
|
|
|
rows: [],
|
|
|
|
|
|
counts: { high: 0, medium: 0, low: 0 },
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
const g = groups.get(match.bill_id);
|
|
|
|
|
|
if (!g.rows.find(r => r.row_id === row.row_id)) {
|
|
|
|
|
|
g.rows.push({ ...row, _match: match });
|
|
|
|
|
|
g.counts[match.match_confidence] = (g.counts[match.match_confidence] || 0) + 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return [...groups.values()].sort((a, b) =>
|
|
|
|
|
|
b.rows.length !== a.rows.length ? b.rows.length - a.rows.length : b.counts.high - a.counts.high
|
|
|
|
|
|
);
|
|
|
|
|
|
}, [previewRows, allBills]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rowDateLabel(row) {
|
|
|
|
|
|
if (row.detected_year && row.detected_month)
|
|
|
|
|
|
return `${row.detected_year}-${String(row.detected_month).padStart(2, '0')}`;
|
|
|
|
|
|
return row.detected_paid_date ?? '—';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
|
function billImportProgress(rows, importResult) {
|
|
|
|
|
|
const completedRowIds = importResult?.completedRowIds ?? new Set();
|
|
|
|
|
|
const remainingRows = rows.filter(row => !completedRowIds.has(row.row_id));
|
|
|
|
|
|
return {
|
|
|
|
|
|
completedCount: rows.length - remainingRows.length,
|
|
|
|
|
|
remainingRows,
|
|
|
|
|
|
remainingCount: remainingRows.length,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function detailImportedAnything(detail) {
|
|
|
|
|
|
return ['created', 'updated', 'overwritten', 'imported'].includes(detail?.result)
|
|
|
|
|
|
|| detail?.payment === 'created';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function detailCompletesImport(detail) {
|
|
|
|
|
|
if (!detail?.row_id) return false;
|
|
|
|
|
|
if (['error', 'ambiguous', 'skipped_conflict'].includes(detail.result)) return false;
|
|
|
|
|
|
if (detail.result === 'skipped') return false;
|
|
|
|
|
|
return detailImportedAnything(detail)
|
|
|
|
|
|
|| detail.result === 'skipped_duplicate'
|
|
|
|
|
|
|| detail.payment === 'skipped_duplicate';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 01:36:56 -05:00
|
|
|
|
function BillDetailView({ group, onBack, onImport, isImporting, importResult }) {
|
|
|
|
|
|
const { bill, rows } = group;
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const { completedCount, remainingCount } = billImportProgress(rows, importResult);
|
2026-05-15 01:36:56 -05:00
|
|
|
|
const sorted = [...rows].sort((a, b) => {
|
|
|
|
|
|
const da = (a.detected_year ?? 0) * 100 + (a.detected_month ?? 0);
|
|
|
|
|
|
const db = (b.detected_year ?? 0) * 100 + (b.detected_month ?? 0);
|
|
|
|
|
|
return da - db;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="px-4 py-2.5 border-b border-border/50 flex items-center justify-between gap-3">
|
|
|
|
|
|
<button type="button" onClick={onBack}
|
|
|
|
|
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors">
|
|
|
|
|
|
<ChevronLeft className="h-3.5 w-3.5" /> All bills
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span className="text-sm font-medium truncate">{bill.name}</span>
|
2026-05-16 15:38:28 -05:00
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
|
|
|
|
{importResult && (
|
|
|
|
|
|
<div className="text-right">
|
|
|
|
|
|
<span className={`text-xs font-medium ${
|
|
|
|
|
|
completedCount === rows.length ? 'text-emerald-500' : 'text-amber-400'
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
{completedCount === rows.length
|
|
|
|
|
|
? 'All imported'
|
|
|
|
|
|
: `${completedCount} imported · ${remainingCount} remaining`}
|
|
|
|
|
|
{importResult.duplicates > 0
|
|
|
|
|
|
&& ` · ${importResult.duplicates} dupes`}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{importResult.duplicates > 0 && importResult.earliestDup && (
|
|
|
|
|
|
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
|
|
|
|
|
|
{importResult.duplicates} dup{importResult.duplicates > 1 ? 's' : ''} · recorded{' '}
|
|
|
|
|
|
{importResult.earliestDup.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Button size="sm" onClick={onImport} disabled={isImporting || remainingCount === 0} className="h-7 text-xs px-3 shrink-0 gap-1.5">
|
2026-05-15 01:36:56 -05:00
|
|
|
|
{isImporting && <Loader2 className="h-3 w-3 animate-spin" />}
|
2026-05-16 15:38:28 -05:00
|
|
|
|
{remainingCount === 0 ? 'All imported' : `Import all ${remainingCount}`}
|
2026-05-15 01:36:56 -05:00
|
|
|
|
</Button>
|
2026-05-16 15:38:28 -05:00
|
|
|
|
</div>
|
2026-05-15 01:36:56 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="divide-y divide-border/30 max-h-80 overflow-y-auto">
|
|
|
|
|
|
{sorted.map(row => (
|
|
|
|
|
|
<div key={row.row_id} className="px-4 py-2 flex items-center gap-3">
|
|
|
|
|
|
<ConfidenceDot level={row._match.match_confidence} />
|
|
|
|
|
|
<span className="text-xs tabular-nums text-muted-foreground w-16 shrink-0">{rowDateLabel(row)}</span>
|
|
|
|
|
|
<span className="text-xs font-mono tabular-nums w-16 shrink-0">
|
|
|
|
|
|
{row.detected_amount != null ? `$${Number(row.detected_amount).toFixed(2)}` : '—'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-xs text-muted-foreground truncate flex-1">{row.detected_name ?? '—'}</span>
|
|
|
|
|
|
<span className={cn('text-[10px] shrink-0',
|
|
|
|
|
|
row._match.match_confidence === 'high' ? 'text-emerald-500' :
|
|
|
|
|
|
row._match.match_confidence === 'medium' ? 'text-amber-500' : 'text-muted-foreground/40')}>
|
|
|
|
|
|
{row._match.match_confidence}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function BillHistoryView({ previewRows, allBills, importingBillId, billImportResults, onImportBill }) {
|
|
|
|
|
|
const [selectedBillId, setSelectedBillId] = useState(null);
|
|
|
|
|
|
const billGroups = useBillGroups(previewRows, allBills);
|
|
|
|
|
|
|
|
|
|
|
|
if (billGroups.length === 0) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
|
|
|
|
|
|
No existing bills matched rows in this file.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (selectedBillId) {
|
|
|
|
|
|
const group = billGroups.find(g => g.bill.id === selectedBillId);
|
|
|
|
|
|
return group
|
|
|
|
|
|
? <BillDetailView group={group}
|
|
|
|
|
|
isImporting={importingBillId === group.bill.id}
|
|
|
|
|
|
importResult={billImportResults.get(group.bill.id) ?? null}
|
|
|
|
|
|
onBack={() => setSelectedBillId(null)}
|
|
|
|
|
|
onImport={() => onImportBill(group)} />
|
|
|
|
|
|
: null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="divide-y divide-border/50">
|
|
|
|
|
|
{billGroups.map(g => {
|
|
|
|
|
|
const { bill, rows, counts } = g;
|
|
|
|
|
|
const isImporting = importingBillId === bill.id;
|
|
|
|
|
|
const importResult = billImportResults.get(bill.id) ?? null;
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const { completedCount, remainingCount } = billImportProgress(rows, importResult);
|
2026-05-15 01:36:56 -05:00
|
|
|
|
|
|
|
|
|
|
const sorted3 = [...rows]
|
|
|
|
|
|
.sort((a, b) => {
|
|
|
|
|
|
const da = (a.detected_year ?? 0) * 100 + (a.detected_month ?? 0);
|
|
|
|
|
|
const db = (b.detected_year ?? 0) * 100 + (b.detected_month ?? 0);
|
|
|
|
|
|
return da - db;
|
|
|
|
|
|
})
|
|
|
|
|
|
.slice(0, 3);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={bill.id} className="px-4 py-3 flex items-start gap-3">
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
<span className="text-sm font-medium truncate">{bill.name}</span>
|
|
|
|
|
|
<span className="text-[10px] text-muted-foreground border border-border/50 rounded px-1.5 py-0.5 shrink-0">
|
|
|
|
|
|
{rows.length} row{rows.length !== 1 ? 's' : ''}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{counts.high > 0 && <span className="text-[10px] text-emerald-500">{counts.high} high</span>}
|
|
|
|
|
|
{counts.medium > 0 && <span className="text-[10px] text-amber-500">{counts.medium} med</span>}
|
|
|
|
|
|
{counts.low > 0 && <span className="text-[10px] text-muted-foreground/50">{counts.low} low</span>}
|
2026-05-15 02:26:10 -05:00
|
|
|
|
{importResult && (() => {
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const allImported = completedCount === rows.length;
|
2026-05-15 02:26:10 -05:00
|
|
|
|
const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
|
|
|
|
const dupDate = importResult.earliestDup ? ` · ${fmtDate(importResult.earliestDup)}` : '';
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-0.5">
|
2026-05-16 15:38:28 -05:00
|
|
|
|
<span className={`text-[10px] font-medium ${allImported ? 'text-emerald-500' : 'text-amber-400'}`}>
|
|
|
|
|
|
{allImported ? 'All imported' : `${completedCount} imported · ${remainingCount} remaining`}
|
|
|
|
|
|
{importResult.duplicates > 0 && ` · ${importResult.duplicates} dupes`}
|
2026-05-15 02:26:10 -05:00
|
|
|
|
{importResult.errored > 0 && ` · ${importResult.errored} errors`}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{importResult.duplicates > 0 && (
|
|
|
|
|
|
<p className="text-[10px] text-muted-foreground/70">
|
|
|
|
|
|
{importResult.duplicates} duplicate{importResult.duplicates > 1 ? 's' : ''}{dupDate}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()}
|
2026-05-15 01:36:56 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-1.5 space-y-0.5">
|
|
|
|
|
|
{sorted3.map(row => (
|
|
|
|
|
|
<div key={row.row_id} className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
|
|
|
|
<ConfidenceDot level={row._match.match_confidence} />
|
|
|
|
|
|
<span className="tabular-nums w-16 shrink-0">{rowDateLabel(row)}</span>
|
|
|
|
|
|
{row.detected_amount != null && (
|
|
|
|
|
|
<span className="tabular-nums">${Number(row.detected_amount).toFixed(2)}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{row.detected_name &&
|
|
|
|
|
|
row.detected_name.toLowerCase() !== bill.name.toLowerCase() && (
|
|
|
|
|
|
<span className="truncate italic opacity-60">"{row.detected_name}"</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{rows.length > 3 && (
|
|
|
|
|
|
<button type="button" onClick={() => setSelectedBillId(bill.id)}
|
|
|
|
|
|
className="text-[10px] text-primary/70 hover:text-primary transition-colors">
|
|
|
|
|
|
+{rows.length - 3} more — view all
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-col gap-1 shrink-0 pt-0.5">
|
|
|
|
|
|
{importResult ? (
|
|
|
|
|
|
<Button size="sm" variant="outline" onClick={() => onImportBill(g)}
|
2026-05-16 15:38:28 -05:00
|
|
|
|
disabled={!!importingBillId || remainingCount === 0} className="h-7 text-xs px-3 gap-1.5">
|
|
|
|
|
|
{remainingCount === 0 ? 'All imported' : `Import ${remainingCount}`}
|
2026-05-15 01:36:56 -05:00
|
|
|
|
</Button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button size="sm" onClick={() => onImportBill(g)}
|
|
|
|
|
|
disabled={!!importingBillId} className="h-7 text-xs px-3 gap-1.5">
|
|
|
|
|
|
{isImporting && <Loader2 className="h-3 w-3 animate-spin" />}
|
|
|
|
|
|
Import {rows.length}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Button size="sm" variant="ghost" onClick={() => setSelectedBillId(bill.id)}
|
|
|
|
|
|
disabled={!!importingBillId} className="h-7 text-xs px-3">
|
|
|
|
|
|
Review
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
export function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
|
|
|
|
|
const fileRef = useRef(null);
|
|
|
|
|
|
const [file, setFile] = useState(null);
|
|
|
|
|
|
const [options, setOptions] = useState(INITIAL_OPTIONS);
|
|
|
|
|
|
const [preview, setPreview] = useState({ status: 'idle', data: null, error: null });
|
|
|
|
|
|
const [decisions, setDecisions] = useState({});
|
|
|
|
|
|
const [applyState, setApplyState] = useState({ status: 'idle', result: null, error: null });
|
|
|
|
|
|
const [allBills, setAllBills] = useState([]);
|
|
|
|
|
|
const [categories, setCategories] = useState([]);
|
|
|
|
|
|
const [selectedRows, setSelectedRows] = useState(new Set());
|
2026-05-15 01:36:56 -05:00
|
|
|
|
const [viewMode, setViewMode] = useState('rows'); // 'rows' | 'bills'
|
|
|
|
|
|
const [importingBillId, setImportingBillId] = useState(null);
|
|
|
|
|
|
const [billImportResults, setBillImportResults] = useState(new Map()); // bill_id → { created, updated, errored }
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
|
|
// Load bills/categories for the decision controls
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
api.bills().then(setAllBills).catch(() => {});
|
|
|
|
|
|
api.categories().then(setCategories).catch(() => {});
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const opt = (k, v) => setOptions(prev => ({ ...prev, [k]: v }));
|
|
|
|
|
|
|
|
|
|
|
|
// ── Preview ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
const handlePreview = async () => {
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
setPreview({ status: 'loading', data: null, error: null });
|
|
|
|
|
|
setDecisions({});
|
|
|
|
|
|
setSelectedRows(new Set());
|
|
|
|
|
|
setApplyState({ status: 'idle', result: null, error: null });
|
2026-05-15 01:36:56 -05:00
|
|
|
|
setViewMode('rows');
|
|
|
|
|
|
setImportingBillId(null);
|
|
|
|
|
|
setBillImportResults(new Map());
|
2026-05-03 19:51:57 -05:00
|
|
|
|
try {
|
|
|
|
|
|
const data = await api.previewSpreadsheetImport(file, {
|
|
|
|
|
|
parseAllSheets: options.parseAllSheets,
|
|
|
|
|
|
defaultYear: options.defaultYear ? parseInt(options.defaultYear, 10) : null,
|
|
|
|
|
|
defaultMonth: options.defaultMonth ? parseInt(options.defaultMonth, 10) : null,
|
|
|
|
|
|
});
|
|
|
|
|
|
setPreview({ status: 'ready', data, error: null });
|
|
|
|
|
|
setDecisions(buildInitialDecisions(data.rows));
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setPreview({ status: 'error', data: null, error: importErrorState(err, 'Preview failed.') });
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ── Decision update ──────────────────────────────────────────────────────────
|
|
|
|
|
|
const handleDecisionChange = (rowId, decision) => {
|
|
|
|
|
|
setDecisions(prev => ({ ...prev, [rowId]: decision }));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSelectedChange = (rowId, selected) => {
|
|
|
|
|
|
setSelectedRows(prev => {
|
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
|
if (selected) next.add(rowId);
|
|
|
|
|
|
else next.delete(rowId);
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const clearSelection = () => setSelectedRows(new Set());
|
|
|
|
|
|
|
2026-05-15 01:36:56 -05:00
|
|
|
|
// ── Bill-history direct import ────────────────────────────────────────────
|
|
|
|
|
|
// Applies all matching rows for a bill immediately — no queue, no review step.
|
|
|
|
|
|
const handleDirectImportBill = async (group) => {
|
|
|
|
|
|
const sessionId = preview.data?.import_session_id;
|
|
|
|
|
|
if (!sessionId || importingBillId) return;
|
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const previousResult = billImportResults.get(group.bill.id) ?? null;
|
|
|
|
|
|
const rowsToImport = billImportProgress(group.rows, previousResult).remainingRows;
|
|
|
|
|
|
if (rowsToImport.length === 0) {
|
|
|
|
|
|
toast.info(`All rows for "${group.bill.name}" have already been imported.`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 01:36:56 -05:00
|
|
|
|
setImportingBillId(group.bill.id);
|
|
|
|
|
|
try {
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const decisionsList = rowsToImport.map(row => ({
|
2026-05-15 01:36:56 -05:00
|
|
|
|
row_id: row.row_id,
|
|
|
|
|
|
action: 'match_existing_bill',
|
|
|
|
|
|
bill_id: group.bill.id,
|
|
|
|
|
|
actual_amount: row.detected_amount ?? null,
|
|
|
|
|
|
payment_amount: row.detected_payment_amount ?? row.detected_amount ?? null,
|
|
|
|
|
|
payment_date: row.detected_paid_date ?? null,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
const result = await api.applySpreadsheetImport({
|
|
|
|
|
|
import_session_id: sessionId,
|
|
|
|
|
|
decisions: decisionsList,
|
|
|
|
|
|
options: {},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-15 02:26:10 -05:00
|
|
|
|
const created = result.rows_created ?? 0;
|
|
|
|
|
|
const updated = result.rows_updated ?? 0;
|
|
|
|
|
|
const errored = result.rows_errored ?? 0;
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const details = result.details ?? [];
|
|
|
|
|
|
const duplicateRowIds = new Set(
|
|
|
|
|
|
details
|
|
|
|
|
|
.filter(d => d.result === 'skipped_duplicate' || d.payment === 'skipped_duplicate')
|
|
|
|
|
|
.map(d => d.row_id)
|
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
|
);
|
|
|
|
|
|
const duplicates = duplicateRowIds.size || (result.rows_duplicates ?? 0);
|
2026-05-15 02:26:10 -05:00
|
|
|
|
|
|
|
|
|
|
// Collect created_at dates from duplicate detail entries so we can show
|
|
|
|
|
|
// when the existing payments were originally recorded.
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const dupDates = details
|
2026-05-15 04:22:33 -05:00
|
|
|
|
.filter(d => (d.result === 'skipped_duplicate' || d.payment === 'skipped_duplicate') && d.existing_created_at)
|
2026-05-15 02:26:10 -05:00
|
|
|
|
.map(d => new Date(d.existing_created_at))
|
|
|
|
|
|
.filter(d => !isNaN(d.getTime()))
|
|
|
|
|
|
.sort((a, b) => a - b);
|
2026-05-15 01:36:56 -05:00
|
|
|
|
|
2026-05-15 02:26:10 -05:00
|
|
|
|
const earliestDup = dupDates[0] ?? null;
|
|
|
|
|
|
const latestDup = dupDates.at(-1) ?? null;
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const completedRowIds = new Set(previousResult?.completedRowIds ?? []);
|
|
|
|
|
|
const erroredRowIds = new Set(previousResult?.erroredRowIds ?? []);
|
|
|
|
|
|
|
|
|
|
|
|
for (const detail of details) {
|
|
|
|
|
|
if (detailCompletesImport(detail)) {
|
|
|
|
|
|
completedRowIds.add(detail.row_id);
|
|
|
|
|
|
erroredRowIds.delete(detail.row_id);
|
|
|
|
|
|
} else if (['error', 'ambiguous', 'skipped_conflict'].includes(detail?.result)) {
|
|
|
|
|
|
erroredRowIds.add(detail.row_id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-15 02:26:10 -05:00
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const mergedResult = {
|
|
|
|
|
|
created: (previousResult?.created ?? 0) + created,
|
|
|
|
|
|
updated: (previousResult?.updated ?? 0) + updated,
|
|
|
|
|
|
errored: erroredRowIds.size,
|
|
|
|
|
|
duplicates: (previousResult?.duplicates ?? 0) + duplicates,
|
|
|
|
|
|
earliestDup: previousResult?.earliestDup && earliestDup
|
|
|
|
|
|
? (previousResult.earliestDup < earliestDup ? previousResult.earliestDup : earliestDup)
|
|
|
|
|
|
: (previousResult?.earliestDup ?? earliestDup),
|
|
|
|
|
|
latestDup: previousResult?.latestDup && latestDup
|
|
|
|
|
|
? (previousResult.latestDup > latestDup ? previousResult.latestDup : latestDup)
|
|
|
|
|
|
: (previousResult?.latestDup ?? latestDup),
|
|
|
|
|
|
completedRowIds,
|
|
|
|
|
|
erroredRowIds,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
setBillImportResults(prev => new Map(prev).set(group.bill.id, mergedResult));
|
2026-05-15 02:26:10 -05:00
|
|
|
|
|
|
|
|
|
|
const imported = created + updated;
|
|
|
|
|
|
const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const remainingCount = billImportProgress(group.rows, mergedResult).remainingCount;
|
2026-05-15 02:26:10 -05:00
|
|
|
|
|
|
|
|
|
|
if (imported === 0 && duplicates > 0) {
|
|
|
|
|
|
const dateHint = earliestDup
|
|
|
|
|
|
? ` (first recorded ${fmtDate(earliestDup)})`
|
|
|
|
|
|
: '';
|
|
|
|
|
|
toast.warning(
|
2026-05-16 15:38:28 -05:00
|
|
|
|
remainingCount === 0
|
|
|
|
|
|
? `All rows for "${group.bill.name}" are now imported${dateHint}.`
|
|
|
|
|
|
: `${duplicates} row${duplicates > 1 ? 's' : ''} for "${group.bill.name}" already exist${dateHint}. ${remainingCount} remaining.`,
|
2026-05-15 02:26:10 -05:00
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const parts = [`${imported} entr${imported === 1 ? 'y' : 'ies'} imported`];
|
|
|
|
|
|
if (duplicates > 0) {
|
|
|
|
|
|
const dateHint = earliestDup ? ` (recorded ${fmtDate(earliestDup)})` : '';
|
|
|
|
|
|
parts.push(`${duplicates} already existed${dateHint}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (errored > 0) parts.push(`${errored} error${errored > 1 ? 's' : ''}`);
|
2026-05-16 15:38:28 -05:00
|
|
|
|
if (remainingCount > 0) parts.push(`${remainingCount} remaining`);
|
2026-05-15 02:26:10 -05:00
|
|
|
|
toast.success(`${group.bill.name} — ${parts.join(' · ')}`);
|
|
|
|
|
|
}
|
2026-05-15 01:36:56 -05:00
|
|
|
|
onHistoryRefresh?.();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || `Import failed for "${group.bill.name}"`);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setImportingBillId(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
const selectAllVisibleRows = () => {
|
|
|
|
|
|
setSelectedRows(new Set((preview.data?.rows || []).map(r => r.row_id)));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const selectedPreviewRows = () => {
|
|
|
|
|
|
const selected = selectedRows;
|
|
|
|
|
|
return (preview.data?.rows || []).filter(r => selected.has(r.row_id));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleBulkSkip = () => {
|
|
|
|
|
|
const rows = selectedPreviewRows();
|
|
|
|
|
|
setDecisions(prev => {
|
|
|
|
|
|
const next = { ...prev };
|
|
|
|
|
|
rows.forEach(row => {
|
|
|
|
|
|
next[row.row_id] = { ...(next[row.row_id] || {}), action: 'skip_row', bill_id: null, bill_name: null };
|
|
|
|
|
|
});
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} marked skipped.`);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleBulkCreateNew = () => {
|
|
|
|
|
|
const rows = selectedPreviewRows();
|
|
|
|
|
|
let missingNames = 0;
|
|
|
|
|
|
setDecisions(prev => {
|
|
|
|
|
|
const next = { ...prev };
|
|
|
|
|
|
rows.forEach(row => {
|
|
|
|
|
|
const decision = buildCreateNewDecision(row, next[row.row_id] || {});
|
|
|
|
|
|
if (!decision.bill_name?.trim()) missingNames++;
|
|
|
|
|
|
next[row.row_id] = decision;
|
|
|
|
|
|
});
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (missingNames > 0) {
|
|
|
|
|
|
toast.warning(`${missingNames} selected row${missingNames === 1 ? '' : 's'} still need a bill name.`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} set to create new bills.`);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleBulkReset = () => {
|
|
|
|
|
|
const rows = selectedPreviewRows();
|
|
|
|
|
|
setDecisions(prev => {
|
|
|
|
|
|
const next = { ...prev };
|
|
|
|
|
|
rows.forEach(row => {
|
|
|
|
|
|
next[row.row_id] = initialDecisionFromRecommendation(row);
|
|
|
|
|
|
});
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} reset to recommendation.`);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const buildApplyDecision = (row, d) => {
|
|
|
|
|
|
if (!d?.action) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const base = {
|
|
|
|
|
|
row_id: row.row_id,
|
|
|
|
|
|
action: d.action,
|
|
|
|
|
|
actual_amount: d.actual_amount ?? row.detected_amount ?? undefined,
|
|
|
|
|
|
year: row.detected_year ?? undefined,
|
|
|
|
|
|
month: row.detected_month ?? undefined,
|
|
|
|
|
|
notes: d.notes ?? row.detected_notes ?? undefined,
|
|
|
|
|
|
payment_amount: d.payment_amount ?? row.detected_payment_amount ?? undefined,
|
|
|
|
|
|
payment_date: d.payment_date ?? row.detected_paid_date ?? undefined,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (d.action === 'create_new_bill') {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...base,
|
|
|
|
|
|
bill_name: d.bill_name?.trim() || undefined,
|
|
|
|
|
|
category_id: d.category_id ?? undefined,
|
|
|
|
|
|
due_day: d.due_day ?? undefined,
|
|
|
|
|
|
expected_amount: d.expected_amount ?? undefined,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (ACTIONS_NEEDING_BILL.has(d.action)) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...base,
|
|
|
|
|
|
bill_id: d.bill_id ?? undefined,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return base;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ── Apply ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
const handleApply = async () => {
|
|
|
|
|
|
if (!preview.data) return;
|
|
|
|
|
|
setApplyState({ status: 'loading', result: null, error: null });
|
|
|
|
|
|
try {
|
|
|
|
|
|
const decisionsList = preview.data.rows
|
|
|
|
|
|
.map(row => {
|
|
|
|
|
|
const d = decisions[row.row_id];
|
|
|
|
|
|
if (d?.action === 'skip_row') return null;
|
|
|
|
|
|
return buildApplyDecision(row, d);
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
|
|
if (decisionsList.length === 0) {
|
|
|
|
|
|
throw new Error('No rows are selected to import. Choose at least one row to match or create, or keep the preview for review.');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = await api.applySpreadsheetImport({
|
|
|
|
|
|
import_session_id: preview.data.import_session_id,
|
|
|
|
|
|
decisions: decisionsList,
|
|
|
|
|
|
options: { reviewed_skipped_count: skipRows.length },
|
|
|
|
|
|
});
|
|
|
|
|
|
setApplyState({ status: 'done', result, error: null });
|
|
|
|
|
|
setSelectedRows(new Set());
|
|
|
|
|
|
toast.success(`Import applied — ${result.rows_created} created, ${result.rows_updated} updated.`);
|
|
|
|
|
|
onHistoryRefresh();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
const errorState = importErrorState(err, 'Apply failed.');
|
|
|
|
|
|
setApplyState({ status: 'error', result: null, error: errorState });
|
|
|
|
|
|
toast.error(errorState.message || 'Apply failed.');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ── Reset ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
const handleReset = () => {
|
|
|
|
|
|
setFile(null);
|
|
|
|
|
|
setOptions(INITIAL_OPTIONS);
|
|
|
|
|
|
setPreview({ status: 'idle', data: null, error: null });
|
|
|
|
|
|
setDecisions({});
|
|
|
|
|
|
setSelectedRows(new Set());
|
|
|
|
|
|
setApplyState({ status: 'idle', result: null, error: null });
|
2026-05-15 04:22:33 -05:00
|
|
|
|
setViewMode('rows');
|
|
|
|
|
|
setImportingBillId(null);
|
|
|
|
|
|
setBillImportResults(new Map());
|
2026-05-03 19:51:57 -05:00
|
|
|
|
if (fileRef.current) fileRef.current.value = '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ── Derived state ────────────────────────────────────────────────────────────
|
|
|
|
|
|
const previewRows = preview.data?.rows ?? [];
|
|
|
|
|
|
const unresolvedRows = previewRows.filter(r => {
|
|
|
|
|
|
const d = decisions[r.row_id];
|
|
|
|
|
|
return !d?.action || !isDecisionComplete(d.action, d);
|
|
|
|
|
|
});
|
|
|
|
|
|
const pendingRows = previewRows.filter(r => decisions[r.row_id]?.action && decisions[r.row_id]?.action !== 'skip_row');
|
|
|
|
|
|
const skipRows = previewRows.filter(r => decisions[r.row_id]?.action === 'skip_row');
|
|
|
|
|
|
const canApply = unresolvedRows.length === 0 && pendingRows.length > 0 && applyState.status !== 'loading';
|
|
|
|
|
|
|
|
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
return (
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
title="Import Spreadsheet History"
|
|
|
|
|
|
subtitle='Export your Google Sheet as .xlsx, upload it here, review the matches, then apply only the rows you approve.'
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Upload panel ──────────────────────────────────────────────────────── */}
|
|
|
|
|
|
<div className="px-6 py-5 space-y-5">
|
|
|
|
|
|
|
|
|
|
|
|
{/* File picker */}
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<label className="text-xs font-medium text-muted-foreground">XLSX File</label>
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<input ref={fileRef} type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
|
|
|
|
className="hidden" id="xlsx-upload"
|
|
|
|
|
|
onChange={e => setFile(e.target.files?.[0] ?? null)} />
|
|
|
|
|
|
<label htmlFor="xlsx-upload">
|
|
|
|
|
|
<Button size="sm" variant="outline" asChild>
|
|
|
|
|
|
<span className="cursor-pointer gap-2">
|
|
|
|
|
|
<Upload className="h-3.5 w-3.5" />
|
|
|
|
|
|
{file ? 'Change file' : 'Choose .xlsx file'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
{file && (
|
|
|
|
|
|
<span className="text-sm text-muted-foreground truncate max-w-[260px]">
|
|
|
|
|
|
<FileSpreadsheet className="inline h-3.5 w-3.5 mr-1" />{file.name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Options */}
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-x-8 gap-y-3">
|
|
|
|
|
|
<div className="flex items-center gap-2.5">
|
|
|
|
|
|
<Switch checked={options.parseAllSheets} onCheckedChange={v => opt('parseAllSheets', v)}
|
|
|
|
|
|
id="parse-all" />
|
|
|
|
|
|
<label htmlFor="parse-all" className="text-sm cursor-pointer">Parse all sheet tabs</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<label className="text-xs text-muted-foreground">Default year</label>
|
|
|
|
|
|
<Input type="number" min="2000" max="2100" placeholder="2026"
|
|
|
|
|
|
value={options.defaultYear}
|
|
|
|
|
|
onChange={e => opt('defaultYear', e.target.value)}
|
|
|
|
|
|
className="w-24 h-8 text-sm" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{!options.parseAllSheets && (
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<label className="text-xs text-muted-foreground">Default month</label>
|
|
|
|
|
|
<Input type="number" min="1" max="12" placeholder="1–12"
|
|
|
|
|
|
value={options.defaultMonth}
|
|
|
|
|
|
onChange={e => opt('defaultMonth', e.target.value)}
|
|
|
|
|
|
className="w-20 h-8 text-sm" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Preview button */}
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<Button size="sm" onClick={handlePreview}
|
|
|
|
|
|
disabled={!file || preview.status === 'loading'}
|
|
|
|
|
|
className="gap-2">
|
|
|
|
|
|
{preview.status === 'loading'
|
|
|
|
|
|
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Parsing…</>
|
|
|
|
|
|
: <><FileSpreadsheet className="h-3.5 w-3.5" />Preview Import</>}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
{(preview.status === 'ready' || preview.status === 'error' || applyState.status !== 'idle') && (
|
|
|
|
|
|
<Button size="sm" variant="ghost" onClick={handleReset} className="text-muted-foreground">
|
|
|
|
|
|
New import
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Error from preview */}
|
|
|
|
|
|
{preview.status === 'error' && (
|
|
|
|
|
|
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
|
|
|
|
|
|
<AlertTriangle className="inline h-4 w-4 mr-1.5" />{preview.error?.message || preview.error || 'Preview failed.'}
|
|
|
|
|
|
{preview.error?.details?.length > 0 && (
|
|
|
|
|
|
<ul className="mt-2 space-y-1 text-xs">
|
|
|
|
|
|
{preview.error.details.map((d, i) => (
|
|
|
|
|
|
<li key={i}>{d.row_id ? `${d.row_id}: ` : ''}{d.message}</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Preview results ────────────────────────────────────────────────────── */}
|
|
|
|
|
|
{preview.status === 'ready' && preview.data && !['loading', 'done'].includes(applyState.status) && (
|
|
|
|
|
|
<div className="px-6 pb-5 space-y-4">
|
|
|
|
|
|
|
|
|
|
|
|
{/* Workbook summary */}
|
|
|
|
|
|
<WorkbookSummaryCard workbook={preview.data.workbook} />
|
|
|
|
|
|
|
|
|
|
|
|
{/* Row decision table */}
|
|
|
|
|
|
{previewRows.length > 0 ? (
|
|
|
|
|
|
<div className="rounded-lg border border-border overflow-hidden bg-background">
|
2026-05-15 01:36:56 -05:00
|
|
|
|
{/* Tab header */}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
<div className="px-4 py-3 border-b border-border bg-muted/40 flex items-center justify-between gap-3 flex-wrap">
|
2026-05-15 01:36:56 -05:00
|
|
|
|
<div className="flex items-center gap-1 bg-background rounded-md border border-border p-0.5">
|
|
|
|
|
|
<button type="button" onClick={() => setViewMode('rows')}
|
|
|
|
|
|
className={cn('inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs font-medium transition-colors',
|
|
|
|
|
|
viewMode === 'rows'
|
|
|
|
|
|
? 'bg-primary text-primary-foreground shadow-sm'
|
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground')}>
|
|
|
|
|
|
<List className="h-3.5 w-3.5" />
|
|
|
|
|
|
Rows ({previewRows.length})
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" onClick={() => setViewMode('bills')}
|
|
|
|
|
|
className={cn('inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs font-medium transition-colors',
|
|
|
|
|
|
viewMode === 'bills'
|
|
|
|
|
|
? 'bg-primary text-primary-foreground shadow-sm'
|
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground')}>
|
|
|
|
|
|
<Building2 className="h-3.5 w-3.5" />
|
|
|
|
|
|
Bills
|
|
|
|
|
|
</button>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</div>
|
2026-05-15 01:36:56 -05:00
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
|
{viewMode === 'rows'
|
|
|
|
|
|
? 'Select rows, apply bulk decisions, then import.'
|
|
|
|
|
|
: 'Click a bill to queue its entire history from this file.'}
|
|
|
|
|
|
</span>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</div>
|
2026-05-15 01:36:56 -05:00
|
|
|
|
|
|
|
|
|
|
{/* Rows view */}
|
|
|
|
|
|
{viewMode === 'rows' && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<BulkActionBar
|
|
|
|
|
|
rows={previewRows}
|
|
|
|
|
|
selectedRows={selectedRows}
|
|
|
|
|
|
onSelectAll={selectAllVisibleRows}
|
|
|
|
|
|
onClearSelection={clearSelection}
|
|
|
|
|
|
onBulkSkip={handleBulkSkip}
|
|
|
|
|
|
onBulkCreateNew={handleBulkCreateNew}
|
|
|
|
|
|
onBulkReset={handleBulkReset}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<PreviewTable
|
|
|
|
|
|
rows={previewRows}
|
|
|
|
|
|
decisions={decisions}
|
|
|
|
|
|
onDecisionChange={handleDecisionChange}
|
|
|
|
|
|
allBills={allBills}
|
|
|
|
|
|
categories={categories}
|
|
|
|
|
|
selectedRows={selectedRows}
|
|
|
|
|
|
onSelectedChange={handleSelectedChange}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Bills view */}
|
|
|
|
|
|
{viewMode === 'bills' && (
|
|
|
|
|
|
<BillHistoryView
|
|
|
|
|
|
previewRows={previewRows}
|
|
|
|
|
|
allBills={allBills}
|
|
|
|
|
|
importingBillId={importingBillId}
|
|
|
|
|
|
billImportResults={billImportResults}
|
|
|
|
|
|
onImportBill={handleDirectImportBill}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p className="text-sm text-muted-foreground text-center py-4">No data rows found in this file.</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Apply bar */}
|
|
|
|
|
|
{previewRows.length > 0 && (
|
|
|
|
|
|
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 flex items-center justify-between flex-wrap gap-3">
|
|
|
|
|
|
<div className="flex items-center gap-4 text-xs text-muted-foreground flex-wrap">
|
|
|
|
|
|
<span className="text-foreground font-medium">{previewRows.length} rows reviewed</span>
|
|
|
|
|
|
<span className="text-emerald-600">{pendingRows.length} to apply</span>
|
|
|
|
|
|
<span>{skipRows.length} skipped</span>
|
|
|
|
|
|
{unresolvedRows.length > 0 && (
|
|
|
|
|
|
<span className="text-orange-500 font-medium">{unresolvedRows.length} need a decision</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button size="sm" onClick={handleApply} disabled={!canApply} className="gap-2">
|
|
|
|
|
|
<CheckCheck className="h-3.5 w-3.5" />
|
|
|
|
|
|
Apply {pendingRows.length} rows
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Applying ──────────────────────────────────────────────────────────── */}
|
|
|
|
|
|
{applyState.status === 'loading' && (
|
|
|
|
|
|
<div className="px-6 py-8 flex items-center justify-center gap-3 text-muted-foreground">
|
|
|
|
|
|
<Loader2 className="h-5 w-5 animate-spin" />
|
|
|
|
|
|
<span className="text-sm">Applying import…</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Apply result ──────────────────────────────────────────────────────── */}
|
|
|
|
|
|
{applyState.status === 'done' && applyState.result && (
|
|
|
|
|
|
<div className="px-6 pb-6 space-y-4">
|
|
|
|
|
|
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-4">
|
|
|
|
|
|
<div className="flex items-center gap-2 mb-3">
|
|
|
|
|
|
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
|
|
|
|
|
<p className="text-sm font-medium text-emerald-600">Import applied successfully</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
|
|
|
|
{[
|
|
|
|
|
|
{ label: 'Created', value: applyState.result.rows_created, color: 'text-emerald-600' },
|
|
|
|
|
|
{ label: 'Updated', value: applyState.result.rows_updated, color: 'text-blue-600' },
|
|
|
|
|
|
{ label: 'Skipped', value: applyState.result.rows_skipped, color: 'text-muted-foreground' },
|
|
|
|
|
|
{ label: 'Errors', value: applyState.result.rows_errored, color: 'text-red-500' },
|
|
|
|
|
|
].map(({ label, value, color }) => (
|
|
|
|
|
|
<div key={label} className="text-center">
|
|
|
|
|
|
<p className={cn('text-xl font-bold tabular-nums', color)}>{value}</p>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button size="sm" variant="outline" onClick={handleReset} className="gap-2">
|
|
|
|
|
|
<Plus className="h-3.5 w-3.5" />New Import
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Apply error ───────────────────────────────────────────────────────── */}
|
|
|
|
|
|
{applyState.status === 'error' && (
|
|
|
|
|
|
<div className="px-6 pb-5">
|
|
|
|
|
|
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
|
|
|
|
|
|
<AlertTriangle className="inline h-4 w-4 mr-1.5" />{applyState.error?.message || applyState.error || 'Apply failed.'}
|
|
|
|
|
|
{applyState.error?.details?.length > 0 && (
|
|
|
|
|
|
<ul className="mt-2 space-y-1 text-xs">
|
|
|
|
|
|
{applyState.error.details.map((d, i) => (
|
|
|
|
|
|
<li key={i}>
|
|
|
|
|
|
{d.row_id ? `${d.row_id}: ` : ''}
|
|
|
|
|
|
{d.field ? `${d.field} - ` : ''}
|
|
|
|
|
|
{d.message}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{applyState.error?.error_id && (
|
|
|
|
|
|
<p className="mt-2 text-xs opacity-80">Error ID: {applyState.error.error_id}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── DataPage ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
|
function SeedDemoDataSection({ onSeeded }) {
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [seeded, setSeeded] = useState(false);
|
2026-05-14 01:17:05 -05:00
|
|
|
|
const [counts, setCounts] = useState({ bills: 0, categories: 0 });
|
2026-05-09 13:03:36 -05:00
|
|
|
|
const [clearing, setClearing] = useState(false);
|
|
|
|
|
|
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
2026-05-11 15:00:35 -05:00
|
|
|
|
const [statusLoading, setStatusLoading] = useState(true);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-05-14 01:17:05 -05:00
|
|
|
|
api.seededStatus()
|
|
|
|
|
|
.then(data => {
|
|
|
|
|
|
setSeeded(data.seeded);
|
|
|
|
|
|
if (data.seeded) setCounts({ bills: data.seededBills || 0, categories: data.seededCategories || 0 });
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(err => console.error('Failed to check seeded status:', err))
|
|
|
|
|
|
.finally(() => setStatusLoading(false));
|
2026-05-11 15:00:35 -05:00
|
|
|
|
}, []);
|
2026-05-09 13:03:36 -05:00
|
|
|
|
|
|
|
|
|
|
const handleSeed = async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await api.seedDemoData();
|
2026-05-14 01:17:05 -05:00
|
|
|
|
if (!data || typeof data !== 'object') throw new Error('Invalid response from server');
|
|
|
|
|
|
setCounts({ bills: data.billsCreated || 0, categories: data.categoriesCreated || 0 });
|
2026-05-09 13:03:36 -05:00
|
|
|
|
setSeeded(true);
|
|
|
|
|
|
toast.success(`Created ${data.billsCreated || 0} demo bills successfully.`);
|
2026-05-14 01:17:05 -05:00
|
|
|
|
setTimeout(() => onSeeded?.(), 100);
|
2026-05-09 13:03:36 -05:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Seed error:', err);
|
|
|
|
|
|
toast.error(err?.message || err?.error || 'Failed to seed demo data.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleClearDemoData = async () => {
|
|
|
|
|
|
setClearing(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await api.clearDemoData();
|
|
|
|
|
|
setSeeded(false);
|
2026-05-14 01:17:05 -05:00
|
|
|
|
setCounts({ bills: 0, categories: 0 });
|
2026-05-09 13:03:36 -05:00
|
|
|
|
setShowClearConfirm(false);
|
|
|
|
|
|
toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`);
|
|
|
|
|
|
onSeeded?.();
|
|
|
|
|
|
} catch (err) {
|
2026-05-14 01:17:05 -05:00
|
|
|
|
toast.error(err.message || 'Failed to clear demo data.');
|
2026-05-09 13:03:36 -05:00
|
|
|
|
} finally {
|
|
|
|
|
|
setClearing(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" icon={Sparkles}>
|
|
|
|
|
|
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
|
2026-05-14 01:17:05 -05:00
|
|
|
|
{statusLoading ? (
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
|
|
|
|
|
) : seeded ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">Demo data seeded</p>
|
|
|
|
|
|
<div className="mt-3 grid grid-cols-2 gap-4 text-xs sm:grid-cols-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-muted-foreground">Bills</p>
|
|
|
|
|
|
<p className="font-semibold">{counts.bills}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-muted-foreground">Categories</p>
|
|
|
|
|
|
<p className="font-semibold">{counts.categories}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
Create 20 realistic demo bills and 8 demo categories for testing purposes.
|
|
|
|
|
|
The data will be associated with your account.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="mt-4 flex items-center justify-between gap-3 border-t border-border pt-4">
|
|
|
|
|
|
<Button size="sm" variant="outline" onClick={handleSeed} disabled={loading || seeded || statusLoading}>
|
|
|
|
|
|
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Seeding…</> : 'Seed Demo Data'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
<AlertDialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
|
|
|
|
|
|
<AlertDialogTrigger asChild>
|
|
|
|
|
|
<Button size="sm" variant="destructive" disabled={!seeded || clearing || statusLoading}>
|
|
|
|
|
|
{clearing ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Clearing…</> : 'Clear Demo Data'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</AlertDialogTrigger>
|
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogTitle>Clear Demo Data</AlertDialogTitle>
|
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
|
This will remove {counts.bills} demo bills and {counts.categories} demo categories from your account. This cannot be undone.
|
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
|
|
|
|
<AlertDialogAction onClick={handleClearDemoData} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
|
|
|
|
{clearing ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Clearing…</> : 'Clear Data'}
|
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
|
</AlertDialog>
|
2026-05-09 13:03:36 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
export default function DataPage() {
|
2026-05-04 23:34:24 -05:00
|
|
|
|
const [history, setHistory] = useState(null);
|
|
|
|
|
|
const [historyLoading, setHistoryLoading] = useState(true);
|
2026-05-16 21:36:04 -05:00
|
|
|
|
const [transactionRefreshKey, setTransactionRefreshKey] = useState(0);
|
2026-05-04 23:34:24 -05:00
|
|
|
|
|
|
|
|
|
|
const loadHistory = async () => {
|
|
|
|
|
|
setHistoryLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { history } = await api.importHistory();
|
|
|
|
|
|
setHistory(history);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setHistory([]);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setHistoryLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { loadHistory(); }, []);
|
|
|
|
|
|
|
2026-05-16 21:36:04 -05:00
|
|
|
|
const handleTransactionImportComplete = () => {
|
|
|
|
|
|
loadHistory();
|
|
|
|
|
|
setTransactionRefreshKey(key => key + 1);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-04 23:34:24 -05:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="mx-auto w-full max-w-6xl space-y-5">
|
|
|
|
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h1 className="text-2xl font-bold tracking-tight">Data</h1>
|
|
|
|
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
|
|
|
|
|
Import, export, and review your user-owned bill tracker records.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="rounded-full border border-border/70 bg-card/80 px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
|
|
|
|
|
User data only
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-15 01:36:56 -05:00
|
|
|
|
<div className="space-y-5">
|
2026-05-16 21:36:04 -05:00
|
|
|
|
<ImportTransactionCsvSection onHistoryRefresh={handleTransactionImportComplete} />
|
|
|
|
|
|
<TransactionMatchingSection refreshKey={transactionRefreshKey} />
|
2026-05-04 23:34:24 -05:00
|
|
|
|
<ImportSpreadsheetSection onHistoryRefresh={loadHistory} />
|
|
|
|
|
|
<ImportMyDataSection onHistoryRefresh={loadHistory} />
|
|
|
|
|
|
</div>
|
2026-05-09 13:03:36 -05:00
|
|
|
|
<SeedDemoDataSection onSeeded={loadHistory} />
|
2026-05-04 23:34:24 -05:00
|
|
|
|
<DownloadMyDataSection />
|
|
|
|
|
|
<ImportHistorySection history={history} loading={historyLoading} onRefresh={loadHistory} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
}
|