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:
parent
dfedb75e6d
commit
5a2e37fd61
|
|
@ -75,7 +75,7 @@ function isSnowballCat(categories: Category[], catId: string | null | undefined)
|
|||
|
||||
interface BillModalProps {
|
||||
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[];
|
||||
onClose: () => void;
|
||||
onSave: (savedBill?: Bill) => void;
|
||||
|
|
|
|||
|
|
@ -1,20 +1,58 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Database, Upload, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { errMessage } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} 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 = {} }) {
|
||||
const fileRef = useRef(null);
|
||||
const [file, setFile] = useState(null);
|
||||
const [preview, setPreview] = useState({ status: 'idle', data: null, error: null });
|
||||
const [applyState, setApplyState] = useState({ status: 'idle', result: null, error: null });
|
||||
interface ImportSummaryEntry {
|
||||
create?: number;
|
||||
skip?: number;
|
||||
conflict?: number;
|
||||
}
|
||||
|
||||
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 reset = () => {
|
||||
|
|
@ -32,12 +70,12 @@ export default function ImportMyDataSection({ onHistoryRefresh, cardProps = {} }
|
|||
setPreview({ status: 'loading', data: null, error: null });
|
||||
setApplyState({ status: 'idle', result: null, error: null });
|
||||
try {
|
||||
const data = await api.previewUserDbImport(file);
|
||||
const data = await api.previewUserDbImport(file) as PreviewData;
|
||||
setPreview({ status: 'ready', data, error: null });
|
||||
toast.success('SQLite export preview ready.');
|
||||
} catch (err) {
|
||||
setPreview({ status: 'error', data: null, error: importErrorState(err, 'SQLite import preview failed.') });
|
||||
toast.error(err.message || 'SQLite import preview failed.');
|
||||
toast.error(errMessage(err, 'SQLite import preview failed.'));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -47,19 +85,20 @@ export default function ImportMyDataSection({ onHistoryRefresh, cardProps = {} }
|
|||
};
|
||||
|
||||
const handleConfirmImport = async () => {
|
||||
if (!preview.data?.import_session_id) return;
|
||||
setConfirmOpen(false);
|
||||
setApplyState({ status: 'loading', result: null, error: null });
|
||||
try {
|
||||
const result = await api.applyUserDbImport({
|
||||
import_session_id: preview.data.import_session_id,
|
||||
options: { overwrite: false },
|
||||
});
|
||||
}) as ApplyResult;
|
||||
setApplyState({ status: 'done', result, error: null });
|
||||
toast.success('SQLite data import applied.');
|
||||
onHistoryRefresh?.();
|
||||
} catch (err) {
|
||||
setApplyState({ status: 'error', result: null, error: importErrorState(err, 'SQLite import apply failed.') });
|
||||
toast.error(err.message || 'SQLite import apply failed.');
|
||||
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">
|
||||
<AlertTriangle className="inline h-4 w-4 mr-1.5" />
|
||||
{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">
|
||||
{preview.error.details.map((d, i) => (
|
||||
<li key={i}>{d.message || d.table || JSON.stringify(d)}</li>
|
||||
))}
|
||||
{preview.error?.details.map((d, i) => {
|
||||
const dd = d as { message?: string; table?: string };
|
||||
return <li key={i}>{dd.message || dd.table || JSON.stringify(d)}</li>;
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -155,9 +195,9 @@ export default function ImportMyDataSection({ onHistoryRefresh, cardProps = {} }
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
{preview.data.warnings?.length > 0 && (
|
||||
{(preview.data.warnings?.length ?? 0) > 0 && (
|
||||
<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">
|
||||
<AlertTriangle className="mr-1 inline h-3.5 w-3.5" />{warning}
|
||||
</p>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useRef, type ReactNode } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Upload, Loader2, CheckCircle2, CheckCheck, AlertTriangle, Plus, FileText,
|
||||
|
|
@ -7,7 +7,52 @@ import { api } from '@/api';
|
|||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 = [
|
||||
'posted_date',
|
||||
|
|
@ -25,19 +70,19 @@ const CSV_MAPPING_FIELDS = [
|
|||
'transacted_at',
|
||||
];
|
||||
|
||||
function compactMapping(mapping) {
|
||||
function compactMapping(mapping: CsvMapping | null | undefined): CsvMapping {
|
||||
return Object.fromEntries(
|
||||
Object.entries(mapping || {}).filter(([, value]) => value),
|
||||
);
|
||||
) as CsvMapping;
|
||||
}
|
||||
|
||||
function canCommitCsvMapping(mapping) {
|
||||
return !!mapping?.posted_date && !!(mapping.amount || mapping.debit_amount || mapping.credit_amount);
|
||||
function canCommitCsvMapping(mapping: CsvMapping | null | undefined): boolean {
|
||||
return !!mapping?.posted_date && !!(mapping?.amount || mapping?.debit_amount || mapping?.credit_amount);
|
||||
}
|
||||
|
||||
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 === 'loading') return 3;
|
||||
if (preview.status === 'ready') return canCommitCsvMapping(mapping) ? 3 : 2;
|
||||
|
|
@ -45,7 +90,7 @@ function csvImportStepIndex(preview, mapping, commitState) {
|
|||
return 0;
|
||||
}
|
||||
|
||||
function CsvImportStepper({ activeIndex }) {
|
||||
function CsvImportStepper({ activeIndex }: { activeIndex: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||
{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 (['amount', 'debit_amount', 'credit_amount'].includes(field)) {
|
||||
return canCommitCsvMapping({ ...mapping, posted_date: mapping?.posted_date || '__date__' })
|
||||
|
|
@ -87,9 +132,9 @@ function csvFieldRequirement(field, mapping) {
|
|||
return 'Optional';
|
||||
}
|
||||
|
||||
function csvFieldSamples(preview, header) {
|
||||
function csvFieldSamples(preview: CsvPreviewData | null | undefined, header: string): string[] {
|
||||
if (!header) return [];
|
||||
const values = [];
|
||||
const values: string[] = [];
|
||||
for (const row of preview?.sampleRows || []) {
|
||||
const value = String(row?.[header] || '').trim();
|
||||
if (value && !values.includes(value)) values.push(value);
|
||||
|
|
@ -98,7 +143,16 @@ function csvFieldSamples(preview, header) {
|
|||
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 suggested = preview?.suggestedMapping?.[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 mappedCount = mappingFields.filter(field => mapping[field]).length;
|
||||
const hasSuggestedMapping = Object.values(preview?.suggestedMapping || {}).some(Boolean);
|
||||
|
|
@ -227,7 +291,7 @@ function CsvMappingReview({ preview, fields, mapping, onMappingChange, onUseSugg
|
|||
<CsvMappingRow
|
||||
key={field}
|
||||
field={field}
|
||||
label={fields[field]}
|
||||
label={fields[field] ?? field}
|
||||
preview={preview}
|
||||
mapping={mapping}
|
||||
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 sampleRows = preview?.sampleRows || [];
|
||||
const visibleHeaders = headers.slice(0, 8);
|
||||
|
|
@ -267,7 +331,7 @@ function CsvSampleTable({ preview }) {
|
|||
<tr key={index} className="hover:bg-muted/20">
|
||||
{visibleHeaders.map(header => (
|
||||
<td key={header} className="max-w-48 truncate px-3 py-2 text-muted-foreground">
|
||||
{row[header] || '—'}
|
||||
{(row[header] as ReactNode) || '—'}
|
||||
</td>
|
||||
))}
|
||||
{hiddenCount > 0 && (
|
||||
|
|
@ -281,18 +345,21 @@ function CsvSampleTable({ preview }) {
|
|||
);
|
||||
}
|
||||
|
||||
function formatCsvRowDetail(detail) {
|
||||
function formatCsvRowDetail(detail: CsvRowDetail | null | undefined): string {
|
||||
if (!detail) return '';
|
||||
const field = detail.field ? `${detail.field}: ` : '';
|
||||
return `${field}${detail.message || detail.value || JSON.stringify(detail)}`;
|
||||
}
|
||||
|
||||
export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProps = {} }) {
|
||||
const fileRef = useRef(null);
|
||||
const [file, setFile] = useState(null);
|
||||
const [preview, setPreview] = useState({ status: 'idle', data: null, error: null });
|
||||
const [mapping, setMapping] = useState({});
|
||||
const [commitState, setCommitState] = useState({ status: 'idle', result: null, error: null });
|
||||
export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProps = {} }: {
|
||||
onHistoryRefresh?: () => void;
|
||||
cardProps?: Partial<SectionCardProps>;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(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 = () => {
|
||||
setFile(null);
|
||||
|
|
@ -302,7 +369,7 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProp
|
|||
if (fileRef.current) fileRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleMappingChange = (field, header) => {
|
||||
const handleMappingChange = (field: string, header: string) => {
|
||||
if (commitState.status === 'done') return;
|
||||
setMapping(prev => {
|
||||
const next = { ...prev };
|
||||
|
|
@ -322,7 +389,7 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProp
|
|||
setMapping({});
|
||||
setCommitState({ status: 'idle', result: null, error: null });
|
||||
try {
|
||||
const data = await api.previewCsvTransactionImport(file);
|
||||
const data = await api.previewCsvTransactionImport(file) as CsvPreviewData;
|
||||
setPreview({ status: 'ready', data, error: null });
|
||||
setMapping(compactMapping(data.suggestedMapping || {}));
|
||||
toast.success('CSV preview ready.');
|
||||
|
|
@ -340,7 +407,7 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProp
|
|||
const result = await api.commitCsvTransactionImport({
|
||||
import_session_id: preview.data.import_session_id,
|
||||
mapping: compactMapping(mapping),
|
||||
});
|
||||
}) as CsvCommitResult;
|
||||
setCommitState({ status: 'done', result, error: null });
|
||||
toast.success(`CSV imported — ${result.imported} imported, ${result.skipped} skipped.`);
|
||||
onHistoryRefresh?.();
|
||||
|
|
@ -365,7 +432,7 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProp
|
|||
|
||||
const fields = preview.data?.fields || {};
|
||||
const canCommit = preview.status === 'ready'
|
||||
&& preview.data?.import_session_id
|
||||
&& !!preview.data?.import_session_id
|
||||
&& canCommitCsvMapping(mapping)
|
||||
&& commitState.status !== 'loading'
|
||||
&& 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">
|
||||
<AlertTriangle className="mr-1.5 inline h-4 w-4" />
|
||||
{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">
|
||||
{preview.error.details.map((d, i) => (
|
||||
<li key={i}>{d.message || JSON.stringify(d)}</li>
|
||||
))}
|
||||
{preview.error?.details.map((d, i) => {
|
||||
const dd = d as { message?: string };
|
||||
return <li key={i}>{dd.message || JSON.stringify(d)}</li>;
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -452,11 +520,11 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProp
|
|||
</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">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-amber-600 dark:text-amber-400">Review mapping</p>
|
||||
<ul className="mt-2 space-y-1 text-xs text-amber-700 dark:text-amber-300">
|
||||
{preview.data.errors.map((issue, i) => (
|
||||
{preview.data.errors?.map((issue, i) => (
|
||||
<li key={i} className="flex items-start gap-1.5">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{issue.message || JSON.stringify(issue)}</span>
|
||||
|
|
@ -533,9 +601,9 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProp
|
|||
{failedRows.map((row, index) => (
|
||||
<li key={`${row.row}-${index}`} className="rounded border border-destructive/10 bg-destructive/5 px-2 py-1.5">
|
||||
<p>Row {row.row}: {row.message}</p>
|
||||
{row.details?.length > 0 && (
|
||||
{(row.details?.length ?? 0) > 0 && (
|
||||
<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>
|
||||
))}
|
||||
</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">
|
||||
<AlertTriangle className="mr-1.5 inline h-4 w-4" />
|
||||
{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">
|
||||
{commitState.error.details.map((d, i) => (
|
||||
<li key={i}>{d.message || JSON.stringify(d)}</li>
|
||||
))}
|
||||
{commitState.error?.details.map((d, i) => {
|
||||
const dd = d as { message?: string };
|
||||
return <li key={i}>{dd.message || JSON.stringify(d)}</li>;
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -565,5 +634,3 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProp
|
|||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section 3: Import My Data Export ────────────────────────────────────────
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Sparkles, CheckCircle2, Loader2, RefreshCw, Link2, Link2Off,
|
||||
XCircle, Eye, EyeOff, Search, Clock, ChevronLeft, ChevronRight,
|
||||
ArrowUp, ArrowDown, ArrowUpDown,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api, type QueryParams } from '@/api';
|
||||
import { cn, errMessage } from '@/lib/utils';
|
||||
import { asDollars } from '@/lib/money';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SectionCard } from './dataShared';
|
||||
import { SectionCard, type SectionCardProps } from './dataShared';
|
||||
import BillModal from '@/components/BillModal';
|
||||
import {
|
||||
MatchBillDialog,
|
||||
|
|
@ -17,8 +18,25 @@ import {
|
|||
transactionDate,
|
||||
formatTransactionAmount,
|
||||
} 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: 'unmatched', label: 'Unmatched', params: { match_status: 'unmatched', 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' } },
|
||||
];
|
||||
|
||||
function transactionStatus(tx) {
|
||||
function transactionStatus(tx: BankTransaction | null | undefined): string {
|
||||
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 styles = {
|
||||
const styles: Record<string, string> = {
|
||||
matched: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600',
|
||||
ignored: 'border-muted-foreground/30 bg-muted/40 text-muted-foreground',
|
||||
unmatched: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400',
|
||||
|
|
@ -49,14 +67,22 @@ function TransactionStatusBadge({ tx }) {
|
|||
);
|
||||
}
|
||||
|
||||
function matchScoreTone(score) {
|
||||
function matchScoreTone(score?: number | string): string {
|
||||
const value = Number(score) || 0;
|
||||
if (value >= 80) return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
|
||||
if (value >= 55) return 'border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400';
|
||||
return 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400';
|
||||
}
|
||||
|
||||
function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onReject }) {
|
||||
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 (
|
||||
<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">
|
||||
|
|
@ -83,8 +109,8 @@ function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onRej
|
|||
) : (
|
||||
<div className="grid gap-2 p-3 xl:grid-cols-2">
|
||||
{suggestions.map(suggestion => {
|
||||
const tx = suggestion.transaction || {};
|
||||
const bill = suggestion.bill || {};
|
||||
const tx = suggestion.transaction || ({} as BankTransaction);
|
||||
const bill = suggestion.bill || ({} as Bill);
|
||||
const acceptBusy = actionId === `suggestion-match:${suggestion.id}`;
|
||||
const rejectBusy = actionId === `suggestion-reject:${suggestion.id}`;
|
||||
const busy = acceptBusy || rejectBusy;
|
||||
|
|
@ -110,7 +136,7 @@ function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onRej
|
|||
'shrink-0 text-sm font-semibold tabular-nums',
|
||||
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
|
||||
)}>
|
||||
{formatTransactionAmount(tx.amount, tx.currency)}
|
||||
{formatTransactionAmount(tx.amount, tx.currency ?? undefined)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -124,9 +150,9 @@ function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onRej
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{suggestion.reasons?.length > 0 && (
|
||||
{(suggestion.reasons?.length ?? 0) > 0 && (
|
||||
<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">
|
||||
{reason}
|
||||
</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;
|
||||
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
|
||||
return new Date(normalized);
|
||||
}
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return null;
|
||||
const secs = Math.floor((Date.now() - parseUtc(iso).getTime()) / 1000);
|
||||
function timeAgo(iso: string | null | undefined): string | null {
|
||||
const d = parseUtc(iso);
|
||||
if (!d) return null;
|
||||
const secs = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (secs < 60) return 'just now';
|
||||
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
|
||||
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
|
||||
return `${Math.floor(secs / 86400)}d ago`;
|
||||
}
|
||||
|
||||
export default function TransactionMatchingSection({ refreshKey, simplefinConn, cardProps = {} }) {
|
||||
const [transactions, setTransactions] = useState([]);
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [bills, setBills] = useState([]);
|
||||
interface TransactionMatchingSectionProps {
|
||||
refreshKey?: number | string;
|
||||
simplefinConn?: SimplefinConn | null;
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [suggestionsLoading, setSuggestionsLoading] = 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 [matchTransaction, setMatchTransaction] = useState(null);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [createBillSourceTx, setCreateBillSourceTx] = useState(null);
|
||||
const [matchTransaction, setMatchTransaction] = useState<BankTransaction | null>(null);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [createBillSourceTx, setCreateBillSourceTx] = useState<{ tx: BankTransaction; initialBill: Partial<Bill> } | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortBy, setSortBy] = useState('date');
|
||||
const [sortDir, setSortDir] = useState('desc');
|
||||
const searchTimerRef = useRef(null);
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
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 q = searchOverride !== undefined ? searchOverride : search;
|
||||
const sb = sortByOverride !== undefined ? sortByOverride : sortBy;
|
||||
const sd = sortDirOverride !== undefined ? sortDirOverride : sortDir;
|
||||
setLoading(true);
|
||||
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;
|
||||
const resp = await api.transactions(params);
|
||||
const resp = await api.transactions(params) as { transactions?: BankTransaction[]; total?: number };
|
||||
setTransactions(resp.transactions || []);
|
||||
setTotalCount(resp.total || 0);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to load transactions.');
|
||||
toast.error(errMessage(err, 'Failed to load transactions.'));
|
||||
setTransactions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -228,10 +261,10 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
|
|||
const loadSuggestions = async () => {
|
||||
setSuggestionsLoading(true);
|
||||
try {
|
||||
const data = await api.matchSuggestions({ limit: 8 });
|
||||
const data = await api.matchSuggestions({ limit: 8 }) as MatchSuggestion[];
|
||||
setSuggestions(data || []);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to load match suggestions.');
|
||||
toast.error(errMessage(err, 'Failed to load match suggestions.'));
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setSuggestionsLoading(false);
|
||||
|
|
@ -245,41 +278,41 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
|
|||
const loadBills = async () => {
|
||||
setBillsLoading(true);
|
||||
try {
|
||||
const data = await api.bills();
|
||||
const data = await api.bills() as Bill[];
|
||||
setBills(data || []);
|
||||
} catch (err) {
|
||||
setBills([]);
|
||||
toast.error(err.message || 'Failed to load bills for matching.');
|
||||
toast.error(errMessage(err, 'Failed to load bills for matching.'));
|
||||
} finally {
|
||||
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.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { setSearch(''); setPage(1); loadTransactions(1, ''); }, [filter, refreshKey]);
|
||||
useEffect(() => { loadSuggestions(); }, [refreshKey]);
|
||||
useEffect(() => { loadSuggestions(); }, [refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
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);
|
||||
loadTransactions(newPage);
|
||||
};
|
||||
|
||||
const handleSearchChange = (e) => {
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setSearch(value);
|
||||
clearTimeout(searchTimerRef.current);
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
setPage(1);
|
||||
loadTransactions(1, value);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleSortClick = (column) => {
|
||||
const handleSortClick = (column: string) => {
|
||||
const newDir = sortBy === column && sortDir === 'desc' ? 'asc' : 'desc';
|
||||
setSortBy(column);
|
||||
setSortDir(newDir);
|
||||
|
|
@ -287,13 +320,14 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
|
|||
loadTransactions(1, undefined, column, newDir);
|
||||
};
|
||||
|
||||
const openMatchDialog = (tx) => {
|
||||
const openMatchDialog = (tx: BankTransaction) => {
|
||||
setMatchTransaction(tx);
|
||||
setMatchOpen(true);
|
||||
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 dateStr = transactionDate(tx);
|
||||
const day = dateStr ? parseInt(dateStr.slice(8, 10), 10) : 1;
|
||||
|
|
@ -301,7 +335,7 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
|
|||
tx,
|
||||
initialBill: {
|
||||
name: nameOverride || transactionTitle(tx),
|
||||
expected_amount: amount || 0,
|
||||
expected_amount: asDollars(amount || 0),
|
||||
due_day: day >= 1 && day <= 31 ? day : 1,
|
||||
billing_cycle: '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;
|
||||
setCreateBillSourceTx(null);
|
||||
if (tx && newBill?.id) {
|
||||
|
|
@ -319,13 +353,13 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
|
|||
await api.matchTransaction(tx.id, newBill.id);
|
||||
toast.success('Bill created and matched to transaction.');
|
||||
} 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()]);
|
||||
};
|
||||
|
||||
const runTransactionAction = async (tx, action) => {
|
||||
const runTransactionAction = async (tx: BankTransaction, action: string) => {
|
||||
setActionId(`${action}:${tx.id}`);
|
||||
try {
|
||||
if (action === 'unmatch') {
|
||||
|
|
@ -340,13 +374,13 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
|
|||
}
|
||||
await refreshTransactionWorkbench();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Transaction action failed.');
|
||||
toast.error(errMessage(err, 'Transaction action failed.'));
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmMatch = async (billId) => {
|
||||
const confirmMatch = async (billId: number | string) => {
|
||||
if (!matchTransaction) return;
|
||||
setActionId(`match:${matchTransaction.id}`);
|
||||
try {
|
||||
|
|
@ -356,33 +390,33 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
|
|||
setMatchTransaction(null);
|
||||
await refreshTransactionWorkbench();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Transaction match failed.');
|
||||
toast.error(errMessage(err, 'Transaction match failed.'));
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const acceptSuggestion = async (suggestion) => {
|
||||
const acceptSuggestion = async (suggestion: MatchSuggestion) => {
|
||||
setActionId(`suggestion-match:${suggestion.id}`);
|
||||
try {
|
||||
await api.matchTransaction(suggestion.transactionId, suggestion.billId);
|
||||
toast.success('Suggested match confirmed.');
|
||||
await refreshTransactionWorkbench();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Suggested match failed.');
|
||||
toast.error(errMessage(err, 'Suggested match failed.'));
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const rejectSuggestion = async (suggestion) => {
|
||||
const rejectSuggestion = async (suggestion: MatchSuggestion) => {
|
||||
setActionId(`suggestion-reject:${suggestion.id}`);
|
||||
try {
|
||||
await api.rejectMatchSuggestion(suggestion.id);
|
||||
toast.success('Suggestion rejected.');
|
||||
await loadSuggestions();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Suggestion could not be rejected.');
|
||||
toast.error(errMessage(err, 'Suggestion could not be rejected.'));
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
|
|
@ -397,7 +431,7 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
|
|||
await api.syncDataSource(simplefinConn.id);
|
||||
await refreshTransactionWorkbench();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Sync failed');
|
||||
toast.error(errMessage(err, 'Sync failed'));
|
||||
} finally {
|
||||
setQuickSyncing(false);
|
||||
}
|
||||
|
|
@ -559,8 +593,8 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
|
|||
) : null}
|
||||
</div>
|
||||
{tx.matched_bill_name ? (
|
||||
<span className="truncate text-xs text-foreground">{tx.matched_bill_name}</span>
|
||||
) : tx.advisory_filter?.confidence === 'high' ? (
|
||||
<span className="truncate text-xs text-foreground">{tx.matched_bill_name as string}</span>
|
||||
) : (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">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',
|
||||
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
|
||||
)}>
|
||||
{formatTransactionAmount(tx.amount, tx.currency)}
|
||||
{formatTransactionAmount(tx.amount, tx.currency ?? undefined)}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex justify-end gap-1.5 whitespace-nowrap">
|
||||
Loading…
Reference in New Issue