'use client' import { useCallback, useEffect, useMemo, useState } from 'react' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Loader2, Search, Sparkles, Check } from 'lucide-react' import { formatCurrency, formatDate, cn } from '@/lib/utils' import type { AIProposal, MatchProposalPayload } from '@/types' type ChangeSource = 'user_alternative' | 'user_manual' | 'ai_regenerated' interface ChangeTransactionDialogProps { open: boolean onOpenChange: (open: boolean) => void proposal: AIProposal receiptTotal: number | null receiptDate: string | null onChanged: () => void } interface PickerTx { id: string date: string description: string | null amount: number currency: string | null merchant_name: string | null } export default function ChangeTransactionDialog({ open, onOpenChange, proposal, receiptTotal, receiptDate, onChanged, }: ChangeTransactionDialogProps) { const payload = proposal.proposal_json as MatchProposalPayload const alternatives = payload.alternatives ?? [] const [selected, setSelected] = useState<{ id: string; source: ChangeSource } | null>(null) const [saving, setSaving] = useState(false) const [showAll, setShowAll] = useState(alternatives.length === 0) const [search, setSearch] = useState('') const [allTx, setAllTx] = useState([]) const [alternativeTx, setAlternativeTx] = useState>({}) const [loadingList, setLoadingList] = useState(false) const [error, setError] = useState(null) // Reset when dialog opens/closes or proposal changes. useEffect(() => { if (!open) { setSelected(null) setSearch('') setError(null) } }, [open, proposal.id]) // Fetch human-readable context for the AI's alternatives so the user // can compare description/amount/date, not bare UUIDs. useEffect(() => { if (!open || alternatives.length === 0) return const ids = alternatives.map((a) => a.transaction_id) fetch(`/api/transactions/uncategorized?limit=50&offset=0`) .then((r) => r.json()) .then((body) => { const list: PickerTx[] = body?.data?.transactions ?? [] const map: Record = {} for (const tx of list) if (ids.includes(tx.id)) map[tx.id] = tx setAlternativeTx(map) }) .catch(() => { /* alternatives still show with reasoning text */ }) }, [open, alternatives]) // Fetch the full picker list when the user opens "Visa alla". const loadAllTransactions = useCallback(async () => { setLoadingList(true) setError(null) try { const params = new URLSearchParams() params.set('limit', '30') if (search) params.set('search', search) if (receiptTotal) { params.set('amount_center', String(-Math.abs(receiptTotal))) params.set('amount_window', String(Math.max(5, Math.abs(receiptTotal) * 0.1))) } if (receiptDate) { params.set('date_center', receiptDate) params.set('date_window', '60') } const res = await fetch(`/api/transactions/uncategorized?${params.toString()}`) const body = await res.json() if (!res.ok) { setError(body?.error ?? 'Kunde inte hämta transaktioner') return } setAllTx(body?.data?.transactions ?? []) } catch { setError('Nätverksfel') } finally { setLoadingList(false) } }, [search, receiptTotal, receiptDate]) // Refetch whenever the "show all" pane is open and the search changes. useEffect(() => { if (!open || !showAll) return loadAllTransactions() }, [open, showAll, loadAllTransactions]) const handleConfirm = async () => { if (!selected) return setSaving(true) setError(null) try { const res = await fetch(`/api/ai/proposals/${proposal.id}/change-match`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: proposal.version, matched_transaction_id: selected.id, source: selected.source, }), }) const body = await res.json() if (!res.ok) { setError(body?.error ?? 'Kunde inte uppdatera förslaget') setSaving(false) return } onChanged() onOpenChange(false) } catch { setError('Nätverksfel') } finally { setSaving(false) } } const currentMatchId = payload.matched_transaction_id const alternativesWithContext = useMemo( () => alternatives.map((alt) => ({ ...alt, tx: alternativeTx[alt.transaction_id] ?? null, })), [alternatives, alternativeTx] ) return ( Byt transaktion Välj en annan transaktion att koppla kvittot till. Du kan välja bland AI:ns alternativ eller söka i alla okategoriserade transaktioner.
{/* AI alternatives */} {alternatives.length > 0 && (

AI:ns alternativ ({alternatives.length})

{alternativesWithContext.map((alt) => ( setSelected({ id: alt.transaction_id, source: 'user_alternative' }) } /> ))}
)} {/* Toggle manual picker */} {alternatives.length > 0 && !showAll && ( )} {/* Manual picker */} {showAll && (

Alla okategoriserade transaktioner

setSearch(e.target.value)} className="pl-8" />
{receiptTotal && (

Filtrerat på belopp runt {formatCurrency(-Math.abs(receiptTotal), 'SEK')} och datum runt{' '} {receiptDate ? formatDate(receiptDate) : '—'}. Rensa sökrutan för att se fler.

)} {loadingList ? (
) : allTx.length === 0 ? (

Inga matchande transaktioner

) : (
{allTx.map((tx) => ( setSelected({ id: tx.id, source: 'user_manual' })} /> ))}
)}
)}
{error && (

{error}

)}
) } function AlternativeRow({ tx, transactionId, confidence, reasoning, isCurrent, isSelected, onSelect, }: { tx: PickerTx | null transactionId: string confidence: number reasoning: string isCurrent: boolean isSelected: boolean onSelect: () => void }) { return ( ) } function PickerRow({ tx, isCurrent, isSelected, onSelect, }: { tx: PickerTx isCurrent: boolean isSelected: boolean onSelect: () => void }) { return ( ) }