Files
accounted/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx
T
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00

999 lines
33 KiB
TypeScript

'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { ArrowLeft, ArrowRight, Loader2, Lock, Plus, Trash2 } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { cn, formatCurrency } from '@/lib/utils'
import {
PERIODISERING_TEMPLATES,
type PeriodiseringTemplate,
} from '@/lib/bokslut/accruals/templates'
import type { AccrualsProposal } from '@/lib/bokslut/accruals/types'
import type {
PeriodiseringSuggestion,
PeriodiseringConfidence,
} from '@/lib/bokslut/accruals/auto-detect'
import type { FiscalPeriod } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
type Step = 'vacation' | 'audit' | 'auto' | 'manual' | 'review'
const STEP_ORDER: Step[] = ['vacation', 'audit', 'auto', 'manual', 'review']
const STEP_LABELS: Record<Step, string> = {
vacation: 'Semester',
audit: 'Revisionsarvode',
auto: 'Auto-detektering',
manual: 'Manuella tillägg',
review: 'Granska & posta',
}
interface PeriodOption {
id: string
name: string
period_start: string
period_end: string
}
type ProposalResponse = AccrualsProposal & { autoDetected: PeriodiseringSuggestion[] }
interface AuditState {
enabled: boolean
amount: string
liabilityAccount: '2991' | '2992'
}
interface AutoState {
/** key = source_invoice_id + '|' + source_type, value = accepted */
selections: Record<string, boolean>
}
interface ManualEntry {
id: string
templateKind: PeriodiseringTemplate['kind']
amount: string
description: string
/** Editable accounts (pre-filled from template). */
primaryAccount: string
secondaryAccount: string
}
function uid() {
return Math.random().toString(36).slice(2, 10)
}
function suggestionKey(s: PeriodiseringSuggestion): string {
return `${s.source_invoice_id}|${s.source_type}`
}
function confidenceVariant(c: PeriodiseringConfidence): 'success' | 'secondary' | 'outline' {
if (c === 'high') return 'success'
if (c === 'medium') return 'secondary'
return 'outline'
}
function confidenceLabel(c: PeriodiseringConfidence): string {
if (c === 'high') return 'Hög säkerhet'
if (c === 'medium') return 'Medel säkerhet'
return 'Låg säkerhet'
}
export default function PeriodiseringWizardPage() {
const searchParams = useSearchParams()
const { toast } = useToast()
const { canWrite } = useCanWrite()
const [periods, setPeriods] = useState<PeriodOption[] | null>(null)
const [periodsError, setPeriodsError] = useState<string | null>(null)
const [selectedPeriodId, setSelectedPeriodId] = useState<string | null>(
searchParams.get('period') ?? null,
)
const [step, setStep] = useState<Step>('vacation')
const [proposal, setProposal] = useState<ProposalResponse | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [vacationAccepted, setVacationAccepted] = useState(true)
const [auditState, setAuditState] = useState<AuditState>({
enabled: false,
amount: '',
liabilityAccount: '2992',
})
const [autoState, setAutoState] = useState<AutoState>({ selections: {} })
const [manualEntries, setManualEntries] = useState<ManualEntry[]>([])
const [posting, setPosting] = useState(false)
const [postError, setPostError] = useState<string | null>(null)
const [postSummary, setPostSummary] = useState<{ created: number; skipped: number } | null>(null)
// ---- 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 today = new Date().toISOString().split('T')[0]
const eligible = (data ?? []).filter(
(p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today,
)
eligible.sort((a, b) => a.period_start.localeCompare(b.period_start))
if (cancelled) return
setPeriods(eligible)
if (!selectedPeriodId && eligible.length > 0) {
setSelectedPeriodId(eligible[0].id)
}
} catch {
if (!cancelled) setPeriodsError('Kunde inte hämta perioder')
}
}
void load()
return () => {
cancelled = true
}
}, [selectedPeriodId])
// ---- Fetch accruals snapshot once period chosen ----
useEffect(() => {
if (!selectedPeriodId) return
let cancelled = false
setLoading(true)
setLoadError(null)
setProposal(null)
fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/accruals`)
.then(async (res) => {
const body = await res.json()
if (cancelled) return
if (!res.ok) {
setLoadError(getUserErrorMessage(body?.error) ?? 'Kunde inte ladda periodiseringar')
return
}
const data = body.data as ProposalResponse
setProposal(data)
// Default-check all high-confidence suggestions.
const initial: Record<string, boolean> = {}
for (const s of data.autoDetected ?? []) {
initial[suggestionKey(s)] = s.confidence === 'high'
}
setAutoState({ selections: initial })
})
.catch(() => {
if (!cancelled) setLoadError('Kunde inte ladda periodiseringar')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [selectedPeriodId])
const vacationProposal = useMemo(
() => proposal?.proposals.find((p) => p.kind === 'vacation_liability_change') ?? null,
[proposal],
)
const currentStepIndex = STEP_ORDER.indexOf(step)
const progressValue = ((currentStepIndex + 1) / STEP_ORDER.length) * 100
const showWizard = selectedPeriodId !== null && (periods?.length ?? 0) > 0 && !loading && !loadError
// ---- Manual entry editing helpers ----
const addManualFromTemplate = useCallback((template: PeriodiseringTemplate) => {
const primary =
template.prepaid_account ?? template.deferred_account ?? template.accrued_account ?? ''
const secondary =
template.expense_account ?? template.revenue_account ?? ''
setManualEntries((prev) => [
...prev,
{
id: uid(),
templateKind: template.kind,
amount: '',
description: '',
primaryAccount: primary,
secondaryAccount: secondary,
},
])
}, [])
const updateManual = useCallback((id: string, patch: Partial<ManualEntry>) => {
setManualEntries((prev) => prev.map((m) => (m.id === id ? { ...m, ...patch } : m)))
}, [])
const removeManual = useCallback((id: string) => {
setManualEntries((prev) => prev.filter((m) => m.id !== id))
}, [])
// ---- Final post ----
const handlePost = useCallback(async () => {
if (!selectedPeriodId) return
setPosting(true)
setPostError(null)
try {
const items: unknown[] = []
if (vacationProposal && vacationAccepted) {
items.push({ kind: 'vacation_liability_change' })
}
if (auditState.enabled) {
const amount = parseFloat(auditState.amount)
if (Number.isFinite(amount) && amount > 0) {
items.push({
kind: 'audit_fee',
amount,
liability_account: auditState.liabilityAccount,
})
}
}
for (const s of proposal?.autoDetected ?? []) {
if (!autoState.selections[suggestionKey(s)]) continue
if (s.source_type === 'supplier_invoice') {
items.push({
kind: 'manual_prepaid_expense',
amount: s.periodisering_amount,
expense_account: '5800', // safe fallback; user can override in manual list
prepaid_account: s.suggested_prepaid_account ?? '1710',
description: s.source_label,
})
} else {
items.push({
kind: 'deferred_revenue',
amount: s.periodisering_amount,
revenue_account: '3001',
deferred_account: s.suggested_deferred_account ?? '2970',
description: s.source_label,
})
}
}
for (const m of manualEntries) {
const amount = parseFloat(m.amount)
if (!Number.isFinite(amount) || amount <= 0) continue
if (!m.description.trim()) continue
const tpl = PERIODISERING_TEMPLATES.find((t) => t.kind === m.templateKind)
if (!tpl) continue
switch (tpl.side) {
case 'prepaid':
items.push({
kind: 'manual_prepaid_expense',
amount,
expense_account: m.secondaryAccount,
prepaid_account: m.primaryAccount,
description: m.description,
})
break
case 'accrued':
items.push({
kind: 'manual_accrued_expense',
amount,
expense_account: m.secondaryAccount,
accrued_account: m.primaryAccount,
description: m.description,
})
break
case 'deferred_revenue':
items.push({
kind: 'deferred_revenue',
amount,
revenue_account: m.secondaryAccount,
deferred_account: m.primaryAccount,
description: m.description,
})
break
case 'accrued_interest':
items.push({
kind: 'accrued_interest',
amount,
expense_account: m.secondaryAccount,
accrued_account: m.primaryAccount,
description: m.description,
})
break
case 'accrued_utility':
items.push({
kind: 'accrued_utility',
amount,
expense_account: m.secondaryAccount,
accrued_account: m.primaryAccount,
description: m.description,
})
break
}
}
if (items.length === 0) {
toast({
title: 'Inga periodiseringar att bokföra',
description: 'Markera minst en post innan du postar.',
})
return
}
const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/accruals`, {
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 periodiseringarna')
return
}
const created = body.data?.created?.length ?? 0
const skipped = body.data?.skipped?.length ?? 0
setPostSummary({ created, skipped })
toast({
title: `${created} verifikation${created === 1 ? '' : 'er'} bokförd${created === 1 ? '' : 'a'}`,
description: skipped > 0 ? `${skipped} hoppades över (redan postade).` : undefined,
})
} catch (err) {
setPostError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
setPosting(false)
}
}, [
selectedPeriodId,
vacationProposal,
vacationAccepted,
auditState,
autoState,
manualEntries,
proposal,
toast,
])
const closingYear = useMemo(() => {
if (!proposal) return null
return proposal.fiscalPeriod.period_end.slice(0, 4)
}, [proposal])
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<h1 className="font-display text-3xl md:text-4xl tracking-tight">
{closingYear ? `Periodisering: Bokslut ${closingYear}` : 'Periodisering'}
</h1>
<Button variant="outline" asChild>
<Link href="/bookkeeping/year-end">
<ArrowLeft className="mr-2 h-4 w-4" /> Tillbaka till bokslut
</Link>
</Button>
</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 && (
<EmptyState
icon={Lock}
title="Inga perioder att periodisera"
description="Periodiseringar görs efter att räkenskapsperiodens slutdatum har passerat. Det finns ingen sådan öppen period."
/>
)}
{loadError && (
<Card>
<CardContent className="p-6 text-destructive">{loadError}</CardContent>
</Card>
)}
{loading && (
<Card>
<CardContent className="p-6 space-y-2">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
)}
{showWizard && proposal && (
<>
<Card>
<CardContent className="p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="sm:hidden text-primary font-medium">
Steg {currentStepIndex + 1}/{STEP_ORDER.length}: {STEP_LABELS[step]}
</span>
{STEP_ORDER.map((s, i) => (
<span
key={s}
className={cn(
'hidden sm:inline',
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground',
)}
>
{STEP_LABELS[s]}
</span>
))}
</div>
<Progress value={progressValue} className="h-2" />
</CardContent>
</Card>
{step === 'vacation' && (
<VacationStep
proposal={vacationProposal}
accepted={vacationAccepted}
onChange={setVacationAccepted}
onNext={() => setStep('audit')}
/>
)}
{step === 'audit' && (
<AuditStep
state={auditState}
onChange={setAuditState}
onBack={() => setStep('vacation')}
onNext={() => setStep('auto')}
/>
)}
{step === 'auto' && (
<AutoStep
suggestions={proposal.autoDetected ?? []}
selections={autoState.selections}
onToggle={(key, val) =>
setAutoState({ selections: { ...autoState.selections, [key]: val } })
}
onBack={() => setStep('audit')}
onNext={() => setStep('manual')}
/>
)}
{step === 'manual' && (
<ManualStep
entries={manualEntries}
onAdd={addManualFromTemplate}
onUpdate={updateManual}
onRemove={removeManual}
onBack={() => setStep('auto')}
onNext={() => setStep('review')}
/>
)}
{step === 'review' && (
<ReviewStep
vacationProposal={vacationProposal}
vacationAccepted={vacationAccepted}
auditState={auditState}
suggestions={proposal.autoDetected ?? []}
selections={autoState.selections}
manualEntries={manualEntries}
postError={postError}
postSummary={postSummary}
posting={posting}
canWrite={canWrite}
onBack={() => setStep('manual')}
onPost={handlePost}
/>
)}
</>
)}
</div>
)
}
// ============================================================
// Step components
// ============================================================
function VacationStep({
proposal,
accepted,
onChange,
onNext,
}: {
proposal: AccrualsProposal['proposals'][number] | null
accepted: boolean
onChange: (v: boolean) => void
onNext: () => void
}) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Steg 1: Semesterlöneskuld</CardTitle>
<p className="text-sm text-muted-foreground">
Justering av 2920 mot 7090 plus 31,42 % sociala avgifter (2940 / 7519).
Saldot rullas vidare till nästa år.
</p>
</CardHeader>
<CardContent>
{proposal ? (
<div className="flex items-start justify-between gap-4 rounded-md border border-border p-4">
<div className="flex-1 space-y-2">
<p className="text-sm font-medium">{proposal.label}</p>
<p className="text-xs text-muted-foreground">{proposal.description}</p>
<div className="flex items-center gap-2 pt-1">
<Checkbox
id="accept-vacation"
checked={accepted}
onCheckedChange={(c) => onChange(Boolean(c))}
/>
<Label htmlFor="accept-vacation" className="text-sm cursor-pointer select-none">
Boka denna justering
</Label>
</div>
</div>
<p className="font-display text-2xl tabular-nums shrink-0">
{formatCurrency(proposal.amount)}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground italic">
Ingen justering behövs: semesterlöneskulden ligger redan rätt.
</p>
)}
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={onNext}>
Nästa <ArrowRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
)
}
function AuditStep({
state,
onChange,
onBack,
onNext,
}: {
state: AuditState
onChange: (s: AuditState) => void
onBack: () => void
onNext: () => void
}) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Steg 2: Revisions- / bokslutsarvode</CardTitle>
<p className="text-sm text-muted-foreground">
Periodisera arvode för revision (2992) eller bokslut (2991). Posten
vänds första dagen i nästa räkenskapsår när fakturan kommer.
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-2">
<Checkbox
id="audit-enabled"
checked={state.enabled}
onCheckedChange={(c) => onChange({ ...state, enabled: Boolean(c) })}
/>
<Label htmlFor="audit-enabled" className="text-sm cursor-pointer select-none">
Periodisera arvode för detta bokslut
</Label>
</div>
{state.enabled && (
<div className="grid grid-cols-2 gap-4 rounded-md border border-border p-4">
<div className="space-y-1">
<Label className="text-xs">Belopp (kr)</Label>
<Input
type="number"
step="1"
min="0"
value={state.amount}
onChange={(e) => onChange({ ...state, amount: e.target.value })}
className="tabular-nums h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Konto</Label>
<select
className="border border-border rounded-md h-9 text-sm px-2 w-full bg-background"
value={state.liabilityAccount}
onChange={(e) =>
onChange({ ...state, liabilityAccount: e.target.value as '2991' | '2992' })
}
>
<option value="2992">2992: Revision</option>
<option value="2991">2991: Bokslut</option>
</select>
</div>
</div>
)}
</CardContent>
</Card>
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>
Tillbaka
</Button>
<Button onClick={onNext}>
Nästa <ArrowRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
)
}
function AutoStep({
suggestions,
selections,
onToggle,
onBack,
onNext,
}: {
suggestions: PeriodiseringSuggestion[]
selections: Record<string, boolean>
onToggle: (key: string, val: boolean) => void
onBack: () => void
onNext: () => void
}) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Steg 3: Auto-detekterade periodiseringar</CardTitle>
<p className="text-sm text-muted-foreground">
Fakturor (kund och leverantör) i den stängda perioden vars beskrivning
innehåller en datumintervall som sträcker sig in i nästa räkenskapsår.
Granska och bekräfta: högst säkra förslag är förvalda.
</p>
</CardHeader>
<CardContent className="space-y-3">
{suggestions.length === 0 && (
<p className="text-sm text-muted-foreground italic">
Inga fakturor med tydlig datumintervall hittades. Du kan ändå lägga
till manuella periodiseringar i nästa steg.
</p>
)}
{suggestions.map((s) => {
const key = suggestionKey(s)
return (
<div
key={key}
className="flex items-start gap-3 rounded-md border border-border p-3"
>
<Checkbox
id={`auto-${key}`}
checked={!!selections[key]}
onCheckedChange={(c) => onToggle(key, Boolean(c))}
className="mt-1"
/>
<div className="flex-1 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<Label
htmlFor={`auto-${key}`}
className="text-sm font-medium cursor-pointer select-none"
>
{s.source_label}
</Label>
<Badge variant={confidenceVariant(s.confidence)}>
{confidenceLabel(s.confidence)}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{s.reason}</p>
<div className="flex items-center gap-4 pt-1 text-xs">
<span className="text-muted-foreground">
{s.source_type === 'supplier_invoice'
? 'Förutbetald kostnad → 1710'
: 'Förutbetald intäkt → 2970'}
</span>
<span className="ml-auto font-display tabular-nums text-base">
{formatCurrency(s.periodisering_amount)}
</span>
</div>
</div>
</div>
)
})}
</CardContent>
</Card>
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>
Tillbaka
</Button>
<Button onClick={onNext}>
Nästa <ArrowRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
)
}
function ManualStep({
entries,
onAdd,
onUpdate,
onRemove,
onBack,
onNext,
}: {
entries: ManualEntry[]
onAdd: (t: PeriodiseringTemplate) => void
onUpdate: (id: string, patch: Partial<ManualEntry>) => void
onRemove: (id: string) => void
onBack: () => void
onNext: () => void
}) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Steg 4: Manuella periodiseringar</CardTitle>
<p className="text-sm text-muted-foreground">
Använd mallarna nedan för vanliga fall, eller hoppa direkt till granskning.
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{PERIODISERING_TEMPLATES.map((t) => (
<Button
key={t.kind}
variant="outline"
size="sm"
onClick={() => onAdd(t)}
className="justify-start text-left h-auto py-2 px-3"
>
<Plus className="mr-2 h-3.5 w-3.5 shrink-0" />
<span className="flex-1 min-w-0">
<span className="block font-medium">{t.name}</span>
<span className="block text-xs text-muted-foreground truncate">{t.hint}</span>
</span>
</Button>
))}
</div>
{entries.length > 0 && (
<div className="space-y-3 pt-2">
{entries.map((entry) => (
<ManualEntryEditor
key={entry.id}
entry={entry}
onChange={(patch) => onUpdate(entry.id, patch)}
onRemove={() => onRemove(entry.id)}
/>
))}
</div>
)}
</CardContent>
</Card>
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>
Tillbaka
</Button>
<Button onClick={onNext}>
Granska <ArrowRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
)
}
function ManualEntryEditor({
entry,
onChange,
onRemove,
}: {
entry: ManualEntry
onChange: (patch: Partial<ManualEntry>) => void
onRemove: () => void
}) {
const template = PERIODISERING_TEMPLATES.find((t) => t.kind === entry.templateKind)
if (!template) return null
const primaryLabel =
template.side === 'prepaid'
? '17xx-konto'
: template.side === 'deferred_revenue'
? '29xx-konto (deferred)'
: '29xx-konto'
const secondaryLabel =
template.side === 'deferred_revenue' ? 'Intäktskonto' : 'Kostnadskonto'
return (
<div className="rounded-md border border-border p-3 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{template.name}</p>
<Button variant="ghost" size="sm" onClick={onRemove} className="h-7 px-2" aria-label="Ta bort">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Belopp (kr)</Label>
<Input
type="number"
step="1"
min="0"
value={entry.amount}
onChange={(e) => onChange({ amount: e.target.value })}
className="tabular-nums h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{primaryLabel}</Label>
<Input
value={entry.primaryAccount}
onChange={(e) => onChange({ primaryAccount: e.target.value })}
className="tabular-nums h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{secondaryLabel}</Label>
<Input
value={entry.secondaryAccount}
onChange={(e) => onChange({ secondaryAccount: e.target.value })}
className="tabular-nums h-8"
/>
</div>
<div className="space-y-1 col-span-2">
<Label className="text-xs">Beskrivning</Label>
<Input
value={entry.description}
onChange={(e) => onChange({ description: e.target.value })}
placeholder="t.ex. Försäkring 2026"
className="h-8"
/>
</div>
</div>
</div>
)
}
function ReviewStep({
vacationProposal,
vacationAccepted,
auditState,
suggestions,
selections,
manualEntries,
postError,
postSummary,
posting,
canWrite,
onBack,
onPost,
}: {
vacationProposal: AccrualsProposal['proposals'][number] | null
vacationAccepted: boolean
auditState: AuditState
suggestions: PeriodiseringSuggestion[]
selections: Record<string, boolean>
manualEntries: ManualEntry[]
postError: string | null
postSummary: { created: number; skipped: number } | null
posting: boolean
canWrite: boolean
onBack: () => void
onPost: () => void
}) {
const auditAmount = parseFloat(auditState.amount)
const auditValid = auditState.enabled && Number.isFinite(auditAmount) && auditAmount > 0
const selectedSuggestions = suggestions.filter((s) => selections[suggestionKey(s)])
const validManual = manualEntries.filter(
(m) => Number.isFinite(parseFloat(m.amount)) && parseFloat(m.amount) > 0 && m.description.trim(),
)
const totalCount =
(vacationProposal && vacationAccepted ? 1 : 0) +
(auditValid ? 1 : 0) +
selectedSuggestions.length +
validManual.length
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Steg 5: Granska & posta</CardTitle>
<p className="text-sm text-muted-foreground">
{totalCount === 0
? 'Inga periodiseringar valda. Gå tillbaka och välj minst en.'
: `${totalCount} periodisering${totalCount === 1 ? '' : 'ar'} kommer att bokföras som separata verifikationer.`}
</p>
</CardHeader>
<CardContent className="space-y-3">
{vacationProposal && vacationAccepted && (
<ReviewLine label={vacationProposal.label} amount={vacationProposal.amount} note="Rullas vidare" />
)}
{auditValid && (
<ReviewLine
label={
auditState.liabilityAccount === '2991'
? 'Beräknat arvode för bokslut'
: 'Beräknat arvode för revision'
}
amount={auditAmount}
note="Vänds 1 januari"
/>
)}
{selectedSuggestions.map((s) => (
<ReviewLine
key={suggestionKey(s)}
label={`Auto: ${s.source_label}`}
amount={s.periodisering_amount}
note={s.source_type === 'supplier_invoice' ? 'Förutbetald kostnad' : 'Förutbetald intäkt'}
/>
))}
{validManual.map((m) => {
const tpl = PERIODISERING_TEMPLATES.find((t) => t.kind === m.templateKind)
return (
<ReviewLine
key={m.id}
label={`${tpl?.name ?? 'Periodisering'}: ${m.description}`}
amount={parseFloat(m.amount)}
note={tpl?.name}
/>
)
})}
</CardContent>
</Card>
{postError && (
<Card>
<CardContent className="p-4 text-sm text-destructive">{postError}</CardContent>
</Card>
)}
{postSummary && (
<Card>
<CardContent className="p-4 text-sm">
{postSummary.created} verifikation{postSummary.created === 1 ? '' : 'er'} bokförd
{postSummary.created === 1 ? '' : 'a'}.
{postSummary.skipped > 0 && ` ${postSummary.skipped} hoppades över (redan postade).`}
</CardContent>
</Card>
)}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack} disabled={posting}>
Tillbaka
</Button>
<Button
onClick={onPost}
disabled={!canWrite || posting || totalCount === 0 || postSummary !== null}
title={!canWrite ? 'Endast användare med skrivrättigheter kan posta periodiseringar.' : undefined}
>
{!canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" /> Posta alla
</>
) : posting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Bokför
</>
) : (
'Posta alla'
)}
</Button>
</div>
</div>
)
}
function ReviewLine({ label, amount, note }: { label: string; amount: number; note?: string }) {
return (
<div className="flex items-center justify-between gap-4 rounded-md border border-border px-3 py-2">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{label}</p>
{note && <p className="text-xs text-muted-foreground">{note}</p>}
</div>
<p className="font-display text-base tabular-nums shrink-0">{formatCurrency(amount)}</p>
</div>
)
}