'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. The * right pane follows the left: with exactly ONE bank row picked it is * multi-select (one row settling several verifikat, the 1:N split of #1553); * with several rows picked it is single-select (N rows settling one * verifikat). The footer sums both sides; Koppla is enabled only when the * selection nets to zero, because the engine links N rows to ONE verifikat, * ONE row to N verifikat with slices summing to the row, and (for the * skattekonto) refuses a group whose sum the verifikat does not settle. A * non-zero difference is shown, not hidden: for the N:1 shape the residual * can be booked in the same gesture; a split must close exactly. */ 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 [pickedEntries, setPickedEntries] = useState>(new Set()) 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 entries = useMemo( () => (ledger ?? []).filter((i) => pickedEntries.has(i.item_id)), [ledger, pickedEntries], ) const entry = entries.length === 1 ? entries[0] : null const ledgerSum = roundOre(entries.reduce((s, i) => s + i.amount, 0)) const difference = roundOre(externalSum - ledgerSum) // One bank row may settle several verifikat; several rows settle one. // N:M has no engine shape and the right pane never lets it be built. const splitMode = !isSkv && pickedExternal.size === 1 const isSplit = entries.length > 1 const canLink = pickedExternal.size > 0 && entries.length > 0 && !(pickedExternal.size > 1 && isSplit) && 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) // Leaving the one-row shape with several verifikat picked would build // an N:M selection: drop the verifikat side, the user re-picks one. if (next.size !== 1) { setPickedEntries((picked) => (picked.size > 1 ? new Set() : picked)) } return next }) } function toggleEntry(id: string) { setPickedEntries((prev) => { if (!splitMode) return prev.has(id) ? new Set() : new Set([id]) const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } async function link() { if (entries.length === 0 || pickedExternal.size === 0) return setBusy(true) try { // The split sends no allocations: each slice defaults to the voucher's // bank line and the engine refuses the set unless the slices sum to // the row, which is exactly the difference-is-zero gate above. const res = await fetch(`${base}/links`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pairs: [{ external_ids: [...pickedExternal], journal_entry_ids: entries.map((e) => e.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 if (isSplit) { toast({ title: t('toast_split_matched', { count: applied }) }) } else { toast({ title: skipped.length > 0 ? t('toast_matched_skipped', { applied, skipped: skipped.length }) : t('toast_matched', { applied }), }) } setPickedExternal(new Set()) setPickedEntries(new Set()) 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()) setPickedEntries(new Set()) 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 for N:1, multi-select while exactly one bank row is picked (1:N). */}

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

{splitMode && ledger.length > 0 && (

{t('match_pick_vouchers')}

)} {ledger.length === 0 ? (

{t('match_empty_right')}

) : ( {ledger.map((item) => { const picked = pickedEntries.has(item.item_id) return ( toggleEntry(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')}
{splitMode ? ( toggleEntry(item.item_id)} onClick={(e) => e.stopPropagation()} aria-label={item.description} /> ) : ( toggleEntry(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) })} {isSplit ? t('match_selected_entries', { count: entries.length, amount: formatCurrency(ledgerSum, currency) }) : entry ? t('match_selected_entry', { amount: formatCurrency(ledgerSum, currency) }) : t('match_no_entry')} = 0.005 && pickedExternal.size > 0 && entries.length > 0 ? 'text-warning' : 'text-muted-foreground')} data-ph-mask > {t('match_difference', { amount: formatCurrency(difference, currency) })} {/* A split has no residual: the slices must sum to the row exactly (the engine refuses anything else), so the footer says so instead of offering a fee/interest booking against several verifikat. */} {Math.abs(difference) >= 0.005 && pickedExternal.size > 0 && isSplit && ( {t('match_hint_split')} )} {Math.abs(difference) >= 0.005 && pickedExternal.size > 0 && entry && ( isSkv ? ( {t('match_hint_residual_skv')} ) : ( ) )}
) }