* 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>
731 lines
26 KiB
TypeScript
731 lines
26 KiB
TypeScript
'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<Proposal | null>(null)
|
|
const [tax, setTax] = useState<TaxView | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(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 (
|
|
<Card>
|
|
<CardContent className="p-6 space-y-2">
|
|
<Skeleton className="h-5 w-1/3" />
|
|
<Skeleton className="h-20 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="p-6 text-destructive">{error}</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
if (!proposal) return null
|
|
|
|
const taxPanel = tax ? (
|
|
<TaxDepreciationCard
|
|
periodId={periodId}
|
|
view={tax}
|
|
onSaved={load}
|
|
onDirtyChange={handleTaxDirtyChange}
|
|
/>
|
|
) : 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 (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Planenliga avskrivningar</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="text-sm text-muted-foreground">
|
|
Inga aktiva anläggningstillgångar att skriva av.{' '}
|
|
<Link href="/assets" className="text-primary hover:underline">
|
|
Lägg till tillgångar
|
|
</Link>{' '}
|
|
så räknar bokslutet ut avskrivningarna automatiskt.
|
|
</CardContent>
|
|
</Card>
|
|
{taxPanel}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1">
|
|
<CardTitle className="text-base">Planenliga avskrivningar</CardTitle>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{proposal.items.length} tillgång{proposal.items.length === 1 ? '' : 'ar'}.
|
|
{allPosted ? ' Allt redan bokfört.' : ' Bokförs som separata verifikationer.'}
|
|
</p>
|
|
</div>
|
|
<p className="font-display text-2xl tabular-nums shrink-0">
|
|
{formatCurrency(proposal.totalAmount)}
|
|
</p>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Tillgång</TableHead>
|
|
<TableHead className="text-right">Avskrivning</TableHead>
|
|
<TableHead className="text-right">Restvärde</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{proposal.items.map((item) => (
|
|
<TableRow key={item.asset.id}>
|
|
<TableCell className="text-sm">{item.asset.name}</TableCell>
|
|
<TableCell className="text-right tabular-nums">
|
|
{formatCurrency(item.amount)}
|
|
{item.proRated && (
|
|
<span className="block text-[10px] text-muted-foreground">pro-rata</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-right tabular-nums">
|
|
{formatCurrency(item.netBookValueAfter)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.existingJournalEntryId ? (
|
|
<Badge variant="success">Bokförd</Badge>
|
|
) : (
|
|
<Badge variant="outline">Föreslagen</Badge>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
{anyPending && (
|
|
<div className="flex justify-end">
|
|
<Button onClick={handlePost} disabled={posting || taxDirty}>
|
|
{posting ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Bokför…
|
|
</>
|
|
) : (
|
|
'Bokför alla avskrivningar'
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
{taxPanel}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TaxDepreciationCard({
|
|
periodId,
|
|
view,
|
|
onSaved,
|
|
onDirtyChange,
|
|
}: {
|
|
periodId: string
|
|
view: TaxView
|
|
onSaved: () => Promise<void>
|
|
onDirtyChange?: (dirty: boolean) => void
|
|
}) {
|
|
const { toast } = useToast()
|
|
const [method, setMethod] = useState<TaxMethod>(view.method ?? 'rakenskapsenlig')
|
|
const [rule, setRule] = useState<TaxRule>(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<TaxView | null>(null)
|
|
const [dirty, setDirty] = useState(false)
|
|
const [previewing, setPreviewing] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [saveError, setSaveError] = useState<string | null>(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 (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<CardTitle className="text-base">Skattemässig avskrivning</CardTitle>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Beräkna högsta avdrag för den gemensamma inventariepoolen enligt IL 18 kap.
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Du sparar det faktiska avdraget efter avstämning. Eventuell överavskrivning
|
|
bokförs separat.
|
|
</p>
|
|
</div>
|
|
{dirty && <Badge variant="warning">Ej sparad</Badge>}
|
|
{!dirty && view.snapshot && !view.isStale && (
|
|
<span className="text-xs text-muted-foreground">Sparad</span>
|
|
)}
|
|
{!dirty && view.isStale && <Badge variant="warning">Behöver sparas om</Badge>}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{view.openingSource === 'previous_period_required' && (
|
|
<p className="text-sm text-destructive" role="alert">
|
|
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.
|
|
</p>
|
|
)}
|
|
{view.status === 'needs_period_history' && (
|
|
<p className="text-sm text-destructive" role="alert">
|
|
Kompletteringsregeln kan inte beräknas utan fullständig räkenskapsårshistorik för
|
|
alla kvarvarande inventarier. Välj huvudregeln eller komplettera periodhistoriken.
|
|
</p>
|
|
)}
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tax-depreciation-method">Metod</Label>
|
|
<Select
|
|
value={method}
|
|
onValueChange={(value) => {
|
|
setMethod(value as TaxMethod)
|
|
resetCalculatedDraft()
|
|
}}
|
|
disabled={view.methodLocked || controlsBusy || formBlocked}
|
|
>
|
|
<SelectTrigger id="tax-depreciation-method"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="rakenskapsenlig">Räkenskapsenlig</SelectItem>
|
|
<SelectItem value="restvarde">Restvärdeavskrivning</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{view.methodLocked && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Metoden följer föregående sparade år. Ett byte kräver en separat övergångsbedömning.
|
|
</p>
|
|
)}
|
|
</div>
|
|
{method === 'rakenskapsenlig' && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tax-depreciation-rule">Årets regel</Label>
|
|
<Select
|
|
value={rule}
|
|
onValueChange={(value) => {
|
|
setRule(value as TaxRule)
|
|
resetCalculatedDraft()
|
|
}}
|
|
disabled={controlsBusy || formBlocked}
|
|
>
|
|
<SelectTrigger id="tax-depreciation-rule"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="huvudregel_30">Huvudregeln 30 %</SelectItem>
|
|
<SelectItem value="kompletteringsregel_20">Kompletteringsregeln 20 %</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tax-opening-value">Skattemässigt värde vid ingången</Label>
|
|
<Input
|
|
id="tax-opening-value"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={openingValue}
|
|
onChange={(event) => {
|
|
setOpeningValue(event.target.value)
|
|
resetCalculatedDraft()
|
|
}}
|
|
disabled={
|
|
view.openingSource === 'previous_period'
|
|
|| formBlocked
|
|
|| controlsBusy
|
|
}
|
|
className="tabular-nums"
|
|
/>
|
|
{view.openingSource === 'previous_period' && (
|
|
<p className="text-xs text-muted-foreground">Hämtat från föregående sparade år.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{dirty && !preview && (
|
|
<p className="rounded-md border border-border bg-muted/20 p-3 text-sm text-muted-foreground">
|
|
Inställningarna är ändrade. Beräkna förslaget för att se rätt gränsbelopp innan du
|
|
sparar.
|
|
</p>
|
|
)}
|
|
|
|
{result && (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border border-border bg-muted/20 p-4 text-sm sm:grid-cols-3">
|
|
<TaxValue label="Ingående värde" value={result.openingTaxValue} />
|
|
<TaxValue label="Årets anskaffningar" value={result.additions} />
|
|
<TaxValue label="Årets avyttringar" value={-result.disposals} />
|
|
<TaxValue label="Avskrivningsunderlag" value={result.basis} />
|
|
<TaxValue label="Högsta avdrag" value={-result.maximumDeduction} />
|
|
{!dirty && view.snapshot ? (
|
|
<TaxValue label="Faktiskt sparat avdrag" value={-result.deduction} strong />
|
|
) : (
|
|
<TaxValue label="Lägsta skattemässigt värde" value={result.closingTaxValue} strong />
|
|
)}
|
|
</div>
|
|
{method === 'rakenskapsenlig' && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
Jämförelse
|
|
</p>
|
|
{result.alternatives.map((alternative) => (
|
|
<div key={alternative.rule} className="flex justify-between text-sm">
|
|
<span>{alternative.rule === 'huvudregel_30' ? '30-procentsregeln' : '20-procentsregeln'}</span>
|
|
<span className="tabular-nums">
|
|
{formatCurrency(alternative.closingTaxValue)} kvar
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{dirty && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tax-elected-deduction">Faktiskt avdrag efter avstämning</Label>
|
|
<Input
|
|
id="tax-elected-deduction"
|
|
type="number"
|
|
min="0"
|
|
max={result.maximumDeduction}
|
|
step="0.01"
|
|
value={electedDeduction}
|
|
onChange={(event) => setElectedDeduction(event.target.value)}
|
|
disabled={controlsBusy}
|
|
className="max-w-xs tabular-nums"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Beloppet får vara lägre än gränsbeloppet men aldrig högre.
|
|
</p>
|
|
</div>
|
|
)}
|
|
{dirty && method === 'rakenskapsenlig' && (
|
|
<div className="flex items-start gap-2 rounded-md border border-border p-3">
|
|
<Checkbox
|
|
id="tax-book-conformity"
|
|
checked={bookConformityConfirmed}
|
|
onCheckedChange={(checked) => setBookConformityConfirmed(checked === true)}
|
|
disabled={controlsBusy}
|
|
/>
|
|
<Label htmlFor="tax-book-conformity" className="cursor-pointer text-sm leading-5">
|
|
Jag har stämt av att det faktiska avdraget motsvarar bokslutets totala
|
|
avskrivning, inklusive eventuell bokförd överavskrivning.
|
|
</Label>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{view.excludedAssetCount > 0 && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{view.excludedAssetCount} tillgång{view.excludedAssetCount === 1 ? '' : 'ar'} i andra
|
|
kategorier ingår inte i inventariepoolen och måste bedömas separat.
|
|
</p>
|
|
)}
|
|
{result && result.excessDisposals > 0 && (
|
|
<p className="text-xs text-destructive">
|
|
Avyttringsersättningen överstiger underlaget med {formatCurrency(result.excessDisposals)}.
|
|
Överskjutande belopp behöver hanteras i deklarationen.
|
|
</p>
|
|
)}
|
|
{saveError && <p className="text-sm text-destructive" role="alert">{saveError}</p>}
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<p className="text-xs text-muted-foreground">
|
|
{view.eligibleAssetCount} tillgång{view.eligibleAssetCount === 1 ? '' : 'ar'} i poolen,
|
|
{view.periodMonths} månader i räkenskapsåret.
|
|
</p>
|
|
<div className="flex flex-col gap-2 sm:flex-row">
|
|
{dirty && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={restoreSavedValues}
|
|
disabled={controlsBusy}
|
|
className="w-full sm:w-auto"
|
|
>
|
|
Återställ
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={loadPreview}
|
|
disabled={
|
|
previewing
|
|
|| saving
|
|
|| formBlocked
|
|
}
|
|
className="w-full sm:w-auto"
|
|
>
|
|
{previewing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Beräkna förslag
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={save}
|
|
disabled={
|
|
saving
|
|
|| !dirty
|
|
|| !result
|
|
|| formBlocked
|
|
}
|
|
className="w-full sm:w-auto"
|
|
>
|
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Spara faktiskt avdrag
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function TaxValue({ label, value, strong = false }: { label: string; value: number; strong?: boolean }) {
|
|
return (
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
<p className={`tabular-nums ${strong ? 'font-medium' : ''}`}>{formatCurrency(value)}</p>
|
|
</div>
|
|
)
|
|
}
|