Files
accounted/components/reconciliation/ReconciliationWorkspace.tsx
T
Jakob Wennberg 43a569d248 fix(reconciliation): declutter the Avstämning header and quiet the underlag section (#1886)
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>
2026-08-25 10:43:32 +02:00

236 lines
8.3 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">
<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>
)
}
const railFooter = (
<Link href="/reports/bokslutsbilagor" className={cn(QUIET_LINK_CLASS, 'block px-3 pt-4 text-[12.5px]')}>
{tParm('open_parm')}
</Link>
)
return (
<div className="space-y-6">
{header}
{/* How the selected account is worked: a view of the body, so it sits with the body, not with the page scope. */}
<SegmentedControl
value={mode}
onChange={setMode}
aria-label={t('title')}
options={[
{ value: 'overview', label: t('mode_overview') },
{ value: 'match', label: t('mode_match') },
]}
/>
{selected && mode === 'match' && (
<div className="grid gap-8 lg:grid-cols-[220px_1fr]">
<ReconciliationRail accounts={accounts} selectedKey={selected.account_key} onSelect={select} footer={railFooter} />
<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} footer={railFooter} />}
otherBankAccounts={accounts.filter((a) => a.kind === 'bank' && a.account_key !== selected.account_key && !a.superseded_by)}
window={window}
onChanged={() => void load()}
/>
)}
</div>
)
}