11621bb79f
* 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>
521 lines
21 KiB
TypeScript
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>
|
|
)
|
|
}
|