Files
accounted/components/reports/BokslutsbilagorView.tsx
T
9ce1ebc65f feat(reports): bokslutsbilagor, the pärm per räkenskapsår (Reko bilagor, PR 4) (#1874)
* feat(reports): bokslutsbilagor, the pärm per räkenskapsår (Reko bilagor, PR 4)

One bilaga per balance account as of the balansdag: IB, movement and UB
from the trial balance, what it was reconciled against, the difference,
the sign-off with who, when and note, and every attached file with its
SHA-256; the closing checklist as the first page. JSON and PDF through
/api/reports/bokslutsbilagor, in the reports library and on the
Avstämning page, and written into every period folder of the full
archive. Built from the attested rows, never by recomputing live status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(reports): load the pärm renderer on demand in the full archive so PDF stubs elsewhere keep working

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(reconciliation): neutral rail dot for a manual account that is merely not attested yet

An unsigned manual account without a system specification has nothing to
compare against, so an amber dot read as a problem on every balance account
of a freshly migrated company. Neutral until it is signed or a
specification differs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
2026-08-25 09:35:02 +02:00

164 lines
7.0 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
import { AlertCircle, FolderArchive } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { BokslutsbilagorReport } from '@/lib/reports/bokslutsbilagor-types'
/**
* The bokslutsbilagor pärm on screen: one row per balance account as of the
* balansdag (booked, per underlag, difference, sign-off, files) with the PDF
* export; each row links into the Avstämning page where the work is done.
*/
interface BokslutsbilagorViewProps {
periodId: string
}
export function BokslutsbilagorView({ periodId }: BokslutsbilagorViewProps) {
const t = useTranslations('bokslutsbilagor')
const locale = useLocale()
const [report, setReport] = useState<BokslutsbilagorReport | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Keyed on periodId by the caller: a new period is a fresh mount, so the
// initial loading state is the reset.
useEffect(() => {
let cancelled = false
fetch(`/api/reports/bokslutsbilagor?period_id=${encodeURIComponent(periodId)}`)
.then(async (res) => {
const json = await res.json().catch(() => ({}))
if (cancelled) return
if (!res.ok) {
setError(getErrorMessage(json, { statusCode: res.status }))
return
}
setReport(json.data as BokslutsbilagorReport)
})
.catch(() => {
if (!cancelled) setError(t('load_failed'))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [periodId, t])
if (loading) {
return (
<div className="space-y-3" aria-busy>
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-40 w-full" />
</div>
)
}
if (error) {
return (
<Card>
<CardContent className="py-8 text-center text-sm text-destructive">
<AlertCircle className="mx-auto mb-2 h-6 w-6" />
{error}
</CardContent>
</Card>
)
}
if (!report || report.accounts.length === 0) {
return <EmptyState icon={FolderArchive} title={t('empty_title')} description={t('empty_desc')} actionLabel={t('open_reconciliation')} actionHref="/reconciliation" />
}
const money = (n: number | null) => (n == null ? '-' : formatCurrency(n, 'SEK'))
const externalLabel = (a: BokslutsbilagorReport['accounts'][number]) => (locale === 'en' ? a.external_label_en : a.external_label_sv)
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground tabular-nums">
{t('summary', {
accounts: report.summary.accounts,
signed: report.summary.signed_on_balansdag,
files: report.summary.attachments,
date: formatDate(report.period.end),
})}
{' · '}
{t('checklist_summary', { done: report.checklist.summary.done + report.checklist.summary.not_applicable, total: report.checklist.summary.total })}
</p>
<ReportExportMenu items={[{ format: 'pdf', href: `/api/reports/bokslutsbilagor?period_id=${encodeURIComponent(periodId)}&format=pdf` }]} />
</div>
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full min-w-[820px] text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-[240px]')}>{t('col_account')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('col_booked')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('col_external')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('col_difference')}</th>
<th className={TH_CLASS}>{t('col_signoff')}</th>
<th className={cn(TH_CLASS, 'text-right w-[70px]')}>{t('col_files')}</th>
</tr>
</thead>
<tbody>
{report.accounts.map((a) => {
const files = a.attachments.filter((f) => !f.removed_at).length
return (
<tr key={a.account_key} className="border-b border-border/60 last:border-b-0 align-top">
<td className={cn(TD_CLASS, 'max-w-0')}>
<Link href={`/reconciliation?account=${encodeURIComponent(a.account_key)}`} className={QUIET_LINK_CLASS} data-ph-mask>
<span className="tabular-nums">{a.account_number}</span> {a.name}
</Link>
<div className="truncate text-[11.5px] text-muted-foreground">{externalLabel(a)}</div>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')} data-ph-mask>
{money(a.closing_balance)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')} data-ph-mask>
{money(a.external_balance)}
</td>
<td
className={cn(
TD_CLASS,
'whitespace-nowrap text-right tabular-nums',
a.difference != null && Math.abs(a.difference) >= 0.005 && 'text-warning',
)}
data-ph-mask
>
{money(a.difference)}
</td>
<td className={cn(TD_CLASS, 'text-[12.5px]')}>
{a.signoff ? (
<span className={cn(!a.signoff.on_balansdag && 'text-warning')} data-ph-mask>
{a.signoff.on_balansdag
? t('signed', { who: a.signoff.signed_by_label, when: formatDate(a.signoff.signed_at) })
: t('signed_other_date', { date: formatDate(a.signoff.through_date), who: a.signoff.signed_by_label })}
</span>
) : (
<span className="text-muted-foreground">{t('unsigned')}</span>
)}
</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')} data-ph-mask>
{files}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)
}