'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, 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, } from '@/lib/bokslut/types' interface DispositionsStepProps { periodId: string onBack: () => void onContinue: () => void } interface UiState { /** kind → user-controlled selection state */ selections: Record } /** * 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 }: 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) // ---- 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(body?.error?.message ?? 'Kunde inte ladda dispositioner') return } const data = body.data as DispositionsProposal setProposal(data) const selections: UiState['selections'] = {} data.proposals.forEach((p, index) => { // Key must match the render loop and buildPostItems, which both pass // the array index — omitting it here defaulted every non-ateforing // key to ":0", so only the first proposal card ever rendered. const key = proposalKey(p, index) selections[key] = { accept: true, overrideAmount: p.amount, lockedSkip: Boolean(p.required), } }) setUi({ selections }) } catch { setFetchError('Kunde inte ladda dispositioner') } finally { setLoading(false) } }, [periodId]) useEffect(() => { void loadProposals() }, [loadProposals]) // ---- POST accepted dispositions ---- const handleCommit = useCallback(async () => { if (!proposal) 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(body?.error?.message ?? '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 ? err.message : 'Okänt fel') } finally { setPosting(false) } }, [proposal, ui, periodId, onContinue, toast]) // ---- Render branches ---- if (loading) { return ( ) } if (fetchError) { return (

{fetchError}

) } if (!proposal) return null // 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()} />
) } if (proposal.proposals.length === 0) { return (
void loadProposals()} /> Inga dispositioner föreslagna Bolaget har varken pensionskostnader, periodiseringsfonder att hantera, vinst att skatta på, eller skattemässiga avskrivningar att boka.
) } return (
void loadProposals()} /> Bokslutsdispositioner

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

{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} )}
) } 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.kind) 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(kind: DispositionKind): 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 kind === 'periodiseringsfond_avsattning' || kind === 'overavskrivningar' } 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', manualAdjustments: {} }) 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': items.push({ kind: 'overavskrivningar', additionalAmount: sel.overrideAmount ?? p.amount, }) 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 }