feat(reconciliation): absorb the last bank-view tools and retire /reports/bank-reconciliation (#1871)

The old Bankavstämning report page was the only place a user could still tag
a bank row as ingående balans or move it to another bank account, so the new
/reconciliation page kept linking out to it and the reconciliation lived in
two places. Both row tools now live on the account overview (hover-revealed,
same endpoints), the ?autorun=1 deep link from the transactions inbox runs the
matcher on the new page, and the report slug redirects: old links, ⌘K, the
bokslut readiness wizard and the ignore toast all land on /reconciliation.

BankReconciliationView and its FocusedReport branches (own range preset,
help popover, autoRun plumbing) are removed; the source-grepping parity test
for its quick-book path goes with it.

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:
Jakob Wennberg
2026-08-25 09:03:17 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent d88df74b85
commit 9ebb2e518f
12 changed files with 130 additions and 2023 deletions
+5 -4
View File
@@ -19,7 +19,11 @@ export default async function ReportSlugPage({
const [{ slug }, query] = await Promise.all([params, searchParams])
const report = getReport(slug)
if (!report) notFound()
if (report.route) redirect(report.route)
if (report.route) {
// The old bankavstämning deep link (?autorun=1 from the transactions
// inbox) keeps working on the page that absorbed it.
redirect(slug === 'bank-reconciliation' && query.autorun === '1' ? `${report.route}?autorun=1` : report.route)
}
const [{ supabase }, companyId] = await Promise.all([
getDashboardAuthContext(),
@@ -39,9 +43,6 @@ export default async function ReportSlugPage({
slug={slug}
initialPeriods={(periods ?? []) as FiscalPeriod[]}
initialCompanyId={companyId}
// ?autorun=1 deep-links (e.g. the transactions inbox banner) ask the
// bank-reconciliation view to run its dry-run preview once on load.
autoRun={query.autorun === '1'}
/>
)
}
+3 -3
View File
@@ -3180,7 +3180,7 @@ export default function TransactionsPage() {
description: `${successes} transaktioner ignorerade`,
action: (
<ToastAction altText="Öppna Bankavstämning" asChild>
<Link href="/reports/bank-reconciliation">Bankavstämning</Link>
<Link href="/reconciliation">Avstämning</Link>
</ToastAction>
),
})
@@ -3852,7 +3852,7 @@ export default function TransactionsPage() {
className="px-1 pt-3"
action={{
label: t('recon_attn_action'),
href: '/reports/bank-reconciliation?autorun=1',
href: '/reconciliation?autorun=1',
}}
>
{t('recon_attn', { count: selectableInboxIds.length })}
@@ -3900,7 +3900,7 @@ export default function TransactionsPage() {
<BankSyncNowButton />
<BankSyncSinceLastVisit />
<Link
href="/reports/bank-reconciliation"
href="/reconciliation"
className="ml-auto transition-colors duration-150 hover:text-foreground"
>
Bankavstämning →
+1 -1
View File
@@ -70,7 +70,7 @@ const PAGE_ENTRIES: Entry[] = [
{ id: 'rapport-huvudbok', label: 'Visa rapport: Huvudbok', icon: BookOpen, href: '/reports/huvudbok', keywords: 'rapport huvudbok ledger general konto saldo transaktioner per konto verifikat verifikationer verifikationer per konto kontoutdrag kontoanalys kontokort kontohistorik balance account statement transactions vouchers' },
{ id: 'rapport-kundreskontra', label: 'Visa rapport: Kundreskontra', icon: Users, href: '/reports/kundreskontra', keywords: 'rapport kundreskontra ar kundfordringar' },
{ id: 'avstamning', label: 'Avstämning', hint: 'Stäm av bank och skattekonto', icon: Scale, href: '/reconciliation', keywords: 'avstämning stäm av bank skattekonto matcha reconcile reconciliation 1630 1930' },
{ id: 'rapport-bankavstamning', label: 'Bankavstämning', hint: 'Stäm av bank mot bokföring', icon: ArrowLeftRight, href: '/reports/bank-reconciliation', keywords: 'avstämning stäm av bank matcha banktransaktioner reconcile reconciliation 1930' },
{ id: 'rapport-bankavstamning', label: 'Bankavstämning', hint: 'Stäm av bank mot bokföring', icon: ArrowLeftRight, href: '/reconciliation', keywords: 'avstämning stäm av bank matcha banktransaktioner reconcile reconciliation 1930' },
{ id: 'importera', label: 'Importera', icon: Upload, href: '/import' },
{ id: 'granskning', label: 'Granskning', icon: ClipboardCheck, href: '/pending', keywords: 'pending review' },
{ id: 'löner', label: 'Löner', icon: HandCoins, href: '/salary' },
+100 -12
View File
@@ -1,9 +1,10 @@
'use client'
import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
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'
@@ -67,6 +68,8 @@ export interface ReconciliationWindow {
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. */
@@ -75,7 +78,7 @@ interface AccountOverviewProps {
onChanged: () => void
}
export function AccountOverview({ account, rail, window, onChanged }: AccountOverviewProps) {
export function AccountOverview({ account, rail, otherBankAccounts = [], window, onChanged }: AccountOverviewProps) {
const t = useTranslations('reconciliation')
const locale = useLocale()
const { toast } = useToast()
@@ -87,6 +90,9 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
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'
const base = `/api/reconciliation/accounts/${encodeURIComponent(account.account_key)}`
@@ -317,6 +323,48 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
}
}
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) {
@@ -431,7 +479,6 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
(byBucket.get('unmatched_external')?.length ?? 0) +
(byBucket.get('unmatched_ledger')?.length ?? 0)
const bankViewHref = '/reports/bank-reconciliation'
// Default sign-off date: the window end, never past today nor past the
// skattekonto snapshot. The button hides when that date is already signed.
@@ -537,20 +584,26 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
{t('signoff_button', { date: formatDate(signoffDefaultDate) })}
</Button>
)}
<span className="ml-auto">
<Link href={isSkv ? '/skattekonto' : bankViewHref} className={QUIET_LINK_CLASS}>
{isSkv ? t('action_open_skattekonto') : t('action_open_bank_view')}
</Link>
</span>
{isSkv && (
<span className="ml-auto">
<Link href="/skattekonto" className={QUIET_LINK_CLASS}>
{t('action_open_skattekonto')}
</Link>
</span>
)}
</div>
{items.older_unmatched_count > 0 && (
<p className="text-[12.5px] text-muted-foreground">
{t('older_unmatched', { count: items.older_unmatched_count })}
{' · '}
<Link href={isSkv ? '/skattekonto' : bankViewHref} className={QUIET_LINK_CLASS}>
{t('older_show')}
</Link>
{isSkv && (
<>
{' · '}
<Link href="/skattekonto" className={QUIET_LINK_CLASS}>
{t('older_show')}
</Link>
</>
)}
</p>
)}
@@ -635,6 +688,9 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
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>
@@ -710,6 +766,11 @@ interface ItemRowProps {
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({
@@ -724,6 +785,9 @@ function ItemRow({
onIgnore,
onUnignore,
onBook,
onMarkIb,
moveTargets,
onMove,
}: ItemRowProps) {
const t = useTranslations('reconciliation')
const can = (a: ReconciliationItem['actions'][number]) => item.actions.includes(a)
@@ -837,6 +901,30 @@ function ItemRow({
{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>
@@ -214,6 +214,7 @@ export function ReconciliationWorkspace({ initialPeriods, initialCompanyId }: Re
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()}
/>
File diff suppressed because it is too large Load Diff
+1 -49
View File
@@ -7,7 +7,6 @@ import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { ChevronLeft } from 'lucide-react'
import { PageHeader } from '@/components/ui/page-header'
import { HelpPopover } from '@/components/ui/help-popover'
import { EmptyState } from '@/components/ui/empty-state'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
@@ -18,10 +17,6 @@ import { DimensionFilter, type DimensionFilterValue } from '@/components/reports
import { DATE_RANGE_SLUGS, DIMENSION_FILTER_SLUGS, getReport } from '@/lib/reports/catalog'
import type { FiscalPeriod } from '@/types'
/** Preset memory for the bank-reconciliation range, deliberately separate from
* the shared report-family key so the two cannot steer each other. */
const RECONCILIATION_RANGE_KEY_PREFIX = 'Accounted:recon-range-preset:'
function ReportViewLoading() {
return (
<Card>
@@ -60,10 +55,6 @@ const BehandlingshistorikView = dynamic(() =>
import('./BehandlingshistorikView').then((module) => ({ default: module.BehandlingshistorikView })),
{ loading: ReportViewLoading },
)
const BankReconciliationView = dynamic(() =>
import('./BankReconciliationView').then((module) => ({ default: module.BankReconciliationView })),
{ loading: ReportViewLoading },
)
/**
* The focused single-report experience at /reports/[slug]. Carries one report:
@@ -76,13 +67,10 @@ function FocusedReportInner({
slug,
initialPeriods,
initialCompanyId,
autoRun,
}: {
slug: string
initialPeriods: FiscalPeriod[]
initialCompanyId: string | null
/** ?autorun=1: the bank-reconciliation view runs its preview once on load. */
autoRun?: boolean
}) {
const router = useRouter()
const searchParams = useSearchParams()
@@ -134,16 +122,7 @@ function FocusedReportInner({
// Page help behind a "?" (UI-migration convention 7): the report
// bodies carry no instructional copy in the page flow.
help={
slug === 'bank-reconciliation' ? (
<HelpPopover>
<div className="space-y-2">
<p>{t('help_bank_reconciliation_scope')}</p>
<p>{t('help_bank_reconciliation_preview')}</p>
<p>{t('help_bank_reconciliation_ib')}</p>
<p>{t('help_bank_reconciliation_ignored')}</p>
</div>
</HelpPopover>
) : undefined
undefined
}
action={
<FyPicker
@@ -171,14 +150,6 @@ function FocusedReportInner({
periodEnd={selectedPeriodBounds.end}
value={dateRange}
onChange={setDateRange}
// A reconciliation is carried out over a whole räkenskapsår, so it
// opens on the full year and keeps its own preset memory: inheriting
// a "Denna månad" left over from Resultatrapport would show an
// alarming difference for a window the user never chose here.
defaultPreset={slug === 'bank-reconciliation' ? 'full_year' : undefined}
storageKeyPrefix={
slug === 'bank-reconciliation' ? RECONCILIATION_RANGE_KEY_PREFIX : undefined
}
/>
)}
@@ -198,14 +169,12 @@ function FocusedReportInner({
slug={slug}
reportName={reportName}
periodId={selectedPeriod}
periodBounds={selectedPeriodBounds}
dateRange={dateRange}
dimensionFilter={dimensionFilter}
accountFilter={accountFilter}
isEnskildFirma={isEnskildFirma}
isAktiebolag={isAktiebolag}
onNavigateToAccount={navigateToAccount}
autoRun={autoRun}
/>
) : (
<EmptyState
@@ -223,26 +192,22 @@ function FocusedView({
slug,
reportName,
periodId,
periodBounds,
dateRange,
dimensionFilter,
accountFilter,
isEnskildFirma,
isAktiebolag,
onNavigateToAccount,
autoRun,
}: {
slug: string
reportName: string
periodId: string
periodBounds: { start: string; end: string } | null
dateRange: DateRangeValue
dimensionFilter: DimensionFilterValue | null
accountFilter: string | null
isEnskildFirma: boolean
isAktiebolag: boolean
onNavigateToAccount: (account: string) => void
autoRun?: boolean
}) {
switch (slug) {
case 'resultatrapport':
@@ -275,15 +240,6 @@ function FocusedView({
return <SupplierLedgerView periodId={periodId} />
case 'behandlingshistorik':
return <BehandlingshistorikView periodId={periodId} dateRange={dateRange} />
case 'bank-reconciliation':
return (
<BankReconciliationView
periodId={periodId}
periodBounds={periodBounds}
dateRange={dateRange}
autoRun={autoRun}
/>
)
default:
return null
}
@@ -293,13 +249,10 @@ export function FocusedReport({
slug,
initialPeriods,
initialCompanyId,
autoRun,
}: {
slug: string
initialPeriods: FiscalPeriod[]
initialCompanyId: string | null
/** ?autorun=1: the bank-reconciliation view runs its preview once on load. */
autoRun?: boolean
}) {
return (
<Suspense fallback={<div className="space-y-8" />}>
@@ -307,7 +260,6 @@ export function FocusedReport({
slug={slug}
initialPeriods={initialPeriods}
initialCompanyId={initialCompanyId}
autoRun={autoRun}
/>
</Suspense>
)
@@ -112,14 +112,11 @@ describe('transactions page booking feedback', () => {
* Duplicate-guard feedback parity: every client of POST /categorize must route
* a TRANSACTION_BOOK_POSSIBLE_DUPLICATE 409 into DuplicateBookingDialog (which
* offers match / ignore / book-anyway), never into a destructive toast that
* names no way forward. Two clients used to dead-end: the counterparty-
* template branch of handleQuickReviewConfirm, and BankReconciliationView's
* quick-book.
* names no way forward. The counterparty-template branch of
* handleQuickReviewConfirm used to dead-end. (The old BankReconciliationView
* quick-book was retired with the view on 2026-08-25; /reconciliation books
* through the shared transactions inbox flow instead.)
*/
const BANK_RECON_SRC = fs.readFileSync(
path.resolve(__dirname, '../../reports/BankReconciliationView.tsx'),
'utf8',
)
describe('duplicate-guard 409 routing parity', () => {
it('handles the duplicate code on both booking paths of the transactions page', () => {
@@ -136,14 +133,4 @@ describe('duplicate-guard 409 routing parity', () => {
/TRANSACTION_BOOK_POSSIBLE_DUPLICATE'[\s\S]{0,600}cpCategorize\(\{\s*\n?\s*expectedDuplicateJournalEntryId: candidate\.journal_entry_id,/,
)
})
it('routes the quick-book 409 on the reconciliation page into the shared dialog', () => {
expect(BANK_RECON_SRC).toContain("'TRANSACTION_BOOK_POSSIBLE_DUPLICATE'")
expect(BANK_RECON_SRC).toContain('<DuplicateBookingDialog')
expect(BANK_RECON_SRC).toMatch(/setDuplicateWarning\(\{/)
// The retry re-runs the quick-book with force bound to the candidate.
expect(BANK_RECON_SRC).toMatch(
/handleQuickBook\(transactionId, templateId, \{\s*\n?\s*expectedDuplicateJournalEntryId: candidate\.journal_entry_id,/,
)
})
})
+1 -4
View File
@@ -152,10 +152,7 @@ export async function buildBokslutReadinessReport(
reconciliation.unmatched_transaction_count > 0
? `${reconciliation.unmatched_transaction_count} banktransaktioner är inte matchade. Avstäm banken innan bokslut.`
: `Bankavstämningen visar en differens på ${reconciliation.difference.toFixed(2)} kr.`,
// Bankavstämning's real route: the earlier '/reconciliation/bank' href
// pointed at a page that has never existed, so the wizard's "Öppna"
// link 404ed.
href: '/reports/bank-reconciliation',
href: '/reconciliation',
})
}
+4
View File
@@ -305,6 +305,10 @@ export const REPORT_CATALOG: ReportDescriptor[] = [
// uses the shared ReportDateRange like every other report, mounted with a
// full-year default and its own preset memory (see FocusedReport).
params: 'fiscal-range',
// 2026-08-25: the bank view was absorbed by /reconciliation (matcher,
// manual N:1 matching, residual booking, IB tag, move-to-account all live
// there). The slug stays for old links and the report library; it redirects.
route: '/reconciliation',
},
// --- Export & arkiv: library-only ---
+5 -1
View File
@@ -7922,7 +7922,11 @@
"residual_interest_expense": "Interest expense (8410)",
"residual_interest_income": "Interest income (8310)",
"residual_rounding": "Rounding (3740)",
"toast_residual_booked": "{amount} booked and linked"
"toast_residual_booked": "{amount} booked and linked",
"row_mark_ib": "Mark as opening balance",
"row_move": "Move to account",
"toast_marked_ib": "The voucher was marked as opening balance",
"toast_moved": "The transaction was moved to {account}"
},
"skattekonto": {
"help_text": "The balance and events are fetched from Skatteverket and synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically; anything that cannot be matched is flagged in the list. Pay in via bankgiro 5050-1055 with your OCR number.",
+5 -1
View File
@@ -7922,7 +7922,11 @@
"residual_interest_expense": "Räntekostnad (8410)",
"residual_interest_income": "Ränteintäkt (8310)",
"residual_rounding": "Öresavrundning (3740)",
"toast_residual_booked": "{amount} bokfört och kopplat"
"toast_residual_booked": "{amount} bokfört och kopplat",
"row_mark_ib": "Märk som IB",
"row_move": "Flytta till konto",
"toast_marked_ib": "Verifikatet markerades som ingående balans",
"toast_moved": "Transaktionen flyttades till {account}"
},
"skattekonto": {
"help_text": "Saldot och händelserna hämtas från Skatteverket och synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt; det som inte kan matchas flaggas i listan. Betala in via bankgiro 5050-1055 med ditt OCR-nummer.",