BillTracker/client/components/data/ImportSpreadsheetSection.jsx

1436 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef, useMemo } from 'react';
import { toast } from 'sonner';
import {
Upload, FileSpreadsheet, AlertTriangle, CheckCircle2, CheckCheck,
Loader2, RefreshCw, ChevronDown, ChevronUp, SkipForward, Plus,
List, Building2, ChevronLeft, FileText, XCircle, Sparkles,
} 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';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { SectionCard, CountPill, fmt, importErrorState } from './dataShared';
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 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>
);
}
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: '',
};
// ─── 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 ?? '—';
}
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';
}
function BillDetailView({ group, onBack, onImport, isImporting, importResult }) {
const { bill, rows } = group;
const { completedCount, remainingCount } = billImportProgress(rows, importResult);
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>
<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">
{isImporting && <Loader2 className="h-3 w-3 animate-spin" />}
{remainingCount === 0 ? 'All imported' : `Import all ${remainingCount}`}
</Button>
</div>
</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;
const { completedCount, remainingCount } = billImportProgress(rows, importResult);
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>}
{importResult && (() => {
const allImported = completedCount === rows.length;
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">
<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`}
{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>
);
})()}
</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)}
disabled={!!importingBillId || remainingCount === 0} className="h-7 text-xs px-3 gap-1.5">
{remainingCount === 0 ? 'All imported' : `Import ${remainingCount}`}
</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>
);
}
// ─────────────────────────────────────────────────────────────────────────────
export default 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());
const [viewMode, setViewMode] = useState('rows'); // 'rows' | 'bills'
const [importingBillId, setImportingBillId] = useState(null);
const [billImportResults, setBillImportResults] = useState(new Map()); // bill_id → { created, updated, errored }
// 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 });
setViewMode('rows');
setImportingBillId(null);
setBillImportResults(new Map());
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());
// ── 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;
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;
}
setImportingBillId(group.bill.id);
try {
const decisionsList = rowsToImport.map(row => ({
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: {},
});
const created = result.rows_created ?? 0;
const updated = result.rows_updated ?? 0;
const errored = result.rows_errored ?? 0;
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);
// Collect created_at dates from duplicate detail entries so we can show
// when the existing payments were originally recorded.
const dupDates = details
.filter(d => (d.result === 'skipped_duplicate' || d.payment === 'skipped_duplicate') && d.existing_created_at)
.map(d => new Date(d.existing_created_at))
.filter(d => !isNaN(d.getTime()))
.sort((a, b) => a - b);
const earliestDup = dupDates[0] ?? null;
const latestDup = dupDates.at(-1) ?? null;
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);
}
}
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));
const imported = created + updated;
const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
const remainingCount = billImportProgress(group.rows, mergedResult).remainingCount;
if (imported === 0 && duplicates > 0) {
const dateHint = earliestDup
? ` (first recorded ${fmtDate(earliestDup)})`
: '';
toast.warning(
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.`,
);
} 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' : ''}`);
if (remainingCount > 0) parts.push(`${remainingCount} remaining`);
toast.success(`${group.bill.name}${parts.join(' · ')}`);
}
onHistoryRefresh?.();
} catch (err) {
toast.error(err.message || `Import failed for "${group.bill.name}"`);
} finally {
setImportingBillId(null);
}
};
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 });
setViewMode('rows');
setImportingBillId(null);
setBillImportResults(new Map());
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="112"
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">
{/* Tab header */}
<div className="px-4 py-3 border-b border-border bg-muted/40 flex items-center justify-between gap-3 flex-wrap">
<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>
</div>
<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>
</div>
{/* 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}
/>
)}
</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 ─────────────────────────────────────────────────────────────────