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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
c62321988b
commit
9ce1ebc65f
@@ -81,9 +81,17 @@ export function ReconciliationRail({ accounts, selectedKey, onSelect }: Reconcil
|
||||
return synced ? t('rail_synced', { date: formatDate(synced) }) : t('rail_never_synced')
|
||||
}
|
||||
|
||||
// A manual account with nothing to compare against is not a problem, just
|
||||
// not attested yet: neutral until it is signed or a specification differs.
|
||||
const dotState = (account: ReconciliationAccount): keyof typeof DOT_CLASS => {
|
||||
const state = account.status?.state ?? 'unknown'
|
||||
if (account.kind === 'manual' && state === 'open' && account.status?.unexplained_difference == null) return 'unknown'
|
||||
return state
|
||||
}
|
||||
|
||||
const renderRow = (account: ReconciliationAccount) => {
|
||||
const selected = account.account_key === selectedKey
|
||||
const state = account.status?.state ?? 'unknown'
|
||||
const state = dotState(account)
|
||||
const open = account.status
|
||||
? account.status.open_counts.proposed +
|
||||
account.status.open_counts.unmatched_external +
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Scale } from 'lucide-react'
|
||||
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
@@ -41,6 +44,7 @@ interface ReconciliationWorkspaceProps {
|
||||
|
||||
export function ReconciliationWorkspace({ initialPeriods, initialCompanyId }: ReconciliationWorkspaceProps) {
|
||||
const t = useTranslations('reconciliation')
|
||||
const tParm = useTranslations('bokslutsbilagor')
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
@@ -120,6 +124,9 @@ export function ReconciliationWorkspace({ initialPeriods, initialCompanyId }: Re
|
||||
}
|
||||
action={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Link href="/reports/bokslutsbilagor" className={cn(QUIET_LINK_CLASS, 'mr-2')}>
|
||||
{tParm('open_parm')}
|
||||
</Link>
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -55,6 +55,10 @@ const BehandlingshistorikView = dynamic(() =>
|
||||
import('./BehandlingshistorikView').then((module) => ({ default: module.BehandlingshistorikView })),
|
||||
{ loading: ReportViewLoading },
|
||||
)
|
||||
const BokslutsbilagorView = dynamic(() =>
|
||||
import('./BokslutsbilagorView').then((module) => ({ default: module.BokslutsbilagorView })),
|
||||
{ loading: ReportViewLoading },
|
||||
)
|
||||
|
||||
/**
|
||||
* The focused single-report experience at /reports/[slug]. Carries one report:
|
||||
@@ -240,6 +244,8 @@ function FocusedView({
|
||||
return <SupplierLedgerView periodId={periodId} />
|
||||
case 'behandlingshistorik':
|
||||
return <BehandlingshistorikView periodId={periodId} dateRange={dateRange} />
|
||||
case 'bokslutsbilagor':
|
||||
return <BokslutsbilagorView key={periodId} periodId={periodId} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user