'use client' import { useCallback, useEffect, useState } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { ArrowRight, AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react' import { formatCurrency } from '@/lib/utils' import { useToast } from '@/components/ui/use-toast' import { DepreciationPanel } from './DepreciationPanel' import { EfDeclarationSection } from './EfDeclarationSection' import type { DispositionsProposal, ProposedDisposition, DispositionKind, TaxAdjustmentSnapshot, } from '@/lib/bokslut/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' interface DispositionsStepProps { periodId: string onBack: () => void onContinue: () => void onNavigationBlockedChange?: (blocked: boolean) => void } interface UiState { /** kind → user-controlled selection state */ selections: Record } interface TaxAdjustmentDraft { nonDeductibleExpenses: string nonTaxableIncome: string detectedAccounts: { '6992': boolean; '8423': boolean } } /** * Phase 2 bokslutsdispositioner step. Fetches proposals from the dispositions * API, lets the user adjust amounts (or skip) per proposal, then POSTs the * accepted ones. Mandatory p-fond reversals (cohort ≥ 6 years old) cannot be * skipped: checkbox stays disabled-on. * * EF companies get an empty `proposals` array from the server, so this step * renders a short pass-through note and lets the user continue. */ export function DispositionsStep({ periodId, onBack, onContinue, onNavigationBlockedChange, }: DispositionsStepProps) { const { toast } = useToast() const [proposal, setProposal] = useState(null) const [loading, setLoading] = useState(true) const [fetchError, setFetchError] = useState(null) const [ui, setUi] = useState({ selections: {} }) const [posting, setPosting] = useState(false) const [postError, setPostError] = useState(null) const [savingAdjustments, setSavingAdjustments] = useState(false) const [adjustmentError, setAdjustmentError] = useState(null) const [taxAdjustmentDraft, setTaxAdjustmentDraft] = useState(emptyTaxDraft) const [taxDepreciationDirty, setTaxDepreciationDirty] = useState(false) useEffect(() => { onNavigationBlockedChange?.(taxDepreciationDirty) }, [onNavigationBlockedChange, taxDepreciationDirty]) useEffect(() => () => onNavigationBlockedChange?.(false), [onNavigationBlockedChange]) // ---- Fetch proposals ---- const loadProposals = useCallback(async () => { setLoading(true) setFetchError(null) try { const res = await fetch( `/api/bookkeeping/fiscal-periods/${periodId}/bokslutsdispositioner`, ) const body = await res.json() if (!res.ok) { setFetchError(getUserErrorMessage(body?.error) ?? 'Kunde inte ladda dispositioner') return } const data = body.data as DispositionsProposal setProposal(data) setUi(createUiState(data)) setTaxAdjustmentDraft(createTaxAdjustmentDraft(data.taxAdjustments)) } catch { setFetchError('Kunde inte ladda dispositioner') } finally { setLoading(false) } }, [periodId]) const handleSaveAdjustments = useCallback(async () => { setSavingAdjustments(true) setAdjustmentError(null) try { const res = await fetch( `/api/bookkeeping/fiscal-periods/${periodId}/bokslutsdispositioner`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ manualAdjustments: { nonDeductibleExpenses: parseNonNegativeAmount( taxAdjustmentDraft.nonDeductibleExpenses, ), nonTaxableIncome: parseNonNegativeAmount(taxAdjustmentDraft.nonTaxableIncome), }, detectedAccounts: taxAdjustmentDraft.detectedAccounts, }), }, ) const body = await res.json() if (!res.ok) { setAdjustmentError( getUserErrorMessage(body?.error) ?? 'Kunde inte spara skattemässiga justeringar', ) return } const data = body.data as DispositionsProposal setProposal(data) setUi(createUiState(data)) setTaxAdjustmentDraft(createTaxAdjustmentDraft(data.taxAdjustments)) toast({ title: 'Skatteunderlaget är uppdaterat', description: 'Bolagsskatten har räknats om utan att ändra periodiseringsfonden.', }) } catch (err) { setAdjustmentError( err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte spara skattemässiga justeringar', ) } finally { setSavingAdjustments(false) } }, [periodId, taxAdjustmentDraft, toast]) useEffect(() => { void loadProposals() }, [loadProposals]) // ---- POST accepted dispositions ---- const handleCommit = useCallback(async () => { if (!proposal) return if (taxDepreciationDirty) { setPostError('Spara eller återställ ändringarna i skattemässig avskrivning innan du fortsätter.') return } if (proposal.completedDispositions?.some((item) => item.status === 'needs_correction')) { setPostError('Rätta den bokförda bolagsskatten och ladda om sidan innan du fortsätter.') return } setPosting(true) setPostError(null) try { const items = buildPostItems(proposal, ui) if (items.length === 0) { // Nothing selected: just move on onContinue() return } const res = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/bokslutsdispositioner`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items }), }) const body = await res.json() if (!res.ok) { setPostError(getUserErrorMessage(body?.error) ?? 'Kunde inte bokföra dispositioner') return } const created = body.data?.created ?? [] toast({ title: `${created.length} verifikation${created.length === 1 ? '' : 'er'} bokförd${ created.length === 1 ? '' : 'a' }`, description: 'Dispositionerna ligger nu i bokföringen.', }) onContinue() } catch (err) { setPostError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel') } finally { setPosting(false) } }, [proposal, ui, periodId, onContinue, toast, taxDepreciationDirty]) // ---- Render branches ---- if (loading) { return ( ) } if (fetchError) { return (

