'use client' import { useCallback, useEffect, useRef, useState } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Checkbox } from '@/components/ui/checkbox' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' import { Loader2 } from 'lucide-react' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import Link from 'next/link' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { formatCurrency } from '@/lib/utils' import { useToast } from '@/components/ui/use-toast' import type { Asset } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' interface ProposalItem { asset: Asset amount: number netBookValueAfter: number proRated: boolean existingScheduleId?: string existingJournalEntryId?: string | null } interface Proposal { fiscalPeriod: { id: string; name: string; period_start: string; period_end: string } items: ProposalItem[] totalAmount: number } type TaxMethod = 'rakenskapsenlig' | 'restvarde' type TaxRule = 'huvudregel_30' | 'kompletteringsregel_20' interface TaxAlternative { rule: TaxRule | 'restvarde_25' rate: number | null deduction: number closingTaxValue: number } interface TaxResult { openingTaxValue: number additions: number disposals: number basis: number maximumDeduction: number deduction: number closingTaxValue: number excessDisposals: number alternatives: TaxAlternative[] } interface TaxView { status: | 'needs_previous_period' | 'needs_period_history' | 'needs_method' | 'needs_opening_value' | 'needs_rule' | 'ready' method: TaxMethod | null selectedRule: TaxRule | null methodLocked: boolean openingTaxValue: number | null openingSource: 'saved' | 'previous_period' | 'previous_period_required' | 'manual_required' periodMonths: number eligibleAssetCount: number excludedAssetCount: number excludedCategories: string[] cohortHistoryComplete: boolean incompleteCohortCount: number result: TaxResult | null snapshot: { deduction: number } | null isStale: boolean } interface DepreciationPanelProps { periodId: string /** Called after a successful post: parent refetches dispositions because * posted avskrivningar change the result which affects bolagsskatt etc. */ onPosted: () => void onTaxDirtyChange?: (dirty: boolean) => void } export function DepreciationPanel({ periodId, onPosted, onTaxDirtyChange }: DepreciationPanelProps) { const { toast } = useToast() const [proposal, setProposal] = useState(null) const [tax, setTax] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [posting, setPosting] = useState(false) const [taxDirty, setTaxDirty] = useState(false) const handleTaxDirtyChange = useCallback((dirty: boolean) => { setTaxDirty(dirty) onTaxDirtyChange?.(dirty) }, [onTaxDirtyChange]) const load = useCallback(async () => { setLoading(true) setError(null) try { const res = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/depreciation`) const body = await res.json() if (!res.ok) { setError(getUserErrorMessage(body?.error) ?? 'Kunde inte ladda avskrivningar') return } setProposal(body.data as Proposal) setTax(body.data.tax as TaxView) } catch (err) { setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel') } finally { setLoading(false) } }, [periodId]) useEffect(() => { void load() }, [load]) const handlePost = useCallback(async () => { if (taxDirty) { setError('Spara eller återställ ändringarna i skattemässig avskrivning först.') return } setPosting(true) try { const res = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/depreciation`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const body = await res.json() if (!res.ok) { setError(getUserErrorMessage(body?.error) ?? 'Kunde inte bokföra avskrivningar') return } const posted = body.data?.posted?.length ?? 0 toast({ title: `${posted} avskrivning${posted === 1 ? '' : 'ar'} bokförd${ posted === 1 ? '' : 'a' }`, }) onPosted() await load() } catch (err) { setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel') } finally { setPosting(false) } }, [periodId, onPosted, load, toast, taxDirty]) if (loading) { return ( ) } if (error) { return ( {error} ) } if (!proposal) return null const taxPanel = tax ? ( ) : null const allPosted = proposal.items.length > 0 && proposal.items.every((i) => Boolean(i.existingJournalEntryId)) const anyPending = proposal.items.length > 0 && proposal.items.some((i) => !i.existingJournalEntryId) if (proposal.items.length === 0) { return (
Planenliga avskrivningar Inga aktiva anläggningstillgångar att skriva av.{' '} Lägg till tillgångar {' '} så räknar bokslutet ut avskrivningarna automatiskt. {taxPanel}
) } return (
Planenliga avskrivningar

{proposal.items.length} tillgång{proposal.items.length === 1 ? '' : 'ar'}. {allPosted ? ' Allt redan bokfört.' : ' Bokförs som separata verifikationer.'}

{formatCurrency(proposal.totalAmount)}

Tillgång Avskrivning Restvärde Status {proposal.items.map((item) => ( {item.asset.name} {formatCurrency(item.amount)} {item.proRated && ( pro-rata )} {formatCurrency(item.netBookValueAfter)} {item.existingJournalEntryId ? ( Bokförd ) : ( Föreslagen )} ))}
{anyPending && (
)}
{taxPanel}
) } function TaxDepreciationCard({ periodId, view, onSaved, onDirtyChange, }: { periodId: string view: TaxView onSaved: () => Promise onDirtyChange?: (dirty: boolean) => void }) { const { toast } = useToast() const [method, setMethod] = useState(view.method ?? 'rakenskapsenlig') const [rule, setRule] = useState(view.selectedRule ?? 'huvudregel_30') const [openingValue, setOpeningValue] = useState( view.openingTaxValue === null ? '' : String(view.openingTaxValue), ) const [electedDeduction, setElectedDeduction] = useState( view.snapshot ? String(view.snapshot.deduction) : '', ) const [bookConformityConfirmed, setBookConformityConfirmed] = useState(false) const [preview, setPreview] = useState(null) const [dirty, setDirty] = useState(false) const [previewing, setPreviewing] = useState(false) const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(null) const requestVersion = useRef(0) const saveSequence = useRef(0) useEffect(() => { requestVersion.current += 1 setMethod(view.method ?? 'rakenskapsenlig') setRule(view.selectedRule ?? 'huvudregel_30') setOpeningValue(view.openingTaxValue === null ? '' : String(view.openingTaxValue)) setElectedDeduction(view.snapshot ? String(view.snapshot.deduction) : '') setBookConformityConfirmed(false) setPreview(null) setDirty(false) }, [view]) useEffect(() => { onDirtyChange?.(dirty) }, [dirty, onDirtyChange]) useEffect(() => () => onDirtyChange?.(false), [onDirtyChange]) const resetCalculatedDraft = () => { setPreview(null) setElectedDeduction('') setBookConformityConfirmed(false) setSaveError(null) setDirty(true) } const restoreSavedValues = () => { requestVersion.current += 1 setMethod(view.method ?? 'rakenskapsenlig') setRule(view.selectedRule ?? 'huvudregel_30') setOpeningValue(view.openingTaxValue === null ? '' : String(view.openingTaxValue)) setElectedDeduction(view.snapshot ? String(view.snapshot.deduction) : '') setBookConformityConfirmed(false) setPreview(null) setPreviewing(false) setSaving(false) setSaveError(null) setDirty(false) } const parseOpening = (): number | null => { if (openingValue.trim() === '') { setSaveError('Ange skattemässigt värde vid årets ingång.') return null } const opening = Number(openingValue.replace(',', '.')) if (!Number.isFinite(opening) || opening < 0) { setSaveError('Ange ett ingående skattemässigt värde på 0 kr eller mer.') return null } return opening } const loadPreview = async () => { setSaveError(null) const opening = parseOpening() if (opening === null) return setDirty(true) setPreviewing(true) const version = ++requestVersion.current try { const query = new URLSearchParams({ tax_method: method, opening_tax_value: String(opening), }) if (method === 'rakenskapsenlig') query.set('tax_rule', rule) const response = await fetch( `/api/bookkeeping/fiscal-periods/${periodId}/depreciation?${query.toString()}`, ) const body = await response.json() if (version !== requestVersion.current) return if (!response.ok) { setSaveError(getUserErrorMessage(body?.error ?? body) ?? 'Kunde inte beräkna förslaget') return } const nextPreview = body.data.tax as TaxView setPreview(nextPreview) setElectedDeduction( nextPreview.result ? String(nextPreview.result.maximumDeduction) : '', ) setBookConformityConfirmed(false) setDirty(true) } catch (err) { if (version !== requestVersion.current) return setSaveError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel') } finally { if (version === requestVersion.current) setPreviewing(false) } } const save = async () => { setSaveError(null) const opening = parseOpening() if (opening === null) return const calculation = preview ?? (dirty && view.result ? view : null) if (!calculation?.result) { setSaveError('Beräkna förslaget innan valet sparas.') return } if (electedDeduction.trim() === '') { setSaveError('Ange årets faktiska skattemässiga avdrag.') return } const deduction = Number(electedDeduction.replace(',', '.')) if (!Number.isFinite(deduction) || deduction < 0) { setSaveError('Årets faktiska avdrag måste vara 0 kr eller mer.') return } if (deduction > calculation.result.maximumDeduction) { setSaveError('Avdraget får inte överstiga högsta avdrag enligt den valda regeln.') return } if (method === 'rakenskapsenlig' && !bookConformityConfirmed) { setSaveError('Bekräfta att avdraget motsvarar bokslutets totala avskrivning.') return } setSaving(true) const version = ++requestVersion.current const saveId = ++saveSequence.current try { const response = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/depreciation`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method, ...(method === 'rakenskapsenlig' ? { selected_rule: rule } : {}), opening_tax_value: opening, elected_deduction: deduction, ...(method === 'rakenskapsenlig' ? { book_conformity_confirmed: bookConformityConfirmed } : {}), }), }) const body = await response.json() if (version !== requestVersion.current) return if (!response.ok) { setSaveError(getUserErrorMessage(body?.error) ?? 'Kunde inte spara beräkningen') return } toast({ title: 'Skattemässig avskrivning sparad' }) setDirty(false) await onSaved() } catch (err) { if (version !== requestVersion.current) return setSaveError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel') } finally { // A successful save triggers onSaved -> parent load -> new view prop, // which bumps requestVersion before this finally runs. Gate on the // save sequence instead so the card never stays stuck busy after its // own save completes. if (saveId === saveSequence.current) setSaving(false) } } const calculation = dirty ? preview : view const result = calculation?.result ?? null const controlsBusy = previewing || saving const formBlocked = view.openingSource === 'previous_period_required' return (
Skattemässig avskrivning

Beräkna högsta avdrag för den gemensamma inventariepoolen enligt IL 18 kap.

Du sparar det faktiska avdraget efter avstämning. Eventuell överavskrivning bokförs separat.

{dirty && Ej sparad} {!dirty && view.snapshot && !view.isStale && ( Sparad )} {!dirty && view.isStale && Behöver sparas om}
{view.openingSource === 'previous_period_required' && (

Spara skattemässig avskrivning för närmast föregående räkenskapsår först. Ett osparat mellanår får inte hoppas över.

)} {view.status === 'needs_period_history' && (

Kompletteringsregeln kan inte beräknas utan fullständig räkenskapsårshistorik för alla kvarvarande inventarier. Välj huvudregeln eller komplettera periodhistoriken.

)}
{view.methodLocked && (

Metoden följer föregående sparade år. Ett byte kräver en separat övergångsbedömning.

)}
{method === 'rakenskapsenlig' && (
)}
{ setOpeningValue(event.target.value) resetCalculatedDraft() }} disabled={ view.openingSource === 'previous_period' || formBlocked || controlsBusy } className="tabular-nums" /> {view.openingSource === 'previous_period' && (

Hämtat från föregående sparade år.

)}
{dirty && !preview && (

Inställningarna är ändrade. Beräkna förslaget för att se rätt gränsbelopp innan du sparar.

)} {result && ( <>
{!dirty && view.snapshot ? ( ) : ( )}
{method === 'rakenskapsenlig' && (

Jämförelse

{result.alternatives.map((alternative) => (
{alternative.rule === 'huvudregel_30' ? '30-procentsregeln' : '20-procentsregeln'} {formatCurrency(alternative.closingTaxValue)} kvar
))}
)} {dirty && (
setElectedDeduction(event.target.value)} disabled={controlsBusy} className="max-w-xs tabular-nums" />

Beloppet får vara lägre än gränsbeloppet men aldrig högre.

)} {dirty && method === 'rakenskapsenlig' && (
setBookConformityConfirmed(checked === true)} disabled={controlsBusy} />
)} )} {view.excludedAssetCount > 0 && (

{view.excludedAssetCount} tillgång{view.excludedAssetCount === 1 ? '' : 'ar'} i andra kategorier ingår inte i inventariepoolen och måste bedömas separat.

)} {result && result.excessDisposals > 0 && (

Avyttringsersättningen överstiger underlaget med {formatCurrency(result.excessDisposals)}. Överskjutande belopp behöver hanteras i deklarationen.

)} {saveError &&

{saveError}

}

{view.eligibleAssetCount} tillgång{view.eligibleAssetCount === 1 ? '' : 'ar'} i poolen, {view.periodMonths} månader i räkenskapsåret.

{dirty && ( )}
) } function TaxValue({ label, value, strong = false }: { label: string; value: number; strong?: boolean }) { return (

{label}

{formatCurrency(value)}

) }