Salary module (#245)

* feat: implement salary module with personnummer encryption, salary entries, tax tables, and AGI tracking

- Added personnummer encryption and decryption functions for secure storage.
- Created salary entries handling for journal entries including gross salary, tax withholding, and employer contributions.
- Implemented tax table lookup functionality for calculating tax amounts based on monthly income.
- Developed SQL migration for salary module including tables for payroll configuration, tax rates, employees, salary runs, salary run employees, salary line items, and AGI declarations.
- Established row-level security policies for all new tables to ensure company-scoped access.

* feat: add salary calculation modules for 2026

- Implemented engångsskatt calculation for one-time payments with tax brackets.
- Added löneväxling functionality for salary sacrifice to pension, including employer savings and warnings.
- Created pain.001 generator for salary batch payments in compliance with Swedish banking standards.
- Developed PDF template for payslips, including detailed breakdowns and employer costs.
- Generated seed data for Swedish tax tables for 2026, including SQL insert statements.
- Implemented traktamente calculations for per diem and mileage allowances, adhering to Skatteverket regulations.
- Added seed script for populating tax tables in the database.

* feat: Update meal reduction percentages in traktamente calculation

fix: Remove obsolete seed script for 2026 tax tables

feat: Extend SalaryRunStatus type to include 'corrected' status

feat: Implement KU10 XML generation endpoint for annual employee income statements

feat: Add endpoint for creating corrections to booked salary runs

feat: Implement endpoint for sending payslip PDFs to employees

feat: Create KU10 XML generator for annual reporting

feat: Add salary transaction matcher for auto-linking bank transactions to salary entries

chore: Add database migration for salary correction support

* feat: replace select elements with custom Select component for employment and salary types

* feat: enhance salary calculations with pension entry and avgifter category support
This commit is contained in:
Mattsson
2026-04-15 11:17:39 +02:00
committed by GitHub
parent b387a77bfd
commit 04dbb31d7e
78 changed files with 10529 additions and 14 deletions
+3 -2
View File
@@ -74,6 +74,7 @@ export default async function DashboardPage() {
{ data: recentReceiptActivity },
{ count: sieImportCount },
{ count: staleUncategorizedCount },
{ count: uncategorizedCount },
] = await Promise.all([
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
supabase.from('company_settings').select('*').eq('company_id', companyId).single(),
@@ -99,6 +100,7 @@ export default async function DashboardPage() {
supabase.from('receipts').select('created_at').eq('company_id', companyId).eq('status', 'confirmed').order('created_at', { ascending: false }).limit(30),
supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('journal_entry_id', null).not('is_business', 'eq', false).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('is_business', null),
])
const firstName = profile?.full_name?.split(' ')[0] || null
@@ -148,7 +150,6 @@ export default async function DashboardPage() {
const uncategorizedTxns = (transactions || []).filter(
(t) => t.is_business === null
)
const uncategorizedCount = uncategorizedTxns.length
const uncategorizedIncome = uncategorizedTxns
.filter((t) => t.amount > 0)
.reduce((sum, t) => sum + Number(t.amount_sek || t.amount), 0)
@@ -234,7 +235,7 @@ export default async function DashboardPage() {
summary={{
ytd: ytdTotals,
mtd: mtdTotals,
uncategorizedCount,
uncategorizedCount: uncategorizedCount || 0,
uncategorizedIncome,
uncategorizedExpenses,
unpaidInvoicesCount: (unpaidInvoices || []).length,
@@ -0,0 +1,226 @@
'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 { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { ArrowLeft, Save, Trash2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency } from '@/lib/utils'
import type { Employee } from '@/types'
const EMPLOYMENT_LABELS: Record<string, string> = {
employee: 'Anställd',
company_owner: 'Företagsledare',
board_member: 'Styrelseledamot',
}
export default function EmployeeDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const router = useRouter()
const { toast } = useToast()
const canWrite = useCanWrite()
const [employee, setEmployee] = useState<Employee | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [employmentType, setEmploymentType] = useState('employee')
useEffect(() => {
async function load() {
const res = await fetch(`/api/salary/employees/${id}`)
if (res.ok) {
const { data } = await res.json()
setEmployee(data)
setEmploymentType(data.employment_type)
}
setLoading(false)
}
load()
}, [id])
async function handleSave(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
const form = new FormData(e.currentTarget)
const body = {
first_name: form.get('first_name') as string,
last_name: form.get('last_name') as string,
employment_type: employmentType,
employment_degree: parseFloat(form.get('employment_degree') as string) || 100,
monthly_salary: parseFloat(form.get('monthly_salary') as string) || undefined,
hourly_rate: parseFloat(form.get('hourly_rate') as string) || undefined,
tax_table_number: parseInt(form.get('tax_table_number') as string) || undefined,
tax_column: parseInt(form.get('tax_column') as string) || 1,
email: form.get('email') as string || undefined,
phone: form.get('phone') as string || undefined,
clearing_number: form.get('clearing_number') as string || undefined,
bank_account_number: form.get('bank_account_number') as string || undefined,
}
const res = await fetch(`/api/salary/employees/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
const { data } = await res.json()
setEmployee(data)
toast({ title: 'Anställd uppdaterad' })
} else {
const result = await res.json()
toast({
title: 'Fel',
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setSaving(false)
}
async function handleDeactivate() {
if (!confirm('Vill du inaktivera denna anställd?')) return
const res = await fetch(`/api/salary/employees/${id}`, { method: 'DELETE' })
if (res.ok) {
toast({ title: 'Anställd inaktiverad' })
router.push('/salary/employees')
}
}
if (loading) {
return (
<div className="space-y-6">
<div className="h-9 w-60 bg-muted rounded animate-pulse" />
<div className="h-64 bg-muted rounded-lg animate-pulse" />
</div>
)
}
if (!employee) {
return <p className="text-muted-foreground">Anställd hittades inte</p>
}
return (
<div className="space-y-6">
<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/employees"><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{employee.first_name} {employee.last_name}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{employee.personnummer} · {EMPLOYMENT_LABELS[employee.employment_type]}
</p>
</div>
</div>
{canWrite && (
<Button variant="outline" size="sm" onClick={handleDeactivate} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Inaktivera
</Button>
)}
</div>
<form onSubmit={handleSave} className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Uppgifter</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first_name">Förnamn</Label>
<Input id="first_name" name="first_name" defaultValue={employee.first_name} required disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="last_name">Efternamn</Label>
<Input id="last_name" name="last_name" defaultValue={employee.last_name} required disabled={!canWrite} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="employment_type">Typ</Label>
<Select value={employmentType} onValueChange={setEmploymentType} disabled={!canWrite}>
<SelectTrigger id="employment_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="employee">Anställd</SelectItem>
<SelectItem value="company_owner">Företagsledare</SelectItem>
<SelectItem value="board_member">Styrelseledamot</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="employment_degree">Sysselsättningsgrad (%)</Label>
<Input id="employment_degree" name="employment_degree" type="number" defaultValue={employee.employment_degree} disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="monthly_salary">Månadslön</Label>
<Input id="monthly_salary" name="monthly_salary" type="number" defaultValue={employee.monthly_salary || ''} disabled={!canWrite} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="hourly_rate">Timlön</Label>
<Input id="hourly_rate" name="hourly_rate" type="number" step="0.01" defaultValue={employee.hourly_rate || ''} disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="tax_table_number">Skattetabell</Label>
<Input id="tax_table_number" name="tax_table_number" type="number" defaultValue={employee.tax_table_number || ''} disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="tax_column">Kolumn</Label>
<Input id="tax_column" name="tax_column" type="number" defaultValue={employee.tax_column} disabled={!canWrite} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Input id="email" name="email" type="email" defaultValue={employee.email || ''} disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Input id="phone" name="phone" defaultValue={employee.phone || ''} disabled={!canWrite} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="clearing_number">Clearingnummer</Label>
<Input id="clearing_number" name="clearing_number" defaultValue={employee.clearing_number || ''} disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="bank_account_number">Kontonummer</Label>
<Input id="bank_account_number" name="bank_account_number" defaultValue={employee.bank_account_number || ''} disabled={!canWrite} />
</div>
</div>
</CardContent>
</Card>
{canWrite && (
<div className="flex justify-end gap-3">
<Button variant="outline" asChild>
<Link href="/salary/employees">Avbryt</Link>
</Button>
<Button type="submit" disabled={saving}>
<Save className="mr-2 h-4 w-4" />
{saving ? 'Sparar...' : 'Spara ändringar'}
</Button>
</div>
)}
</form>
</div>
)
}
@@ -0,0 +1,231 @@
'use client'
import { useState } 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 { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { ArrowLeft, Save } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
export default function NewEmployeePage() {
const router = useRouter()
const { toast } = useToast()
const [saving, setSaving] = useState(false)
const [employmentType, setEmploymentType] = useState('employee')
const [salaryType, setSalaryType] = useState('monthly')
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
const form = new FormData(e.currentTarget)
const body = {
first_name: form.get('first_name') as string,
last_name: form.get('last_name') as string,
personnummer: (form.get('personnummer') as string).replace(/\D/g, ''),
employment_type: employmentType,
employment_start: form.get('employment_start') as string,
employment_degree: parseFloat(form.get('employment_degree') as string) || 100,
salary_type: salaryType,
monthly_salary: parseFloat(form.get('monthly_salary') as string) || undefined,
hourly_rate: parseFloat(form.get('hourly_rate') as string) || undefined,
tax_table_number: parseInt(form.get('tax_table_number') as string) || undefined,
tax_column: parseInt(form.get('tax_column') as string) || 1,
tax_municipality: form.get('tax_municipality') as string || undefined,
email: form.get('email') as string || undefined,
phone: form.get('phone') as string || undefined,
clearing_number: form.get('clearing_number') as string || undefined,
bank_account_number: form.get('bank_account_number') as string || undefined,
}
const res = await fetch('/api/salary/employees', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
toast({ title: 'Anställd skapad' })
router.push('/salary/employees')
} else {
const result = await res.json()
toast({
title: 'Fel',
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setSaving(false)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href="/salary/employees"><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Ny anställd</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Personal info */}
<Card>
<CardHeader>
<CardTitle className="text-base">Personuppgifter</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first_name">Förnamn</Label>
<Input id="first_name" name="first_name" required />
</div>
<div className="space-y-2">
<Label htmlFor="last_name">Efternamn</Label>
<Input id="last_name" name="last_name" required />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="personnummer">Personnummer (12 siffror)</Label>
<Input id="personnummer" name="personnummer" placeholder="ÅÅÅÅMMDDNNNN" required maxLength={13} />
<p className="text-xs text-muted-foreground">Krypteras vid lagring</p>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Input id="email" name="email" type="email" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Input id="phone" name="phone" className="max-w-xs" />
</div>
</CardContent>
</Card>
{/* Employment */}
<Card>
<CardHeader>
<CardTitle className="text-base">Anställning</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="employment_type">Typ</Label>
<Select value={employmentType} onValueChange={setEmploymentType}>
<SelectTrigger id="employment_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="employee">Anställd</SelectItem>
<SelectItem value="company_owner">Företagsledare</SelectItem>
<SelectItem value="board_member">Styrelseledamot</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="employment_start">Anställningsdatum</Label>
<Input id="employment_start" name="employment_start" type="date" required />
</div>
<div className="space-y-2">
<Label htmlFor="employment_degree">Sysselsättningsgrad (%)</Label>
<Input id="employment_degree" name="employment_degree" type="number" defaultValue="100" min="1" max="100" />
</div>
</div>
</CardContent>
</Card>
{/* Salary */}
<Card>
<CardHeader>
<CardTitle className="text-base">Lön</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="salary_type">Löneform</Label>
<Select value={salaryType} onValueChange={setSalaryType}>
<SelectTrigger id="salary_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månadslön</SelectItem>
<SelectItem value="hourly">Timlön</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="monthly_salary">Månadslön (brutto, SEK)</Label>
<Input id="monthly_salary" name="monthly_salary" type="number" step="1" min="0" />
</div>
<div className="space-y-2">
<Label htmlFor="hourly_rate">Timlön (SEK)</Label>
<Input id="hourly_rate" name="hourly_rate" type="number" step="0.01" min="0" />
</div>
</div>
</CardContent>
</Card>
{/* Tax */}
<Card>
<CardHeader>
<CardTitle className="text-base">Skatt</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="tax_table_number">Skattetabell (29-42)</Label>
<Input id="tax_table_number" name="tax_table_number" type="number" min="29" max="42" />
<p className="text-xs text-muted-foreground">Baseras på folkbokföringskommun</p>
</div>
<div className="space-y-2">
<Label htmlFor="tax_column">Kolumn (1-6)</Label>
<Input id="tax_column" name="tax_column" type="number" defaultValue="1" min="1" max="6" />
<p className="text-xs text-muted-foreground">1 = standard under 66 år</p>
</div>
<div className="space-y-2">
<Label htmlFor="tax_municipality">Folkbokföringskommun</Label>
<Input id="tax_municipality" name="tax_municipality" />
</div>
</div>
</CardContent>
</Card>
{/* Bank */}
<Card>
<CardHeader>
<CardTitle className="text-base">Bankkonto</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="clearing_number">Clearingnummer</Label>
<Input id="clearing_number" name="clearing_number" />
</div>
<div className="space-y-2">
<Label htmlFor="bank_account_number">Kontonummer</Label>
<Input id="bank_account_number" name="bank_account_number" />
</div>
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-3">
<Button variant="outline" asChild>
<Link href="/salary/employees">Avbryt</Link>
</Button>
<Button type="submit" disabled={saving}>
<Save className="mr-2 h-4 w-4" />
{saving ? 'Sparar...' : 'Spara'}
</Button>
</div>
</form>
</div>
)
}
+124
View File
@@ -0,0 +1,124 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Plus, ArrowLeft, UserCircle } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency } from '@/lib/utils'
import type { Employee } from '@/types'
const EMPLOYMENT_LABELS: Record<string, string> = {
employee: 'Anställd',
company_owner: 'Företagsledare',
board_member: 'Styrelseledamot',
}
export default function EmployeesPage() {
const [employees, setEmployees] = useState<Employee[]>([])
const [loading, setLoading] = useState(true)
const canWrite = useCanWrite()
useEffect(() => {
async function load() {
const res = await fetch('/api/salary/employees')
if (res.ok) {
const { data } = await res.json()
setEmployees(data || [])
}
setLoading(false)
}
load()
}, [])
return (
<div className="space-y-6">
<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">Anställda</h1>
<p className="text-sm text-muted-foreground mt-1">{employees.length} registrerade</p>
</div>
</div>
{canWrite && (
<Button asChild>
<Link href="/salary/employees/new">
<Plus className="mr-2 h-4 w-4" />
Ny anställd
</Link>
</Button>
)}
</div>
{loading ? (
<div className="space-y-2">
{[1, 2, 3].map(i => (
<div key={i} className="h-16 bg-muted rounded-lg animate-pulse" />
))}
</div>
) : employees.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<UserCircle className="h-10 w-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground mb-4">Inga anställda registrerade</p>
{canWrite && (
<Button asChild size="sm">
<Link href="/salary/employees/new">
<Plus className="mr-2 h-4 w-4" />
Lägg till anställd
</Link>
</Button>
)}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<table className="w-full">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="px-4 py-2 font-medium">Namn</th>
<th className="px-4 py-2 font-medium">Personnummer</th>
<th className="px-4 py-2 font-medium">Typ</th>
<th className="px-4 py-2 font-medium text-right">Månadslön</th>
<th className="px-4 py-2 font-medium text-right">Sysselsättningsgrad</th>
<th className="px-4 py-2 font-medium">Skattetabell</th>
</tr>
</thead>
<tbody>
{employees.map(emp => (
<tr key={emp.id} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
<td className="px-4 py-3">
<Link href={`/salary/employees/${emp.id}`} className="text-sm font-medium hover:underline">
{emp.first_name} {emp.last_name}
</Link>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground tabular-nums">
{emp.personnummer}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{EMPLOYMENT_LABELS[emp.employment_type] || emp.employment_type}
</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">
{emp.monthly_salary ? formatCurrency(emp.monthly_salary) : '—'}
</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">
{emp.employment_degree}%
</td>
<td className="px-4 py-3 text-sm text-muted-foreground tabular-nums">
{emp.tax_table_number ? `Tabell ${emp.tax_table_number}, kol ${emp.tax_column}` : '—'}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
</div>
)
}
+208
View File
@@ -0,0 +1,208 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Plus, Users, HandCoins, CalendarDays, ArrowRight } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency } from '@/lib/utils'
import type { SalaryRun } from '@/types'
const STATUS_LABELS: Record<string, string> = {
draft: 'Utkast',
review: 'Granskning',
approved: 'Godkänd',
paid: 'Betald',
booked: 'Bokförd',
}
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',
}
export default function SalaryPage() {
const [runs, setRuns] = useState<SalaryRun[]>([])
const [employeeCount, setEmployeeCount] = useState(0)
const [loading, setLoading] = useState(true)
const canWrite = useCanWrite()
useEffect(() => {
async function load() {
const [runsRes, empRes] = await Promise.all([
fetch('/api/salary/runs'),
fetch('/api/salary/employees'),
])
if (runsRes.ok) {
const { data } = await runsRes.json()
setRuns(data || [])
}
if (empRes.ok) {
const { data } = await empRes.json()
setEmployeeCount((data || []).length)
}
setLoading(false)
}
load()
}, [])
const currentYear = new Date().getFullYear()
const yearRuns = runs.filter(r => r.period_year === currentYear)
const totalGrossYTD = yearRuns.filter(r => r.status === 'booked').reduce((sum, r) => sum + r.total_gross, 0)
const totalAvgifterYTD = yearRuns.filter(r => r.status === 'booked').reduce((sum, r) => sum + r.total_avgifter, 0)
const latestRun = runs[0]
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="h-9 w-40 bg-muted rounded animate-pulse" />
<div className="h-9 w-32 bg-muted rounded animate-pulse" />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="h-24 bg-muted rounded-lg animate-pulse" />
))}
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Löner</h1>
<p className="text-sm text-muted-foreground mt-1">Hantera anställda och lönekörningar</p>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href="/salary/employees">
<Users className="mr-2 h-4 w-4" />
Anställda
</Link>
</Button>
{canWrite && (
<Button asChild>
<Link href="/salary/runs/new">
<Plus className="mr-2 h-4 w-4" />
Ny lönekörning
</Link>
</Button>
)}
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Users className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm text-muted-foreground">Anställda</p>
<p className="text-2xl font-semibold tabular-nums">{employeeCount}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<HandCoins className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm text-muted-foreground">Bruttolöner {currentYear}</p>
<p className="text-2xl font-semibold tabular-nums">{formatCurrency(totalGrossYTD)}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<CalendarDays className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm text-muted-foreground">Avgifter {currentYear}</p>
<p className="text-2xl font-semibold tabular-nums">{formatCurrency(totalAvgifterYTD)}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Recent runs */}
<Card>
<CardHeader>
<CardTitle className="text-base">Lönekörningar</CardTitle>
</CardHeader>
<CardContent className="p-0">
{runs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<HandCoins className="h-10 w-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground mb-4">Inga lönekörningar ännu</p>
{canWrite && (
<Button asChild size="sm">
<Link href="/salary/runs/new">
<Plus className="mr-2 h-4 w-4" />
Skapa första lönekörningen
</Link>
</Button>
)}
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="px-4 py-2 font-medium">Period</th>
<th className="px-4 py-2 font-medium">Utbetalningsdag</th>
<th className="px-4 py-2 font-medium text-right">Brutto</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">Status</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{runs.slice(0, 12).map(run => (
<tr key={run.id} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
<td className="px-4 py-3 text-sm font-medium tabular-nums">
{run.period_year}-{String(run.period_month).padStart(2, '0')}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground tabular-nums">
{run.payment_date}
</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">
{formatCurrency(run.total_gross)}
</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">
{formatCurrency(run.total_net)}
</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">
{formatCurrency(run.total_avgifter)}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${STATUS_COLORS[run.status]}`}>
{STATUS_LABELS[run.status]}
</span>
</td>
<td className="px-4 py-3 text-right">
<Link href={`/salary/runs/${run.id}`} className="text-muted-foreground hover:text-foreground">
<ArrowRight className="h-4 w-4" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
)}
</CardContent>
</Card>
</div>
)
}
+381
View File
@@ -0,0 +1,381 @@
'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,
} 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',
}
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',
}
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)
}
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 }> }
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">
<span className="text-muted-foreground">{step.label}: <span className="font-mono">{step.formula}</span></span>
<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>
)}
{/* 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>
)
}
+107
View File
@@ -0,0 +1,107 @@
'use client'
import { useState } 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 { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
export default function NewSalaryRunPage() {
const router = useRouter()
const { toast } = useToast()
const [saving, setSaving] = useState(false)
const now = new Date()
const defaultYear = now.getFullYear()
const defaultMonth = now.getMonth() + 1
const defaultPayDate = `${defaultYear}-${String(defaultMonth).padStart(2, '0')}-25`
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
const form = new FormData(e.currentTarget)
const body = {
period_year: parseInt(form.get('period_year') as string),
period_month: parseInt(form.get('period_month') as string),
payment_date: form.get('payment_date') as string,
voucher_series: form.get('voucher_series') as string || 'A',
}
const res = await fetch('/api/salary/runs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
const { data } = await res.json()
toast({ title: 'Lönekörning skapad' })
router.push(`/salary/runs/${data.id}`)
} else {
const result = await res.json()
toast({
title: 'Fel',
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setSaving(false)
}
return (
<div className="space-y-6">
<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>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Ny lönekörning</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6 max-w-xl">
<Card>
<CardHeader>
<CardTitle className="text-base">Period och utbetalning</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="period_year">År</Label>
<Input id="period_year" name="period_year" type="number" defaultValue={defaultYear} required />
</div>
<div className="space-y-2">
<Label htmlFor="period_month">Månad (1-12)</Label>
<Input id="period_month" name="period_month" type="number" min="1" max="12" defaultValue={defaultMonth} required />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="payment_date">Utbetalningsdag</Label>
<Input id="payment_date" name="payment_date" type="date" defaultValue={defaultPayDate} required />
</div>
<div className="space-y-2">
<Label htmlFor="voucher_series">Verifikationsserie</Label>
<Input id="voucher_series" name="voucher_series" defaultValue="A" maxLength={1} className="max-w-20" />
<p className="text-xs text-muted-foreground">En bokstav A–Z. Standard: A</p>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button variant="outline" asChild>
<Link href="/salary">Avbryt</Link>
</Button>
<Button type="submit" disabled={saving}>
{saving ? 'Skapar...' : 'Skapa och fortsätt'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</form>
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export default function SalarySettingsPage() {
return (
<div className="space-y-6">
<div>
<h2 className="font-display text-xl font-medium tracking-tight">Löneinställningar</h2>
<p className="text-sm text-muted-foreground mt-1">
Konfiguration för lönemodulen
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Bokföring</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Standard verifikationsserie för löner</label>
<select className="flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm" defaultValue="A">
<option value="A">A — Standard</option>
<option value="L">L — Löner</option>
</select>
<p className="text-xs text-muted-foreground">
Kan ändras per lönekörning. Varje serie har obrutna verifikationsnummer per räkenskapsår.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Semester</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Standard semesterregel</label>
<select className="flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm" defaultValue="procentregeln">
<option value="procentregeln">Procentregeln (12%)</option>
<option value="sammaloneregeln">Sammalöneregeln</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Semestertillägg</label>
<select className="flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm" defaultValue="0.0043">
<option value="0.0043">0,43% (lagstadgat minimum)</option>
<option value="0.008">0,80% (vanligt kollektivavtalsbelopp)</option>
</select>
<p className="text-xs text-muted-foreground">
Tillämpas vid sammalöneregeln. Kan ändras per anställd.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Information</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground space-y-2">
<p>Lönemodulen hanterar löner för aktiebolag. Enskild firma-ägare använder eget uttag istället.</p>
<p>Arbetsgivaravgifter och skattetabeller uppdateras årligen baserat på Skatteverkets publiceringar.</p>
<p>
<strong>Aktuellt år:</strong> 2026 — Arbetsgivaravgifter 31,42%, prisbasbelopp 59 200 SEK
</p>
</div>
</CardContent>
</Card>
</div>
)
}
+20 -7
View File
@@ -85,6 +85,9 @@ export default function TransactionsPage() {
const [hasMore, setHasMore] = useState(false)
const [isLoadingMore, setIsLoadingMore] = useState(false)
// True uncategorized count from DB (not limited by pagination)
const [totalUncategorizedCount, setTotalUncategorizedCount] = useState<number | null>(null)
// Set of transaction IDs that are animating out (just categorized)
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
@@ -108,12 +111,19 @@ export default function TransactionsPage() {
async function fetchTransactions() {
if (!company) return
setIsLoading(true)
const { data: txData, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('company_id', company.id)
.order('date', { ascending: false })
.limit(PAGE_SIZE)
const [{ data: txData, error: txError }, { count: uncatCount }] = await Promise.all([
supabase
.from('transactions')
.select('*')
.eq('company_id', company.id)
.order('date', { ascending: false })
.limit(PAGE_SIZE),
supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('company_id', company.id)
.is('is_business', null),
])
if (txError) {
toast({ title: 'Kunde inte ladda transaktioner', description: 'Kontrollera din anslutning och försök igen.', variant: 'destructive' })
@@ -164,6 +174,7 @@ export default function TransactionsPage() {
}))
setTransactions(transactionsWithInvoices)
setTotalUncategorizedCount(uncatCount ?? 0)
setHasMore((txData || []).length >= PAGE_SIZE)
setIsLoading(false)
}
@@ -314,6 +325,7 @@ export default function TransactionsPage() {
// Mark as exiting for animation, then update state
setExitingIds((prev) => new Set(prev).add(id))
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
if (result.journal_entry_created) {
toast({
@@ -331,6 +343,7 @@ export default function TransactionsPage() {
: t
)
)
setTotalUncategorizedCount((prev) => (prev ?? 0) + 1)
toast({ title: 'Ångrad', description: 'Kategorisering har ångrats' })
} else {
const errData = await undoRes.json()
@@ -771,7 +784,7 @@ export default function TransactionsPage() {
<div className="space-y-6">
{/* Status bar with mode toggle */}
<TransactionStatusBar
uncategorizedCount={uncategorizedTransactions.length}
uncategorizedCount={totalUncategorizedCount ?? uncategorizedTransactions.length}
invoiceMatchCount={transactionsWithMatches.length}
mode={mode}
onModeChange={setMode}