refactor(ts): convert data/ import + matching sections to TypeScript

ImportMyData (SQLite), ImportTransactionCsv, TransactionMatching sections.
Branded BankTransaction/Bill/Category flow through the matching workbench;
relaxed BillModal initialBill to Partial<Bill> (it's a pre-filled draft).

TypeScript caught a real bug: ImportTransactionCsvSection used <CountPill> 6×
but never imported it — a ReferenceError that crashed the CSV preview/results
views. Added the missing import.

typecheck 0, build green.
This commit is contained in:
null 2026-07-04 21:42:18 -05:00
parent dfedb75e6d
commit 5a2e37fd61
4 changed files with 262 additions and 121 deletions

View File

@ -75,7 +75,7 @@ function isSnowballCat(categories: Category[], catId: string | null | undefined)
interface BillModalProps { interface BillModalProps {
bill?: Bill | null; bill?: Bill | null;
initialBill?: Bill | null; initialBill?: Partial<Bill> | null; // a pre-filled draft (from a transaction/subscription/template); lacks id + full Bill fields
categories: Category[]; categories: Category[];
onClose: () => void; onClose: () => void;
onSave: (savedBill?: Bill) => void; onSave: (savedBill?: Bill) => void;

View File

@ -1,20 +1,58 @@
import React, { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Database, Upload, AlertTriangle, Loader2 } from 'lucide-react'; import { Database, Upload, AlertTriangle, Loader2 } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { 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 { import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { SectionCard, CountPill, fmt, importErrorState } from './dataShared'; import { SectionCard, CountPill, fmt, importErrorState, type SectionCardProps, type ImportErrorState } from './dataShared';
export default function ImportMyDataSection({ onHistoryRefresh, cardProps = {} }) { interface ImportSummaryEntry {
const fileRef = useRef(null); create?: number;
const [file, setFile] = useState(null); skip?: number;
const [preview, setPreview] = useState({ status: 'idle', data: null, error: null }); conflict?: number;
const [applyState, setApplyState] = useState({ status: 'idle', result: null, error: null }); }
interface PreviewData {
import_session_id?: string;
counts?: Record<string, number>;
summary?: Record<string, ImportSummaryEntry>;
metadata?: { exported_at?: string | null };
source_filename?: string | null;
warnings?: string[];
}
interface ApplyResult {
rows_created?: number;
rows_skipped?: number;
rows_conflicted?: number;
rows_errored?: 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;
}
export default function ImportMyDataSection({ onHistoryRefresh, cardProps = {} }: {
onHistoryRefresh?: () => void;
cardProps?: Partial<SectionCardProps>;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<PreviewState>({ status: 'idle', data: null, error: null });
const [applyState, setApplyState] = useState<ApplyState>({ status: 'idle', result: null, error: null });
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const reset = () => { const reset = () => {
@ -32,12 +70,12 @@ export default function ImportMyDataSection({ onHistoryRefresh, cardProps = {} }
setPreview({ status: 'loading', data: null, error: null }); setPreview({ status: 'loading', data: null, error: null });
setApplyState({ status: 'idle', result: null, error: null }); setApplyState({ status: 'idle', result: null, error: null });
try { try {
const data = await api.previewUserDbImport(file); const data = await api.previewUserDbImport(file) as PreviewData;
setPreview({ status: 'ready', data, error: null }); setPreview({ status: 'ready', data, error: null });
toast.success('SQLite export preview ready.'); toast.success('SQLite export preview ready.');
} catch (err) { } catch (err) {
setPreview({ status: 'error', data: null, error: importErrorState(err, 'SQLite import preview failed.') }); setPreview({ status: 'error', data: null, error: importErrorState(err, 'SQLite import preview failed.') });
toast.error(err.message || 'SQLite import preview failed.'); toast.error(errMessage(err, 'SQLite import preview failed.'));
} }
}; };
@ -47,19 +85,20 @@ export default function ImportMyDataSection({ onHistoryRefresh, cardProps = {} }
}; };
const handleConfirmImport = async () => { const handleConfirmImport = async () => {
if (!preview.data?.import_session_id) return;
setConfirmOpen(false); setConfirmOpen(false);
setApplyState({ status: 'loading', result: null, error: null }); setApplyState({ status: 'loading', result: null, error: null });
try { try {
const result = await api.applyUserDbImport({ const result = await api.applyUserDbImport({
import_session_id: preview.data.import_session_id, import_session_id: preview.data.import_session_id,
options: { overwrite: false }, options: { overwrite: false },
}); }) as ApplyResult;
setApplyState({ status: 'done', result, error: null }); setApplyState({ status: 'done', result, error: null });
toast.success('SQLite data import applied.'); toast.success('SQLite data import applied.');
onHistoryRefresh?.(); onHistoryRefresh?.();
} catch (err) { } catch (err) {
setApplyState({ status: 'error', result: null, error: importErrorState(err, 'SQLite import apply failed.') }); setApplyState({ status: 'error', result: null, error: importErrorState(err, 'SQLite import apply failed.') });
toast.error(err.message || 'SQLite import apply failed.'); toast.error(errMessage(err, 'SQLite import apply failed.'));
} }
}; };
@ -114,11 +153,12 @@ export default function ImportMyDataSection({ onHistoryRefresh, cardProps = {} }
<div className="mt-4 rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"> <div className="mt-4 rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="inline h-4 w-4 mr-1.5" /> <AlertTriangle className="inline h-4 w-4 mr-1.5" />
{preview.error?.message || 'SQLite import preview failed.'} {preview.error?.message || 'SQLite import preview failed.'}
{preview.error?.details?.length > 0 && ( {(preview.error?.details?.length ?? 0) > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs"> <ul className="mt-2 list-disc pl-5 text-xs">
{preview.error.details.map((d, i) => ( {preview.error?.details.map((d, i) => {
<li key={i}>{d.message || d.table || JSON.stringify(d)}</li> const dd = d as { message?: string; table?: string };
))} return <li key={i}>{dd.message || dd.table || JSON.stringify(d)}</li>;
})}
</ul> </ul>
)} )}
</div> </div>
@ -155,9 +195,9 @@ export default function ImportMyDataSection({ onHistoryRefresh, cardProps = {} }
</div> </div>
))} ))}
</div> </div>
{preview.data.warnings?.length > 0 && ( {(preview.data.warnings?.length ?? 0) > 0 && (
<div className="mt-4 space-y-1"> <div className="mt-4 space-y-1">
{preview.data.warnings.map((warning, i) => ( {preview.data.warnings?.map((warning, i) => (
<p key={i} className="text-xs text-amber-600 dark:text-amber-400"> <p key={i} className="text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="mr-1 inline h-3.5 w-3.5" />{warning} <AlertTriangle className="mr-1 inline h-3.5 w-3.5" />{warning}
</p> </p>

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react'; import { useState, useRef, type ReactNode } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Upload, Loader2, CheckCircle2, CheckCheck, AlertTriangle, Plus, FileText, Upload, Loader2, CheckCircle2, CheckCheck, AlertTriangle, Plus, FileText,
@ -7,7 +7,52 @@ import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn } 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 { SectionCard, importErrorState } from './dataShared'; import { SectionCard, CountPill, importErrorState, type SectionCardProps, type ImportErrorState } from './dataShared';
type CsvMapping = Record<string, string>;
interface CsvPreviewData {
import_session_id?: string;
headers?: string[];
sampleRows?: Record<string, unknown>[];
suggestedMapping?: Record<string, string>;
fields?: Record<string, string>;
rowCount?: number;
errors?: { message?: string }[];
}
interface CsvRowDetail {
field?: string;
message?: string;
value?: unknown;
}
interface CsvCommitRow {
row: number;
result?: string;
provider_transaction_id?: string;
message?: string;
details?: CsvRowDetail[];
}
interface CsvCommitResult {
imported?: number;
skipped?: number;
failed?: number;
details?: CsvCommitRow[];
}
interface CsvPreviewState {
status: 'idle' | 'loading' | 'ready' | 'error';
data: CsvPreviewData | null;
error: ImportErrorState | null;
}
interface CsvCommitState {
status: 'idle' | 'loading' | 'done' | 'error';
result: CsvCommitResult | null;
error: ImportErrorState | null;
}
const CSV_MAPPING_FIELDS = [ const CSV_MAPPING_FIELDS = [
'posted_date', 'posted_date',
@ -25,19 +70,19 @@ const CSV_MAPPING_FIELDS = [
'transacted_at', 'transacted_at',
]; ];
function compactMapping(mapping) { function compactMapping(mapping: CsvMapping | null | undefined): CsvMapping {
return Object.fromEntries( return Object.fromEntries(
Object.entries(mapping || {}).filter(([, value]) => value), Object.entries(mapping || {}).filter(([, value]) => value),
); ) as CsvMapping;
} }
function canCommitCsvMapping(mapping) { function canCommitCsvMapping(mapping: CsvMapping | null | undefined): boolean {
return !!mapping?.posted_date && !!(mapping.amount || mapping.debit_amount || mapping.credit_amount); return !!mapping?.posted_date && !!(mapping?.amount || mapping?.debit_amount || mapping?.credit_amount);
} }
const CSV_IMPORT_STEPS = ['Upload', 'Preview', 'Map', 'Commit', 'Results']; const CSV_IMPORT_STEPS = ['Upload', 'Preview', 'Map', 'Commit', 'Results'];
function csvImportStepIndex(preview, mapping, commitState) { function csvImportStepIndex(preview: CsvPreviewState, mapping: CsvMapping, commitState: CsvCommitState): number {
if (commitState.status === 'done') return 4; if (commitState.status === 'done') return 4;
if (commitState.status === 'loading') return 3; if (commitState.status === 'loading') return 3;
if (preview.status === 'ready') return canCommitCsvMapping(mapping) ? 3 : 2; if (preview.status === 'ready') return canCommitCsvMapping(mapping) ? 3 : 2;
@ -45,7 +90,7 @@ function csvImportStepIndex(preview, mapping, commitState) {
return 0; return 0;
} }
function CsvImportStepper({ activeIndex }) { function CsvImportStepper({ activeIndex }: { activeIndex: number }) {
return ( return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{CSV_IMPORT_STEPS.map((step, index) => { {CSV_IMPORT_STEPS.map((step, index) => {
@ -77,7 +122,7 @@ function CsvImportStepper({ activeIndex }) {
); );
} }
function csvFieldRequirement(field, mapping) { function csvFieldRequirement(field: string, mapping: CsvMapping): string {
if (field === 'posted_date') return 'Required'; if (field === 'posted_date') return 'Required';
if (['amount', 'debit_amount', 'credit_amount'].includes(field)) { if (['amount', 'debit_amount', 'credit_amount'].includes(field)) {
return canCommitCsvMapping({ ...mapping, posted_date: mapping?.posted_date || '__date__' }) return canCommitCsvMapping({ ...mapping, posted_date: mapping?.posted_date || '__date__' })
@ -87,9 +132,9 @@ function csvFieldRequirement(field, mapping) {
return 'Optional'; return 'Optional';
} }
function csvFieldSamples(preview, header) { function csvFieldSamples(preview: CsvPreviewData | null | undefined, header: string): string[] {
if (!header) return []; if (!header) return [];
const values = []; const values: string[] = [];
for (const row of preview?.sampleRows || []) { for (const row of preview?.sampleRows || []) {
const value = String(row?.[header] || '').trim(); const value = String(row?.[header] || '').trim();
if (value && !values.includes(value)) values.push(value); if (value && !values.includes(value)) values.push(value);
@ -98,7 +143,16 @@ function csvFieldSamples(preview, header) {
return values; return values;
} }
function CsvMappingRow({ field, label, preview, mapping, onChange, disabled = false }) { interface CsvMappingRowProps {
field: string;
label: string;
preview: CsvPreviewData | null | undefined;
mapping: CsvMapping;
onChange: (field: string, header: string) => void;
disabled?: boolean;
}
function CsvMappingRow({ field, label, preview, mapping, onChange, disabled = false }: CsvMappingRowProps) {
const headers = preview?.headers || []; const headers = preview?.headers || [];
const suggested = preview?.suggestedMapping?.[field] || ''; const suggested = preview?.suggestedMapping?.[field] || '';
const current = mapping[field] || ''; const current = mapping[field] || '';
@ -187,7 +241,17 @@ function CsvMappingRow({ field, label, preview, mapping, onChange, disabled = fa
); );
} }
function CsvMappingReview({ preview, fields, mapping, onMappingChange, onUseSuggested, onClearMapping, disabled = false }) { interface CsvMappingReviewProps {
preview: CsvPreviewData | null | undefined;
fields: Record<string, string>;
mapping: CsvMapping;
onMappingChange: (field: string, header: string) => void;
onUseSuggested: () => void;
onClearMapping: () => void;
disabled?: boolean;
}
function CsvMappingReview({ preview, fields, mapping, onMappingChange, onUseSuggested, onClearMapping, disabled = false }: CsvMappingReviewProps) {
const mappingFields = CSV_MAPPING_FIELDS.filter(field => fields[field]); const mappingFields = CSV_MAPPING_FIELDS.filter(field => fields[field]);
const mappedCount = mappingFields.filter(field => mapping[field]).length; const mappedCount = mappingFields.filter(field => mapping[field]).length;
const hasSuggestedMapping = Object.values(preview?.suggestedMapping || {}).some(Boolean); const hasSuggestedMapping = Object.values(preview?.suggestedMapping || {}).some(Boolean);
@ -227,7 +291,7 @@ function CsvMappingReview({ preview, fields, mapping, onMappingChange, onUseSugg
<CsvMappingRow <CsvMappingRow
key={field} key={field}
field={field} field={field}
label={fields[field]} label={fields[field] ?? field}
preview={preview} preview={preview}
mapping={mapping} mapping={mapping}
onChange={onMappingChange} onChange={onMappingChange}
@ -239,7 +303,7 @@ function CsvMappingReview({ preview, fields, mapping, onMappingChange, onUseSugg
); );
} }
function CsvSampleTable({ preview }) { function CsvSampleTable({ preview }: { preview: CsvPreviewData | null | undefined }) {
const headers = preview?.headers || []; const headers = preview?.headers || [];
const sampleRows = preview?.sampleRows || []; const sampleRows = preview?.sampleRows || [];
const visibleHeaders = headers.slice(0, 8); const visibleHeaders = headers.slice(0, 8);
@ -267,7 +331,7 @@ function CsvSampleTable({ preview }) {
<tr key={index} className="hover:bg-muted/20"> <tr key={index} className="hover:bg-muted/20">
{visibleHeaders.map(header => ( {visibleHeaders.map(header => (
<td key={header} className="max-w-48 truncate px-3 py-2 text-muted-foreground"> <td key={header} className="max-w-48 truncate px-3 py-2 text-muted-foreground">
{row[header] || '—'} {(row[header] as ReactNode) || '—'}
</td> </td>
))} ))}
{hiddenCount > 0 && ( {hiddenCount > 0 && (
@ -281,18 +345,21 @@ function CsvSampleTable({ preview }) {
); );
} }
function formatCsvRowDetail(detail) { function formatCsvRowDetail(detail: CsvRowDetail | null | undefined): string {
if (!detail) return ''; if (!detail) return '';
const field = detail.field ? `${detail.field}: ` : ''; const field = detail.field ? `${detail.field}: ` : '';
return `${field}${detail.message || detail.value || JSON.stringify(detail)}`; return `${field}${detail.message || detail.value || JSON.stringify(detail)}`;
} }
export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProps = {} }) { export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProps = {} }: {
const fileRef = useRef(null); onHistoryRefresh?: () => void;
const [file, setFile] = useState(null); cardProps?: Partial<SectionCardProps>;
const [preview, setPreview] = useState({ status: 'idle', data: null, error: null }); }) {
const [mapping, setMapping] = useState({}); const fileRef = useRef<HTMLInputElement>(null);
const [commitState, setCommitState] = useState({ status: 'idle', result: null, error: null }); const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<CsvPreviewState>({ status: 'idle', data: null, error: null });
const [mapping, setMapping] = useState<CsvMapping>({});
const [commitState, setCommitState] = useState<CsvCommitState>({ status: 'idle', result: null, error: null });
const reset = () => { const reset = () => {
setFile(null); setFile(null);
@ -302,7 +369,7 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProp
if (fileRef.current) fileRef.current.value = ''; if (fileRef.current) fileRef.current.value = '';
}; };
const handleMappingChange = (field, header) => { const handleMappingChange = (field: string, header: string) => {
if (commitState.status === 'done') return; if (commitState.status === 'done') return;
setMapping(prev => { setMapping(prev => {
const next = { ...prev }; const next = { ...prev };
@ -322,7 +389,7 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProp
setMapping({}); setMapping({});
setCommitState({ status: 'idle', result: null, error: null }); setCommitState({ status: 'idle', result: null, error: null });
try { try {
const data = await api.previewCsvTransactionImport(file); const data = await api.previewCsvTransactionImport(file) as CsvPreviewData;
setPreview({ status: 'ready', data, error: null }); setPreview({ status: 'ready', data, error: null });
setMapping(compactMapping(data.suggestedMapping || {})); setMapping(compactMapping(data.suggestedMapping || {}));
toast.success('CSV preview ready.'); toast.success('CSV preview ready.');
@ -340,7 +407,7 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProp
const result = await api.commitCsvTransactionImport({ const result = await api.commitCsvTransactionImport({
import_session_id: preview.data.import_session_id, import_session_id: preview.data.import_session_id,
mapping: compactMapping(mapping), mapping: compactMapping(mapping),
}); }) as CsvCommitResult;
setCommitState({ status: 'done', result, error: null }); setCommitState({ status: 'done', result, error: null });
toast.success(`CSV imported — ${result.imported} imported, ${result.skipped} skipped.`); toast.success(`CSV imported — ${result.imported} imported, ${result.skipped} skipped.`);
onHistoryRefresh?.(); onHistoryRefresh?.();
@ -365,7 +432,7 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProp
const fields = preview.data?.fields || {}; const fields = preview.data?.fields || {};
const canCommit = preview.status === 'ready' const canCommit = preview.status === 'ready'
&& preview.data?.import_session_id && !!preview.data?.import_session_id
&& canCommitCsvMapping(mapping) && canCommitCsvMapping(mapping)
&& commitState.status !== 'loading' && commitState.status !== 'loading'
&& commitState.status !== 'done'; && commitState.status !== 'done';
@ -427,11 +494,12 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProp
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"> <div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="mr-1.5 inline h-4 w-4" /> <AlertTriangle className="mr-1.5 inline h-4 w-4" />
{preview.error?.message || 'CSV preview failed.'} {preview.error?.message || 'CSV preview failed.'}
{preview.error?.details?.length > 0 && ( {(preview.error?.details?.length ?? 0) > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs"> <ul className="mt-2 list-disc pl-5 text-xs">
{preview.error.details.map((d, i) => ( {preview.error?.details.map((d, i) => {
<li key={i}>{d.message || JSON.stringify(d)}</li> const dd = d as { message?: string };
))} return <li key={i}>{dd.message || JSON.stringify(d)}</li>;
})}
</ul> </ul>
)} )}
</div> </div>
@ -452,11 +520,11 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProp
</div> </div>
</div> </div>
{preview.data.errors?.length > 0 && ( {(preview.data.errors?.length ?? 0) > 0 && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3"> <div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3">
<p className="text-xs font-semibold uppercase tracking-widest text-amber-600 dark:text-amber-400">Review mapping</p> <p className="text-xs font-semibold uppercase tracking-widest text-amber-600 dark:text-amber-400">Review mapping</p>
<ul className="mt-2 space-y-1 text-xs text-amber-700 dark:text-amber-300"> <ul className="mt-2 space-y-1 text-xs text-amber-700 dark:text-amber-300">
{preview.data.errors.map((issue, i) => ( {preview.data.errors?.map((issue, i) => (
<li key={i} className="flex items-start gap-1.5"> <li key={i} className="flex items-start gap-1.5">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" /> <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{issue.message || JSON.stringify(issue)}</span> <span>{issue.message || JSON.stringify(issue)}</span>
@ -533,9 +601,9 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProp
{failedRows.map((row, index) => ( {failedRows.map((row, index) => (
<li key={`${row.row}-${index}`} className="rounded border border-destructive/10 bg-destructive/5 px-2 py-1.5"> <li key={`${row.row}-${index}`} className="rounded border border-destructive/10 bg-destructive/5 px-2 py-1.5">
<p>Row {row.row}: {row.message}</p> <p>Row {row.row}: {row.message}</p>
{row.details?.length > 0 && ( {(row.details?.length ?? 0) > 0 && (
<ul className="mt-1 space-y-0.5 pl-3 text-destructive/90"> <ul className="mt-1 space-y-0.5 pl-3 text-destructive/90">
{row.details.map((detail, detailIndex) => ( {row.details?.map((detail, detailIndex) => (
<li key={detailIndex}>{formatCsvRowDetail(detail)}</li> <li key={detailIndex}>{formatCsvRowDetail(detail)}</li>
))} ))}
</ul> </ul>
@ -552,11 +620,12 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProp
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"> <div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="mr-1.5 inline h-4 w-4" /> <AlertTriangle className="mr-1.5 inline h-4 w-4" />
{commitState.error?.message || 'CSV import failed.'} {commitState.error?.message || 'CSV import failed.'}
{commitState.error?.details?.length > 0 && ( {(commitState.error?.details?.length ?? 0) > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs"> <ul className="mt-2 list-disc pl-5 text-xs">
{commitState.error.details.map((d, i) => ( {commitState.error?.details.map((d, i) => {
<li key={i}>{d.message || JSON.stringify(d)}</li> const dd = d as { message?: string };
))} return <li key={i}>{dd.message || JSON.stringify(d)}</li>;
})}
</ul> </ul>
)} )}
</div> </div>
@ -565,5 +634,3 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProp
</SectionCard> </SectionCard>
); );
} }
// ─── Section 3: Import My Data Export ────────────────────────────────────────

View File

@ -1,15 +1,16 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Sparkles, CheckCircle2, Loader2, RefreshCw, Link2, Link2Off, Sparkles, CheckCircle2, Loader2, RefreshCw, Link2, Link2Off,
XCircle, Eye, EyeOff, Search, Clock, ChevronLeft, ChevronRight, XCircle, Eye, EyeOff, Search, Clock, ChevronLeft, ChevronRight,
ArrowUp, ArrowDown, ArrowUpDown, ArrowUp, ArrowDown, ArrowUpDown,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api, type QueryParams } from '@/api';
import { cn } from '@/lib/utils'; import { cn, errMessage } from '@/lib/utils';
import { asDollars } from '@/lib/money';
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 { SectionCard } from './dataShared'; import { SectionCard, type SectionCardProps } from './dataShared';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { import {
MatchBillDialog, MatchBillDialog,
@ -17,8 +18,25 @@ import {
transactionDate, transactionDate,
formatTransactionAmount, formatTransactionAmount,
} from '@/components/transactions/MatchBillDialog'; } from '@/components/transactions/MatchBillDialog';
import type { BankTransaction, Bill, Category } from '@/types';
const TRANSACTION_FILTERS = [ interface MatchSuggestion {
id: number | string;
score?: number;
transactionId: number | string;
billId: number | string;
transaction?: BankTransaction;
bill?: Bill;
reasons?: string[];
}
interface SimplefinConn {
id: number | string;
last_error?: string | null;
last_sync_at?: string | null;
}
const TRANSACTION_FILTERS: { id: string; label: string; params: Record<string, string> }[] = [
{ id: 'open', label: 'Open', params: { ignored: 'false' } }, { id: 'open', label: 'Open', params: { ignored: 'false' } },
{ id: 'unmatched', label: 'Unmatched', params: { match_status: 'unmatched', ignored: 'false' } }, { id: 'unmatched', label: 'Unmatched', params: { match_status: 'unmatched', ignored: 'false' } },
{ id: 'matched', label: 'Matched', params: { match_status: 'matched', ignored: 'false' } }, { id: 'matched', label: 'Matched', params: { match_status: 'matched', ignored: 'false' } },
@ -26,14 +44,14 @@ const TRANSACTION_FILTERS = [
{ id: 'all', label: 'All', params: { ignored: 'all' } }, { id: 'all', label: 'All', params: { ignored: 'all' } },
]; ];
function transactionStatus(tx) { function transactionStatus(tx: BankTransaction | null | undefined): string {
if (tx?.ignored) return 'ignored'; if (tx?.ignored) return 'ignored';
return tx?.match_status || 'unmatched'; return (tx?.match_status as string) || 'unmatched';
} }
function TransactionStatusBadge({ tx }) { function TransactionStatusBadge({ tx }: { tx: BankTransaction }) {
const status = transactionStatus(tx); const status = transactionStatus(tx);
const styles = { const styles: Record<string, string> = {
matched: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600', matched: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600',
ignored: 'border-muted-foreground/30 bg-muted/40 text-muted-foreground', ignored: 'border-muted-foreground/30 bg-muted/40 text-muted-foreground',
unmatched: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400', unmatched: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400',
@ -49,14 +67,22 @@ function TransactionStatusBadge({ tx }) {
); );
} }
function matchScoreTone(score) { function matchScoreTone(score?: number | string): string {
const value = Number(score) || 0; const value = Number(score) || 0;
if (value >= 80) return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'; if (value >= 80) return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
if (value >= 55) return 'border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400'; if (value >= 55) return 'border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400';
return 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'; return 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400';
} }
function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onReject }) { interface SuggestedMatchesPanelProps {
suggestions: MatchSuggestion[];
loading: boolean;
actionId: string | null;
onAccept: (suggestion: MatchSuggestion) => void;
onReject: (suggestion: MatchSuggestion) => void;
}
function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onReject }: SuggestedMatchesPanelProps) {
return ( return (
<div className="rounded-lg border border-sky-500/20 bg-sky-500/[0.035]"> <div className="rounded-lg border border-sky-500/20 bg-sky-500/[0.035]">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-sky-500/10 px-4 py-3"> <div className="flex flex-wrap items-center justify-between gap-3 border-b border-sky-500/10 px-4 py-3">
@ -83,8 +109,8 @@ function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onRej
) : ( ) : (
<div className="grid gap-2 p-3 xl:grid-cols-2"> <div className="grid gap-2 p-3 xl:grid-cols-2">
{suggestions.map(suggestion => { {suggestions.map(suggestion => {
const tx = suggestion.transaction || {}; const tx = suggestion.transaction || ({} as BankTransaction);
const bill = suggestion.bill || {}; const bill = suggestion.bill || ({} as Bill);
const acceptBusy = actionId === `suggestion-match:${suggestion.id}`; const acceptBusy = actionId === `suggestion-match:${suggestion.id}`;
const rejectBusy = actionId === `suggestion-reject:${suggestion.id}`; const rejectBusy = actionId === `suggestion-reject:${suggestion.id}`;
const busy = acceptBusy || rejectBusy; const busy = acceptBusy || rejectBusy;
@ -110,7 +136,7 @@ function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onRej
'shrink-0 text-sm font-semibold tabular-nums', 'shrink-0 text-sm font-semibold tabular-nums',
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600', Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}> )}>
{formatTransactionAmount(tx.amount, tx.currency)} {formatTransactionAmount(tx.amount, tx.currency ?? undefined)}
</p> </p>
</div> </div>
@ -124,9 +150,9 @@ function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onRej
</div> </div>
</div> </div>
{suggestion.reasons?.length > 0 && ( {(suggestion.reasons?.length ?? 0) > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
{suggestion.reasons.slice(0, 4).map(reason => ( {suggestion.reasons?.slice(0, 4).map(reason => (
<span key={reason} className="rounded-full border border-border/60 bg-muted/30 px-2 py-0.5 text-[10px] text-muted-foreground"> <span key={reason} className="rounded-full border border-border/60 bg-muted/30 px-2 py-0.5 text-[10px] text-muted-foreground">
{reason} {reason}
</span> </span>
@ -166,59 +192,66 @@ function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onRej
); );
} }
function parseUtc(str) { function parseUtc(str: string | null | undefined): Date | null {
if (!str) return null; if (!str) return null;
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z'; const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
return new Date(normalized); return new Date(normalized);
} }
function timeAgo(iso) { function timeAgo(iso: string | null | undefined): string | null {
if (!iso) return null; const d = parseUtc(iso);
const secs = Math.floor((Date.now() - parseUtc(iso).getTime()) / 1000); if (!d) return null;
const secs = Math.floor((Date.now() - d.getTime()) / 1000);
if (secs < 60) return 'just now'; if (secs < 60) return 'just now';
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
return `${Math.floor(secs / 86400)}d ago`; return `${Math.floor(secs / 86400)}d ago`;
} }
export default function TransactionMatchingSection({ refreshKey, simplefinConn, cardProps = {} }) { interface TransactionMatchingSectionProps {
const [transactions, setTransactions] = useState([]); refreshKey?: number | string;
const [suggestions, setSuggestions] = useState([]); simplefinConn?: SimplefinConn | null;
const [bills, setBills] = useState([]); cardProps?: Partial<SectionCardProps>;
}
export default function TransactionMatchingSection({ refreshKey, simplefinConn, cardProps = {} }: TransactionMatchingSectionProps) {
const [transactions, setTransactions] = useState<BankTransaction[]>([]);
const [suggestions, setSuggestions] = useState<MatchSuggestion[]>([]);
const [bills, setBills] = useState<Bill[]>([]);
const [filter, setFilter] = useState('open'); const [filter, setFilter] = useState('open');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [suggestionsLoading, setSuggestionsLoading] = useState(true); const [suggestionsLoading, setSuggestionsLoading] = useState(true);
const [billsLoading, setBillsLoading] = useState(true); const [billsLoading, setBillsLoading] = useState(true);
const [actionId, setActionId] = useState(null); const [actionId, setActionId] = useState<string | null>(null);
const [matchOpen, setMatchOpen] = useState(false); const [matchOpen, setMatchOpen] = useState(false);
const [matchTransaction, setMatchTransaction] = useState(null); const [matchTransaction, setMatchTransaction] = useState<BankTransaction | null>(null);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState<Category[]>([]);
const [createBillSourceTx, setCreateBillSourceTx] = useState(null); const [createBillSourceTx, setCreateBillSourceTx] = useState<{ tx: BankTransaction; initialBill: Partial<Bill> } | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sortBy, setSortBy] = useState('date'); const [sortBy, setSortBy] = useState('date');
const [sortDir, setSortDir] = useState('desc'); const [sortDir, setSortDir] = useState('desc');
const searchTimerRef = useRef(null); const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
const totalPages = Math.ceil(totalCount / PAGE_SIZE) || 1; const totalPages = Math.ceil(totalCount / PAGE_SIZE) || 1;
const currentFilter = TRANSACTION_FILTERS.find(item => item.id === filter) || TRANSACTION_FILTERS[0]; const currentFilter = TRANSACTION_FILTERS.find(item => item.id === filter) || TRANSACTION_FILTERS[0]!;
const loadTransactions = async (pageNum, searchOverride, sortByOverride, sortDirOverride) => { const loadTransactions = async (pageNum?: number, searchOverride?: string, sortByOverride?: string, sortDirOverride?: string) => {
const p = pageNum ?? page; const p = pageNum ?? page;
const q = searchOverride !== undefined ? searchOverride : search; const q = searchOverride !== undefined ? searchOverride : search;
const sb = sortByOverride !== undefined ? sortByOverride : sortBy; const sb = sortByOverride !== undefined ? sortByOverride : sortBy;
const sd = sortDirOverride !== undefined ? sortDirOverride : sortDir; const sd = sortDirOverride !== undefined ? sortDirOverride : sortDir;
setLoading(true); setLoading(true);
try { try {
const params = { limit: PAGE_SIZE, offset: (p - 1) * PAGE_SIZE, sort_by: sb, sort_dir: sd, ...currentFilter.params }; const params: QueryParams = { limit: PAGE_SIZE, offset: (p - 1) * PAGE_SIZE, sort_by: sb, sort_dir: sd, ...currentFilter.params };
if (q) params.q = q; if (q) params.q = q;
const resp = await api.transactions(params); const resp = await api.transactions(params) as { transactions?: BankTransaction[]; total?: number };
setTransactions(resp.transactions || []); setTransactions(resp.transactions || []);
setTotalCount(resp.total || 0); setTotalCount(resp.total || 0);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to load transactions.'); toast.error(errMessage(err, 'Failed to load transactions.'));
setTransactions([]); setTransactions([]);
} finally { } finally {
setLoading(false); setLoading(false);
@ -228,10 +261,10 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
const loadSuggestions = async () => { const loadSuggestions = async () => {
setSuggestionsLoading(true); setSuggestionsLoading(true);
try { try {
const data = await api.matchSuggestions({ limit: 8 }); const data = await api.matchSuggestions({ limit: 8 }) as MatchSuggestion[];
setSuggestions(data || []); setSuggestions(data || []);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to load match suggestions.'); toast.error(errMessage(err, 'Failed to load match suggestions.'));
setSuggestions([]); setSuggestions([]);
} finally { } finally {
setSuggestionsLoading(false); setSuggestionsLoading(false);
@ -245,41 +278,41 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
const loadBills = async () => { const loadBills = async () => {
setBillsLoading(true); setBillsLoading(true);
try { try {
const data = await api.bills(); const data = await api.bills() as Bill[];
setBills(data || []); setBills(data || []);
} catch (err) { } catch (err) {
setBills([]); setBills([]);
toast.error(err.message || 'Failed to load bills for matching.'); toast.error(errMessage(err, 'Failed to load bills for matching.'));
} finally { } finally {
setBillsLoading(false); setBillsLoading(false);
} }
}; };
useEffect(() => { loadBills(); }, []); useEffect(() => { loadBills(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
// 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]); useEffect(() => { loadSuggestions(); }, [refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
api.categories().then(data => setCategories(data || [])).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));
}, []); }, []);
const changePage = (newPage) => { const changePage = (newPage: number) => {
setPage(newPage); setPage(newPage);
loadTransactions(newPage); loadTransactions(newPage);
}; };
const handleSearchChange = (e) => { const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; const value = e.target.value;
setSearch(value); setSearch(value);
clearTimeout(searchTimerRef.current); if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => { searchTimerRef.current = setTimeout(() => {
setPage(1); setPage(1);
loadTransactions(1, value); loadTransactions(1, value);
}, 300); }, 300);
}; };
const handleSortClick = (column) => { const handleSortClick = (column: string) => {
const newDir = sortBy === column && sortDir === 'desc' ? 'asc' : 'desc'; const newDir = sortBy === column && sortDir === 'desc' ? 'asc' : 'desc';
setSortBy(column); setSortBy(column);
setSortDir(newDir); setSortDir(newDir);
@ -287,13 +320,14 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
loadTransactions(1, undefined, column, newDir); loadTransactions(1, undefined, column, newDir);
}; };
const openMatchDialog = (tx) => { const openMatchDialog = (tx: BankTransaction) => {
setMatchTransaction(tx); setMatchTransaction(tx);
setMatchOpen(true); setMatchOpen(true);
if (!bills.length && !billsLoading) loadBills(); if (!bills.length && !billsLoading) loadBills();
}; };
const openCreateBill = (tx, nameOverride) => { const openCreateBill = (tx: BankTransaction | null, nameOverride?: string) => {
if (!tx) return;
const amount = Math.abs(Number(tx.amount || 0)) / 100; const amount = Math.abs(Number(tx.amount || 0)) / 100;
const dateStr = transactionDate(tx); const dateStr = transactionDate(tx);
const day = dateStr ? parseInt(dateStr.slice(8, 10), 10) : 1; const day = dateStr ? parseInt(dateStr.slice(8, 10), 10) : 1;
@ -301,7 +335,7 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
tx, tx,
initialBill: { initialBill: {
name: nameOverride || transactionTitle(tx), name: nameOverride || transactionTitle(tx),
expected_amount: amount || 0, expected_amount: asDollars(amount || 0),
due_day: day >= 1 && day <= 31 ? day : 1, due_day: day >= 1 && day <= 31 ? day : 1,
billing_cycle: 'monthly', billing_cycle: 'monthly',
cycle_type: 'monthly', cycle_type: 'monthly',
@ -311,7 +345,7 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
}); });
}; };
const handleBillCreated = async (newBill) => { const handleBillCreated = async (newBill?: Bill) => {
const tx = createBillSourceTx?.tx; const tx = createBillSourceTx?.tx;
setCreateBillSourceTx(null); setCreateBillSourceTx(null);
if (tx && newBill?.id) { if (tx && newBill?.id) {
@ -319,13 +353,13 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
await api.matchTransaction(tx.id, newBill.id); await api.matchTransaction(tx.id, newBill.id);
toast.success('Bill created and matched to transaction.'); toast.success('Bill created and matched to transaction.');
} catch (err) { } catch (err) {
toast.error(err.message || 'Bill created but match failed.'); toast.error(errMessage(err, 'Bill created but match failed.'));
} }
} }
await Promise.all([loadBills(), refreshTransactionWorkbench()]); await Promise.all([loadBills(), refreshTransactionWorkbench()]);
}; };
const runTransactionAction = async (tx, action) => { const runTransactionAction = async (tx: BankTransaction, action: string) => {
setActionId(`${action}:${tx.id}`); setActionId(`${action}:${tx.id}`);
try { try {
if (action === 'unmatch') { if (action === 'unmatch') {
@ -340,13 +374,13 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
} }
await refreshTransactionWorkbench(); await refreshTransactionWorkbench();
} catch (err) { } catch (err) {
toast.error(err.message || 'Transaction action failed.'); toast.error(errMessage(err, 'Transaction action failed.'));
} finally { } finally {
setActionId(null); setActionId(null);
} }
}; };
const confirmMatch = async (billId) => { const confirmMatch = async (billId: number | string) => {
if (!matchTransaction) return; if (!matchTransaction) return;
setActionId(`match:${matchTransaction.id}`); setActionId(`match:${matchTransaction.id}`);
try { try {
@ -356,33 +390,33 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
setMatchTransaction(null); setMatchTransaction(null);
await refreshTransactionWorkbench(); await refreshTransactionWorkbench();
} catch (err) { } catch (err) {
toast.error(err.message || 'Transaction match failed.'); toast.error(errMessage(err, 'Transaction match failed.'));
} finally { } finally {
setActionId(null); setActionId(null);
} }
}; };
const acceptSuggestion = async (suggestion) => { const acceptSuggestion = async (suggestion: MatchSuggestion) => {
setActionId(`suggestion-match:${suggestion.id}`); setActionId(`suggestion-match:${suggestion.id}`);
try { try {
await api.matchTransaction(suggestion.transactionId, suggestion.billId); await api.matchTransaction(suggestion.transactionId, suggestion.billId);
toast.success('Suggested match confirmed.'); toast.success('Suggested match confirmed.');
await refreshTransactionWorkbench(); await refreshTransactionWorkbench();
} catch (err) { } catch (err) {
toast.error(err.message || 'Suggested match failed.'); toast.error(errMessage(err, 'Suggested match failed.'));
} finally { } finally {
setActionId(null); setActionId(null);
} }
}; };
const rejectSuggestion = async (suggestion) => { const rejectSuggestion = async (suggestion: MatchSuggestion) => {
setActionId(`suggestion-reject:${suggestion.id}`); setActionId(`suggestion-reject:${suggestion.id}`);
try { try {
await api.rejectMatchSuggestion(suggestion.id); await api.rejectMatchSuggestion(suggestion.id);
toast.success('Suggestion rejected.'); toast.success('Suggestion rejected.');
await loadSuggestions(); await loadSuggestions();
} catch (err) { } catch (err) {
toast.error(err.message || 'Suggestion could not be rejected.'); toast.error(errMessage(err, 'Suggestion could not be rejected.'));
} finally { } finally {
setActionId(null); setActionId(null);
} }
@ -397,7 +431,7 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
await api.syncDataSource(simplefinConn.id); await api.syncDataSource(simplefinConn.id);
await refreshTransactionWorkbench(); await refreshTransactionWorkbench();
} catch (err) { } catch (err) {
toast.error(err.message || 'Sync failed'); toast.error(errMessage(err, 'Sync failed'));
} finally { } finally {
setQuickSyncing(false); setQuickSyncing(false);
} }
@ -559,8 +593,8 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
) : null} ) : null}
</div> </div>
{tx.matched_bill_name ? ( {tx.matched_bill_name ? (
<span className="truncate text-xs text-foreground">{tx.matched_bill_name}</span> <span className="truncate text-xs text-foreground">{tx.matched_bill_name as string}</span>
) : tx.advisory_filter?.confidence === 'high' ? ( ) : (tx.advisory_filter as { confidence?: string } | undefined)?.confidence === 'high' ? (
<span className="text-xs text-muted-foreground italic">Probably not a bill</span> <span className="text-xs text-muted-foreground italic">Probably not a bill</span>
) : ( ) : (
<span className="text-xs text-muted-foreground">No bill linked</span> <span className="text-xs text-muted-foreground">No bill linked</span>
@ -571,7 +605,7 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
'px-4 py-3 text-right font-semibold tabular-nums whitespace-nowrap', 'px-4 py-3 text-right font-semibold tabular-nums whitespace-nowrap',
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600', Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}> )}>
{formatTransactionAmount(tx.amount, tx.currency)} {formatTransactionAmount(tx.amount, tx.currency ?? undefined)}
</td> </td>
<td className="px-3 py-3"> <td className="px-3 py-3">
<div className="flex justify-end gap-1.5 whitespace-nowrap"> <div className="flex justify-end gap-1.5 whitespace-nowrap">