From 80fb0043b694f30ff6d420a6b3ace3db55e2945e Mon Sep 17 00:00:00 2001 From: null Date: Sat, 4 Jul 2026 21:56:04 -0500 Subject: [PATCH] =?UTF-8?q?refactor(ts):=20convert=20ImportSpreadsheetSect?= =?UTF-8?q?ion=20to=20TypeScript=20=E2=80=94=20data/=20dir=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/components/admin/EmailNotifCard.tsx | 2 +- ...ction.jsx => ImportSpreadsheetSection.tsx} | 491 ++++++++++++------ .../data/TransactionMatchingSection.tsx | 4 +- 3 files changed, 339 insertions(+), 158 deletions(-) rename client/components/data/{ImportSpreadsheetSection.jsx => ImportSpreadsheetSection.tsx} (80%) diff --git a/client/components/admin/EmailNotifCard.tsx b/client/components/admin/EmailNotifCard.tsx index 0cfe96e..cf61a7f 100644 --- a/client/components/admin/EmailNotifCard.tsx +++ b/client/components/admin/EmailNotifCard.tsx @@ -49,7 +49,7 @@ export default function EmailNotifCard() { .then(d => setCfg({ ...DEFAULTS, ...(d as Partial) })) .catch(err => setLoadError(errMessage(err, 'Failed to load email settings'))) .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); diff --git a/client/components/data/ImportSpreadsheetSection.jsx b/client/components/data/ImportSpreadsheetSection.tsx similarity index 80% rename from client/components/data/ImportSpreadsheetSection.jsx rename to client/components/data/ImportSpreadsheetSection.tsx index 57a3d96..c4edd7c 100644 --- a/client/components/data/ImportSpreadsheetSection.jsx +++ b/client/components/data/ImportSpreadsheetSection.tsx @@ -1,33 +1,169 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { 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, + Loader2, ChevronDown, ChevronUp, SkipForward, Plus, + List, Building2, ChevronLeft, XCircle, } from 'lucide-react'; import { api } from '@/api'; -import { cn } from '@/lib/utils'; +import { cn, errMessage } 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'; +import { SectionCard, importErrorState, type SectionCardProps, type ImportErrorState } from './dataShared'; +import type { Bill, Category } from '@/types'; -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 })); +interface BillMatch { + bill_id: number; + bill_name?: string; + expected_amount?: number | string; + match_confidence: string; } -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; + erroredRowIds?: Set; +} + +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(); + 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 action = rec.action === 'ambiguous' ? null : (rec.action || row.proposed_action || null); @@ -62,7 +198,7 @@ function initialDecisionFromRecommendation(row) { return { action }; } -function safeRawBillName(row) { +function safeRawBillName(row: PreviewRow): string { const raw = row.raw_values?.find((v) => { const text = String(v || '').trim(); if (!text || text.length > 80) return false; @@ -75,7 +211,7 @@ function safeRawBillName(row) { return raw ? String(raw).trim() : ''; } -function buildCreateNewDecision(row, currentDecision = {}) { +function buildCreateNewDecision(row: PreviewRow, currentDecision: Decision = {}): Decision { const rec = row.recommendation || {}; const billName = currentDecision.bill_name || row.detected_bill_name @@ -98,10 +234,10 @@ function buildCreateNewDecision(row, currentDecision = {}) { }; } -function buildInitialDecisions(rows) { - const d = {}; +function buildInitialDecisions(rows: PreviewRow[]): Record { + const d: Record = {}; 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') { d[row.row_id] = { action: 'skip_row' }; } else { @@ -111,7 +247,7 @@ function buildInitialDecisions(rows) { return d; } -function isDecisionComplete(action, decision) { +function isDecisionComplete(action: string | null | undefined, decision: Decision | undefined): boolean { if (!action) return false; if (action === 'skip_row') return true; if (action === 'create_new_bill') return !!(decision?.bill_name?.trim()); @@ -123,28 +259,29 @@ function isDecisionComplete(action, decision) { // ─── Badges ─────────────────────────────────────────────────────────────────── -function SourceBadge({ source }) { - const MAP = { +function SourceBadge({ source }: { source?: string }) { + const MAP: Record = { 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' }; + const LABELS: Record = { row_date: 'date cell', sheet_name: 'tab name', default: 'default', ambiguous: 'ambiguous' }; + const key = source ?? ''; return ( - - {LABELS[source] ?? source} + + {LABELS[key] ?? source} ); } -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 {confidence}; +function ConfidenceBadge({ confidence }: { confidence?: string }) { + const MAP: Record = { high: 'text-emerald-600 dark:text-emerald-400', medium: 'text-amber-600 dark:text-amber-500', low: 'text-red-500' }; + return {confidence}; } -function actionLabel(action) { - const MAP = { +function actionLabel(action?: string | null): string { + const MAP: Record = { match_existing_bill: 'Match existing bill', create_new_bill: 'Create new bill', skip_row: 'Skip row', @@ -153,27 +290,28 @@ function actionLabel(action) { add_monthly_note: 'Add monthly note', 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 }) { - const MAP = { +function SheetStatusBadge({ status }: { status?: string }) { + const MAP: Record = { 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 = { + const LABELS: Record = { parsed: 'parsed', parsed_month_only: 'month only', ambiguous: 'ambiguous', skipped: 'skipped', }; + const key = status ?? ''; return ( - - {LABELS[status] ?? status} + + {LABELS[key] ?? status} ); } -function WorkbookSummaryCard({ workbook }) { + +function WorkbookSummaryCard({ workbook }: { workbook: Workbook }) { const isMulti = workbook.parse_mode === 'all_sheets'; return ( @@ -181,12 +319,12 @@ function WorkbookSummaryCard({ workbook }) {

Workbook Summary

- {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}`}
- {isMulti && workbook.sheets?.length > 0 && ( + {isMulti && (workbook.sheets?.length ?? 0) > 0 && (
- {workbook.sheets.map(s => ( + {workbook.sheets?.map(s => (
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 action = decision?.action ?? null; const isSkip = action === 'skip_row'; - const hasError = row.errors?.length > 0; + const hasError = (row.errors?.length ?? 0) > 0; const complete = isDecisionComplete(action, decision); 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 otherBills = allBills.filter(b => !suggestedIds.has(b.id)); - const handleAction = (val) => { - const next = { ...decision, action: val }; + const handleAction = (val: string | null) => { + const next: Decision = { ...decision, action: val }; if (val === 'create_new_bill') { 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_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; @@ -244,15 +392,15 @@ function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories, if (val === 'skip_row') setExpanded(false); }; - const handleBill = (e) => { + const handleBill = (e: React.ChangeEvent) => { onDecisionChange(row.row_id, { ...decision, bill_id: parseInt(e.target.value, 10) || null }); }; - const handleBillName = (e) => { + const handleBillName = (e: React.ChangeEvent) => { 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 }); }; @@ -313,8 +461,8 @@ function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories, paid {row.detected_paid_date} )} - {row.detected_labels?.length > 0 && ( - {row.detected_labels.join(', ')} + {(row.detected_labels?.length ?? 0) > 0 && ( + {row.detected_labels?.join(', ')} )} {row.detected_notes && ( {row.detected_notes} @@ -360,7 +508,7 @@ function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories, )} {/* Warnings */} - {(rec.warnings?.length > 0 || row.warnings?.length > 0) && ( + {((rec.warnings?.length ?? 0) > 0 || (row.warnings?.length ?? 0) > 0) && (
{Array.from(new Set([...(rec.warnings || []), ...(row.warnings || [])])).map((w, i) => (

@@ -413,7 +561,7 @@ function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories,

{/* Bill selector (for actions that need a bill) */} - {ACTIONS_NEEDING_BILL.has(action) && ( + {action != null && ACTIONS_NEEDING_BILL.has(action) && (