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 {
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 ────────────────────────────────────────
|
|
||||||
|
|
@ -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">
|
||||||
Loading…
Reference in New Issue