refactor(ts): convert ImportSpreadsheetSection to TypeScript — data/ dir complete
The XLSX importer (workbook parse → per-row decision tree → bulk actions → bill-history grouped import). Typed PreviewRow/Decision/BillGroup/ImportDetail and the merged BillImportResult accumulator; fixed Date subtraction in the duplicate-date sort to use getTime(). Dropped two unused imports (CountPill/fmt) the original .jsx never used. Trimmed unused eslint-disable directives. This completes the entire client/components/data/ directory (14 files) → .tsx. typecheck 0, lint 0 errors, build green.
This commit is contained in:
parent
0cc5cbd957
commit
80fb0043b6
|
|
@ -49,7 +49,7 @@ export default function EmailNotifCard() {
|
||||||
.then(d => setCfg({ ...DEFAULTS, ...(d as Partial<EmailConfig>) }))
|
.then(d => setCfg({ ...DEFAULTS, ...(d as Partial<EmailConfig>) }))
|
||||||
.catch(err => setLoadError(errMessage(err, 'Failed to load email settings')))
|
.catch(err => setLoadError(errMessage(err, 'Failed to load email settings')))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []);
|
||||||
|
|
||||||
const set = (k: keyof EmailConfig, v: string | boolean) => setCfg(p => ({ ...p, [k]: v }) as EmailConfig);
|
const set = (k: keyof EmailConfig, v: string | boolean) => setCfg(p => ({ ...p, [k]: v }) as EmailConfig);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,169 @@
|
||||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Upload, FileSpreadsheet, AlertTriangle, CheckCircle2, CheckCheck,
|
Upload, FileSpreadsheet, AlertTriangle, CheckCircle2, CheckCheck,
|
||||||
Loader2, RefreshCw, ChevronDown, ChevronUp, SkipForward, Plus,
|
Loader2, ChevronDown, ChevronUp, SkipForward, Plus,
|
||||||
List, Building2, ChevronLeft, FileText, XCircle, Sparkles,
|
List, Building2, ChevronLeft, XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, errMessage } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import {
|
import { SectionCard, importErrorState, type SectionCardProps, type ImportErrorState } from './dataShared';
|
||||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
import type { Bill, Category } from '@/types';
|
||||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from '@/components/ui/alert-dialog';
|
|
||||||
import { SectionCard, CountPill, fmt, importErrorState } from './dataShared';
|
|
||||||
|
|
||||||
function groupRowsBySheet(rows) {
|
interface BillMatch {
|
||||||
const map = new Map();
|
bill_id: number;
|
||||||
for (const row of rows) {
|
bill_name?: string;
|
||||||
const key = row.sheet_name || '(unknown sheet)';
|
expected_amount?: number | string;
|
||||||
if (!map.has(key)) map.set(key, []);
|
match_confidence: string;
|
||||||
map.get(key).push(row);
|
|
||||||
}
|
|
||||||
return Array.from(map.entries()).map(([name, rows]) => ({ name, rows }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initialDecisionFromRecommendation(row) {
|
interface Recommendation {
|
||||||
|
action?: string | null;
|
||||||
|
bill_id?: number | null;
|
||||||
|
bill_name?: string | null;
|
||||||
|
category_id?: number | null;
|
||||||
|
category_name?: string | null;
|
||||||
|
due_day?: number | null;
|
||||||
|
actual_amount?: number | null;
|
||||||
|
expected_amount?: number | null;
|
||||||
|
payment_amount?: number | null;
|
||||||
|
payment_date?: string | null;
|
||||||
|
confidence?: string;
|
||||||
|
reason?: string;
|
||||||
|
warnings?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewRow {
|
||||||
|
row_id: number | string;
|
||||||
|
sheet_name?: string | null;
|
||||||
|
source_row_number?: number;
|
||||||
|
proposed_action?: string | null;
|
||||||
|
requires_user_decision?: boolean;
|
||||||
|
recommendation?: Recommendation;
|
||||||
|
possible_bill_matches?: BillMatch[];
|
||||||
|
raw_values?: unknown[];
|
||||||
|
errors?: unknown[];
|
||||||
|
warnings?: string[];
|
||||||
|
detected_bill_name?: string | null;
|
||||||
|
detected_name?: string | null;
|
||||||
|
detected_amount?: number | null;
|
||||||
|
detected_payment_amount?: number | null;
|
||||||
|
detected_paid_date?: string | null;
|
||||||
|
detected_notes?: string | null;
|
||||||
|
detected_labels?: string[];
|
||||||
|
detected_year?: number | null;
|
||||||
|
detected_month?: number | null;
|
||||||
|
year_month_source?: string | null;
|
||||||
|
_match?: BillMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Decision {
|
||||||
|
action?: string | null;
|
||||||
|
bill_id?: number | null;
|
||||||
|
bill_name?: string | null;
|
||||||
|
previous_match_bill_id?: number | null;
|
||||||
|
category_id?: number | null;
|
||||||
|
due_day?: number | null;
|
||||||
|
expected_amount?: number | null;
|
||||||
|
actual_amount?: number | null;
|
||||||
|
payment_amount?: number | null;
|
||||||
|
payment_date?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkbookSheet {
|
||||||
|
name: string;
|
||||||
|
status?: string;
|
||||||
|
row_count?: number;
|
||||||
|
detected_year?: number | null;
|
||||||
|
detected_month?: number | null;
|
||||||
|
is_non_month_sheet?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Workbook {
|
||||||
|
parse_mode?: string;
|
||||||
|
total_row_count?: number;
|
||||||
|
row_count?: number;
|
||||||
|
selected_sheet?: string;
|
||||||
|
sheet_names?: string[];
|
||||||
|
sheets?: WorkbookSheet[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewData {
|
||||||
|
import_session_id?: string;
|
||||||
|
rows: PreviewRow[];
|
||||||
|
workbook: Workbook;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportDetail {
|
||||||
|
row_id?: number | string;
|
||||||
|
result?: string;
|
||||||
|
payment?: string;
|
||||||
|
existing_created_at?: string;
|
||||||
|
field?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApplyResult {
|
||||||
|
rows_created?: number;
|
||||||
|
rows_updated?: number;
|
||||||
|
rows_skipped?: number;
|
||||||
|
rows_errored?: number;
|
||||||
|
rows_duplicates?: number;
|
||||||
|
details?: ImportDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BillImportResult {
|
||||||
|
created?: number;
|
||||||
|
updated?: number;
|
||||||
|
errored?: number;
|
||||||
|
duplicates?: number;
|
||||||
|
earliestDup?: Date | null;
|
||||||
|
latestDup?: Date | null;
|
||||||
|
completedRowIds?: Set<number | string>;
|
||||||
|
erroredRowIds?: Set<number | string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BillGroup {
|
||||||
|
bill: Bill;
|
||||||
|
rows: PreviewRow[];
|
||||||
|
counts: { high: number; medium: number; low: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewState {
|
||||||
|
status: 'idle' | 'loading' | 'ready' | 'error';
|
||||||
|
data: PreviewData | null;
|
||||||
|
error: ImportErrorState | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApplyState {
|
||||||
|
status: 'idle' | 'loading' | 'done' | 'error';
|
||||||
|
result: ApplyResult | null;
|
||||||
|
error: ImportErrorState | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
parseAllSheets: boolean;
|
||||||
|
defaultYear: number | string;
|
||||||
|
defaultMonth: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupRowsBySheet(rows: PreviewRow[]): { name: string; rows: PreviewRow[] }[] {
|
||||||
|
const map = new Map<string, PreviewRow[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = row.sheet_name || '(unknown sheet)';
|
||||||
|
const list = map.get(key);
|
||||||
|
if (list) list.push(row);
|
||||||
|
else map.set(key, [row]);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).map(([name, groupRows]) => ({ name, rows: groupRows }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialDecisionFromRecommendation(row: PreviewRow): Decision {
|
||||||
const rec = row.recommendation || {};
|
const rec = row.recommendation || {};
|
||||||
const action = rec.action === 'ambiguous' ? null : (rec.action || row.proposed_action || null);
|
const action = rec.action === 'ambiguous' ? null : (rec.action || row.proposed_action || null);
|
||||||
|
|
||||||
|
|
@ -62,7 +198,7 @@ function initialDecisionFromRecommendation(row) {
|
||||||
return { action };
|
return { action };
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeRawBillName(row) {
|
function safeRawBillName(row: PreviewRow): string {
|
||||||
const raw = row.raw_values?.find((v) => {
|
const raw = row.raw_values?.find((v) => {
|
||||||
const text = String(v || '').trim();
|
const text = String(v || '').trim();
|
||||||
if (!text || text.length > 80) return false;
|
if (!text || text.length > 80) return false;
|
||||||
|
|
@ -75,7 +211,7 @@ function safeRawBillName(row) {
|
||||||
return raw ? String(raw).trim() : '';
|
return raw ? String(raw).trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCreateNewDecision(row, currentDecision = {}) {
|
function buildCreateNewDecision(row: PreviewRow, currentDecision: Decision = {}): Decision {
|
||||||
const rec = row.recommendation || {};
|
const rec = row.recommendation || {};
|
||||||
const billName = currentDecision.bill_name
|
const billName = currentDecision.bill_name
|
||||||
|| row.detected_bill_name
|
|| row.detected_bill_name
|
||||||
|
|
@ -98,10 +234,10 @@ function buildCreateNewDecision(row, currentDecision = {}) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInitialDecisions(rows) {
|
function buildInitialDecisions(rows: PreviewRow[]): Record<string, Decision> {
|
||||||
const d = {};
|
const d: Record<string, Decision> = {};
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const hasError = row.errors?.length > 0;
|
const hasError = (row.errors?.length ?? 0) > 0;
|
||||||
if (hasError || row.proposed_action === 'skip_row') {
|
if (hasError || row.proposed_action === 'skip_row') {
|
||||||
d[row.row_id] = { action: 'skip_row' };
|
d[row.row_id] = { action: 'skip_row' };
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -111,7 +247,7 @@ function buildInitialDecisions(rows) {
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDecisionComplete(action, decision) {
|
function isDecisionComplete(action: string | null | undefined, decision: Decision | undefined): boolean {
|
||||||
if (!action) return false;
|
if (!action) return false;
|
||||||
if (action === 'skip_row') return true;
|
if (action === 'skip_row') return true;
|
||||||
if (action === 'create_new_bill') return !!(decision?.bill_name?.trim());
|
if (action === 'create_new_bill') return !!(decision?.bill_name?.trim());
|
||||||
|
|
@ -123,28 +259,29 @@ function isDecisionComplete(action, decision) {
|
||||||
|
|
||||||
// ─── Badges ───────────────────────────────────────────────────────────────────
|
// ─── Badges ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SourceBadge({ source }) {
|
function SourceBadge({ source }: { source?: string }) {
|
||||||
const MAP = {
|
const MAP: Record<string, string> = {
|
||||||
row_date: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
|
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',
|
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',
|
default: 'bg-amber-500/15 text-amber-600 dark:text-amber-500',
|
||||||
ambiguous: 'bg-red-500/15 text-red-600 dark:text-red-400',
|
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' };
|
const LABELS: Record<string, string> = { row_date: 'date cell', sheet_name: 'tab name', default: 'default', ambiguous: 'ambiguous' };
|
||||||
|
const key = source ?? '';
|
||||||
return (
|
return (
|
||||||
<span className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium', MAP[source] ?? MAP.ambiguous)}>
|
<span className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium', MAP[key] ?? MAP.ambiguous)}>
|
||||||
{LABELS[source] ?? source}
|
{LABELS[key] ?? source}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfidenceBadge({ confidence }) {
|
function ConfidenceBadge({ confidence }: { confidence?: string }) {
|
||||||
const MAP = { high: 'text-emerald-600 dark:text-emerald-400', medium: 'text-amber-600 dark:text-amber-500', low: 'text-red-500' };
|
const MAP: Record<string, string> = { 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>;
|
return <span className={cn('text-[10px] font-semibold uppercase', MAP[confidence ?? ''] ?? '')}>{confidence}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function actionLabel(action) {
|
function actionLabel(action?: string | null): string {
|
||||||
const MAP = {
|
const MAP: Record<string, string> = {
|
||||||
match_existing_bill: 'Match existing bill',
|
match_existing_bill: 'Match existing bill',
|
||||||
create_new_bill: 'Create new bill',
|
create_new_bill: 'Create new bill',
|
||||||
skip_row: 'Skip row',
|
skip_row: 'Skip row',
|
||||||
|
|
@ -153,27 +290,28 @@ function actionLabel(action) {
|
||||||
add_monthly_note: 'Add monthly note',
|
add_monthly_note: 'Add monthly note',
|
||||||
create_payment: 'Record as payment',
|
create_payment: 'Record as payment',
|
||||||
};
|
};
|
||||||
return MAP[action] || (action ? action.replace(/_/g, ' ') : 'Needs decision');
|
return MAP[action ?? ''] || (action ? action.replace(/_/g, ' ') : 'Needs decision');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SheetStatusBadge({ status }: { status?: string }) {
|
||||||
function SheetStatusBadge({ status }) {
|
const MAP: Record<string, string> = {
|
||||||
const MAP = {
|
|
||||||
parsed: 'bg-emerald-500/15 text-emerald-600',
|
parsed: 'bg-emerald-500/15 text-emerald-600',
|
||||||
parsed_month_only: 'bg-amber-500/15 text-amber-600',
|
parsed_month_only: 'bg-amber-500/15 text-amber-600',
|
||||||
ambiguous: 'bg-orange-500/15 text-orange-600',
|
ambiguous: 'bg-orange-500/15 text-orange-600',
|
||||||
skipped: 'bg-muted text-muted-foreground',
|
skipped: 'bg-muted text-muted-foreground',
|
||||||
};
|
};
|
||||||
const LABELS = {
|
const LABELS: Record<string, string> = {
|
||||||
parsed: 'parsed', parsed_month_only: 'month only', ambiguous: 'ambiguous', skipped: 'skipped',
|
parsed: 'parsed', parsed_month_only: 'month only', ambiguous: 'ambiguous', skipped: 'skipped',
|
||||||
};
|
};
|
||||||
|
const key = status ?? '';
|
||||||
return (
|
return (
|
||||||
<span className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium', MAP[status] ?? MAP.ambiguous)}>
|
<span className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium', MAP[key] ?? MAP.ambiguous)}>
|
||||||
{LABELS[status] ?? status}
|
{LABELS[key] ?? status}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function WorkbookSummaryCard({ workbook }) {
|
|
||||||
|
function WorkbookSummaryCard({ workbook }: { workbook: Workbook }) {
|
||||||
const isMulti = workbook.parse_mode === 'all_sheets';
|
const isMulti = workbook.parse_mode === 'all_sheets';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -181,12 +319,12 @@ function WorkbookSummaryCard({ workbook }) {
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
<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>
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Workbook Summary</p>
|
||||||
<span className="text-xs text-muted-foreground">
|
<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}`}
|
{isMulti ? `${workbook.total_row_count} rows across ${workbook.sheet_names?.length ?? 0} tabs` : `${workbook.row_count} rows · ${workbook.selected_sheet}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isMulti && workbook.sheets?.length > 0 && (
|
{isMulti && (workbook.sheets?.length ?? 0) > 0 && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
|
||||||
{workbook.sheets.map(s => (
|
{workbook.sheets?.map(s => (
|
||||||
<div key={s.name} className={cn(
|
<div key={s.name} className={cn(
|
||||||
'flex items-center justify-between rounded px-3 py-1.5 text-xs',
|
'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'
|
s.is_non_month_sheet || s.status === 'skipped' ? 'bg-muted/50 text-muted-foreground/60' : 'bg-background border border-border/60'
|
||||||
|
|
@ -213,12 +351,22 @@ function WorkbookSummaryCard({ workbook }) {
|
||||||
|
|
||||||
const ACTIONS_NEEDING_BILL = new Set(['match_existing_bill','update_monthly_state','add_monthly_note','create_payment']);
|
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 }) {
|
interface RowDecisionRowProps {
|
||||||
|
row: PreviewRow;
|
||||||
|
decision?: Decision;
|
||||||
|
onDecisionChange: (rowId: number | string, decision: Decision) => void;
|
||||||
|
allBills: Bill[];
|
||||||
|
categories: Category[];
|
||||||
|
selected: boolean;
|
||||||
|
onSelectedChange: (rowId: number | string, selected: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories, selected, onSelectedChange }: RowDecisionRowProps) {
|
||||||
const [expanded, setExpanded] = useState(row.requires_user_decision || !decision?.action);
|
const [expanded, setExpanded] = useState(row.requires_user_decision || !decision?.action);
|
||||||
|
|
||||||
const action = decision?.action ?? null;
|
const action = decision?.action ?? null;
|
||||||
const isSkip = action === 'skip_row';
|
const isSkip = action === 'skip_row';
|
||||||
const hasError = row.errors?.length > 0;
|
const hasError = (row.errors?.length ?? 0) > 0;
|
||||||
const complete = isDecisionComplete(action, decision);
|
const complete = isDecisionComplete(action, decision);
|
||||||
const rec = row.recommendation || {};
|
const rec = row.recommendation || {};
|
||||||
|
|
||||||
|
|
@ -226,11 +374,11 @@ function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories,
|
||||||
const suggestedIds = new Set(suggestedBills.map(b => b.bill_id));
|
const suggestedIds = new Set(suggestedBills.map(b => b.bill_id));
|
||||||
const otherBills = allBills.filter(b => !suggestedIds.has(b.id));
|
const otherBills = allBills.filter(b => !suggestedIds.has(b.id));
|
||||||
|
|
||||||
const handleAction = (val) => {
|
const handleAction = (val: string | null) => {
|
||||||
const next = { ...decision, action: val };
|
const next: Decision = { ...decision, action: val };
|
||||||
if (val === 'create_new_bill') {
|
if (val === 'create_new_bill') {
|
||||||
Object.assign(next, buildCreateNewDecision(row, decision));
|
Object.assign(next, buildCreateNewDecision(row, decision));
|
||||||
} else if (ACTIONS_NEEDING_BILL.has(val)) {
|
} else if (val != null && ACTIONS_NEEDING_BILL.has(val)) {
|
||||||
next.bill_name = null;
|
next.bill_name = null;
|
||||||
next.bill_id = decision?.bill_id ?? decision?.previous_match_bill_id ?? rec.bill_id ?? suggestedBills[0]?.bill_id ?? 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.actual_amount = decision?.actual_amount ?? rec.actual_amount ?? row.detected_amount ?? null;
|
||||||
|
|
@ -244,15 +392,15 @@ function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories,
|
||||||
if (val === 'skip_row') setExpanded(false);
|
if (val === 'skip_row') setExpanded(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBill = (e) => {
|
const handleBill = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
onDecisionChange(row.row_id, { ...decision, bill_id: parseInt(e.target.value, 10) || null });
|
onDecisionChange(row.row_id, { ...decision, bill_id: parseInt(e.target.value, 10) || null });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBillName = (e) => {
|
const handleBillName = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
onDecisionChange(row.row_id, { ...decision, bill_name: e.target.value });
|
onDecisionChange(row.row_id, { ...decision, bill_name: e.target.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDecisionField = (field, value) => {
|
const handleDecisionField = (field: string, value: unknown) => {
|
||||||
onDecisionChange(row.row_id, { ...decision, [field]: value });
|
onDecisionChange(row.row_id, { ...decision, [field]: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -313,8 +461,8 @@ function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories,
|
||||||
paid {row.detected_paid_date}
|
paid {row.detected_paid_date}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{row.detected_labels?.length > 0 && (
|
{(row.detected_labels?.length ?? 0) > 0 && (
|
||||||
<span className="text-xs text-muted-foreground/70">{row.detected_labels.join(', ')}</span>
|
<span className="text-xs text-muted-foreground/70">{row.detected_labels?.join(', ')}</span>
|
||||||
)}
|
)}
|
||||||
{row.detected_notes && (
|
{row.detected_notes && (
|
||||||
<span className="text-xs text-muted-foreground/70 italic truncate max-w-[200px]">{row.detected_notes}</span>
|
<span className="text-xs text-muted-foreground/70 italic truncate max-w-[200px]">{row.detected_notes}</span>
|
||||||
|
|
@ -360,7 +508,7 @@ function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Warnings */}
|
{/* Warnings */}
|
||||||
{(rec.warnings?.length > 0 || row.warnings?.length > 0) && (
|
{((rec.warnings?.length ?? 0) > 0 || (row.warnings?.length ?? 0) > 0) && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{Array.from(new Set([...(rec.warnings || []), ...(row.warnings || [])])).map((w, i) => (
|
{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">
|
<p key={i} className="text-xs text-amber-600 flex items-start gap-1.5">
|
||||||
|
|
@ -413,7 +561,7 @@ function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories,
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bill selector (for actions that need a bill) */}
|
{/* Bill selector (for actions that need a bill) */}
|
||||||
{ACTIONS_NEEDING_BILL.has(action) && (
|
{action != null && ACTIONS_NEEDING_BILL.has(action) && (
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<label className="text-xs text-muted-foreground w-14 shrink-0">Bill</label>
|
<label className="text-xs text-muted-foreground w-14 shrink-0">Bill</label>
|
||||||
<select
|
<select
|
||||||
|
|
@ -527,7 +675,17 @@ function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories,
|
||||||
|
|
||||||
// ─── XLSX Import: Preview Table ───────────────────────────────────────────────
|
// ─── XLSX Import: Preview Table ───────────────────────────────────────────────
|
||||||
|
|
||||||
function PreviewTable({ rows, decisions, onDecisionChange, allBills, categories, selectedRows, onSelectedChange }) {
|
interface PreviewTableProps {
|
||||||
|
rows: PreviewRow[];
|
||||||
|
decisions: Record<string, Decision>;
|
||||||
|
onDecisionChange: (rowId: number | string, decision: Decision) => void;
|
||||||
|
allBills: Bill[];
|
||||||
|
categories: Category[];
|
||||||
|
selectedRows: Set<number | string>;
|
||||||
|
onSelectedChange: (rowId: number | string, selected: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewTable({ rows, decisions, onDecisionChange, allBills, categories, selectedRows, onSelectedChange }: PreviewTableProps) {
|
||||||
const groups = groupRowsBySheet(rows);
|
const groups = groupRowsBySheet(rows);
|
||||||
const multiTab = groups.length > 1;
|
const multiTab = groups.length > 1;
|
||||||
|
|
||||||
|
|
@ -560,15 +718,17 @@ function PreviewTable({ rows, decisions, onDecisionChange, allBills, categories,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BulkActionBar({
|
interface BulkActionBarProps {
|
||||||
rows,
|
rows: PreviewRow[];
|
||||||
selectedRows,
|
selectedRows: Set<number | string>;
|
||||||
onSelectAll,
|
onSelectAll: () => void;
|
||||||
onClearSelection,
|
onClearSelection: () => void;
|
||||||
onBulkSkip,
|
onBulkSkip: () => void;
|
||||||
onBulkCreateNew,
|
onBulkCreateNew: () => void;
|
||||||
onBulkReset,
|
onBulkReset: () => void;
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
function BulkActionBar({ rows, selectedRows, onSelectAll, onClearSelection, onBulkSkip, onBulkCreateNew, onBulkReset }: BulkActionBarProps) {
|
||||||
const allSelected = rows.length > 0 && rows.every(r => selectedRows.has(r.row_id));
|
const allSelected = rows.length > 0 && rows.every(r => selectedRows.has(r.row_id));
|
||||||
const selectedCount = selectedRows.size;
|
const selectedCount = selectedRows.size;
|
||||||
|
|
||||||
|
|
@ -613,7 +773,7 @@ function BulkActionBar({
|
||||||
|
|
||||||
// ─── Section 1: Import Spreadsheet History ────────────────────────────────────
|
// ─── Section 1: Import Spreadsheet History ────────────────────────────────────
|
||||||
|
|
||||||
const INITIAL_OPTIONS = {
|
const INITIAL_OPTIONS: Options = {
|
||||||
parseAllSheets: true,
|
parseAllSheets: true,
|
||||||
defaultYear: new Date().getFullYear(),
|
defaultYear: new Date().getFullYear(),
|
||||||
defaultMonth: '',
|
defaultMonth: '',
|
||||||
|
|
@ -621,32 +781,33 @@ const INITIAL_OPTIONS = {
|
||||||
|
|
||||||
// ─── Bill History Import helpers ──────────────────────────────────────────────
|
// ─── Bill History Import helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
function ConfidenceDot({ level }) {
|
function ConfidenceDot({ level }: { level?: string }) {
|
||||||
const cls = level === 'high' ? 'bg-emerald-500'
|
const cls = level === 'high' ? 'bg-emerald-500'
|
||||||
: level === 'medium' ? 'bg-amber-500'
|
: level === 'medium' ? 'bg-amber-500'
|
||||||
: 'bg-muted-foreground/30';
|
: 'bg-muted-foreground/30';
|
||||||
return <span className={cn('h-2 w-2 rounded-full shrink-0 inline-block', cls)} />;
|
return <span className={cn('h-2 w-2 rounded-full shrink-0 inline-block', cls)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useBillGroups(previewRows, allBills) {
|
function useBillGroups(previewRows: PreviewRow[], allBills: Bill[]): BillGroup[] {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const billMap = new Map(allBills.map(b => [b.id, b]));
|
const billMap = new Map<number, Bill>(allBills.map(b => [b.id, b]));
|
||||||
const groups = new Map();
|
const groups = new Map<number, BillGroup>();
|
||||||
|
|
||||||
for (const row of previewRows) {
|
for (const row of previewRows) {
|
||||||
for (const match of (row.possible_bill_matches ?? [])) {
|
for (const match of (row.possible_bill_matches ?? [])) {
|
||||||
if (!billMap.has(match.bill_id)) continue;
|
if (!billMap.has(match.bill_id)) continue;
|
||||||
if (!groups.has(match.bill_id)) {
|
if (!groups.has(match.bill_id)) {
|
||||||
groups.set(match.bill_id, {
|
groups.set(match.bill_id, {
|
||||||
bill: billMap.get(match.bill_id),
|
bill: billMap.get(match.bill_id)!,
|
||||||
rows: [],
|
rows: [],
|
||||||
counts: { high: 0, medium: 0, low: 0 },
|
counts: { high: 0, medium: 0, low: 0 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const g = groups.get(match.bill_id);
|
const g = groups.get(match.bill_id)!;
|
||||||
if (!g.rows.find(r => r.row_id === row.row_id)) {
|
if (!g.rows.find(r => r.row_id === row.row_id)) {
|
||||||
g.rows.push({ ...row, _match: match });
|
g.rows.push({ ...row, _match: match });
|
||||||
g.counts[match.match_confidence] = (g.counts[match.match_confidence] || 0) + 1;
|
const key = match.match_confidence as 'high' | 'medium' | 'low';
|
||||||
|
g.counts[key] = (g.counts[key] || 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -656,14 +817,14 @@ function useBillGroups(previewRows, allBills) {
|
||||||
}, [previewRows, allBills]);
|
}, [previewRows, allBills]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowDateLabel(row) {
|
function rowDateLabel(row: PreviewRow): string {
|
||||||
if (row.detected_year && row.detected_month)
|
if (row.detected_year && row.detected_month)
|
||||||
return `${row.detected_year}-${String(row.detected_month).padStart(2, '0')}`;
|
return `${row.detected_year}-${String(row.detected_month).padStart(2, '0')}`;
|
||||||
return row.detected_paid_date ?? '—';
|
return row.detected_paid_date ?? '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
function billImportProgress(rows, importResult) {
|
function billImportProgress(rows: PreviewRow[], importResult: BillImportResult | null) {
|
||||||
const completedRowIds = importResult?.completedRowIds ?? new Set();
|
const completedRowIds = importResult?.completedRowIds ?? new Set<number | string>();
|
||||||
const remainingRows = rows.filter(row => !completedRowIds.has(row.row_id));
|
const remainingRows = rows.filter(row => !completedRowIds.has(row.row_id));
|
||||||
return {
|
return {
|
||||||
completedCount: rows.length - remainingRows.length,
|
completedCount: rows.length - remainingRows.length,
|
||||||
|
|
@ -672,21 +833,29 @@ function billImportProgress(rows, importResult) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailImportedAnything(detail) {
|
function detailImportedAnything(detail: ImportDetail | undefined): boolean {
|
||||||
return ['created', 'updated', 'overwritten', 'imported'].includes(detail?.result)
|
return ['created', 'updated', 'overwritten', 'imported'].includes(detail?.result ?? '')
|
||||||
|| detail?.payment === 'created';
|
|| detail?.payment === 'created';
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailCompletesImport(detail) {
|
function detailCompletesImport(detail: ImportDetail | undefined): boolean {
|
||||||
if (!detail?.row_id) return false;
|
if (!detail?.row_id) return false;
|
||||||
if (['error', 'ambiguous', 'skipped_conflict'].includes(detail.result)) return false;
|
if (['error', 'ambiguous', 'skipped_conflict'].includes(detail.result ?? '')) return false;
|
||||||
if (detail.result === 'skipped') return false;
|
if (detail.result === 'skipped') return false;
|
||||||
return detailImportedAnything(detail)
|
return detailImportedAnything(detail)
|
||||||
|| detail.result === 'skipped_duplicate'
|
|| detail.result === 'skipped_duplicate'
|
||||||
|| detail.payment === 'skipped_duplicate';
|
|| detail.payment === 'skipped_duplicate';
|
||||||
}
|
}
|
||||||
|
|
||||||
function BillDetailView({ group, onBack, onImport, isImporting, importResult }) {
|
interface BillDetailViewProps {
|
||||||
|
group: BillGroup;
|
||||||
|
onBack: () => void;
|
||||||
|
onImport: () => void;
|
||||||
|
isImporting: boolean;
|
||||||
|
importResult: BillImportResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BillDetailView({ group, onBack, onImport, isImporting, importResult }: BillDetailViewProps) {
|
||||||
const { bill, rows } = group;
|
const { bill, rows } = group;
|
||||||
const { completedCount, remainingCount } = billImportProgress(rows, importResult);
|
const { completedCount, remainingCount } = billImportProgress(rows, importResult);
|
||||||
const sorted = [...rows].sort((a, b) => {
|
const sorted = [...rows].sort((a, b) => {
|
||||||
|
|
@ -712,12 +881,12 @@ function BillDetailView({ group, onBack, onImport, isImporting, importResult })
|
||||||
{completedCount === rows.length
|
{completedCount === rows.length
|
||||||
? 'All imported'
|
? 'All imported'
|
||||||
: `${completedCount} imported · ${remainingCount} remaining`}
|
: `${completedCount} imported · ${remainingCount} remaining`}
|
||||||
{importResult.duplicates > 0
|
{(importResult.duplicates ?? 0) > 0
|
||||||
&& ` · ${importResult.duplicates} dupes`}
|
&& ` · ${importResult.duplicates} dupes`}
|
||||||
</span>
|
</span>
|
||||||
{importResult.duplicates > 0 && importResult.earliestDup && (
|
{(importResult.duplicates ?? 0) > 0 && importResult.earliestDup && (
|
||||||
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
|
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
|
||||||
{importResult.duplicates} dup{importResult.duplicates > 1 ? 's' : ''} · recorded{' '}
|
{importResult.duplicates} dup{(importResult.duplicates ?? 0) > 1 ? 's' : ''} · recorded{' '}
|
||||||
{importResult.earliestDup.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
{importResult.earliestDup.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -732,16 +901,16 @@ function BillDetailView({ group, onBack, onImport, isImporting, importResult })
|
||||||
<div className="divide-y divide-border/30 max-h-80 overflow-y-auto">
|
<div className="divide-y divide-border/30 max-h-80 overflow-y-auto">
|
||||||
{sorted.map(row => (
|
{sorted.map(row => (
|
||||||
<div key={row.row_id} className="px-4 py-2 flex items-center gap-3">
|
<div key={row.row_id} className="px-4 py-2 flex items-center gap-3">
|
||||||
<ConfidenceDot level={row._match.match_confidence} />
|
<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 tabular-nums text-muted-foreground w-16 shrink-0">{rowDateLabel(row)}</span>
|
||||||
<span className="text-xs font-mono tabular-nums w-16 shrink-0">
|
<span className="text-xs font-mono tabular-nums w-16 shrink-0">
|
||||||
{row.detected_amount != null ? `$${Number(row.detected_amount).toFixed(2)}` : '—'}
|
{row.detected_amount != null ? `$${Number(row.detected_amount).toFixed(2)}` : '—'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground truncate flex-1">{row.detected_name ?? '—'}</span>
|
<span className="text-xs text-muted-foreground truncate flex-1">{row.detected_name ?? '—'}</span>
|
||||||
<span className={cn('text-[10px] shrink-0',
|
<span className={cn('text-[10px] shrink-0',
|
||||||
row._match.match_confidence === 'high' ? 'text-emerald-500' :
|
row._match?.match_confidence === 'high' ? 'text-emerald-500' :
|
||||||
row._match.match_confidence === 'medium' ? 'text-amber-500' : 'text-muted-foreground')}>
|
row._match?.match_confidence === 'medium' ? 'text-amber-500' : 'text-muted-foreground')}>
|
||||||
{row._match.match_confidence}
|
{row._match?.match_confidence}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -750,8 +919,16 @@ function BillDetailView({ group, onBack, onImport, isImporting, importResult })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BillHistoryView({ previewRows, allBills, importingBillId, billImportResults, onImportBill }) {
|
interface BillHistoryViewProps {
|
||||||
const [selectedBillId, setSelectedBillId] = useState(null);
|
previewRows: PreviewRow[];
|
||||||
|
allBills: Bill[];
|
||||||
|
importingBillId: number | string | null;
|
||||||
|
billImportResults: Map<number | string, BillImportResult>;
|
||||||
|
onImportBill: (group: BillGroup) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BillHistoryView({ previewRows, allBills, importingBillId, billImportResults, onImportBill }: BillHistoryViewProps) {
|
||||||
|
const [selectedBillId, setSelectedBillId] = useState<number | string | null>(null);
|
||||||
const billGroups = useBillGroups(previewRows, allBills);
|
const billGroups = useBillGroups(previewRows, allBills);
|
||||||
|
|
||||||
if (billGroups.length === 0) {
|
if (billGroups.length === 0) {
|
||||||
|
|
@ -802,18 +979,18 @@ function BillHistoryView({ previewRows, allBills, importingBillId, billImportRes
|
||||||
{counts.low > 0 && <span className="text-[10px] text-muted-foreground/50">{counts.low} low</span>}
|
{counts.low > 0 && <span className="text-[10px] text-muted-foreground/50">{counts.low} low</span>}
|
||||||
{importResult && (() => {
|
{importResult && (() => {
|
||||||
const allImported = completedCount === rows.length;
|
const allImported = completedCount === rows.length;
|
||||||
const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
const fmtDate = (d?: Date | null) => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
const dupDate = importResult.earliestDup ? ` · ${fmtDate(importResult.earliestDup)}` : '';
|
const dupDate = importResult.earliestDup ? ` · ${fmtDate(importResult.earliestDup)}` : '';
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<span className={`text-[10px] font-medium ${allImported ? 'text-emerald-500' : 'text-amber-400'}`}>
|
<span className={`text-[10px] font-medium ${allImported ? 'text-emerald-500' : 'text-amber-400'}`}>
|
||||||
{allImported ? 'All imported' : `${completedCount} imported · ${remainingCount} remaining`}
|
{allImported ? 'All imported' : `${completedCount} imported · ${remainingCount} remaining`}
|
||||||
{importResult.duplicates > 0 && ` · ${importResult.duplicates} dupes`}
|
{(importResult.duplicates ?? 0) > 0 && ` · ${importResult.duplicates} dupes`}
|
||||||
{importResult.errored > 0 && ` · ${importResult.errored} errors`}
|
{(importResult.errored ?? 0) > 0 && ` · ${importResult.errored} errors`}
|
||||||
</span>
|
</span>
|
||||||
{importResult.duplicates > 0 && (
|
{(importResult.duplicates ?? 0) > 0 && (
|
||||||
<p className="text-[10px] text-muted-foreground/70">
|
<p className="text-[10px] text-muted-foreground/70">
|
||||||
{importResult.duplicates} duplicate{importResult.duplicates > 1 ? 's' : ''}{dupDate}
|
{importResult.duplicates} duplicate{(importResult.duplicates ?? 0) > 1 ? 's' : ''}{dupDate}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -823,7 +1000,7 @@ function BillHistoryView({ previewRows, allBills, importingBillId, billImportRes
|
||||||
<div className="mt-1.5 space-y-0.5">
|
<div className="mt-1.5 space-y-0.5">
|
||||||
{sorted3.map(row => (
|
{sorted3.map(row => (
|
||||||
<div key={row.row_id} className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div key={row.row_id} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<ConfidenceDot level={row._match.match_confidence} />
|
<ConfidenceDot level={row._match?.match_confidence} />
|
||||||
<span className="tabular-nums w-16 shrink-0">{rowDateLabel(row)}</span>
|
<span className="tabular-nums w-16 shrink-0">{rowDateLabel(row)}</span>
|
||||||
{row.detected_amount != null && (
|
{row.detected_amount != null && (
|
||||||
<span className="tabular-nums">${Number(row.detected_amount).toFixed(2)}</span>
|
<span className="tabular-nums">${Number(row.detected_amount).toFixed(2)}</span>
|
||||||
|
|
@ -869,27 +1046,30 @@ function BillHistoryView({ previewRows, allBills, importingBillId, billImportRes
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps = {} }) {
|
export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps = {} }: {
|
||||||
const fileRef = useRef(null);
|
onHistoryRefresh?: () => void;
|
||||||
const [file, setFile] = useState(null);
|
cardProps?: Partial<SectionCardProps>;
|
||||||
const [options, setOptions] = useState(INITIAL_OPTIONS);
|
}) {
|
||||||
const [preview, setPreview] = useState({ status: 'idle', data: null, error: null });
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [decisions, setDecisions] = useState({});
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [applyState, setApplyState] = useState({ status: 'idle', result: null, error: null });
|
const [options, setOptions] = useState<Options>(INITIAL_OPTIONS);
|
||||||
const [allBills, setAllBills] = useState([]);
|
const [preview, setPreview] = useState<PreviewState>({ status: 'idle', data: null, error: null });
|
||||||
const [categories, setCategories] = useState([]);
|
const [decisions, setDecisions] = useState<Record<string, Decision>>({});
|
||||||
const [selectedRows, setSelectedRows] = useState(new Set());
|
const [applyState, setApplyState] = useState<ApplyState>({ status: 'idle', result: null, error: null });
|
||||||
const [viewMode, setViewMode] = useState('rows'); // 'rows' | 'bills'
|
const [allBills, setAllBills] = useState<Bill[]>([]);
|
||||||
const [importingBillId, setImportingBillId] = useState(null);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [billImportResults, setBillImportResults] = useState(new Map()); // bill_id → { created, updated, errored }
|
const [selectedRows, setSelectedRows] = useState<Set<number | string>>(new Set());
|
||||||
|
const [viewMode, setViewMode] = useState<'rows' | 'bills'>('rows');
|
||||||
|
const [importingBillId, setImportingBillId] = useState<number | string | null>(null);
|
||||||
|
const [billImportResults, setBillImportResults] = useState<Map<number | string, BillImportResult>>(new Map());
|
||||||
|
|
||||||
// Load bills/categories for the decision controls
|
// Load bills/categories for the decision controls
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.bills().then(setAllBills).catch(err => console.error('[ImportSpreadsheetSection] failed to load bills', err));
|
api.bills().then(b => setAllBills(b as Bill[])).catch(err => console.error('[ImportSpreadsheetSection] failed to load bills', err));
|
||||||
api.categories().then(setCategories).catch(err => console.error('[ImportSpreadsheetSection] failed to load categories', err));
|
api.categories().then(c => setCategories(c as Category[])).catch(err => console.error('[ImportSpreadsheetSection] failed to load categories', err));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const opt = (k, v) => setOptions(prev => ({ ...prev, [k]: v }));
|
const opt = (k: keyof Options, v: boolean | string) => setOptions(prev => ({ ...prev, [k]: v }));
|
||||||
|
|
||||||
// ── Preview ──────────────────────────────────────────────────────────────────
|
// ── Preview ──────────────────────────────────────────────────────────────────
|
||||||
const handlePreview = async () => {
|
const handlePreview = async () => {
|
||||||
|
|
@ -904,9 +1084,9 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps =
|
||||||
try {
|
try {
|
||||||
const data = await api.previewSpreadsheetImport(file, {
|
const data = await api.previewSpreadsheetImport(file, {
|
||||||
parseAllSheets: options.parseAllSheets,
|
parseAllSheets: options.parseAllSheets,
|
||||||
defaultYear: options.defaultYear ? parseInt(options.defaultYear, 10) : null,
|
defaultYear: options.defaultYear ? parseInt(String(options.defaultYear), 10) : undefined,
|
||||||
defaultMonth: options.defaultMonth ? parseInt(options.defaultMonth, 10) : null,
|
defaultMonth: options.defaultMonth ? parseInt(String(options.defaultMonth), 10) : undefined,
|
||||||
});
|
}) as PreviewData;
|
||||||
setPreview({ status: 'ready', data, error: null });
|
setPreview({ status: 'ready', data, error: null });
|
||||||
setDecisions(buildInitialDecisions(data.rows));
|
setDecisions(buildInitialDecisions(data.rows));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -915,11 +1095,11 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps =
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Decision update ──────────────────────────────────────────────────────────
|
// ── Decision update ──────────────────────────────────────────────────────────
|
||||||
const handleDecisionChange = (rowId, decision) => {
|
const handleDecisionChange = (rowId: number | string, decision: Decision) => {
|
||||||
setDecisions(prev => ({ ...prev, [rowId]: decision }));
|
setDecisions(prev => ({ ...prev, [rowId]: decision }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectedChange = (rowId, selected) => {
|
const handleSelectedChange = (rowId: number | string, selected: boolean) => {
|
||||||
setSelectedRows(prev => {
|
setSelectedRows(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (selected) next.add(rowId);
|
if (selected) next.add(rowId);
|
||||||
|
|
@ -932,7 +1112,7 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps =
|
||||||
|
|
||||||
// ── Bill-history direct import ────────────────────────────────────────────
|
// ── Bill-history direct import ────────────────────────────────────────────
|
||||||
// Applies all matching rows for a bill immediately — no queue, no review step.
|
// Applies all matching rows for a bill immediately — no queue, no review step.
|
||||||
const handleDirectImportBill = async (group) => {
|
const handleDirectImportBill = async (group: BillGroup) => {
|
||||||
const sessionId = preview.data?.import_session_id;
|
const sessionId = preview.data?.import_session_id;
|
||||||
if (!sessionId || importingBillId) return;
|
if (!sessionId || importingBillId) return;
|
||||||
|
|
||||||
|
|
@ -958,7 +1138,7 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps =
|
||||||
import_session_id: sessionId,
|
import_session_id: sessionId,
|
||||||
decisions: decisionsList,
|
decisions: decisionsList,
|
||||||
options: {},
|
options: {},
|
||||||
});
|
}) as ApplyResult;
|
||||||
|
|
||||||
const created = result.rows_created ?? 0;
|
const created = result.rows_created ?? 0;
|
||||||
const updated = result.rows_updated ?? 0;
|
const updated = result.rows_updated ?? 0;
|
||||||
|
|
@ -976,25 +1156,24 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps =
|
||||||
// when the existing payments were originally recorded.
|
// when the existing payments were originally recorded.
|
||||||
const dupDates = details
|
const dupDates = details
|
||||||
.filter(d => (d.result === 'skipped_duplicate' || d.payment === 'skipped_duplicate') && d.existing_created_at)
|
.filter(d => (d.result === 'skipped_duplicate' || d.payment === 'skipped_duplicate') && d.existing_created_at)
|
||||||
.map(d => new Date(d.existing_created_at))
|
.map(d => new Date(d.existing_created_at as string))
|
||||||
.filter(d => !isNaN(d.getTime()))
|
.filter(d => !isNaN(d.getTime()))
|
||||||
.sort((a, b) => a - b);
|
.sort((a, b) => a.getTime() - b.getTime());
|
||||||
|
|
||||||
const earliestDup = dupDates[0] ?? null;
|
const earliestDup = dupDates[0] ?? null;
|
||||||
const latestDup = dupDates.at(-1) ?? null;
|
const latestDup = dupDates.at(-1) ?? null;
|
||||||
const completedRowIds = new Set(previousResult?.completedRowIds ?? []);
|
const completedRowIds = new Set<number | string>(previousResult?.completedRowIds ?? []);
|
||||||
const erroredRowIds = new Set(previousResult?.erroredRowIds ?? []);
|
const erroredRowIds = new Set<number | string>(previousResult?.erroredRowIds ?? []);
|
||||||
|
|
||||||
for (const detail of details) {
|
for (const detail of details) {
|
||||||
if (detailCompletesImport(detail)) {
|
if (detailCompletesImport(detail)) {
|
||||||
completedRowIds.add(detail.row_id);
|
if (detail.row_id != null) { completedRowIds.add(detail.row_id); erroredRowIds.delete(detail.row_id); }
|
||||||
erroredRowIds.delete(detail.row_id);
|
} else if (['error', 'ambiguous', 'skipped_conflict'].includes(detail?.result ?? '')) {
|
||||||
} else if (['error', 'ambiguous', 'skipped_conflict'].includes(detail?.result)) {
|
if (detail.row_id != null) erroredRowIds.add(detail.row_id);
|
||||||
erroredRowIds.add(detail.row_id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedResult = {
|
const mergedResult: BillImportResult = {
|
||||||
created: (previousResult?.created ?? 0) + created,
|
created: (previousResult?.created ?? 0) + created,
|
||||||
updated: (previousResult?.updated ?? 0) + updated,
|
updated: (previousResult?.updated ?? 0) + updated,
|
||||||
errored: erroredRowIds.size,
|
errored: erroredRowIds.size,
|
||||||
|
|
@ -1012,7 +1191,7 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps =
|
||||||
setBillImportResults(prev => new Map(prev).set(group.bill.id, mergedResult));
|
setBillImportResults(prev => new Map(prev).set(group.bill.id, mergedResult));
|
||||||
|
|
||||||
const imported = created + updated;
|
const imported = created + updated;
|
||||||
const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
const fmtDate = (d?: Date | null) => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
const remainingCount = billImportProgress(group.rows, mergedResult).remainingCount;
|
const remainingCount = billImportProgress(group.rows, mergedResult).remainingCount;
|
||||||
|
|
||||||
if (imported === 0 && duplicates > 0) {
|
if (imported === 0 && duplicates > 0) {
|
||||||
|
|
@ -1036,7 +1215,7 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps =
|
||||||
}
|
}
|
||||||
onHistoryRefresh?.();
|
onHistoryRefresh?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || `Import failed for "${group.bill.name}"`);
|
toast.error(errMessage(err, `Import failed for "${group.bill.name}"`));
|
||||||
} finally {
|
} finally {
|
||||||
setImportingBillId(null);
|
setImportingBillId(null);
|
||||||
}
|
}
|
||||||
|
|
@ -1046,7 +1225,7 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps =
|
||||||
setSelectedRows(new Set((preview.data?.rows || []).map(r => r.row_id)));
|
setSelectedRows(new Set((preview.data?.rows || []).map(r => r.row_id)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedPreviewRows = () => {
|
const selectedPreviewRows = (): PreviewRow[] => {
|
||||||
const selected = selectedRows;
|
const selected = selectedRows;
|
||||||
return (preview.data?.rows || []).filter(r => selected.has(r.row_id));
|
return (preview.data?.rows || []).filter(r => selected.has(r.row_id));
|
||||||
};
|
};
|
||||||
|
|
@ -1094,7 +1273,7 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps =
|
||||||
toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} reset to recommendation.`);
|
toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} reset to recommendation.`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildApplyDecision = (row, d) => {
|
const buildApplyDecision = (row: PreviewRow, d: Decision | undefined) => {
|
||||||
if (!d?.action) return null;
|
if (!d?.action) return null;
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
|
|
@ -1149,11 +1328,11 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps =
|
||||||
import_session_id: preview.data.import_session_id,
|
import_session_id: preview.data.import_session_id,
|
||||||
decisions: decisionsList,
|
decisions: decisionsList,
|
||||||
options: { reviewed_skipped_count: skipRows.length },
|
options: { reviewed_skipped_count: skipRows.length },
|
||||||
});
|
}) as ApplyResult;
|
||||||
setApplyState({ status: 'done', result, error: null });
|
setApplyState({ status: 'done', result, error: null });
|
||||||
setSelectedRows(new Set());
|
setSelectedRows(new Set());
|
||||||
toast.success(`Import applied — ${result.rows_created} created, ${result.rows_updated} updated.`);
|
toast.success(`Import applied — ${result.rows_created} created, ${result.rows_updated} updated.`);
|
||||||
onHistoryRefresh();
|
onHistoryRefresh?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorState = importErrorState(err, 'Apply failed.');
|
const errorState = importErrorState(err, 'Apply failed.');
|
||||||
setApplyState({ status: 'error', result: null, error: errorState });
|
setApplyState({ status: 'error', result: null, error: errorState });
|
||||||
|
|
@ -1263,12 +1442,13 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps =
|
||||||
{/* Error from preview */}
|
{/* Error from preview */}
|
||||||
{preview.status === 'error' && (
|
{preview.status === 'error' && (
|
||||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
|
<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.'}
|
<AlertTriangle className="inline h-4 w-4 mr-1.5" />{preview.error?.message || 'Preview failed.'}
|
||||||
{preview.error?.details?.length > 0 && (
|
{(preview.error?.details?.length ?? 0) > 0 && (
|
||||||
<ul className="mt-2 space-y-1 text-xs">
|
<ul className="mt-2 space-y-1 text-xs">
|
||||||
{preview.error.details.map((d, i) => (
|
{preview.error?.details.map((d, i) => {
|
||||||
<li key={i}>{d.row_id ? `${d.row_id}: ` : ''}{d.message}</li>
|
const dd = d as ImportDetail;
|
||||||
))}
|
return <li key={i}>{dd.row_id ? `${dd.row_id}: ` : ''}{dd.message}</li>;
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1388,12 +1568,12 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps =
|
||||||
<p className="text-sm font-medium text-emerald-600">Import applied successfully</p>
|
<p className="text-sm font-medium text-emerald-600">Import applied successfully</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
{[
|
{([
|
||||||
{ label: 'Created', value: applyState.result.rows_created, color: 'text-emerald-600' },
|
{ label: 'Created', value: applyState.result.rows_created, color: 'text-emerald-600' },
|
||||||
{ label: 'Updated', value: applyState.result.rows_updated, color: 'text-blue-600' },
|
{ label: 'Updated', value: applyState.result.rows_updated, color: 'text-blue-600' },
|
||||||
{ label: 'Skipped', value: applyState.result.rows_skipped, color: 'text-muted-foreground' },
|
{ label: 'Skipped', value: applyState.result.rows_skipped, color: 'text-muted-foreground' },
|
||||||
{ label: 'Errors', value: applyState.result.rows_errored, color: 'text-red-500' },
|
{ label: 'Errors', value: applyState.result.rows_errored, color: 'text-red-500' },
|
||||||
].map(({ label, value, color }) => (
|
]).map(({ label, value, color }) => (
|
||||||
<div key={label} className="text-center">
|
<div key={label} className="text-center">
|
||||||
<p className={cn('text-xl font-bold tabular-nums', color)}>{value}</p>
|
<p className={cn('text-xl font-bold tabular-nums', color)}>{value}</p>
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
|
@ -1411,16 +1591,19 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps =
|
||||||
{applyState.status === 'error' && (
|
{applyState.status === 'error' && (
|
||||||
<div className="px-6 pb-5">
|
<div className="px-6 pb-5">
|
||||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
|
<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.'}
|
<AlertTriangle className="inline h-4 w-4 mr-1.5" />{applyState.error?.message || 'Apply failed.'}
|
||||||
{applyState.error?.details?.length > 0 && (
|
{(applyState.error?.details?.length ?? 0) > 0 && (
|
||||||
<ul className="mt-2 space-y-1 text-xs">
|
<ul className="mt-2 space-y-1 text-xs">
|
||||||
{applyState.error.details.map((d, i) => (
|
{applyState.error?.details.map((d, i) => {
|
||||||
<li key={i}>
|
const dd = d as ImportDetail;
|
||||||
{d.row_id ? `${d.row_id}: ` : ''}
|
return (
|
||||||
{d.field ? `${d.field} - ` : ''}
|
<li key={i}>
|
||||||
{d.message}
|
{dd.row_id ? `${dd.row_id}: ` : ''}
|
||||||
</li>
|
{dd.field ? `${dd.field} - ` : ''}
|
||||||
))}
|
{dd.message}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{applyState.error?.error_id && (
|
{applyState.error?.error_id && (
|
||||||
|
|
@ -1432,5 +1615,3 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps =
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── DataPage ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
@ -288,11 +288,11 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { loadBills(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(() => { loadBills(); }, []);
|
||||||
// Intentional: reset + reload page 1 when the filter or refresh key changes.
|
// Intentional: reset + reload page 1 when the filter or refresh key changes.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
useEffect(() => { setSearch(''); setPage(1); loadTransactions(1, ''); }, [filter, refreshKey]);
|
useEffect(() => { setSearch(''); setPage(1); loadTransactions(1, ''); }, [filter, refreshKey]);
|
||||||
useEffect(() => { loadSuggestions(); }, [refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(() => { loadSuggestions(); }, [refreshKey]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.categories().then(data => setCategories((data as Category[]) || [])).catch(err => console.error('[TransactionMatchingSection] failed to load categories', err));
|
api.categories().then(data => setCategories((data as Category[]) || [])).catch(err => console.error('[TransactionMatchingSection] failed to load categories', err));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue