'use client' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Skeleton } from '@/components/ui/skeleton' import { AttnLine } from '@/components/ui/attn-line' import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' import { useToast } from '@/components/ui/use-toast' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' import { roundOre } from '@/lib/money' import type { ReconciliationAccount, ReconciliationItem } from '@/lib/reconciliation/schemas' import type { ReconciliationWindow } from './AccountOverview' import type { ResidualKind } from '@/lib/reconciliation/residual' /** * "Matcha manuellt": the two-pane worksheet from the approved design. Left: * outside rows with no verifikat (bank transactions or skattekonto rows), * multi-select. Right: verifikat on the account with no outside row, * single-select. The footer sums both sides; Koppla is enabled only when the * selection nets to zero, because the engine links N rows to ONE verifikat * and (for the skattekonto) refuses a group whose sum the verifikat does not * settle. A non-zero difference is shown, not hidden: booking the residual * in the same gesture is the next step (6c), until then it says so. */ interface ManualMatchModeProps { account: ReconciliationAccount window: ReconciliationWindow onChanged: () => void } const LIMIT = 200 export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeProps) { const t = useTranslations('reconciliation') const { toast } = useToast() const [external, setExternal] = useState(null) const [ledger, setLedger] = useState(null) const [loadError, setLoadError] = useState(false) const [pickedExternal, setPickedExternal] = useState>(new Set()) const [pickedEntry, setPickedEntry] = useState(null) const [busy, setBusy] = useState(false) const [residualKind, setResidualKind] = useState('') const base = `/api/reconciliation/accounts/${encodeURIComponent(account.account_key)}` const isSkv = account.kind === 'skattekonto' const currency = account.currency const load = useCallback(async () => { try { const qs = new URLSearchParams({ date_from: window.from, date_to: window.to, limit: String(LIMIT) }) const [extRes, ledRes] = await Promise.all([ fetch(`${base}/items?bucket=unmatched_external&${qs.toString()}`), fetch(`${base}/items?bucket=unmatched_ledger&${qs.toString()}`), ]) setLoadError(false) if (!extRes.ok || !ledRes.ok) { setLoadError(true) return } const ext = (await extRes.json()).data as { items: ReconciliationItem[] } const led = (await ledRes.json()).data as { items: ReconciliationItem[] } setExternal(ext.items) setLedger(led.items) } catch { setLoadError(true) } }, [base, window.from, window.to]) useEffect(() => { void load() }, [load]) const externalSum = useMemo( () => roundOre((external ?? []).filter((i) => pickedExternal.has(i.item_id)).reduce((s, i) => s + i.amount, 0)), [external, pickedExternal], ) const entry = useMemo(() => (ledger ?? []).find((i) => i.item_id === pickedEntry) ?? null, [ledger, pickedEntry]) const ledgerSum = entry ? roundOre(entry.amount) : 0 const difference = roundOre(externalSum - ledgerSum) const canLink = pickedExternal.size > 0 && entry !== null && Math.abs(difference) < 0.005 && !busy function toggleExternal(id: string) { setPickedExternal((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } async function link() { if (!entry || pickedExternal.size === 0) return setBusy(true) try { const res = await fetch(`${base}/links`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pairs: [{ external_ids: [...pickedExternal], journal_entry_ids: [entry.item_id] }], }), }) const json = await res.json().catch(() => ({})) if (!res.ok) { toast({ title: t('toast_failed'), description: getUserErrorMessage(json, { statusCode: res.status }), variant: 'destructive' }) return } const applied = (json.data?.applied as unknown[] | undefined)?.length ?? 0 const skipped = (json.data?.skipped as Array<{ message: string }> | undefined) ?? [] if (applied === 0 && skipped.length > 0) { toast({ title: t('toast_failed'), description: skipped[0].message, variant: 'destructive' }) } else { toast({ title: skipped.length > 0 ? t('toast_matched_skipped', { applied, skipped: skipped.length }) : t('toast_matched', { applied }), }) } setPickedExternal(new Set()) setPickedEntry(null) await load() onChanged() } finally { setBusy(false) } } async function bookResidual() { if (!entry || pickedExternal.size === 0 || !residualKind) return setBusy(true) try { const res = await fetch(`${base}/residual`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ external_ids: [...pickedExternal], journal_entry_id: entry.item_id, kind: residualKind }), }) const json = await res.json().catch(() => ({})) if (!res.ok) { toast({ title: t('toast_failed'), description: getUserErrorMessage(json, { statusCode: res.status }), variant: 'destructive' }) return } toast({ title: t('toast_residual_booked', { amount: formatCurrency(Math.abs(Number(json.data?.residual_amount ?? 0)), currency) }) }) setPickedExternal(new Set()) setPickedEntry(null) setResidualKind('') await load() onChanged() } finally { setBusy(false) } } if (loadError) { return ( void load() }}>{t('load_failed')} ) } if (!external || !ledger) { return (
) } return (
{/* Left: outside rows, multi-select. */}

{t(isSkv ? 'bucket_unmatched_external_skv' : 'bucket_unmatched_external_bank')} {external.length}

{external.length === 0 ? (

{t('match_empty_left')}

) : ( {external.map((item) => { const picked = pickedExternal.has(item.item_id) return ( toggleExternal(item.item_id)} className={cn('cursor-pointer', picked ? 'bg-secondary/60' : 'hover:bg-muted/40')} > ) })}
{t('col_date')} {t('col_event')} {t('col_amount')}
toggleExternal(item.item_id)} onClick={(e) => e.stopPropagation()} aria-label={item.description} /> {formatDate(item.date)} {item.description} {formatCurrency(item.amount, currency)}
)}
{/* Right: verifikat without an outside row, single-select. */}

{t(isSkv ? 'bucket_unmatched_ledger_skv' : 'bucket_unmatched_ledger_bank')} {ledger.length}

{ledger.length === 0 ? (

{t('match_empty_right')}

) : ( {ledger.map((item) => { const picked = pickedEntry === item.item_id return ( setPickedEntry(picked ? null : item.item_id)} className={cn('cursor-pointer', picked ? 'bg-secondary/60' : 'hover:bg-muted/40')} > ) })}
{t('col_date')} {t('col_voucher')} {t('col_event')} {t('col_amount')}
setPickedEntry(item.item_id)} onClick={(e) => e.stopPropagation()} aria-label={item.description} className="h-3.5 w-3.5 accent-foreground" /> {formatDate(item.date)} {item.voucher_number != null ? formatVoucher({ voucher_series: item.voucher_series, voucher_number: item.voucher_number }) : item.item_id.slice(0, 8)} {item.description} {formatCurrency(item.amount, currency)}
)}
{/* Footer: the arithmetic of the selection, and the one button. */}
{t('match_selected_external', { count: pickedExternal.size, amount: formatCurrency(externalSum, currency) })} {entry ? t('match_selected_entry', { amount: formatCurrency(ledgerSum, currency) }) : t('match_no_entry')} = 0.005 && pickedExternal.size > 0 && entry ? 'text-warning' : 'text-muted-foreground')} data-ph-mask > {t('match_difference', { amount: formatCurrency(difference, currency) })} {Math.abs(difference) >= 0.005 && pickedExternal.size > 0 && entry && ( isSkv ? ( {t('match_hint_residual_skv')} ) : ( ) )}
) }