{fetchError}

) } if (!proposal) return null const hasCorrectionRequired = proposal.completedDispositions?.some( (item) => item.status === 'needs_correction', ) ?? false // EF: depreciation can apply (skattemässig hanteras separat); replace the // AB-only dispositioner with a NE-bilaga declaration section. if (proposal.entityType !== 'aktiebolag') { const fiscalYear = parseInt(proposal.fiscalPeriod.period_end.slice(0, 4), 10) return (
void loadProposals()} onTaxDirtyChange={setTaxDepreciationDirty} /> {taxDepreciationDirty && (

Spara eller återställ ändringarna i skattemässig avskrivning innan du lämnar steget.

)}
) } return (
void loadProposals()} onTaxDirtyChange={setTaxDepreciationDirty} /> Bokslutsdispositioner

Resultat före dispositioner:{' '} {formatCurrency(proposal.netResultBefore)} . Justera beloppen och bocka av de dispositioner du vill boka.

{proposal.taxAdjustments && ( item.kind === 'bolagsskatt')} saving={savingAdjustments} error={adjustmentError} onChange={setTaxAdjustmentDraft} onSave={() => void handleSaveAdjustments()} /> )} {proposal.warnings?.map((warning) => ( {warning} ))} {(proposal.completedDispositions ?? []).map((completed) => (
{completed.label} {completed.status === 'booked' ? ( ) : ( )} {completed.status === 'booked' ? 'Redan bokförd' : 'Behöver rättas'}

{formatCurrency(completed.amount)}

{completed.warnings.length > 0 && ( {completed.warnings.map((warning) => (

{warning}

))}
)}
))} {proposal.proposals.length === 0 && (proposal.completedDispositions ?? []).length === 0 && ( Inga bokslutsdispositioner behöver bokföras utifrån det aktuella underlaget. )} {proposal.proposals.map((p, i) => { const key = proposalKey(p, i) const sel = ui.selections[key] if (!sel) return null return ( { setUi((prev) => ({ selections: { ...prev.selections, [key]: { ...sel, ...next } }, })) }} /> ) })} {postError && ( {postError} )} {taxDepreciationDirty && (

Spara eller återställ ändringarna i skattemässig avskrivning innan du lämnar steget.

)}
) } function TaxAdjustmentsCard({ snapshot, draft, taxProposal, saving, error, onChange, onSave, }: { snapshot: TaxAdjustmentSnapshot draft: TaxAdjustmentDraft taxProposal?: ProposedDisposition saving: boolean error: string | null onChange: (draft: TaxAdjustmentDraft) => void onSave: () => void }) { const detected = snapshot.items.filter( (item) => item.source === 'detected' && item.accountNumber && item.amount > 0, ) const computation = taxProposal?.computation return ( Skattemässiga justeringar

Återläggningar påverkar skatten och INK2, men skapar ingen egen verifikation och ändrar inte en redan bokförd periodiseringsfond.

{detected.length > 0 && (
{detected.map((item) => { const account = item.accountNumber as '6992' | '8423' return (
onChange({ ...draft, detectedAccounts: { ...draft.detectedAccounts, [account]: Boolean(checked), }, }) } />
{formatCurrency(item.amount)}
) })}
)}
onChange({ ...draft, nonDeductibleExpenses: event.target.value }) } />
onChange({ ...draft, nonTaxableIncome: event.target.value })} />
{computation && (
)} {error &&

{error}

}
) } function CalculationRow({ label, amount, prefix = '', strong = false, }: { label: string amount: number prefix?: string strong?: boolean }) { return (
{label} {prefix}{formatCurrency(amount)}
) } function ProposalCard({ proposal, accept, overrideAmount, lockedSkip, onChange, }: { proposal: ProposedDisposition accept: boolean overrideAmount: number | undefined lockedSkip: boolean onChange: (next: { accept?: boolean; overrideAmount?: number }) => void }) { const overridable = isOverridable(proposal) const displayedAmount = overridable ? overrideAmount ?? proposal.amount : proposal.amount return (
{proposal.label}

{proposal.description}

{proposal.required && ( Obligatorisk )}

{formatCurrency(displayedAmount)}

{proposal.warnings.map((w, i) => (

{w}

))} {overridable && (
{ const value = parseInt(e.target.value, 10) onChange({ overrideAmount: Number.isFinite(value) ? value : 0 }) }} />
)}
onChange({ accept: Boolean(checked) })} />
) } function isOverridable(proposal: ProposedDisposition): boolean { // Bolagsskatt and SLP are derived from posted entries: overriding the amount // would silently break the journal posting (the calculator would still // recompute server-side). p-fond avsättning and överavskrivningar take a // desired amount as input, so editing is meaningful. p-fond återföring is // composed of mandatory cohorts and isn't safely editable from a single // amount field. return ( proposal.kind === 'periodiseringsfond_avsattning' || ( proposal.kind === 'overavskrivningar' && (proposal.signedAmount ?? proposal.amount) > 0 ) ) } function proposalKey(p: ProposedDisposition, index = 0): string { // For återföring there can be multiple cards (one per cohort): disambiguate // by including the line account in the key. For other kinds, the kind is unique. if (p.kind === 'periodiseringsfond_ateforing') { return `${p.kind}:${p.lines[0]?.account_number ?? index}` } return `${p.kind}:${index}` } interface PostItem { kind: DispositionKind [key: string]: unknown } function buildPostItems(proposal: DispositionsProposal, ui: UiState): PostItem[] { const items: PostItem[] = [] // Group återföring entries: server expects a single item with a `returns` // map keyed by cohort account. const ateforingReturns: Record = {} for (const p of proposal.proposals) { const key = proposalKey(p, proposal.proposals.indexOf(p)) const sel = ui.selections[key] if (!sel || !sel.accept) continue switch (p.kind) { case 'bolagsskatt': items.push({ kind: 'bolagsskatt' }) break case 'sarskild_loneskatt': items.push({ kind: 'sarskild_loneskatt' }) break case 'periodiseringsfond_avsattning': items.push({ kind: 'periodiseringsfond_avsattning', desiredAmount: sel.overrideAmount ?? p.amount, }) break case 'periodiseringsfond_ateforing': { const account = p.lines[0]?.account_number if (account) ateforingReturns[account] = p.amount break } case 'overavskrivningar': { const signedDefault = p.signedAmount ?? p.amount const selectedAmount = sel.overrideAmount ?? p.amount items.push({ kind: 'overavskrivningar', additionalAmount: signedDefault < 0 ? -Math.abs(selectedAmount) : selectedAmount, }) break } case 'uppskjuten_skatt': // K3 only: server recomputes the amount; client just signals intent. items.push({ kind: 'uppskjuten_skatt' }) break } } if (Object.keys(ateforingReturns).length > 0) { items.push({ kind: 'periodiseringsfond_ateforing', returns: ateforingReturns }) } return items } const emptyTaxDraft: TaxAdjustmentDraft = { nonDeductibleExpenses: '0', nonTaxableIncome: '0', detectedAccounts: { '6992': false, '8423': false }, } function createUiState(proposal: DispositionsProposal): UiState { const selections: UiState['selections'] = {} proposal.proposals.forEach((item, index) => { const key = proposalKey(item, index) selections[key] = { accept: true, overrideAmount: item.amount, lockedSkip: Boolean(item.required), } }) return { selections } } function createTaxAdjustmentDraft( snapshot: TaxAdjustmentSnapshot | undefined, ): TaxAdjustmentDraft { if (!snapshot) return emptyTaxDraft const manualNonDeductible = snapshot.items.find( (item) => item.sourceKey === 'manual:non_deductible_expenses', ) const manualNonTaxable = snapshot.items.find( (item) => item.sourceKey === 'manual:non_taxable_income', ) const account6992 = snapshot.items.find((item) => item.sourceKey === 'account:6992') const account8423 = snapshot.items.find((item) => item.sourceKey === 'account:8423') return { nonDeductibleExpenses: String(manualNonDeductible?.amount ?? 0), nonTaxableIncome: String(manualNonTaxable?.amount ?? 0), detectedAccounts: { '6992': Boolean(account6992?.included), '8423': Boolean(account8423?.included), }, } } function parseNonNegativeAmount(value: string): number { const parsed = Number(value.replace(',', '.')) return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0 } function numberValue(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) ? value : 0 }