Second half of the reconciliation redesign, on top of the bridge in #1737. **Toolbar.** The view hosted its own "Datum från / Datum till" inputs behind a Filtrera button: a second period control competing with the header's räkenskapsår picker (convention 8), and the source of a "typed but not applied" state that needed its own attention line to explain. The window is now owned by the page, narrowed through the shared ReportDateRange like every other report, and applied on change. The view holds no date state at all, which also removes the ref-synchronisation dance and the off-by-one it existed to prevent (a year switch fetching the previous year's window because the refs updated a commit late). Reconciliation opens on the FULL year, not the family default of YTD, and keeps its own preset memory: a reconciliation runs over a whole räkenskapsår, and inheriting a "Denna månad" last used on Resultatrapport would show an alarming difference for a window nobody chose here. ReportDateRange gained defaultPreset and storageKeyPrefix for that; every existing caller keeps its behaviour. **Automatic matching.** "Förhandsgranska" told the user nothing about what it did, and the ochre line above it existed only to point at it: people matched a whole migration row by row next to a button they never found. The matcher now runs by itself, once per window+account, whenever there is unmatched work. It is a dry run, so nothing is written and Tillämpa still requires an explicit click. The button stays as a re-run and is renamed to what it does. ?autorun=1 keeps a distinct meaning (run even on a clean window) so the transactions-inbox deep link still produces a result rather than silence. **A way out for rows that cannot be paired.** An unmatched bank row that no voucher on the account could settle is not reconciliation work, it is an unbooked affärshändelse, and the match picker held nothing for it. Those rows now offer "Bokför" into /transactions?highlight=<id>, with a bulk link in the section header. The rule (direction-compatible and equal to the öre) is extracted to lib/reconciliation/voucher-candidate.ts so it is testable and so the component never imports the server-only reconciliation module. Deliberately strict: a false negative offers booking on a row that could also have been paired, which is a legitimate outcome, while a false positive sends the user into an empty picker. 11 new tests for the candidate rule, covering direction, öre equality, float noise, PostgREST numeric strings and the foreign-account case where the candidate RPC projects no FX amount and no match may be claimed. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
309 lines
12 KiB
TypeScript
309 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { Suspense, useState } from 'react'
|
|
import dynamic from 'next/dynamic'
|
|
import Link from 'next/link'
|
|
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'
|
|
import { useCompany } from '@/contexts/CompanyContext'
|
|
import { FyPicker } from '@/components/common/FyPicker'
|
|
import { ReportDateRange, type DateRangeValue } from '@/components/common/ReportDateRange'
|
|
import { DimensionFilter, type DimensionFilterValue } from '@/components/reports/DimensionFilter'
|
|
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>
|
|
<CardContent className="space-y-4 p-6">
|
|
<Skeleton className="h-5 w-32" />
|
|
<Skeleton className="h-64" />
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
const TrialBalanceView = dynamic(() => import('./lazy-views/TrialBalanceView'), { loading: ReportViewLoading })
|
|
const IncomeStatementView = dynamic(() => import('./lazy-views/IncomeStatementView'), { loading: ReportViewLoading })
|
|
const BalanceSheetView = dynamic(() => import('./lazy-views/BalanceSheetView'), { loading: ReportViewLoading })
|
|
const ResultatrapportView = dynamic(() => import('./lazy-views/ResultatrapportView'), { loading: ReportViewLoading })
|
|
const BalansrapportView = dynamic(() => import('./lazy-views/BalansrapportView'), { loading: ReportViewLoading })
|
|
const VatDeclarationView = dynamic(() => import('./lazy-views/VatDeclarationView'), { loading: ReportViewLoading })
|
|
const SupplierLedgerView = dynamic(() => import('./lazy-views/SupplierLedgerView'), { loading: ReportViewLoading })
|
|
const GeneralLedgerView = dynamic(() => import('./lazy-views/GeneralLedgerView'), { loading: ReportViewLoading })
|
|
const JournalRegisterView = dynamic(() => import('./lazy-views/JournalRegisterView'), { loading: ReportViewLoading })
|
|
const ARLedgerView = dynamic(() => import('./lazy-views/ARLedgerView'), { loading: ReportViewLoading })
|
|
const DimensionPnlView = dynamic(() => import('./lazy-views/DimensionPnlView'), { loading: ReportViewLoading })
|
|
const NEDeclarationView = dynamic(() =>
|
|
import('./NEDeclarationView').then((module) => ({ default: module.NEDeclarationView })),
|
|
{ loading: ReportViewLoading },
|
|
)
|
|
const PeriodiskSammanstallningView = dynamic(() =>
|
|
import('./PeriodiskSammanstallningView').then((module) => ({ default: module.PeriodiskSammanstallningView })),
|
|
{ loading: ReportViewLoading },
|
|
)
|
|
const INK2DeclarationView = dynamic(() =>
|
|
import('./INK2DeclarationView').then((module) => ({ default: module.INK2DeclarationView })),
|
|
{ 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:
|
|
* a back link to the library, the shared fiscal-year selector (restored from
|
|
* localStorage so it matches the year picked on the landing), the report's
|
|
* optional date-range control, and the report body. Drilling into an account
|
|
* navigates to /reports/huvudbok?account=…: drill state lives in the URL.
|
|
*/
|
|
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()
|
|
const { company } = useCompany()
|
|
const t = useTranslations('reports')
|
|
|
|
const [selectedPeriod, setSelectedPeriod] = useState('')
|
|
const [selectedPeriodBounds, setSelectedPeriodBounds] = useState<{ start: string; end: string } | null>(null)
|
|
const [dateRange, setDateRange] = useState<DateRangeValue>({})
|
|
const [dimensionFilter, setDimensionFilter] = useState<DimensionFilterValue | null>(null)
|
|
const [isReady, setIsReady] = useState(false)
|
|
|
|
const report = getReport(slug)
|
|
// Calendar (VAT family) and param-less reports don't need a fiscal period.
|
|
const isPeriodless = report?.params === 'calendar' || report?.params === 'none'
|
|
// Nav-promoted pages (Momsdeklaration) drop the library chrome: no back
|
|
// link, no shell fiscal-year selector — the view owns its period controls.
|
|
const isStandalone = !!report?.standalone
|
|
const reportName = report ? t(report.labelKey) : slug
|
|
const accountFilter = searchParams.get('account')
|
|
|
|
const isEnskildFirma = company?.entity_type === 'enskild_firma'
|
|
const isAktiebolag = company?.entity_type === 'aktiebolag'
|
|
|
|
// Drilling from a report into the general ledger is a route change, so the
|
|
// account lands in the URL and the browser back button returns to the report.
|
|
const navigateToAccount = (accountNumber: string) => {
|
|
router.push(`/reports/huvudbok?account=${encodeURIComponent(accountNumber)}`)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{!isStandalone && (
|
|
<Link
|
|
href="/reports"
|
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
{t('back_to_library')}
|
|
</Link>
|
|
)}
|
|
|
|
{/* Standalone pages (Momsdeklaration) render their own PageHeader so
|
|
the primary action can live on the title row; the view receives the
|
|
title via pageTitle instead. */}
|
|
{!isStandalone && (
|
|
<PageHeader
|
|
title={reportName}
|
|
// 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
|
|
}
|
|
action={
|
|
<FyPicker
|
|
value={selectedPeriod || null}
|
|
onChange={(id, period) => {
|
|
setSelectedPeriod(id || '')
|
|
setSelectedPeriodBounds(
|
|
period ? { start: period.period_start, end: period.period_end } : null,
|
|
)
|
|
setDateRange({})
|
|
}}
|
|
includeAllOption={false}
|
|
hideFuturePeriods
|
|
onReady={() => setIsReady(true)}
|
|
initialPeriods={initialPeriods}
|
|
initialCompanyId={initialCompanyId}
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{DATE_RANGE_SLUGS.has(slug) && selectedPeriodBounds && (
|
|
<ReportDateRange
|
|
periodStart={selectedPeriodBounds.start}
|
|
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
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{DIMENSION_FILTER_SLUGS.has(slug) && selectedPeriod && (
|
|
<DimensionFilter value={dimensionFilter} onChange={setDimensionFilter} />
|
|
)}
|
|
|
|
{!isReady && !isPeriodless ? (
|
|
<Card>
|
|
<CardContent className="p-6 space-y-4">
|
|
<Skeleton className="h-5 w-32" />
|
|
<Skeleton className="h-64" />
|
|
</CardContent>
|
|
</Card>
|
|
) : isPeriodless || selectedPeriod ? (
|
|
<FocusedView
|
|
slug={slug}
|
|
reportName={reportName}
|
|
periodId={selectedPeriod}
|
|
periodBounds={selectedPeriodBounds}
|
|
dateRange={dateRange}
|
|
dimensionFilter={dimensionFilter}
|
|
accountFilter={accountFilter}
|
|
isEnskildFirma={isEnskildFirma}
|
|
isAktiebolag={isAktiebolag}
|
|
onNavigateToAccount={navigateToAccount}
|
|
autoRun={autoRun}
|
|
/>
|
|
) : (
|
|
<EmptyState
|
|
title="Inget räkenskapsår valt"
|
|
description="Skapa ett räkenskapsår för att kunna se rapporter."
|
|
actionLabel="Gå till inställningar"
|
|
actionHref="/settings"
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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':
|
|
return <ResultatrapportView periodId={periodId} dateRange={dateRange} dimensionFilter={dimensionFilter} onNavigateToAccount={onNavigateToAccount} />
|
|
case 'dimension-pnl':
|
|
return <DimensionPnlView periodId={periodId} dateRange={dateRange} />
|
|
case 'balansrapport':
|
|
return <BalansrapportView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
|
|
case 'trial-balance':
|
|
return <TrialBalanceView periodId={periodId} onNavigateToAccount={onNavigateToAccount} />
|
|
case 'income-statement':
|
|
return <IncomeStatementView periodId={periodId} dateRange={dateRange} dimensionFilter={dimensionFilter} onNavigateToAccount={onNavigateToAccount} />
|
|
case 'balance-sheet':
|
|
return <BalanceSheetView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
|
|
case 'vat-declaration':
|
|
return <VatDeclarationView pageTitle={reportName} />
|
|
case 'periodisk-sammanstallning':
|
|
return <PeriodiskSammanstallningView />
|
|
case 'ne-declaration':
|
|
return isEnskildFirma ? <NEDeclarationView periodId={periodId} /> : null
|
|
case 'ink2-declaration':
|
|
return isAktiebolag ? <INK2DeclarationView periodId={periodId} /> : null
|
|
case 'huvudbok':
|
|
return <GeneralLedgerView periodId={periodId} initialAccountFilter={accountFilter} dimensionFilter={dimensionFilter} dateRange={dateRange} />
|
|
case 'grundbok':
|
|
return <JournalRegisterView periodId={periodId} />
|
|
case 'kundreskontra':
|
|
return <ARLedgerView periodId={periodId} />
|
|
case 'supplier-ledger':
|
|
return <SupplierLedgerView periodId={periodId} />
|
|
case 'bank-reconciliation':
|
|
return (
|
|
<BankReconciliationView
|
|
periodId={periodId}
|
|
periodBounds={periodBounds}
|
|
dateRange={dateRange}
|
|
autoRun={autoRun}
|
|
/>
|
|
)
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
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" />}>
|
|
<FocusedReportInner
|
|
slug={slug}
|
|
initialPeriods={initialPeriods}
|
|
initialCompanyId={initialCompanyId}
|
|
autoRun={autoRun}
|
|
/>
|
|
</Suspense>
|
|
)
|
|
}
|