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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user