* feat(bokslut): IL 18 kap pooled tax depreciation with method election Rakenskapsenlig (huvudregel 30 / kompletteringsregel 20) and restvarde 25 as a company-level annual pool separate from per-asset book depreciation. Method election persisted with immutable snapshots and book-conformity confirmation for rakenskapsenlig. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(db): move tax depreciation migrations to coordinated versions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): keep tax depreciation view loadable when a saved election goes stale A predecessor's changed closing value can push a saved elected deduction above the new statutory maximum; the view now falls back to the statutory recomputation so the snapshot is flagged stale instead of crashing. Ratchet naive-ore-round baseline down by 3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): resolve tax-depreciation period selects statically The no-phantom-columns guard counts every select it cannot resolve toward a hard ceiling, and the PERIOD_COLUMNS join pushed the repo 4 over (364 > 360). Inline the literal column list at the four call sites so the guard verifies these columns instead of skipping them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): address review findings on tax depreciation election - DepreciationPanel: gate the saving flag on a dedicated save sequence so a successful save (which refreshes the view and bumps the request version) no longer leaves the card permanently busy - computeTaxDepreciation: refuse kompletteringsregel_20 with a positive basis and no acquisition cohorts instead of degenerating to a full write-off the cohort evidence does not support (IL 18 kap. 17 §) - migration 227000: judge the asset-method guards on NEW.disposed_at so reversing a disposal cannot reactivate a grandfathered non-linear row - migration 227200: require snapshot column completeness in the CHECK; SQL NULL semantics let partially populated snapshots pass the pure arithmetic comparisons - depreciation route: use the string issue code 'custom' like the rest of the codebase instead of the Zod 3 compat enum Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
393 lines
15 KiB
TypeScript
393 lines
15 KiB
TypeScript
'use client'
|
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { useRouter, useSearchParams } from 'next/navigation'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import { EmptyState } from '@/components/ui/empty-state'
|
|
import { ContextPicker } from '@/components/common/ContextPicker'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { CalendarPlus, Check, Lock } from 'lucide-react'
|
|
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
|
|
import { cn } from '@/lib/utils'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
|
import type { FiscalPeriod, YearEndPreview, YearEndResult } from '@/types'
|
|
import type { BokslutReadinessReport } from '@/lib/bokslut/readiness-aggregator'
|
|
import { PreflightStep } from '@/components/bookkeeping/year-end/PreflightStep'
|
|
import { DispositionsStep } from '@/components/bookkeeping/year-end/DispositionsStep'
|
|
import { AccrualsStep } from '@/components/bookkeeping/year-end/AccrualsStep'
|
|
import { PreviewStep } from '@/components/bookkeeping/year-end/PreviewStep'
|
|
import { ExecuteStep } from '@/components/bookkeeping/year-end/ExecuteStep'
|
|
import { ResultStep } from '@/components/bookkeeping/year-end/ResultStep'
|
|
|
|
type Step = 'preflight' | 'accruals' | 'dispositions' | 'preview' | 'execute' | 'result'
|
|
|
|
const STEP_ORDER: Step[] = ['preflight', 'accruals', 'dispositions', 'preview', 'execute', 'result']
|
|
const STEP_LABELS: Record<Step, string> = {
|
|
preflight: 'Kontroll',
|
|
accruals: 'Periodiseringar',
|
|
dispositions: 'Dispositioner',
|
|
preview: 'Förhandsgranska',
|
|
execute: 'Verkställ',
|
|
result: 'Klart',
|
|
}
|
|
|
|
interface PeriodOption {
|
|
id: string
|
|
name: string
|
|
period_start: string
|
|
period_end: string
|
|
}
|
|
|
|
export default function YearEndPage() {
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
const { toast } = useToast()
|
|
|
|
// ---- Period selection ----
|
|
const [periods, setPeriods] = useState<PeriodOption[] | null>(null)
|
|
// Whether the company has ANY fiscal periods (eligible or not) — separates
|
|
// "inget att stänga ännu" from "inget räkenskapsår har skapats".
|
|
const [hasAnyPeriods, setHasAnyPeriods] = useState(true)
|
|
const [periodsError, setPeriodsError] = useState<string | null>(null)
|
|
const [selectedPeriodId, setSelectedPeriodId] = useState<string | null>(
|
|
searchParams.get('period') ?? null,
|
|
)
|
|
|
|
// ---- Wizard state ----
|
|
const [step, setStep] = useState<Step>('preflight')
|
|
const [report, setReport] = useState<BokslutReadinessReport | null>(null)
|
|
const [reportLoading, setReportLoading] = useState(false)
|
|
const [reportError, setReportError] = useState<string | null>(null)
|
|
const [preview, setPreview] = useState<YearEndPreview | null>(null)
|
|
const [previewLoading, setPreviewLoading] = useState(false)
|
|
const [previewError, setPreviewError] = useState<string | null>(null)
|
|
const [executing, setExecuting] = useState(false)
|
|
const [executeError, setExecuteError] = useState<string | null>(null)
|
|
const [result, setResult] = useState<YearEndResult | null>(null)
|
|
const [navigationBlocked, setNavigationBlocked] = useState(false)
|
|
|
|
// ---- Load eligible periods ----
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
const load = async () => {
|
|
try {
|
|
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
|
if (!res.ok) {
|
|
if (!cancelled) setPeriodsError('Kunde inte hämta perioder')
|
|
return
|
|
}
|
|
const { data } = (await res.json()) as { data: FiscalPeriod[] }
|
|
const all = data ?? []
|
|
const today = new Date().toISOString().split('T')[0]
|
|
const eligible = all.filter(
|
|
(p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today,
|
|
)
|
|
// Oldest first: accountants close in order.
|
|
eligible.sort((a, b) => a.period_start.localeCompare(b.period_start))
|
|
if (cancelled) return
|
|
setHasAnyPeriods(all.length > 0)
|
|
|
|
// The URL ?period= param may point anywhere: at an ineligible period
|
|
// (e.g. a year that has not ended) or, after a company switch, at a
|
|
// period that does not exist in this company at all. An unknown id is
|
|
// reset to the first eligible period; a known-but-ineligible one is
|
|
// kept selectable in the dropdown so the user can navigate away from
|
|
// it instead of being stuck (the readiness step explains why it
|
|
// cannot be closed).
|
|
let options = eligible
|
|
if (selectedPeriodId) {
|
|
const known = all.find((p) => p.id === selectedPeriodId)
|
|
if (!known) {
|
|
setSelectedPeriodId(eligible.length > 0 ? eligible[0].id : null)
|
|
} else if (!eligible.some((p) => p.id === selectedPeriodId)) {
|
|
options = [...eligible, known].sort((a, b) =>
|
|
a.period_start.localeCompare(b.period_start),
|
|
)
|
|
}
|
|
} else if (eligible.length > 0) {
|
|
setSelectedPeriodId(eligible[0].id)
|
|
}
|
|
setPeriods(options)
|
|
} catch {
|
|
if (!cancelled) setPeriodsError('Kunde inte hämta perioder')
|
|
}
|
|
}
|
|
void load()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [selectedPeriodId])
|
|
|
|
// ---- Sync selected period to URL so users can bookmark / share ----
|
|
useEffect(() => {
|
|
if (!selectedPeriodId) return
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
if (params.get('period') === selectedPeriodId) return
|
|
params.set('period', selectedPeriodId)
|
|
router.replace(`/bookkeeping/year-end?${params.toString()}`, { scroll: false })
|
|
}, [selectedPeriodId, router, searchParams])
|
|
|
|
// ---- Fetch readiness report whenever selected period changes ----
|
|
useEffect(() => {
|
|
if (!selectedPeriodId) return
|
|
let cancelled = false
|
|
setReportLoading(true)
|
|
setReportError(null)
|
|
setReport(null)
|
|
fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/bokslut-readiness`)
|
|
.then(async (res) => {
|
|
const body = await res.json()
|
|
if (cancelled) return
|
|
if (!res.ok) {
|
|
setReportError(getErrorMessage(body?.error) ?? 'Kunde inte ladda bokslutskontroll')
|
|
return
|
|
}
|
|
setReport(body.data as BokslutReadinessReport)
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setReportError('Kunde inte ladda bokslutskontroll')
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setReportLoading(false)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [selectedPeriodId])
|
|
|
|
// ---- Step navigation ----
|
|
const goToPreview = useCallback(async () => {
|
|
if (!selectedPeriodId) return
|
|
setPreviewLoading(true)
|
|
setPreviewError(null)
|
|
setStep('preview')
|
|
try {
|
|
const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`)
|
|
const body = await res.json()
|
|
if (!res.ok) {
|
|
setPreviewError(getErrorMessage(body?.error) ?? 'Kunde inte hämta förhandsgranskning')
|
|
return
|
|
}
|
|
setPreview(body.data.preview as YearEndPreview)
|
|
} catch (err) {
|
|
setPreviewError(getErrorMessage(err))
|
|
} finally {
|
|
setPreviewLoading(false)
|
|
}
|
|
}, [selectedPeriodId])
|
|
|
|
const executeYearEnd = useCallback(async () => {
|
|
if (!selectedPeriodId) return
|
|
setExecuting(true)
|
|
setExecuteError(null)
|
|
try {
|
|
const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`, {
|
|
method: 'POST',
|
|
})
|
|
const body = await res.json()
|
|
if (!res.ok) {
|
|
// body.error.message is the localized Swedish message picked by
|
|
// the structured-error registry. Do NOT interpolate raw details
|
|
// here: they can contain DB-sourced strings (V2.3 finding).
|
|
setExecuteError(getErrorMessage(body?.error) ?? 'Bokslutet kunde inte verkställas')
|
|
return
|
|
}
|
|
setResult(body.data as YearEndResult)
|
|
setStep('result')
|
|
toast({
|
|
title: 'Bokslut verkställt',
|
|
description: `${report?.period.name ?? 'Perioden'} är stängd.`,
|
|
})
|
|
} catch (err) {
|
|
setExecuteError(getErrorMessage(err))
|
|
} finally {
|
|
setExecuting(false)
|
|
}
|
|
}, [selectedPeriodId, report?.period.name, toast])
|
|
|
|
const currentStepIndex = STEP_ORDER.indexOf(step)
|
|
|
|
const showWizard = useMemo(
|
|
() => selectedPeriodId !== null && (periods?.length ?? 0) > 0,
|
|
[selectedPeriodId, periods],
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
|
<h1 className="font-display text-2xl leading-8 tracking-tight">Årsbokslut</h1>
|
|
<div className="flex items-center gap-2">
|
|
<AgentSparkleButton
|
|
intentId="bokslut.step"
|
|
intentArgs={{ step_id: null }}
|
|
contextRef="bokslut:overview"
|
|
size="default"
|
|
/>
|
|
{showWizard && periods && periods.length > 0 && step !== 'result' && (
|
|
<ContextPicker
|
|
items={periods.map((p) => ({
|
|
id: p.id,
|
|
label: p.name,
|
|
annotation: `${p.period_start} till ${p.period_end}`,
|
|
}))}
|
|
value={selectedPeriodId}
|
|
disabled={navigationBlocked}
|
|
onChange={(value) => {
|
|
setSelectedPeriodId(value)
|
|
setStep('preflight')
|
|
setPreview(null)
|
|
setResult(null)
|
|
setExecuteError(null)
|
|
}}
|
|
triggerLabel={
|
|
periods.find((p) => p.id === selectedPeriodId)?.name ?? 'Välj period'
|
|
}
|
|
ariaLabel="Period"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{periods === null && !periodsError && (
|
|
<Card>
|
|
<CardContent className="p-6 space-y-2">
|
|
<Skeleton className="h-6 w-1/3" />
|
|
<Skeleton className="h-4 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{periodsError && (
|
|
<Card>
|
|
<CardContent className="p-6 text-destructive">{periodsError}</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{periods !== null && periods.length === 0 && (
|
|
hasAnyPeriods ? (
|
|
<EmptyState
|
|
icon={Lock}
|
|
title="Inga perioder att stänga"
|
|
description="Det finns ingen öppen räkenskapsperiod vars slutdatum redan har passerat. Bokslut görs efter att periodens slutdatum är passerat."
|
|
/>
|
|
) : (
|
|
<EmptyState
|
|
icon={CalendarPlus}
|
|
title="Inget räkenskapsår ännu"
|
|
description="Bokslut görs per räkenskapsår. Skapa företagets räkenskapsår i bokföringsinställningarna för att komma igång."
|
|
actionLabel="Öppna bokföringsinställningar"
|
|
actionHref="/settings/bookkeeping"
|
|
/>
|
|
)
|
|
)}
|
|
|
|
{/* Stegen (concept scene 34): dots walk check -> filled -> number.
|
|
Steps behind the current one are clickable for going back; forward
|
|
navigation stays with each step's own continue action. */}
|
|
{showWizard && step !== 'result' && (
|
|
<div
|
|
className="mx-auto flex w-full max-w-4xl items-center gap-3 overflow-x-auto px-1"
|
|
role="tablist"
|
|
aria-label="Bokslutets steg"
|
|
>
|
|
{STEP_ORDER.map((s, i) => (
|
|
<React.Fragment key={s}>
|
|
{i > 0 && <span className="h-px min-w-4 flex-1 bg-border" aria-hidden="true" />}
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={s === step}
|
|
disabled={navigationBlocked || i >= currentStepIndex}
|
|
onClick={() => {
|
|
if (!navigationBlocked && i < currentStepIndex) setStep(s)
|
|
}}
|
|
className={cn(
|
|
'group flex shrink-0 items-center gap-2 text-left',
|
|
(navigationBlocked || i >= currentStepIndex) && 'cursor-default',
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs tabular-nums transition-colors duration-150',
|
|
i < currentStepIndex
|
|
? 'border-success/40 text-success'
|
|
: s === step
|
|
? 'border-foreground bg-foreground text-background'
|
|
: 'border-border text-muted-foreground',
|
|
)}
|
|
>
|
|
{i < currentStepIndex ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : i + 1}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
'text-[12.5px] transition-colors duration-150',
|
|
s === step
|
|
? 'font-medium'
|
|
: i < currentStepIndex
|
|
? 'text-muted-foreground group-hover:text-foreground'
|
|
: 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{STEP_LABELS[s]}
|
|
</span>
|
|
</button>
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{showWizard && step === 'preflight' && (
|
|
<div className="mx-auto w-full max-w-3xl"><PreflightStep
|
|
report={report}
|
|
isLoading={reportLoading}
|
|
error={reportError}
|
|
onContinue={() => setStep('accruals')}
|
|
/></div>
|
|
)}
|
|
|
|
{showWizard && step === 'accruals' && selectedPeriodId && (
|
|
<AccrualsStep
|
|
periodId={selectedPeriodId}
|
|
onBack={() => setStep('preflight')}
|
|
onContinue={() => setStep('dispositions')}
|
|
/>
|
|
)}
|
|
|
|
{showWizard && step === 'dispositions' && selectedPeriodId && (
|
|
<DispositionsStep
|
|
periodId={selectedPeriodId}
|
|
onBack={() => setStep('accruals')}
|
|
onContinue={goToPreview}
|
|
onNavigationBlockedChange={setNavigationBlocked}
|
|
/>
|
|
)}
|
|
|
|
{showWizard && step === 'preview' && (
|
|
<div className="mx-auto w-full max-w-3xl"><PreviewStep
|
|
preview={preview}
|
|
isLoading={previewLoading}
|
|
error={previewError}
|
|
onBack={() => setStep('dispositions')}
|
|
onContinue={() => setStep('execute')}
|
|
/></div>
|
|
)}
|
|
|
|
{showWizard && step === 'execute' && report && (
|
|
<div className="mx-auto w-full max-w-3xl"><ExecuteStep
|
|
periodName={report.period.name}
|
|
isRunning={executing}
|
|
error={executeError}
|
|
bolagsskattMissing={preview?.bolagsskattMissing ?? false}
|
|
onBack={() => setStep('preview')}
|
|
onExecute={executeYearEnd}
|
|
/></div>
|
|
)}
|
|
|
|
{step === 'result' && result && (
|
|
<div className="mx-auto w-full max-w-3xl"><ResultStep result={result} /></div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|