diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 93fc9875..0d9d560f 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -38,7 +38,7 @@ import { MessageCircle, } from 'lucide-react' import Link from 'next/link' -import { cn, formatCurrency } from '@/lib/utils' +import { cn, formatCurrency, formatDate } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' import { fetchWithTimeout } from '@/lib/http/fetch-with-timeout' import { copyInboxAddress, type AddressCopyState } from '@/components/extensions/general/inbox-address-copy' @@ -1764,6 +1764,172 @@ function EmptyPreview({ ) } +// ── Proposed kontering ─────────────────────────────────────── + +/** + * What this underlag would be booked as, for a matched transaction. + * + * Read-only on purpose. Per convention 14 nothing AI-suggested posts without + * review, so this shows the answer and the existing booking dialog remains the + * only way to commit it. The lines come from the same builder the commit path + * uses, so what is shown here is what would be posted. + * + * The route answers honestly when it cannot propose: a company with no rule and + * no history, a foreign-currency row the engine would mis-VAT, a transaction + * that already carries a verifikat. Each of those renders as a sentence rather + * than as an empty table, because "we have nothing for this" is information. + */ +type SuggestedBooking = { + source: + | 'mapping_rule' + | 'booking_template' + | 'counterparty_template' + | 'no_mapping' + | 'no_transaction' + | 'already_booked' + | 'currency_unsupported' + lines: { account_number: string; debit_amount: number; credit_amount: number; description: string }[] + confidence: number | null + requires_review?: boolean + direction_mismatch?: boolean + description?: string + rule_name?: string | null + entry_date?: string +} + +const SUGGESTION_SOURCE_LABEL: Record = { + counterparty_template: 'Så du brukar bokföra den här leverantören', + booking_template: 'Från en bokföringsmall', + mapping_rule: 'Från en konteringsregel', +} + +/** Why there is no proposal, said plainly rather than shown as an empty table. */ +const SUGGESTION_EMPTY_REASON: Record = { + no_mapping: + 'Vi har inget förslag: leverantören är obekant och ingen regel matchar. Bokför manuellt en gång, så känns den igen nästa gång.', + currency_unsupported: + 'Köpet är i utländsk valuta och matchades av en konteringsregel. Momsen skulle bli fel, så vi visar inget förslag.', +} + +function ProposedBooking({ itemId }: { itemId: string }) { + const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading') + const [data, setData] = useState(null) + + useEffect(() => { + // Arrowing down a list fires one of these per row. Without the abort the + // superseded requests still run to completion server-side, and a slow one + // can resolve after a faster later one. + const controller = new AbortController() + let cancelled = false + setState('loading') + setData(null) + fetch(`/api/extensions/ext/invoice-inbox/items/${itemId}/suggest-booking`, { + method: 'POST', + signal: controller.signal, + }) + .then(async (res) => { + if (!res.ok) throw new Error(String(res.status)) + return (await res.json()) as { data: SuggestedBooking } + }) + .then((json) => { + if (cancelled) return + setData(json.data) + setState('ready') + }) + .catch(() => { + // A suggestion that cannot be fetched is not something the user did: + // stay quiet rather than showing an error beside their document. + if (!cancelled) setState('failed') + }) + return () => { + cancelled = true + controller.abort() + } + }, [itemId]) + + if (state === 'loading') return + if (state === 'failed' || !data) return null + if (data.source === 'already_booked' || data.source === 'no_transaction') return null + + if (data.lines.length === 0) { + const reason = SUGGESTION_EMPTY_REASON[data.source] + return reason ?

{reason}

: null + } + + const debit = data.lines.reduce((t, l) => t + (l.debit_amount || 0), 0) + const credit = data.lines.reduce((t, l) => t + (l.credit_amount || 0), 0) + const balanced = Math.round((debit - credit) * 100) === 0 + + return ( +
+
+

Föreslagen kontering

+ {data.entry_date && ( + + Bokförs {formatDate(data.entry_date)} + + )} +
+ + + + + + + + + + + {data.lines.map((l, i) => ( + + + + + + + ))} + +
+ Konto + DebetKredit
{l.account_number} + {l.description} + + {l.debit_amount ? formatCurrency(l.debit_amount) : ''} + + {l.credit_amount ? formatCurrency(l.credit_amount) : ''} +
+ + {/* Balance is stated rather than assumed: the builder balances by + construction, so a mismatch here means something upstream is wrong + and the user should see it before booking. */} + {!balanced && ( +

+ Debet {formatCurrency(debit)} · Kredit {formatCurrency(credit)} +

+ )} + + {(SUGGESTION_SOURCE_LABEL[data.source] || data.requires_review || data.direction_mismatch) && ( +
+ Varför så här? +
+ {SUGGESTION_SOURCE_LABEL[data.source] &&

{SUGGESTION_SOURCE_LABEL[data.source]}

} + {data.rule_name &&

Regel: {data.rule_name}

} + {data.direction_mismatch && ( +

+ Beloppets riktning stämmer inte med hur leverantören brukar bokföras. Kontrollera innan du + bokför. +

+ )} + {data.requires_review && !data.direction_mismatch && ( +

Förslaget är osäkert och bör granskas innan du bokför.

+ )} +
+
+ )} +
+ ) +} + // ── Fields rail ────────────────────────────────────────────── function FieldsRail({ @@ -2111,6 +2277,10 @@ function FieldsRail({ Öppna transaktionen → + + {/* The answer the rail never gave: what this would be booked as. + Read-only; booking still goes through the dialog below. */} + {onAskAssistant && (