'use client' import { useEffect, useState } from 'react' import { useTranslations } from 'next-intl' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import type { StoredSkattekontoTransaction } from '@/types/skatteverket' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' interface MatchCandidate { journal_entry_id: string voucher_number: number | null voucher_series: string | null entry_date: string description: string status: 'draft' | 'posted' | 'reversed' matched_amount: number matched_side: 'debit' | 'credit' } /** * Shared dialog for linking a skattekonto_transactions row to an existing * journal entry. Used by both /skattekonto and /transactions so we don't * have two copies of the same dialog drifting apart. * * The dialog owns its own data fetch: pass the row + open flag and it * handles the rest. On successful match it calls onMatched(), letting the * caller refresh its data. */ export function SkattekontoMatchDialog({ row, open, onClose, onMatched, }: { row: StoredSkattekontoTransaction | null open: boolean onClose: () => void onMatched: () => void }) { const t = useTranslations('tx_skattekonto_match') const { toast } = useToast() const [candidates, setCandidates] = useState(null) const [loading, setLoading] = useState(false) const [submittingId, setSubmittingId] = useState(null) useEffect(() => { if (!open || !row) { setCandidates(null) return } let cancelled = false setLoading(true) ;(async () => { try { const res = await fetch( `/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/match-candidates`, ) const json = await res.json() if (cancelled) return if (!res.ok) { // Map the parsed body plus the status, never `new Error(json.error)`: // the Error constructor stringifies a non-string body field, and the // mapper would discard the route's own Swedish reason. toast({ title: t('fetch_candidates_failed_title'), description: getUserErrorMessage(json, { statusCode: res.status }), variant: 'destructive', }) onClose() return } setCandidates(json.data.candidates as MatchCandidate[]) } catch (err) { if (cancelled) return toast({ title: t('fetch_candidates_failed_title'), description: err instanceof Error ? getUserErrorMessage(err) : undefined, variant: 'destructive', }) onClose() } finally { if (!cancelled) setLoading(false) } })() return () => { cancelled = true } }, [open, row, toast, onClose, t]) async function confirmMatch(journalEntryId: string) { if (!row) return setSubmittingId(journalEntryId) try { const res = await fetch( `/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/match`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ journal_entry_id: journalEntryId }), }, ) const json = await res.json() if (!res.ok) { toast({ title: t('match_failed_title'), description: getUserErrorMessage(json, { statusCode: res.status }), variant: 'destructive', }) return } toast({ title: t('match_success_title') }) onMatched() onClose() } catch (err) { toast({ title: t('match_failed_title'), description: err instanceof Error ? getUserErrorMessage(err) : undefined, variant: 'destructive', }) } finally { setSubmittingId(null) } } return ( !o && onClose()}> {t('title')} {row && ( <> {formatDate(row.transaktionsdatum)} • {row.transaktionstext} •{' '} {formatCurrency(Number(row.belopp_skatteverket))} )} {loading && (

{t('searching')}

)} {!loading && candidates && candidates.length === 0 && (

{t('no_candidates_title')}

{t.rich('no_candidates_help', { strong: (chunks) => {chunks}, })}

)} {!loading && candidates && candidates.length > 0 && (
{t('th_date')} {t('th_voucher')} {t('th_description')} {t('th_status')} {candidates.map(c => ( {formatDate(c.entry_date)} {formatVoucher(c)} {c.description} {c.status === 'posted' ? ( {t('status_posted')} ) : c.status === 'draft' ? ( {t('status_draft')} ) : ( {t('status_reversed')} )} ))}
)}
) }