Files
accounted/components/reconciliation/ReconciliationWorkspace.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

232 lines
8.1 KiB
TypeScript

'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'
import { AttnLine } from '@/components/ui/attn-line'
import { Skeleton } from '@/components/ui/skeleton'
import { FyPicker } from '@/components/common/FyPicker'
import { ReportDateRange, type DateRangeValue } from '@/components/common/ReportDateRange'
import { SegmentedControl } from '@/components/ui/segmented-control'
import type { ReconciliationAccount } from '@/lib/reconciliation/schemas'
import type { FiscalPeriod } from '@/types'
import { ReconciliationRail } from './ReconciliationRail'
import { AccountOverview, type ReconciliationWindow } from './AccountOverview'
import { ManualMatchMode } from './ManualMatchMode'
/**
* /reconciliation: one page for every account with an outside truth. The
* rail on the left lists the accounts (bank accounts, the skattekonto) with
* their status; the body shows the selected account's bridge and the rows
* behind it. Selection lives in the URL (?account=) so a link lands on the
* right account and a reload keeps it.
*
* The period (räkenskapsår + range within it) scopes the bank bridge and the
* item windows and sets the default sign-off date. It keeps its own preset
* memory, separate from the reports: reconciling is a monthly ritual, so it
* opens on this month rather than on whatever range a report left behind.
*/
const FY_STORAGE_KEY_PREFIX = 'Accounted:recon-fy:'
const RANGE_STORAGE_KEY_PREFIX = 'Accounted:recon-page-range-preset:'
interface ReconciliationWorkspaceProps {
initialPeriods: FiscalPeriod[]
initialCompanyId: string | null
}
export function ReconciliationWorkspace({ initialPeriods, initialCompanyId }: ReconciliationWorkspaceProps) {
const t = useTranslations('reconciliation')
const tParm = useTranslations('bokslutsbilagor')
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const [accounts, setAccounts] = useState<ReconciliationAccount[] | null>(null)
const [loadError, setLoadError] = useState(false)
const [periodId, setPeriodId] = useState<string | null>(null)
const [periodBounds, setPeriodBounds] = useState<{ start: string; end: string } | null>(null)
const [dateRange, setDateRange] = useState<DateRangeValue>({})
// The effective window: the range within the period, defaulting to the
// period bounds. Null until the period picker has resolved.
const window = useMemo<ReconciliationWindow | null>(() => {
if (!periodBounds) return null
return {
from: dateRange.fromDate ?? periodBounds.start,
to: dateRange.toDate ?? periodBounds.end,
}
}, [periodBounds, dateRange])
const load = useCallback(async () => {
if (!window) return
try {
const qs = new URLSearchParams({ date_from: window.from, date_to: window.to })
const res = await fetch(`/api/reconciliation/accounts?${qs.toString()}`)
setLoadError(false)
if (!res.ok) {
setLoadError(true)
return
}
const json = await res.json()
setAccounts((json.data?.accounts ?? []) as ReconciliationAccount[])
} catch {
setLoadError(true)
}
}, [window])
useEffect(() => {
void load()
}, [load])
const requestedKey = searchParams.get('account')
const mode: 'overview' | 'match' = searchParams.get('mode') === 'match' ? 'match' : 'overview'
const setMode = useCallback(
(next: 'overview' | 'match') => {
const params = new URLSearchParams(searchParams.toString())
if (next === 'match') params.set('mode', 'match')
else params.delete('mode')
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
},
[pathname, router, searchParams],
)
const selected = useMemo(() => {
if (!accounts || accounts.length === 0) return null
return (
accounts.find((a) => a.account_key === requestedKey) ??
accounts.find((a) => !a.superseded_by) ??
accounts[0]
)
}, [accounts, requestedKey])
const select = useCallback(
(accountKey: string) => {
const params = new URLSearchParams(searchParams.toString())
params.set('account', accountKey)
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
},
[pathname, router, searchParams],
)
const header = (
<PageHeader
title={t('title')}
help={
<HelpPopover>
<p>{t('help_text')}</p>
</HelpPopover>
}
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}
aria-label={t('title')}
options={[
{ value: 'overview', label: t('mode_overview') },
{ value: 'match', label: t('mode_match') },
]}
/>
<FyPicker
value={periodId}
onChange={(id, period) => {
setPeriodId(id)
setPeriodBounds(period ? { start: period.period_start, end: period.period_end } : null)
setDateRange({})
}}
includeAllOption={false}
hideFuturePeriods
initialPeriods={initialPeriods}
initialCompanyId={initialCompanyId}
storageKeyPrefix={FY_STORAGE_KEY_PREFIX}
/>
{periodBounds && (
<ReportDateRange
periodStart={periodBounds.start}
periodEnd={periodBounds.end}
value={dateRange}
onChange={setDateRange}
defaultPreset="this_month"
storageKeyPrefix={RANGE_STORAGE_KEY_PREFIX}
/>
)}
</div>
}
/>
)
if (loadError) {
return (
<div className="space-y-6">
{header}
<AttnLine action={{ label: t('older_show'), onClick: () => void load() }}>{t('load_failed')}</AttnLine>
</div>
)
}
if (accounts === null || !window) {
return (
<div className="space-y-6" aria-busy>
{header}
<div className="grid gap-8 lg:grid-cols-[220px_1fr]">
<div className="space-y-2">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
<Skeleton className="h-48 w-full" />
</div>
</div>
)
}
if (accounts.length === 0) {
return (
<div className="space-y-6">
{header}
<EmptyState
icon={Scale}
title={t('empty_title')}
description={t('empty_body')}
actionLabel={t('empty_connect_bank')}
actionHref="/settings/banking"
secondaryActionLabel={t('empty_connect_skv')}
secondaryActionHref="/settings/skatteverket"
/>
</div>
)
}
return (
<div className="space-y-6">
{header}
{selected && mode === 'match' && (
<div className="grid gap-8 lg:grid-cols-[220px_1fr]">
<ReconciliationRail accounts={accounts} selectedKey={selected.account_key} onSelect={select} />
<div className="min-w-0">
<ManualMatchMode key={selected.account_key} account={selected} window={window} onChanged={() => void load()} />
</div>
</div>
)}
{selected && mode === 'overview' && (
<AccountOverview
key={selected.account_key}
account={selected}
rail={<ReconciliationRail accounts={accounts} selectedKey={selected.account_key} onSelect={select} />}
otherBankAccounts={accounts.filter((a) => a.kind === 'bank' && a.account_key !== selected.account_key && !a.superseded_by)}
window={window}
onChanged={() => void load()}
/>
)}
</div>
)
}