Files
accounted/components/reconciliation/AccountOverview.tsx
T
Mattsson 51b68afc87 fix(reconciliation): judge bank sign-off from the fiscal period start and show the refusal (#2200)
A user with a September-to-August fiscal year could not sign off 1930:
the dialog let them press Signera, the server refused, and the dialog
showed "Något gick fel. Försök igen."

Three defects, one flow:

- signOffAccount judged a bank account over the calendar year from
  1 January (the getAccountStatus default) while the page the signer
  looked at was scoped to the fiscal period. The sign-off now resolves
  the fiscal period covering through_date and judges from its start,
  for every caller (dashboard, v1, MCP, pending-operation executor).
- The dialog decided whether the "sign anyway" override was needed from
  the page tile, which can be scoped to a narrower range. It now
  previews the exact sign-off with dry_run on open and on every date
  change, and NOT_RECONCILED carries the unexplained amount in
  details so the warning can name it.
- The routes passed the refusal through getErrorMessage(), which did
  not know the sign-off codes and replaced the Swedish text with its
  generic fallback. The codes are now in the structured error registry
  with a thrown_message_sv flag: the mapper passes the thrower's text
  (dates, amounts) through verbatim and English users get message_en.


Claude-Session: https://claude.ai/code/session_01YDH3nqA8QtMA8WKTKZCMsA

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:11:42 +02:00

1050 lines
40 KiB
TypeScript

'use client'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
import { useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { AttnLine } from '@/components/ui/attn-line'
import { Skeleton } from '@/components/ui/skeleton'
import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, HOVER_REVEAL_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 type {
ReconciliationAccount,
ReconciliationItem,
ReconciliationItemBucket,
ReconciliationStatus,
} from '@/lib/reconciliation/schemas'
import type { SkattekontoBatchRowResult, SkattekontoTransactionWithSuggestion } from '@/types/skatteverket'
import { SignoffDialog, type SignoffPreviewResult, type SignoffSubmitInput } from './SignoffDialog'
import { ReconciliationUnderlag } from './ReconciliationUnderlag'
import { MatcherPreview, type MatcherMatch } from './MatcherPreview'
import { InfoTooltip } from '@/components/ui/info-tooltip'
const SkattekontoBookDialog = dynamic(
() => import('@/components/skattekonto/SkattekontoBookDialog'),
{ loading: DialogLoadingSkeleton },
)
/**
* The body of the Avstämning page for one selected account: the four tiles
* (outside, ledger, difference, unexplained), the bridge that explains the
* difference, the actions row, and the full-width banded table of the rows
* behind the bridge. Everything comes from the PR 2 dashboard routes; the
* same service functions feed the v1 API and the MCP tools, so what the page
* shows is what an agent sees.
*/
interface ItemsPayload {
items: ReconciliationItem[]
count: number
total_count: number
has_more: boolean
older_unmatched_count: number
}
const BUCKET_ORDER: ReconciliationItemBucket[] = [
'proposed',
'unmatched_external',
'unmatched_ledger',
'matched',
'ignored',
'upcoming',
]
/** Buckets that start folded: they explain the bridge but are not work. */
const FOLDED_BY_DEFAULT: ReadonlySet<ReconciliationItemBucket> = new Set(['matched', 'ignored'])
const ITEMS_LIMIT = 200
export interface ReconciliationWindow {
from: string
to: string
}
interface AccountOverviewProps {
account: ReconciliationAccount
/** The other bank accounts in the rail: targets for "Flytta till konto". */
otherBankAccounts?: ReconciliationAccount[]
/** The account rail. Rendered inside the summary grid so the items table below can span the full page width (the approved layout). */
rail: ReactNode
/** The selected period: scopes the bank bridge and the item windows; its end is the default sign-off date. */
window: ReconciliationWindow
/** Called after any write so the rail can refresh its status dots. */
onChanged: () => void
}
export function AccountOverview({ account, rail, otherBankAccounts = [], window, onChanged }: AccountOverviewProps) {
const t = useTranslations('reconciliation')
const locale = useLocale()
const { toast } = useToast()
const [status, setStatus] = useState<ReconciliationStatus | null>(null)
const [items, setItems] = useState<ItemsPayload | null>(null)
const [loadError, setLoadError] = useState(false)
const [busy, setBusy] = useState<string | null>(null)
const [unfolded, setUnfolded] = useState<Set<ReconciliationItemBucket>>(new Set())
const [bookRow, setBookRow] = useState<ReconciliationItem | null>(null)
const [signoffOpen, setSignoffOpen] = useState(false)
const [matcher, setMatcher] = useState<MatcherMatch[] | null>(null)
const searchParams = useSearchParams()
const autorunRequested = searchParams.get('autorun') === '1'
const autorunDone = useRef(false)
const isSkv = account.kind === 'skattekonto'
// Manual accounts have no rows to match or book: the body is the balance
// bridge (IB, movement, UB against a specification or the signer's
// underlag) and the sign-off.
const isManual = account.kind === 'manual'
const base = `/api/reconciliation/accounts/${encodeURIComponent(account.account_key)}`
const load = useCallback(async () => {
try {
const qs = new URLSearchParams({ date_from: window.from, date_to: window.to })
const [statusRes, itemsRes] = await Promise.all([
fetch(`${base}?${qs.toString()}`),
fetch(`${base}/items?limit=${ITEMS_LIMIT}&${qs.toString()}`),
])
setLoadError(false)
if (!statusRes.ok || !itemsRes.ok) {
setLoadError(true)
return
}
const statusJson = await statusRes.json()
const itemsJson = await itemsRes.json()
setStatus(statusJson.data as ReconciliationStatus)
setItems(itemsJson.data as ItemsPayload)
} catch {
setLoadError(true)
}
}, [base, window.from, window.to])
// The workspace keys this component on account_key, so a new account is a
// fresh mount: no state to reset here.
useEffect(() => {
void load()
}, [load])
const refresh = useCallback(async () => {
await load()
onChanged()
}, [load, onChanged])
const byBucket = useMemo(() => {
const map = new Map<ReconciliationItemBucket, ReconciliationItem[]>()
for (const b of BUCKET_ORDER) map.set(b, [])
for (const item of items?.items ?? []) map.get(item.bucket)?.push(item)
return map
}, [items])
const proposedCount = status?.counts.proposed ?? 0
const bookableIds = useMemo(
() =>
(byBucket.get('unmatched_external') ?? [])
.filter((i) => i.item_type === 'skattekonto_transaction' && i.actions.includes('book'))
.map((i) => i.item_id),
[byBucket],
)
// ---- writes -------------------------------------------------------------
async function postJson(url: string, body: unknown, method: 'POST' | 'DELETE' = 'POST') {
const res = await fetch(url, {
method,
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) {
toast({
title: t('toast_failed'),
description: getUserErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
return null
}
return json.data as Record<string, unknown>
}
async function matchProposals() {
setBusy('proposals')
try {
const data = await postJson(`${base}/links`, { use_proposals: true })
if (data) {
const applied = (data.applied as unknown[]).length
const skipped = (data.skipped as unknown[]).length
toast({
title: skipped > 0 ? t('toast_matched_skipped', { applied, skipped }) : t('toast_matched', { applied }),
})
await refresh()
}
} finally {
setBusy(null)
}
}
async function matchOne(item: ReconciliationItem) {
if (!item.proposal) return
setBusy(item.item_id)
try {
const data = await postJson(`${base}/links`, {
pairs: [{ external_ids: [item.item_id], journal_entry_ids: [item.proposal.journal_entry_id] }],
})
if (data) {
const skipped = data.skipped as Array<{ message: string }>
if (skipped.length > 0) {
toast({ title: t('toast_failed'), description: skipped[0].message, variant: 'destructive' })
} else {
toast({ title: t('toast_matched', { applied: 1 }) })
}
await refresh()
}
} finally {
setBusy(null)
}
}
async function unmatchOne(item: ReconciliationItem) {
setBusy(item.item_id)
try {
const data = await postJson(`${base}/links/${item.item_id}`, undefined, 'DELETE')
if (data) {
toast({ title: t('toast_unmatched') })
await refresh()
}
} finally {
setBusy(null)
}
}
async function setIgnored(item: ReconciliationItem, ignored: boolean) {
setBusy(item.item_id)
try {
const data = await postJson(`${base}/items/${item.item_id}/ignore`, { ignored })
if (data) {
toast({ title: ignored ? t('toast_ignored') : t('toast_unignored') })
await refresh()
}
} finally {
setBusy(null)
}
}
async function bookAll() {
if (bookableIds.length === 0) return
setBusy('book')
try {
const data = await postJson('/api/extensions/ext/skatteverket/skattekonto/transaktioner/bokfor-batch', {
ids: bookableIds,
})
if (data) {
const results = (data.results ?? []) as SkattekontoBatchRowResult[]
const ok = results.filter((r) => r.ok).length
const failed = results.length - ok
toast({
title: failed > 0 ? t('toast_book_partial', { ok, failed }) : t('toast_booked', { count: ok }),
variant: failed > 0 && ok === 0 ? 'destructive' : undefined,
})
await refresh()
}
} finally {
setBusy(null)
}
}
// A policy refusal carries a code and a Swedish reason written for the
// signer; show that reason as-is. The generic mapper does not recognise it
// and used to replace it with a "request contains invalid data" string.
function signoffRefusal(json: Record<string, unknown>, statusCode: number): string {
return typeof json.code === 'string' && typeof json.error === 'string' && json.error.trim()
? json.error
: getUserErrorMessage(json, { statusCode })
}
// The same call with dry_run: the server judges the exact sign-off (its own
// window, from the fiscal period start to the date) and the dialog shows
// that verdict instead of the page tile's number, which is scoped to the
// period or range picked above and can differ.
async function previewSignoff(input: SignoffSubmitInput): Promise<SignoffPreviewResult> {
const res = await fetch(`${base}/signoff`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...input, dry_run: true }),
})
const json = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (res.ok) {
const preview = (json.data as { would_sign?: { unexplained_difference?: number | null } } | undefined)?.would_sign
return { kind: 'ok', unexplained: preview?.unexplained_difference ?? null }
}
const code = typeof json.code === 'string' ? json.code : null
if (code === 'NOT_RECONCILED') {
const details = json.details as { unexplained_difference?: number | null } | undefined
return { kind: 'needs_force', unexplained: details?.unexplained_difference ?? null }
}
if (code === 'OUTSIDE_UNKNOWN') return { kind: 'needs_force', unexplained: null }
return { kind: 'blocked', message: signoffRefusal(json, res.status) }
}
async function submitSignoff(input: SignoffSubmitInput) {
const res = await fetch(`${base}/signoff`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
const json = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (!res.ok) return signoffRefusal(json, res.status)
setSignoffOpen(false)
toast({ title: t('toast_signed_off', { date: formatDate(input.through_date) }) })
await refresh()
return null
}
async function reopen(signoffId: string) {
setBusy('reopen')
try {
const data = await postJson(`${base}/signoff/${signoffId}/reopen`, {})
if (data) {
toast({ title: t('toast_reopened') })
await refresh()
}
} finally {
setBusy(null)
}
}
async function runMatcher() {
if (!status) return
setBusy('matcher')
try {
const data = await postJson('/api/reconciliation/bank/run', {
date_from: window.from,
date_to: window.to,
account_number: status.account_number,
dry_run: true,
})
if (data) setMatcher((data.matches ?? []) as MatcherMatch[])
} finally {
setBusy(null)
}
}
async function applyMatches(pairs: MatcherMatch[], strongOnly: boolean) {
if (!status || pairs.length === 0) return
setBusy('matcher')
try {
const data = await postJson('/api/reconciliation/bank/run', {
date_from: window.from,
date_to: window.to,
account_number: status.account_number,
dry_run: false,
selected_matches: pairs.map((m) => ({
transaction_id: m.transaction_id,
journal_entry_id: m.journal_entry_id,
})),
// Strong-only applies re-enforce the floor server-side, same as the
// old bank view; a single hand-picked weaker pair omits it.
...(strongOnly ? { confidence_threshold: 0.85 } : {}),
})
if (data) {
const applied = (data.applied as number) ?? 0
toast({ title: t('toast_matched', { applied }) })
const appliedKeys = new Set(pairs.map((m) => `${m.transaction_id}:${m.journal_entry_id}`))
setMatcher((prev) => (prev ? prev.filter((m) => !appliedKeys.has(`${m.transaction_id}:${m.journal_entry_id}`)) : prev))
await refresh()
}
} finally {
setBusy(null)
}
}
async function markOpeningBalance(item: ReconciliationItem) {
setBusy(item.item_id)
try {
const data = await postJson('/api/reconciliation/bank/mark-opening-balance', { journal_entry_id: item.item_id })
if (data !== null) {
toast({ title: t('toast_marked_ib') })
await refresh()
}
} finally {
setBusy(null)
}
}
async function moveToAccount(item: ReconciliationItem, target: ReconciliationAccount) {
setBusy(item.item_id)
try {
const res = await fetch(`/api/transactions/${item.item_id}/cash-account`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_number: target.account_number }),
})
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_moved', { account: `${target.name} (${target.account_number})` }) })
await refresh()
} finally {
setBusy(null)
}
}
// ?autorun=1 (the old bankavstämning deep link from the transactions inbox):
// run the matcher preview once the bridge is up, once per mount.
useEffect(() => {
if (!autorunRequested || autorunDone.current || isSkv || !status) return
autorunDone.current = true
void runMatcher()
// eslint-disable-next-line react-hooks/exhaustive-deps -- fire once when the status first loads
}, [autorunRequested, isSkv, status])
// ---- render -------------------------------------------------------------
if (loadError) {
return (
<div className="grid gap-8 lg:grid-cols-[220px_1fr]">
{rail}
<div className="min-w-0">
<AttnLine action={{ label: t('older_show'), onClick: () => void load() }}>
{t('load_failed')}
</AttnLine>
</div>
</div>
)
}
if (!status || !items) {
return (
<div className="grid gap-8 lg:grid-cols-[220px_1fr]" aria-busy>
{rail}
<div className="min-w-0 space-y-6">
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="bg-background p-4">
<Skeleton className="h-3 w-32" />
<Skeleton className="mt-3 h-6 w-28" />
</div>
))}
</div>
<Skeleton className="h-4 w-72" />
<Skeleton className="h-40 w-full" />
</div>
</div>
)
}
const currency = status.currency
const money = (n: number | null | undefined) => (n == null ? t('tile_unknown') : formatCurrency(n, currency))
const asOfDate = status.as_of.slice(0, 10)
const fetchedAt = isSkv ? status.skattekonto?.fetched_at : account.source.synced_at
const sourceLabel = isSkv ? t('source_skv') : t('source_bank')
const specification = isManual ? (status.manual?.specification ?? null) : null
const specificationLabel = specification ? (locale === 'en' ? specification.label_en : specification.label_sv) : null
const bankRaw = status.bank as { bank_transaction_inflow?: number; bank_transaction_outflow?: number; bank_transaction_count?: number } | null
const bankBreakdown =
!isSkv && bankRaw && typeof bankRaw.bank_transaction_inflow === 'number' && typeof bankRaw.bank_transaction_outflow === 'number'
? t('tile_bank_breakdown', {
inflow: formatCurrency(bankRaw.bank_transaction_inflow, currency),
outflow: formatCurrency(Math.abs(bankRaw.bank_transaction_outflow), currency),
count: bankRaw.bank_transaction_count ?? 0,
})
: null
const externalTile = isManual
? {
key: 'external',
label: specificationLabel ?? t('tile_external_manual'),
value: money(status.external_balance),
sub: specification
? t('tile_spec_today')
: status.external_balance != null && status.signoff
? t('tile_external_signed', { date: formatDate(status.signoff.through_date) })
: t('tile_external_manual_sub'),
}
: {
key: 'external',
label: isSkv ? t('tile_external_skv') : t('tile_external_bank'),
// The bank tile is the period sum (what its label says), which lives on
// the bridge; external_balance is the reported bank balance and is often
// unknown, which rendered as "okänt" next to a bridge that knows better.
value: money(
isSkv
? status.external_balance
: (status.bridge.find((l) => l.key === 'bank_transactions')?.amount ?? status.external_balance),
),
// Bank: the gross split makes the net self-explanatory; skattekonto: when it was fetched.
sub: bankBreakdown ?? (fetchedAt ? t('tile_synced', { date: formatDate(fetchedAt) }) : t('rail_never_synced')),
}
const tiles: Array<{ key: string; label: string; value: string; sub: string; tone?: 'ok' | 'attn'; help?: string }> = [
externalTile,
{
key: 'ledger',
label: isSkv
? t('tile_ledger', { account: status.account_number })
: t('tile_ledger_bank', { account: status.account_number }),
value: money(status.ledger_balance),
sub: t('tile_per', { date: formatDate(asOfDate) }),
},
{ key: 'difference', label: t('tile_difference'), value: money(status.difference), sub: '' },
{
key: 'unexplained',
label: t('tile_unexplained'),
help: t('tile_unexplained_help'),
value: money(status.unexplained_difference),
sub: '',
tone: status.unexplained_difference == null ? undefined : status.is_reconciled ? 'ok' : 'attn',
},
]
// What the bank itself reports (F7): booked + available balance from the
// last PSD2 balance refresh, with its fetch date. Point-in-time, so it
// lives outside the movement-based tiles.
const bankReportedRaw =
!isSkv && !isManual
? (status.bank as {
bank_reported_balance?: number | null
bank_reported_available_balance?: number | null
bank_balance_updated_at?: string | null
} | null)
: null
// Both the amount and its fetch timestamp must exist: a balance of unknown
// age labeled with today's date is exactly the misleading staleness the
// timestamp exists to prevent, so without it the line is omitted entirely.
const bankReportedLine =
bankReportedRaw &&
typeof bankReportedRaw.bank_reported_balance === 'number' &&
bankReportedRaw.bank_balance_updated_at
? typeof bankReportedRaw.bank_reported_available_balance === 'number'
? t('bank_reported_line_available', {
amount: formatCurrency(bankReportedRaw.bank_reported_balance, currency),
available: formatCurrency(bankReportedRaw.bank_reported_available_balance, currency),
date: formatDate(bankReportedRaw.bank_balance_updated_at),
})
: t('bank_reported_line', {
amount: formatCurrency(bankReportedRaw.bank_reported_balance, currency),
date: formatDate(bankReportedRaw.bank_balance_updated_at),
})
: null
const unexplained = status.unexplained_difference
const attn = status.stale
? t('stale_line', { source: sourceLabel })
: unexplained != null && Math.abs(unexplained) >= 0.005
? t('unexplained_line', { amount: formatCurrency(unexplained, currency) })
: null
const bucketLabel = (bucket: ReconciliationItemBucket): string => {
switch (bucket) {
case 'proposed':
return t('bucket_proposed')
case 'unmatched_external':
return isSkv ? t('bucket_unmatched_external_skv') : t('bucket_unmatched_external_bank')
case 'unmatched_ledger':
return isSkv ? t('bucket_unmatched_ledger_skv') : t('bucket_unmatched_ledger_bank')
case 'matched':
return t('bucket_matched')
case 'ignored':
return t('bucket_ignored')
case 'upcoming':
return t('bucket_upcoming')
}
}
const openWork =
(byBucket.get('proposed')?.length ?? 0) +
(byBucket.get('unmatched_external')?.length ?? 0) +
(byBucket.get('unmatched_ledger')?.length ?? 0)
// Default sign-off date: the window end, never past today nor past the
// skattekonto snapshot. The button hides when that date is already signed.
const todayIso = new Date().toISOString().slice(0, 10)
const signoffMaxDate = isSkv ? (asOfDate < todayIso ? asOfDate : todayIso) : todayIso
const signoffDefaultDate = window.to < signoffMaxDate ? window.to : signoffMaxDate
const signoffEnabled = !status.signoff || status.signoff.through_date < signoffDefaultDate
return (
<div className="space-y-6">
<div className="grid gap-8 lg:grid-cols-[220px_1fr]">
{rail}
<div className="min-w-0 space-y-6">
{/* Tiles: label + number, nothing else. */}
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border stagger-enter">
{tiles.map((tile) => (
<div key={tile.key} className="bg-background px-4 py-3.5">
<div className="flex items-center gap-1 text-[11px] font-medium uppercase tracking-[0.07em] text-muted-foreground">
{tile.label}
{tile.help && <InfoTooltip content={tile.help} iconClassName="h-3 w-3" />}
</div>
<div
className={cn(
'mt-1 text-[22px] font-semibold leading-tight tabular-nums',
tile.tone === 'ok' && 'text-success',
tile.tone === 'attn' && 'text-warning',
)}
data-ph-mask
>
{tile.value}
</div>
{tile.sub && <div className="mt-0.5 text-[11.5px] text-muted-foreground">{tile.sub}</div>}
</div>
))}
</div>
{bankReportedLine && (
<p className="text-[12.5px] text-muted-foreground" data-ph-mask>
{bankReportedLine}
</p>
)}
{attn ? (
<AttnLine>{attn}</AttnLine>
) : status.is_reconciled ? (
<p className="text-[13px] text-muted-foreground">{t('reconciled_line')}</p>
) : null}
{status.signoff && (
<p className="group flex items-center gap-2 text-[13px] text-muted-foreground">
<span>
{t('signed_off_line', { date: formatDate(status.signoff.through_date), when: formatDate(status.signoff.signed_at) })}
{status.signoff.note && <span className="ml-1">· {t('signed_off_forced')}</span>}
</span>
<button
type="button"
onClick={() => void reopen(status.signoff!.id)}
disabled={busy !== null}
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
>
{t('reopen')}
</button>
</p>
)}
{/* Bridge: how the difference is explained. */}
{status.bridge.length > 0 && (
<dl className="max-w-[520px] text-[13px]">
{status.bridge.map((line) => (
<div
key={line.key}
className="flex items-baseline justify-between gap-4 border-b border-border/60 py-1.5 last:border-b-0"
>
<dt className="text-muted-foreground">
{locale === 'en' ? line.label_en : line.label_sv}
{line.count != null && line.count > 0 && (
<span className="ml-1.5 tabular-nums text-muted-foreground/70" data-ph-mask>
({line.count})
</span>
)}
</dt>
<dd className="tabular-nums" data-ph-mask>
{formatCurrency(line.amount, currency)}
</dd>
</div>
))}
</dl>
)}
{/* Actions row: the work, then the way to the richer surfaces. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{proposedCount > 0 && (
<Button size="sm" onClick={() => void matchProposals()} disabled={busy !== null}>
{t('action_match_proposals', { count: proposedCount })}
</Button>
)}
{isSkv && bookableIds.length > 0 && (
<Button size="sm" variant="outline" onClick={() => void bookAll()} disabled={busy !== null}>
{t('action_book_rows', { count: bookableIds.length })}
</Button>
)}
{!isSkv && !isManual && (
<Button size="sm" variant="outline" onClick={() => void runMatcher()} disabled={busy !== null} aria-busy={busy === 'matcher'}>
{t('action_run_bank_matcher')}
</Button>
)}
{signoffEnabled && (
<Button size="sm" variant={status.is_reconciled ? 'default' : 'outline'} onClick={() => setSignoffOpen(true)} disabled={busy !== null}>
{t('signoff_button', { date: formatDate(signoffDefaultDate) })}
</Button>
)}
{isSkv && (
<span className="ml-auto">
<Link href="/skattekonto" className={QUIET_LINK_CLASS}>
{t('action_open_skattekonto')}
</Link>
</span>
)}
{isManual && (
<span className="ml-auto">
<Link href={`/reports/huvudbok?account=${encodeURIComponent(status.account_number)}`} className={QUIET_LINK_CLASS}>
{t('action_open_ledger')}
</Link>
</span>
)}
</div>
{/* Underlag for the balansdag in play: the signed date, else the date the next sign-off would cover. */}
<ReconciliationUnderlag
accountKey={account.account_key}
throughDate={status.signoff && status.signoff.through_date >= signoffDefaultDate ? status.signoff.through_date : signoffDefaultDate}
/>
{items.older_unmatched_count > 0 && (
<p className="text-[12.5px] text-muted-foreground">
{t('older_unmatched', { count: items.older_unmatched_count })}
{isSkv && (
<>
{' · '}
<Link href="/skattekonto" className={QUIET_LINK_CLASS}>
{t('older_show')}
</Link>
</>
)}
</p>
)}
</div>
</div>
{matcher !== null && !isSkv && (
<MatcherPreview
matches={matcher}
currency={currency}
busy={busy !== null}
onApply={(pairs, strongOnly) => void applyMatches(pairs, strongOnly)}
onClose={() => setMatcher(null)}
/>
)}
{/* The table: full width, banded by bucket, paired proposal rows. */}
{isManual ? (
<p className="max-w-[560px] text-[13px] text-muted-foreground">
{specificationLabel ? t('manual_spec_hint', { label: specificationLabel }) : t('manual_hint')}
</p>
) : items.items.length === 0 ? (
<p className="text-[13px] text-muted-foreground">{t('all_clear')}</p>
) : (
<div className="-mx-4 overflow-x-auto sm:mx-0">
<table className="w-full text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-[110px]')}>{t('col_date')}</th>
<th className={TH_CLASS}>{t('col_event')}</th>
<th className={cn(TH_CLASS, 'w-[140px] text-right')}>{t('col_amount')}</th>
<th className={cn(TH_CLASS, 'w-[34%]')}>{t('col_voucher')}</th>
<th className={cn(TH_CLASS, 'w-[170px]')} />
</tr>
</thead>
<tbody className="stagger-enter">
{BUCKET_ORDER.map((bucket) => {
const rows = byBucket.get(bucket) ?? []
if (rows.length === 0) return null
const folded = FOLDED_BY_DEFAULT.has(bucket) && !unfolded.has(bucket)
const toggle = () =>
setUnfolded((prev) => {
const next = new Set(prev)
if (next.has(bucket)) next.delete(bucket)
else next.add(bucket)
return next
})
return (
<Fragment key={bucket}>
<tr className="bg-muted/30">
<td
colSpan={5}
className="px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground"
>
<span className="flex items-center gap-3">
<span>
{bucketLabel(bucket)}
<span className="ml-1.5 font-normal tabular-nums text-muted-foreground/70" data-ph-mask>
{rows.length}
</span>
</span>
{FOLDED_BY_DEFAULT.has(bucket) && (
<button
type="button"
onClick={toggle}
className="normal-case tracking-normal text-[12px] font-normal text-muted-foreground underline decoration-border underline-offset-4 hover:text-foreground"
>
{folded ? t('show_all', { count: rows.length }) : t('hide')}
</button>
)}
</span>
</td>
</tr>
{!folded &&
rows.map((item) => (
<ItemRow
key={item.item_id}
item={item}
isSkv={isSkv}
sourceLabel={sourceLabel}
currency={currency}
busy={busy === item.item_id}
anyBusy={busy !== null}
onMatch={() => void matchOne(item)}
onUnmatch={() => void unmatchOne(item)}
onIgnore={() => void setIgnored(item, true)}
onUnignore={() => void setIgnored(item, false)}
onBook={() => setBookRow(item)}
onMarkIb={!isSkv && item.side === 'ledger' && item.bucket === 'unmatched_ledger' ? () => void markOpeningBalance(item) : undefined}
moveTargets={!isSkv && item.item_type === 'transaction' && item.bucket === 'unmatched_external' ? otherBankAccounts : []}
onMove={(target) => void moveToAccount(item, target)}
/>
))}
</Fragment>
)
})}
</tbody>
</table>
{items.has_more && (
<p className="px-4 pt-3 text-[12px] text-muted-foreground">{t('truncated', { count: ITEMS_LIMIT })}</p>
)}
</div>
)}
{openWork === 0 && items.items.length > 0 && (
<p className="text-[13px] text-muted-foreground">{t('all_clear')}</p>
)}
{isSkv && (
<SkattekontoBookDialog
row={bookRow ? toDialogRow(bookRow) : null}
open={bookRow !== null}
onOpenChange={(open) => {
if (!open) setBookRow(null)
}}
onBooked={() => {
setBookRow(null)
void refresh()
}}
/>
)}
<SignoffDialog
open={signoffOpen}
onOpenChange={setSignoffOpen}
accountName={account.name}
defaultDate={signoffDefaultDate}
maxDate={signoffMaxDate}
unexplained={status.unexplained_difference}
currency={currency}
askExternalBalance={isManual && !specification}
ledgerBalance={status.ledger_balance}
onPreview={previewSignoff}
onSubmit={submitSignoff}
/>
</div>
)
}
/**
* The booking dialog reads six fields of a skattekonto row (id, date, text,
* amount, booking_suggestion, booking_gate). The reconciliation item carries
* the first four; the suggestion is left undefined on purpose, which routes
* the dialog to its draft-confirm path (the /skattekonto page is the place
* with full rule-based suggestions).
*/
function toDialogRow(item: ReconciliationItem): SkattekontoTransactionWithSuggestion {
return {
id: item.item_id,
transaktionsdatum: item.date,
transaktionstext: item.description,
belopp_skatteverket: item.amount,
booking_suggestion: undefined,
booking_gate: null,
} as unknown as SkattekontoTransactionWithSuggestion
}
interface ItemRowProps {
item: ReconciliationItem
isSkv: boolean
sourceLabel: string
currency: string
busy: boolean
anyBusy: boolean
onMatch: () => void
onUnmatch: () => void
onIgnore: () => void
onUnignore: () => void
onBook: () => void
/** "Märk som IB" for a ledger row without a bank counterpart (bank accounts). */
onMarkIb?: () => void
/** Other bank accounts a stray transaction can be moved to. */
moveTargets: ReconciliationAccount[]
onMove: (target: ReconciliationAccount) => void
}
function ItemRow({
item,
isSkv,
sourceLabel,
currency,
busy,
anyBusy,
onMatch,
onUnmatch,
onIgnore,
onUnignore,
onBook,
onMarkIb,
moveTargets,
onMove,
}: ItemRowProps) {
const t = useTranslations('reconciliation')
const can = (a: ReconciliationItem['actions'][number]) => item.actions.includes(a)
const voucherOf = (e: { voucher_series?: string | null; voucher_number?: number | null }) =>
e.voucher_number != null ? formatVoucher({ voucher_series: e.voucher_series, voucher_number: e.voucher_number }) : null
// The voucher column: for a ledger item, its own voucher; for an external
// item, the linked or proposed verifikat.
let voucherCell: React.ReactNode = null
if (item.side === 'ledger') {
const v = voucherOf(item)
voucherCell = (
<span className="flex items-center gap-2">
<Link href={`/bookkeeping/${item.item_id}`} className={QUIET_LINK_CLASS} data-ph-mask>
{v ?? item.item_id.slice(0, 8)}
</Link>
{item.entry_status === 'draft' && <Chip>{t('chip_draft')}</Chip>}
{item.entry_status === 'reversed' && <Chip>{t('chip_reversed')}</Chip>}
{item.awaiting_external && <Chip>{t('chip_awaiting', { source: sourceLabel })}</Chip>}
</span>
)
} else if (item.proposal) {
const p = item.proposal
voucherCell = (
<span className="flex flex-col gap-0.5">
<span className="flex items-center gap-2">
<Link href={`/bookkeeping/${p.journal_entry_id}`} className={QUIET_LINK_CLASS} data-ph-mask>
{voucherOf(p) ?? p.journal_entry_id.slice(0, 8)}
</Link>
<span className="text-[11.5px] tabular-nums text-muted-foreground">{formatDate(p.entry_date)}</span>
<span className="text-[11px] text-muted-foreground">
{t('confidence', { percent: Math.round(p.confidence * 100) })}
</span>
</span>
<span className="truncate text-[12px] text-muted-foreground" data-ph-mask>
{p.description}
</span>
</span>
)
} else if (item.linked_journal_entry_id) {
voucherCell = (
<span className="flex items-center gap-2">
<Link href={`/bookkeeping/${item.linked_journal_entry_id}`} className={QUIET_LINK_CLASS} data-ph-mask>
{item.linked_journal_entry_id.slice(0, 8)}
</Link>
{item.link_problem === 'entry_draft' && <Chip>{t('chip_draft')}</Chip>}
{item.link_problem === 'entry_reversed' && <Chip>{t('chip_reversed')}</Chip>}
{item.link_problem === 'entry_missing' && <Chip>{t('chip_missing')}</Chip>}
</span>
)
}
const openHref = item.item_type === 'transaction' ? `/transactions?highlight=${item.item_id}` : null
return (
<tr className="group">
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>{formatDate(item.date)}</td>
<td className={cn(TD_CLASS, 'max-w-0')}>
<span className="block truncate" data-ph-mask title={item.description}>
{item.description}
</span>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')} data-ph-mask>
{formatCurrency(item.amount, currency)}
</td>
<td className={cn(TD_CLASS, 'max-w-0')}>{voucherCell}</td>
<td className={cn(TD_CLASS, 'text-right')}>
<span className="flex items-center justify-end gap-1.5">
{can('match') && item.proposal && (
<Button size="sm" variant="outline" onClick={onMatch} disabled={anyBusy} aria-busy={busy}>
{t('row_match')}
</Button>
)}
{can('book') && isSkv && (
<Button size="sm" variant="outline" onClick={onBook} disabled={anyBusy}>
{t('row_book')}
</Button>
)}
{can('book') && !isSkv && openHref && (
<Button size="sm" variant="outline" asChild>
<Link href={openHref}>{t('row_open')}</Link>
</Button>
)}
{can('review') && item.side === 'ledger' && (
<Link href={`/bookkeeping/${item.item_id}`} className={QUIET_LINK_CLASS}>
{t('row_review')}
</Link>
)}
{can('unmatch') && (
<button
type="button"
onClick={onUnmatch}
disabled={anyBusy}
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
>
{t('row_unmatch')}
</button>
)}
{can('ignore') && (
<button
type="button"
onClick={onIgnore}
disabled={anyBusy}
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
>
{t('row_ignore')}
</button>
)}
{can('unignore') && (
<button type="button" onClick={onUnignore} disabled={anyBusy} className={QUIET_LINK_CLASS}>
{t('row_unignore')}
</button>
)}
{onMarkIb && (
<button type="button" onClick={onMarkIb} disabled={anyBusy} className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}>
{t('row_mark_ib')}
</button>
)}
{moveTargets.length > 0 && (
<select
aria-label={t('row_move')}
value=""
disabled={anyBusy}
onChange={(e) => {
const target = moveTargets.find((a) => a.account_key === e.target.value)
if (target) onMove(target)
}}
className={cn('h-7 rounded-full border border-border bg-background px-2 text-[11.5px] text-muted-foreground', HOVER_REVEAL_CLASS)}
>
<option value="">{t('row_move')}</option>
{moveTargets.map((a) => (
<option key={a.account_key} value={a.account_key}>
{a.name} ({a.account_number})
</option>
))}
</select>
)}
</span>
</td>
</tr>
)
}
function Chip({ children }: { children: React.ReactNode }) {
return (
<span className="whitespace-nowrap rounded-full bg-muted px-1.5 py-px text-[10.5px] text-muted-foreground">
{children}
</span>
)
}