From 739f18fd1cedc378a091dc16902a850509a0ab57 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:42:49 +0200 Subject: [PATCH] =?UTF-8?q?fix(reconciliation):=20drive=20bank=20avst?= =?UTF-8?q?=C3=A4mning=20period=20from=20the=20report=20header=20(#771)=20?= =?UTF-8?q?(#774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bank reconciliation view hosted its OWN FiscalYearSelector inside the action bar — but that bar renders below the loading-skeleton early-return, so the selector never mounted, its onReady/onChange never fired, the periodReady gate never flipped, and the page hung on a permanent skeleton (#771). Make the report period-scoped like the ledgers: lib/reports/catalog.ts marks bank-reconciliation `params: 'fiscal'`, so the report page's räkenskapsår selector owns the period. FocusedReport passes periodId + periodBounds down, and BankReconciliationView takes them as props instead of self-selecting. The window seeds from periodBounds and a periodId-keyed effect re-seeds (and writes dateFromRef/dateToRef synchronously) on a year switch, preserving the #751 period-scoped IB-floor behaviour without the deadlock. Manual date edits still apply on demand via "Filtrera". Co-authored-by: Claude Opus 4.8 (1M context) --- components/reports/BankReconciliationView.tsx | 113 ++++++++++-------- components/reports/FocusedReport.tsx | 2 +- lib/reports/catalog.ts | 7 +- 3 files changed, 71 insertions(+), 51 deletions(-) diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx index 854cb6ab..05cf7d00 100644 --- a/components/reports/BankReconciliationView.tsx +++ b/components/reports/BankReconciliationView.tsx @@ -13,7 +13,6 @@ import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye, EyeOf import { formatCurrency, formatDate } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { CashAccountSelector } from '@/components/common/CashAccountSelector' -import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' import { MatchVerifikationPicker, type UnlinkedGLLine } from '@/components/reconciliation/MatchVerifikationPicker' import { DropdownMenu, @@ -29,7 +28,7 @@ import { } from '@/components/ui/destructive-confirm-dialog' import { useToast } from '@/components/ui/use-toast' import { ToastAction } from '@/components/ui/toast' -import type { CashAccount, FiscalPeriod } from '@/types' +import type { CashAccount } from '@/types' function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) @@ -122,7 +121,19 @@ interface DryRunMatch { // Component // ============================================================ -export function BankReconciliationView() { +interface BankReconciliationViewProps { + /** + * The fiscal period to reconcile, from the page-level räkenskapsår selector in + * the report header (FocusedReport). The view no longer owns a selector of its + * own — that duplicate, hidden behind the loading skeleton, deadlocked the page + * (#771). + */ + periodId: string + /** period_start / period_end of that period; seeds the date window (#751). */ + periodBounds: { start: string; end: string } | null +} + +export function BankReconciliationView({ periodId, periodBounds }: BankReconciliationViewProps) { const [status, setStatus] = useState(null) const [unmatchedTx, setUnmatchedTx] = useState([]) const [glLines, setGlLines] = useState([]) @@ -130,17 +141,23 @@ export function BankReconciliationView() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - // The window is scoped to a fiscal period (issue #751): a bank reconciliation - // is inherently per-period, and a "full history" window spans the fiscal-year - // boundary — mixing a prior period's movements with the current year's IB and - // manufacturing a phantom difference equal to the IB. dateFrom/dateTo are - // seeded from the selected räkenskapsår below (and stay editable as a manual - // override). The first fetch is gated on `periodReady` so we never flash the - // full-history numbers before the period is known. - const [dateFrom, setDateFrom] = useState('') - const [dateTo, setDateTo] = useState(() => new Date().toISOString().slice(0, 10)) - const [selectedPeriodId, setSelectedPeriodId] = useState(null) - const [periodReady, setPeriodReady] = useState(false) + // The window is scoped to a fiscal period (issue #751/#771): a bank + // reconciliation is inherently per-period, and a "full history" window spans + // the fiscal-year boundary — mixing a prior period's movements with the current + // year's IB and manufacturing a phantom difference equal to the IB. The period + // is owned by the page-level FiscalYearSelector in the report header and passed + // in as props, so the view always mounts with a known window. It used to host + // its OWN selector inside the action bar and gate the first fetch on a + // `periodReady` flag — but that selector lived below the loading-skeleton + // early-return, so it never mounted, the flag never flipped, and the page hung + // on a permanent skeleton (#771). dateFrom/dateTo are seeded from periodBounds + // here and stay editable as a manual override (applied via "Filtrera"). + const [dateFrom, setDateFrom] = useState(periodBounds?.start ?? '') + const [dateTo, setDateTo] = useState(() => { + const today = new Date().toISOString().slice(0, 10) + if (periodBounds && periodBounds.end < today) return periodBounds.end + return today + }) const [accountNumber, setAccountNumber] = useState('1930') const [cashAccounts, setCashAccounts] = useState([]) // Date filters apply on demand (the "Filtrera" button or an account switch), @@ -306,37 +323,42 @@ export function BankReconciliationView() { // "Filtrera" button calls fetchAll() explicitly for date changes. }, [accountNumber, accountCurrency, includeMatched]) - // Seed the reconciliation window from the selected räkenskapsår. dateTo is - // clamped to today for the current (open) year so we don't claim to reconcile - // into the future; a past year ends at its period_end. periodReady unblocks the - // gated initial fetch below. The dates stay editable via the manual inputs. - const handlePeriodChange = useCallback( - (periodId: string | null, period?: FiscalPeriod | null) => { - setSelectedPeriodId(periodId) - if (period) { - const today = new Date().toISOString().slice(0, 10) - setDateFrom(period.period_start) - setDateTo(period.period_end < today ? period.period_end : today) - } - setPeriodReady(true) - }, - [], - ) + // Re-seed the date window whenever the selected räkenskapsår changes (driven + // by the page-level FiscalYearSelector in the report header). dateTo is clamped + // to today for the current (open) year so we don't claim to reconcile into the + // future; a past year ends at its period_end. + // + // We write dateFromRef/dateToRef SYNCHRONOUSLY here, not just the state: fetchAll + // reads the window from the refs, and the [dateFrom]/[dateTo] sync effects above + // only refresh them on the NEXT commit — too late for the fetch effect below, + // which runs on this same period-switch commit. Without the synchronous ref + // write the first load after a year switch would use the PREVIOUS period's + // window (off-by-one). This effect MUST stay declared ABOVE the fetch effect so + // React runs it first. + // + // Keyed on periodId ONLY: switching the bank account must re-fetch (via the + // fetch effect, whose fetchAll identity changes) but must NOT re-seed the dates + // and discard a manual "Datum från/till" edit. + useEffect(() => { + if (!periodBounds) return + const today = new Date().toISOString().slice(0, 10) + const from = periodBounds.start + const to = periodBounds.end < today ? periodBounds.end : today + setDateFrom(from) + setDateTo(to) + dateFromRef.current = from + dateToRef.current = to + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [periodId]) - // Gate the first load until the fiscal period (and therefore the date window) - // is known — otherwise we'd fetch once with the empty full-history window and - // briefly render the phantom-diff numbers before the period seeds the dates. - // The date-seeding setState in handlePeriodChange updates dateFromRef/dateToRef - // (the effects above) before this effect runs on the same render, so fetchAll - // reads the freshly-seeded window. selectedPeriodId is a dependency so that - // switching räkenskapsår re-fetches with the new window, and so a late period - // selection (e.g. if the selector signals ready before the company context has - // hydrated and onChange arrives a tick later) still triggers the real fetch. - // Manual date edits intentionally do NOT auto-fetch — that stays on "Filtrera". + // Load on mount, when the bank account / currency / matched-toggle change + // (fetchAll identity), and when the räkenskapsår switches (periodId). fetchAll + // reads the window from the refs, which the effect above has already refreshed + // for a period switch. Manual date edits intentionally do NOT auto-fetch — that + // stays on the explicit "Filtrera" button (which calls fetchAll() directly). useEffect(() => { - if (!periodReady) return fetchAll() - }, [fetchAll, periodReady, selectedPeriodId]) + }, [fetchAll, periodId]) // Reset transient per-account UI state when the selected account changes. A // verifikation pick or a dry-run preview computed for the previous account is @@ -679,13 +701,6 @@ export function BankReconciliationView() { value={accountNumber} onChange={setAccountNumber} /> - setPeriodReady(true)} - includeAllOption={false} - hideFuturePeriods - />
case 'bank-reconciliation': - return + return default: return null } diff --git a/lib/reports/catalog.ts b/lib/reports/catalog.ts index 53c07c92..5f04ffcc 100644 --- a/lib/reports/catalog.ts +++ b/lib/reports/catalog.ts @@ -236,7 +236,12 @@ export const REPORT_CATALOG: ReportDescriptor[] = [ labelKey: 'name_bank_reconciliation', descKey: 'desc_bank_reconciliation', category: 'reconciliation', - params: 'none', + // Period-scoped like the ledgers: the report page's räkenskapsår selector + // drives the reconciliation window (issue #751). Was 'none' (periodless), + // which left the view to host its OWN fiscal-year selector inside a + // loading-gated action bar — a render deadlock that hung the page on a + // permanent skeleton (#771). + params: 'fiscal', }, // --- Export & arkiv — library-only ---