import { CategoryCombobox } from '@/components/shared/category-combobox'; import { AmountDisplay } from '@/components/ui/amount-display'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { preview } from '@/routes/ai/rule-suggestions'; import { type Category } from '@/types/category'; import { formatDate } from '@/utils/date'; import { __ } from '@/utils/i18n'; import axios from 'axios'; import { ChevronDown, Loader2, Plus, Sparkles, TextSearch, X, } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; export interface AiSuggestionValue { id: string; match_field: string; match_operator: string; match_token: string; } export interface AiSuggestion { id: string; confidence: number; group_size: number; sample_descriptions: string[]; proposed_category: { id: string; name: string } | null; new_category_name: string | null; new_category_direction: string | null; values: AiSuggestionValue[]; } export interface ValueDraft { field: string; operator: string; token: string; } export interface SuggestionDraft { include: boolean; categoryId: string | null; values: ValueDraft[]; } interface PreviewTransaction { id: string; description: string | null; amount: number; currency_code: string; transaction_date: string; } interface PreviewResponse { match_count: number; total_uncategorized: number; transactions: PreviewTransaction[]; } interface AiSuggestionCardProps { suggestion: AiSuggestion; draft: SuggestionDraft; categories: Category[]; onChange: (draft: SuggestionDraft) => void; } const FIELD_LABELS: Record = { description: 'Description', creditor_name: 'Payee', debtor_name: 'Sender', }; function operatorLabel(operator: string): string { return operator === 'equals' ? __('is') : __('contains'); } function conditionsFor(values: ValueDraft[]) { return values .filter((value) => value.token.trim() !== '') .map((value) => ({ match_field: value.field, match_operator: value.operator, match_token: value.token.trim(), })); } export function AiSuggestionCard({ suggestion, draft, categories, onChange, }: AiSuggestionCardProps) { const [expanded, setExpanded] = useState(false); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [previewData, setPreviewData] = useState( null, ); const conditions = useMemo( () => conditionsFor(draft.values), [draft.values], ); const conditionsKey = useMemo( () => JSON.stringify(conditions), [conditions], ); // The server already counted the original values; only refetch once the // user has edited them, so an untouched card costs no request. const initialKey = useRef(conditionsKey).current; useEffect(() => { if (conditionsKey === initialKey) { return; } if (conditions.length === 0) { setPreviewData((current) => ({ match_count: 0, total_uncategorized: current?.total_uncategorized ?? 0, transactions: [], })); return; } const handle = setTimeout(async () => { setLoading(true); try { const { data } = await axios.post( preview().url, { conditions }, ); setPreviewData(data); } finally { setLoading(false); } }, 400); return () => clearTimeout(handle); // eslint-disable-next-line react-hooks/exhaustive-deps }, [conditionsKey]); const matchCount = previewData?.match_count ?? suggestion.group_size; const selectedCategory = categories.find((c) => c.id === draft.categoryId); const categoryLabel = selectedCategory?.name ?? (suggestion.new_category_name ? __('New: :name', { name: suggestion.new_category_name }) : '—'); const valuesSummary = draft.values .filter((value) => value.token.trim() !== '') .map((value) => `“${value.token}”`) .join(` ${__('or')} `); const updateValue = (index: number, patch: Partial) => { onChange({ ...draft, values: draft.values.map((value, i) => i === index ? { ...value, ...patch } : value, ), }); }; const removeValue = (index: number) => { onChange({ ...draft, values: draft.values.filter((_, i) => i !== index), }); }; const addValue = () => { onChange({ ...draft, values: [ ...draft.values, { field: 'description', operator: 'contains', token: '' }, ], }); }; const openPreview = async () => { setOpen(true); if (previewData || conditions.length === 0) { return; } setLoading(true); try { const { data } = await axios.post(preview().url, { conditions, }); setPreviewData(data); } finally { setLoading(false); } }; return (
{/* Collapsed header: select + summary + expand toggle */}
onChange({ ...draft, include: checked === true }) } aria-label={__('Include this rule')} className="shrink-0" />
{/* Expanded body: edit values, category, preview */} {expanded && (
{draft.values.map((value, index) => (
updateValue(index, { token: event.target.value, }) } className="h-9 w-full" aria-label={__('Match text')} />
))}
onChange({ ...draft, categoryId: value }) } categories={categories} /> {!draft.categoryId && suggestion.new_category_name && ( {__('New: :name', { name: suggestion.new_category_name, })} )}
)} {__('Matching transactions')} {previewData ? __( ':count of :total uncategorized transactions match', { count: previewData.match_count, total: previewData.total_uncategorized, }, ) : __('Loading…')}
{loading ? (
{__('Loading…')}
) : ( {__('Date')} {__('Description')} {__('Amount')} {previewData?.transactions.map( (transaction) => ( {formatDate( transaction.transaction_date, 'd MMM yyyy', )} {transaction.description ?? '—'} ), )}
)}
); }