The header keeps only the page scope (räkenskapsår, intervall); the Översikt/Matcha manuellt switch moves down to the body it switches, and the Bokslutsbilagor link sits under the account rail it summarizes. The underlag section is one quiet line when nothing is attached, lists files only when there are some, and comes after the numbers instead of before them. The native file input is display:none: sr-only still leaked the browser's "Ingen fil har valts". Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
980 lines
36 KiB
TypeScript
980 lines
36 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 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)
|
|
}
|
|
}
|
|
|
|
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(() => ({}))
|
|
if (!res.ok) return getUserErrorMessage(json, { statusCode: 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',
|
|
},
|
|
]
|
|
|
|
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>
|
|
|
|
{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}
|
|
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>
|
|
)
|
|
}
|