Files
accounted/app/(dashboard)/salary/runs/[id]/page.tsx
T
Mattsson 11621bb79f Feat/skv integration full (#284)
* feat: add script to import Skatteverket monthly tax tables as fallback TypeScript module

- Implemented a new script `import-tax-tables.ts` to parse fixed-width TXT tax tables from Skatteverket (SKV 434).
- The script generates a TypeScript module for emergency fallback when the Skatteverket open-data API is unavailable.
- Supports command-line argument for specifying the year and handles parsing of B-rows only.
- Outputs a structured TypeScript file containing tax data for specified years.

* feat: gate salary module behind dev-only flag

Temporarily disable the Lön module in production while the feature is
being completed. Sidebar entries ("Löner", "Anställda") still render but
are not clickable and show a "Kommer snart" badge. Middleware redirects
/salary* to / and returns 404 on /api/salary/* so the feature can't be
reached by direct URL. All gates check NODE_ENV === 'development' so
local dev keeps full access for continued development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: refactor bank file import wizard to streamline column mapping and enhance CSV handling

* fix: bump migration timestamp to avoid collision with logos_bucket

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: enhance AGI generation and salary entry calculations with improved status checks and error handling

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:03:23 +02:00

521 lines
21 KiB
TypeScript

'use client'
import { useState, useEffect, use } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
ArrowLeft, Plus, Calculator, Eye, Check, CreditCard, BookOpen,
ArrowLeftCircle, Loader2, Download, Send, CheckCircle2,
} from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { SalaryRun, SalaryRunEmployee, Employee, CreateJournalEntryLineInput } from '@/types'
const STATUS_LABELS: Record<string, string> = {
draft: 'Utkast',
review: 'Granskning',
approved: 'Godkänd',
paid: 'Betald',
booked: 'Bokförd',
corrected: 'Korrigerad',
}
const STATUS_COLORS: Record<string, string> = {
draft: 'bg-muted text-muted-foreground',
review: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
approved: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
paid: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400',
booked: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
corrected: 'bg-muted text-muted-foreground',
}
interface EntryPreview {
description: string
lines: CreateJournalEntryLineInput[]
}
interface PreviewData {
salaryEntry: EntryPreview
avgifterEntry: EntryPreview
vacationEntry: EntryPreview | null
}
export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const router = useRouter()
const { toast } = useToast()
const canWrite = useCanWrite()
const [run, setRun] = useState<SalaryRun | null>(null)
const [availableEmployees, setAvailableEmployees] = useState<Employee[]>([])
const [preview, setPreview] = useState<PreviewData | null>(null)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [addEmployeeKey, setAddEmployeeKey] = useState(0)
async function loadRun() {
const res = await fetch(`/api/salary/runs/${id}`)
if (res.ok) {
const { data } = await res.json()
setRun(data)
}
}
useEffect(() => {
async function load() {
await loadRun()
const empRes = await fetch('/api/salary/employees')
if (empRes.ok) {
const { data } = await empRes.json()
setAvailableEmployees(data || [])
}
setLoading(false)
}
load()
}, [id])
async function handleAction(action: string, method: string = 'POST') {
setActionLoading(action)
const res = await fetch(`/api/salary/runs/${id}/${action}`, { method })
if (res.ok) {
await loadRun()
toast({ title: 'Status uppdaterad' })
} else {
const result = await res.json()
toast({
title: 'Fel',
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setActionLoading(null)
}
async function handleAddEmployee(employeeId: string) {
setActionLoading('add-employee')
const res = await fetch(`/api/salary/runs/${id}/employees`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ employee_id: employeeId }),
})
if (res.ok) {
await loadRun()
toast({ title: 'Anställd tillagd' })
} else {
const result = await res.json()
toast({
title: 'Fel',
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setActionLoading(null)
}
async function handleCalculate() {
setActionLoading('calculate')
const res = await fetch(`/api/salary/runs/${id}/calculate`, { method: 'POST' })
if (res.ok) {
await loadRun()
toast({ title: 'Beräkning klar' })
} else {
const result = await res.json()
toast({
title: 'Beräkningsfel',
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setActionLoading(null)
}
async function handlePreview() {
setActionLoading('preview')
const res = await fetch(`/api/salary/runs/${id}/preview`)
if (res.ok) {
const { data } = await res.json()
setPreview(data)
}
setActionLoading(null)
}
async function handleDownloadAgi() {
setActionLoading('agi-download')
const res = await fetch(`/api/salary/runs/${id}/agi/xml`)
if (!res.ok) {
const result = await res.json().catch(() => ({ error: 'Kunde inte generera AGI-fil' }))
toast({
title: 'AGI-fil kunde inte genereras',
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
setActionLoading(null)
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const periodLabel = `${run!.period_year}${String(run!.period_month).padStart(2, '0')}`
const a = document.createElement('a')
a.href = url
a.download = `AGI_${periodLabel}.xml`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
await loadRun()
toast({ title: 'AGI-fil nedladdad' })
setActionLoading(null)
}
async function handleSubmitAgi() {
setActionLoading('agi-submit')
// Generate AGI XML first if it hasn't been generated yet.
if (!run?.agi_generated_at) {
const xmlRes = await fetch(`/api/salary/runs/${id}/agi/xml`)
if (!xmlRes.ok) {
const result = await xmlRes.json().catch(() => ({ error: 'Kunde inte generera AGI-fil' }))
toast({
title: 'AGI kunde inte genereras',
description: getErrorMessage(result, { context: 'salary', statusCode: xmlRes.status }),
variant: 'destructive',
})
setActionLoading(null)
return
}
}
const res = await fetch(`/api/salary/runs/${id}/agi/submit`, { method: 'POST' })
const payload = await res.json().catch(() => ({}))
if (res.ok) {
await loadRun()
toast({
title: 'AGI skickad till Skatteverket',
description:
(payload?.data?.message as string | undefined) ??
'Signera med BankID hos Skatteverket för att slutföra inlämningen.',
})
} else {
toast({
title: 'Kunde inte skicka till Skatteverket',
description: getErrorMessage(payload, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setActionLoading(null)
}
if (loading) {
return (
<div className="space-y-6">
<div className="h-9 w-60 bg-muted rounded animate-pulse" />
<div className="h-48 bg-muted rounded-lg animate-pulse" />
</div>
)
}
if (!run) {
return <p className="text-muted-foreground">Lönekörning hittades inte</p>
}
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
const employees = (run.employees || []) as SalaryRunEmployee[]
const addedEmployeeIds = new Set(employees.map(e => e.employee_id))
const notAdded = availableEmployees.filter(e => !addedEmployeeIds.has(e.id))
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href="/salary"><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
Lönekörning {periodLabel}
</h1>
<p className="text-sm text-muted-foreground mt-1">
Utbetalning: {run.payment_date}
</p>
</div>
</div>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${STATUS_COLORS[run.status]}`}>
{STATUS_LABELS[run.status]}
</span>
</div>
{/* Summary cards */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{[
{ label: 'Brutto', value: run.total_gross },
{ label: 'Skatt', value: run.total_tax },
{ label: 'Netto', value: run.total_net },
{ label: 'Avgifter', value: run.total_avgifter },
{ label: 'Total kostnad', value: run.total_employer_cost },
].map(({ label, value }) => (
<Card key={label}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-lg font-semibold tabular-nums">{formatCurrency(value)}</p>
</CardContent>
</Card>
))}
</div>
{/* Employees */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Anställda ({employees.length})</CardTitle>
{run.status === 'draft' && canWrite && notAdded.length > 0 && (
<Select
key={addEmployeeKey}
onValueChange={(value) => {
handleAddEmployee(value)
setAddEmployeeKey(k => k + 1)
}}
>
<SelectTrigger className="w-[200px] h-8 text-sm">
<SelectValue placeholder="Lägg till anställd..." />
</SelectTrigger>
<SelectContent>
{notAdded.map(emp => (
<SelectItem key={emp.id} value={emp.id}>{emp.first_name} {emp.last_name}</SelectItem>
))}
</SelectContent>
</Select>
)}
</CardHeader>
<CardContent className="p-0">
{employees.length === 0 ? (
<p className="text-sm text-muted-foreground px-4 py-6 text-center">
Inga anställda tillagda ännu
</p>
) : (
<table className="w-full">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="px-4 py-2 font-medium">Anställd</th>
<th className="px-4 py-2 font-medium text-right">Brutto</th>
<th className="px-4 py-2 font-medium text-right">Skatt</th>
<th className="px-4 py-2 font-medium text-right">Netto</th>
<th className="px-4 py-2 font-medium text-right">Avgifter</th>
<th className="px-4 py-2 font-medium text-right">Semester</th>
</tr>
</thead>
<tbody>
{employees.map(sre => (
<tr key={sre.id} className="border-b last:border-0">
<td className="px-4 py-3 text-sm font-medium">
{(sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string; personnummer: string } }).employee
? `${(sre as SalaryRunEmployee & { employee: { first_name: string; last_name: string } }).employee.first_name} ${(sre as SalaryRunEmployee & { employee: { first_name: string; last_name: string } }).employee.last_name}`
: `Anställd ${sre.employee_id.slice(0, 8)}...`}
</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.gross_salary)}</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.tax_withheld)}</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.net_salary)}</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.avgifter_amount)}</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.vacation_accrual)}</td>
</tr>
))}
</tbody>
</table>
)}
</CardContent>
</Card>
{/* Calculation breakdown (if available) */}
{employees.some(e => e.calculation_breakdown) && (
<Card>
<CardHeader>
<CardTitle className="text-base">Beräkningsdetaljer</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{employees.filter(e => e.calculation_breakdown).map(sre => {
const breakdown = sre.calculation_breakdown as { steps?: Array<{ label: string; formula: string; output: number | null }> }
return (
<div key={sre.id} className="space-y-2">
<h4 className="text-sm font-medium">
{(sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string } }).employee
? `${(sre as SalaryRunEmployee & { employee: { first_name: string; last_name: string } }).employee.first_name} ${(sre as SalaryRunEmployee & { employee: { first_name: string; last_name: string } }).employee.last_name}`
: sre.employee_id.slice(0, 8)}
</h4>
<div className="text-xs space-y-1 bg-muted/50 rounded-lg p-3">
{(breakdown?.steps || []).map((step, i) => (
<div key={i} className="flex justify-between gap-4">
<span className="text-muted-foreground">
{step.label}: <span className="font-mono">{step.formula}</span>
</span>
{step.output !== null && (
<span className="font-medium tabular-nums">{formatCurrency(step.output)}</span>
)}
</div>
))}
</div>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Journal preview */}
{preview && (
<Card>
<CardHeader>
<CardTitle className="text-base">Förhandsgranskning verifikationer</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{[preview.salaryEntry, preview.avgifterEntry, preview.vacationEntry, (preview as unknown as Record<string, EntryPreview | null>).pensionEntry].filter(Boolean).map((entry, idx) => (
<div key={idx} className="space-y-2">
<h4 className="text-sm font-medium">{entry!.description}</h4>
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground">
<th className="text-left py-1">Konto</th>
<th className="text-left py-1">Beskrivning</th>
<th className="text-right py-1">Debet</th>
<th className="text-right py-1">Kredit</th>
</tr>
</thead>
<tbody>
{entry!.lines.map((line, li) => (
<tr key={li} className="border-t border-border/30">
<td className="py-1.5 tabular-nums font-mono">{line.account_number}</td>
<td className="py-1.5 text-muted-foreground">{line.line_description}</td>
<td className="py-1.5 text-right tabular-nums">{line.debit_amount ? formatCurrency(line.debit_amount) : ''}</td>
<td className="py-1.5 text-right tabular-nums">{line.credit_amount ? formatCurrency(line.credit_amount) : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</CardContent>
</Card>
)}
{/* AGI (Arbetsgivardeklaration) — available once the run is booked */}
{run.status === 'booked' && (
<Card>
<CardHeader>
<CardTitle className="text-base">Arbetsgivardeklaration (AGI)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
{run.agi_generated_at ? (
<>
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<span className="text-muted-foreground">
AGI-fil genererad {new Date(run.agi_generated_at).toLocaleString('sv-SE')}
</span>
</>
) : (
<span className="text-muted-foreground">AGI-fil har inte genererats ännu.</span>
)}
</div>
<div className="flex items-center gap-2">
{run.agi_submitted_at ? (
<>
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<span className="text-muted-foreground">
Skickad till Skatteverket {new Date(run.agi_submitted_at).toLocaleString('sv-SE')}
</span>
</>
) : (
<span className="text-muted-foreground">
Inte skickad till Skatteverket ännu. Deadline: 12:e i månaden efter utbetalning.
</span>
)}
</div>
</div>
{canWrite && (
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
onClick={handleDownloadAgi}
disabled={!!actionLoading}
>
{actionLoading === 'agi-download' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Ladda ner AGI-fil
</Button>
<Button
onClick={handleSubmitAgi}
disabled={!!actionLoading || !!run.agi_submitted_at}
>
{actionLoading === 'agi-submit' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Skicka till Skatteverket
</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* Actions */}
{canWrite && (
<div className="flex flex-wrap gap-3 justify-end">
{run.status === 'draft' && (
<>
<Button variant="outline" onClick={handleCalculate} disabled={!!actionLoading || employees.length === 0}>
{actionLoading === 'calculate' ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Calculator className="mr-2 h-4 w-4" />}
Beräkna
</Button>
<Button variant="outline" onClick={handlePreview} disabled={!!actionLoading || run.total_gross === 0}>
{actionLoading === 'preview' ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Eye className="mr-2 h-4 w-4" />}
Förhandsgranska
</Button>
<Button onClick={() => handleAction('review')} disabled={!!actionLoading || run.total_gross === 0}>
Till granskning
</Button>
</>
)}
{run.status === 'review' && (
<>
<Button variant="outline" onClick={() => handleAction('revert')} disabled={!!actionLoading}>
<ArrowLeftCircle className="mr-2 h-4 w-4" />
Tillbaka till utkast
</Button>
<Button variant="outline" onClick={handlePreview} disabled={!!actionLoading}>
<Eye className="mr-2 h-4 w-4" />
Förhandsgranska
</Button>
<Button onClick={() => handleAction('approve')} disabled={!!actionLoading}>
<Check className="mr-2 h-4 w-4" />
Godkänn
</Button>
</>
)}
{run.status === 'approved' && (
<Button onClick={() => handleAction('paid')} disabled={!!actionLoading}>
<CreditCard className="mr-2 h-4 w-4" />
Markera som betald
</Button>
)}
{run.status === 'paid' && (
<Button onClick={() => handleAction('book')} disabled={!!actionLoading}>
{actionLoading === 'book' ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <BookOpen className="mr-2 h-4 w-4" />}
Bokför
</Button>
)}
</div>
)}
</div>
)
}