Bug/employees creation (#440)

* Fix/employees page salary display logic and labels

* feat: add salary_worked_days table and related functionality

- Implemented the salary_worked_days table to track per-day worked hours for hourly employees.
- Established row-level security (RLS) policies to ensure tenant isolation for salary_worked_days.
- Created unique index on (employee_id, work_date) to enforce uniqueness.
- Added trigger to enforce a 24-hour cap across worked and absence days for the same employee and date.
- Developed tests to validate RLS, uniqueness, and 24-hour cap logic.

* Fix: update hourly_salary calculation and refresh logic in salary run processing
This commit is contained in:
Mattsson
2026-05-12 01:10:01 +02:00
committed by GitHub
parent dec920682f
commit 8f15f98687
13 changed files with 1802 additions and 480 deletions
+4 -2
View File
@@ -84,7 +84,7 @@ export default function EmployeesPage() {
<TableHead>Namn</TableHead>
<TableHead>Personnummer</TableHead>
<TableHead>Typ</TableHead>
<TableHead className="text-right">Månadslön</TableHead>
<TableHead className="text-right">Lön</TableHead>
<TableHead className="text-right">Sysselsättningsgrad</TableHead>
<TableHead>Skattetabell</TableHead>
</TableRow>
@@ -104,7 +104,9 @@ export default function EmployeesPage() {
{EMPLOYMENT_LABELS[emp.employment_type] || emp.employment_type}
</TableCell>
<TableCell className="text-right tabular-nums">
{emp.monthly_salary ? formatCurrency(emp.monthly_salary) : '—'}
{emp.salary_type === 'hourly'
? emp.hourly_rate ? `${formatCurrency(emp.hourly_rate)}/tim` : '—'
: emp.monthly_salary ? formatCurrency(emp.monthly_salary) : '—'}
</TableCell>
<TableCell className="text-right tabular-nums">
{emp.employment_degree}%
@@ -2,10 +2,10 @@
import { use, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { ArrowLeft, Loader2 } from 'lucide-react'
import { ArrowLeft, Calculator, Loader2 } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { AbsenceCalendar } from '@/components/salary/AbsenceCalendar'
import { SalaryCalendar } from '@/components/salary/SalaryCalendar'
import { formatCurrency } from '@/lib/utils'
import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, SalaryLineItemType, Employee } from '@/types'
@@ -54,6 +54,10 @@ export default function SalaryRunEmployeeDetailPage({
const [data, setData] = useState<DetailResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [calculating, setCalculating] = useState(false)
// Live counts pushed from the calendar — overrides the stale snapshot from
// the last calculation so badges update immediately on absence save.
const [liveCounts, setLiveCounts] = useState<{ sick: number; vab: number; parental: number } | null>(null)
const load = async () => {
setLoading(true)
@@ -80,6 +84,23 @@ export default function SalaryRunEmployeeDetailPage({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runId, employeeId])
const handleCalculate = async () => {
setCalculating(true)
setError(null)
try {
const res = await fetch(`/api/salary/runs/${runId}/calculate`, { method: 'POST' })
const json = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(json.error || 'Beräkning misslyckades')
}
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
} finally {
setCalculating(false)
}
}
const periodStart = useMemo(() => {
if (!data) return ''
const y = data.run.period_year
@@ -144,8 +165,18 @@ export default function SalaryRunEmployeeDetailPage({
{employee.personnummer} · Lönespecifikation {periodLabel}
</p>
</div>
<Button variant="outline" size="sm" onClick={load}>
Uppdatera
<Button
variant="outline"
size="sm"
onClick={handleCalculate}
disabled={calculating || readOnly}
>
{calculating ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Calculator className="mr-1.5 h-3.5 w-3.5" />
)}
Beräkna
</Button>
</div>
</div>
@@ -158,28 +189,31 @@ export default function SalaryRunEmployeeDetailPage({
<SummaryCard label="Avgifter" value={runEmployee.avgifter_amount} />
</div>
{/* Absence calendar */}
{/* Unified calendar — worked time (for hourly) + absence on the same grid */}
<Card>
<CardHeader>
<CardTitle className="text-base">Frånvaro</CardTitle>
<CardTitle className="text-base">Tid och frånvaro</CardTitle>
<p className="text-xs text-muted-foreground">
Markera sjukdom, VAB, föräldraledighet och annan frånvaro per dag.
Karensavdrag, sjuklön och AGI-rapportering räknas ut automatiskt.
{employee.salary_type === 'hourly'
? 'Markera dagar och ange arbetade timmar eller frånvaro. Grundlönen räknas som timlön × summa arbetade timmar. Karensavdrag, sjuklön och AGI-rapportering härleds automatiskt.'
: 'Markera sjukdom, VAB, föräldraledighet och annan frånvaro per dag. Karensavdrag, sjuklön och AGI-rapportering räknas ut automatiskt.'}
</p>
</CardHeader>
<CardContent>
<AbsenceCalendar
<SalaryCalendar
employeeId={employee.id}
salaryType={employee.salary_type}
periodStart={periodStart}
periodEnd={periodEnd}
salaryRunEmployeeId={runEmployee.id}
readOnly={readOnly}
onChange={load}
onAbsenceCountsChange={setLiveCounts}
/>
<div className="mt-3 grid grid-cols-3 gap-2 text-xs">
<AbsenceCount label="Sjukdagar" days={runEmployee.sick_days} />
<AbsenceCount label="VAB-dagar" days={runEmployee.vab_days} />
<AbsenceCount label="Föräldraledig" days={runEmployee.parental_days} />
<AbsenceCount label="Sjukdagar" days={liveCounts?.sick ?? runEmployee.sick_days} />
<AbsenceCount label="VAB-dagar" days={liveCounts?.vab ?? runEmployee.vab_days} />
<AbsenceCount label="Föräldraledig" days={liveCounts?.parental ?? runEmployee.parental_days} />
</div>
</CardContent>
</Card>
@@ -119,6 +119,11 @@ export async function POST(
.single()
if (error) {
// The 24h cap trigger raises check_violation when worked + absence > 24h
// for the same date. Surface a clean 409 with the Swedish message.
if (error.message?.includes('Total tid') || error.code === '23514') {
return NextResponse.json({ error: error.message }, { status: 409 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
@@ -0,0 +1,97 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { BatchUpsertWorkedDaysSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
interface BatchConflict {
date: string
reason: string
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: employeeId } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { data: employee } = await supabase
.from('employees')
.select('id')
.eq('id', employeeId)
.eq('company_id', companyId)
.maybeSingle()
if (!employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
const validation = await validateBody(request, BatchUpsertWorkedDaysSchema)
if (!validation.success) return validation.response
const body = validation.data
// Dedupe dates so the user can pass an array with accidental duplicates
// (e.g. shift-clicking over the same date twice).
const uniqueDates = Array.from(new Set(body.dates))
// Bulk delete existing rows on these dates first so the per-row insert step
// is a clean replace. Stays within RLS via company_id + employee_id filter.
const { error: deleteError } = await supabase
.from('salary_worked_days')
.delete()
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.in('work_date', uniqueDates)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
}
// Per-row insert so we can isolate trigger failures (24h cap on a date with
// existing absence) without aborting the whole batch. A single multi-row
// insert would fail-fast and surface only the first conflict.
const conflicts: BatchConflict[] = []
let inserted = 0
for (const date of uniqueDates) {
const { error } = await supabase
.from('salary_worked_days')
.insert({
company_id: companyId,
employee_id: employeeId,
work_date: date,
hours: body.hours,
notes: body.notes ?? null,
salary_run_employee_id: body.salary_run_employee_id ?? null,
})
if (error) {
// 24h cap trigger uses ERRCODE check_violation (23514) and a Swedish
// message starting with "Total tid". Other failures are unexpected.
if (error.message?.includes('Total tid') || error.code === '23514') {
conflicts.push({ date, reason: error.message })
continue
}
return NextResponse.json(
{ error: error.message, inserted, conflicts },
{ status: 500 },
)
}
inserted += 1
}
return NextResponse.json(
{ data: { inserted, conflicts } },
{ status: conflicts.length > 0 ? 207 : 201 },
)
}
@@ -0,0 +1,190 @@
import { z } from 'zod'
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody, validateQuery } from '@/lib/api/validate'
import {
UpsertWorkedDaySchema,
WorkedHoursRangeQuerySchema,
} from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
ensureInitialized()
async function loadEmployee(
supabase: Awaited<ReturnType<typeof createClient>>,
employeeId: string,
companyId: string,
) {
const { data } = await supabase
.from('employees')
.select('id, salary_type')
.eq('id', employeeId)
.eq('company_id', companyId)
.maybeSingle()
return data
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: employeeId } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const employee = await loadEmployee(supabase, employeeId, companyId)
if (!employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
const query = validateQuery(request, WorkedHoursRangeQuerySchema)
if (!query.success) return query.response
const { data, error } = await supabase
.from('salary_worked_days')
.select('id, work_date, hours, notes, salary_run_employee_id, created_at, updated_at')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.gte('work_date', query.data.from)
.lte('work_date', query.data.to)
.order('work_date', { ascending: true })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const totalHours = (data ?? []).reduce(
(sum, d) => Math.round((sum + Number(d.hours)) * 100) / 100,
0,
)
return NextResponse.json({ data, total_hours: totalHours })
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: employeeId } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const employee = await loadEmployee(supabase, employeeId, companyId)
if (!employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
const validation = await validateBody(request, UpsertWorkedDaySchema)
if (!validation.success) return validation.response
const body = validation.data
// Upsert via DELETE+INSERT on the natural key (employee, date). Worked days
// have one row per date — re-marking overwrites. Mirrors the absence route's
// pattern so behaviour stays predictable across the two calendars.
const { error: deleteError } = await supabase
.from('salary_worked_days')
.delete()
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.eq('work_date', body.work_date)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
}
const { data, error } = await supabase
.from('salary_worked_days')
.insert({
company_id: companyId,
employee_id: employeeId,
work_date: body.work_date,
hours: body.hours,
notes: body.notes ?? null,
salary_run_employee_id: body.salary_run_employee_id ?? null,
})
.select()
.single()
if (error) {
// The 24h cap trigger raises check_violation when worked + absence > 24h
// for the same date. Surface a clean 409 with a Swedish message.
if (error.message?.includes('Total tid') || error.code === '23514') {
return NextResponse.json({ error: error.message }, { status: 409 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data }, { status: 201 })
}
// Two modes: ?date=YYYY-MM-DD (single row) or ?from=…&to=… (range).
const DeleteQuerySchema = z.object({
from: isoDate.optional(),
to: isoDate.optional(),
date: isoDate.optional(),
})
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: employeeId } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const employee = await loadEmployee(supabase, employeeId, companyId)
if (!employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
const query = validateQuery(request, DeleteQuerySchema)
if (!query.success) return query.response
const { date, from, to } = query.data
const hasSingle = !!date
const hasRange = !!from && !!to
if (!hasSingle && !hasRange) {
return NextResponse.json(
{ error: 'Ange antingen ?date=YYYY-MM-DD eller ?from=...&to=...' },
{ status: 400 },
)
}
let q = supabase
.from('salary_worked_days')
.delete()
.eq('company_id', companyId)
.eq('employee_id', employeeId)
if (hasSingle) {
q = q.eq('work_date', date!)
} else {
q = q.gte('work_date', from!).lte('work_date', to!)
}
const { error } = await q
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: { ok: true } })
}
+74 -1
View File
@@ -166,6 +166,66 @@ export const POST = withRouteContext(
periodEnd,
})
// ── Derive worked hours from per-day records (hourly employees) ────
// For timanställda the calendar entries in salary_worked_days are the
// authoritative source. Sum the period and pass to the calculator. If
// no rows exist (legacy run, or hourly employee added without using the
// calendar), fall back to the snapshot column on salary_run_employees.
let derivedHoursWorked: number | null = null
if (emp.salary_type === 'hourly') {
const { data: workedDays, error: workedError } = await supabase
.from('salary_worked_days')
.select('hours')
.eq('company_id', companyId)
.eq('employee_id', emp.id)
.gte('work_date', periodStart)
.lte('work_date', periodEnd)
if (workedError) {
return errorResponse(workedError, opLog, { requestId })
}
derivedHoursWorked = (workedDays ?? []).reduce(
(sum, d) => Math.round((sum + Number(d.hours)) * 100) / 100,
0,
)
opLog.info('Derived hours_worked from calendar', {
employeeId: emp.id,
periodStart,
periodEnd,
rowCount: workedDays?.length ?? 0,
derivedHoursWorked,
})
// Refresh the hourly_salary line item so the displayed Lönerader table
// matches what the engine actually calculated. Without this, the
// placeholder created at employee-add time keeps showing 0 kr even
// after the user fills the calendar.
if (derivedHoursWorked > 0 && (emp.hourly_rate || 0) > 0) {
const baseAmount = Math.round((emp.hourly_rate as number) * derivedHoursWorked * 100) / 100
// Delete any existing hourly_salary rows for this sre, then insert a
// single fresh one. Avoids the "did the row already exist?" branch.
await supabase
.from('salary_line_items')
.delete()
.eq('salary_run_employee_id', sre.id)
.eq('item_type', 'hourly_salary')
await supabase.from('salary_line_items').insert({
salary_run_employee_id: sre.id,
company_id: companyId,
item_type: 'hourly_salary',
description: 'Timlön',
quantity: derivedHoursWorked,
amount: baseAmount,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
is_gross_deduction: false,
is_net_deduction: false,
account_number: getLineItemAccount('hourly_salary'),
sort_order: 0,
})
}
}
const employeeName = `${emp.first_name} ${emp.last_name}`
if (absenceResult.flagLakarintyg) lakarintygEmployees.push(employeeName)
if (absenceResult.flagFkReporting) fkReportingEmployees.push(employeeName)
@@ -235,7 +295,12 @@ export const POST = withRouteContext(
salaryType: emp.salary_type,
monthlySalary: emp.monthly_salary || 0,
hourlyRate: emp.hourly_rate || undefined,
hoursWorked: sre.hours_worked || undefined,
// Calendar-derived hours win when at least one row exists in the
// period. The legacy snapshot column (sre.hours_worked) only kicks
// in for runs predating the calendar feature.
hoursWorked: derivedHoursWorked !== null && derivedHoursWorked > 0
? derivedHoursWorked
: sre.hours_worked || undefined,
employmentDegree: emp.employment_degree,
taxTableNumber: emp.tax_table_number,
taxColumn: emp.tax_column || 1,
@@ -277,9 +342,17 @@ export const POST = withRouteContext(
// Update salary_run_employee with calculated results. If any individual
// update fails we abort so run totals aren't written from partial data.
// For hourly employees with calendar rows, also mirror the derived total
// into hours_worked so downstream code (reports, storno via correct/route)
// sees a consistent snapshot.
const snapshotHoursWorked =
derivedHoursWorked !== null && derivedHoursWorked > 0
? derivedHoursWorked
: sre.hours_worked
const { error: empUpdateError } = await supabase
.from('salary_run_employees')
.update({
hours_worked: snapshotHoursWorked,
gross_salary: result.grossSalary,
gross_deductions: result.grossDeductions,
benefit_values: result.benefitValues,
-445
View File
@@ -1,445 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import {
addDays,
endOfMonth,
format,
isSameDay,
parseISO,
startOfMonth,
startOfWeek,
} from 'date-fns'
import { sv } from 'date-fns/locale'
import {
Activity,
Baby,
ChevronLeft,
ChevronRight,
Heart,
HeartPulse,
Loader2,
Trash2,
type LucideIcon,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils'
// ─── Types ─────────────────────────────────────────────────────────
type AbsenceType =
| 'sick'
| 'vab'
| 'parental'
| 'pregnancy'
| 'care_relative'
| 'study'
| 'other_leave'
interface AbsenceDay {
id: string
absence_date: string
absence_type: AbsenceType
hours: number
notes: string | null
}
interface AbsenceTypeMeta {
label: string
shortLabel: string
icon: LucideIcon
// Background dot color tokens — paired with icons so color isn't sole indicator (WCAG AA).
dotClass: string
}
const TYPE_META: Record<AbsenceType, AbsenceTypeMeta> = {
sick: { label: 'Sjukfrånvaro', shortLabel: 'Sjuk', icon: HeartPulse, dotClass: 'bg-red-400' },
vab: { label: 'VAB', shortLabel: 'VAB', icon: Baby, dotClass: 'bg-amber-400' },
parental: { label: 'Föräldraledighet', shortLabel: 'Förä.', icon: Heart, dotClass: 'bg-emerald-400' },
pregnancy: { label: 'Graviditetspenning', shortLabel: 'Grav.', icon: Heart, dotClass: 'bg-pink-400' },
care_relative: { label: 'Närståendepenning', shortLabel: 'Närst.', icon: Heart, dotClass: 'bg-blue-400' },
study: { label: 'Studieledig', shortLabel: 'Studie', icon: Activity, dotClass: 'bg-indigo-400' },
other_leave: { label: 'Övrig ledighet', shortLabel: 'Övrigt', icon: Activity, dotClass: 'bg-zinc-400' },
}
const TYPE_ORDER: AbsenceType[] = ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'other_leave']
// ─── Component ─────────────────────────────────────────────────────
export interface AbsenceCalendarProps {
employeeId: string
/** Pay period start (YYYY-MM-DD). The calendar opens on this month. */
periodStart: string
/** Pay period end (YYYY-MM-DD). Days outside the period are still
* visible (and editable, since absence is per-employee not per-run)
* but visually muted. */
periodEnd: string
/** Optional: link new absence rows to a specific salary run. */
salaryRunEmployeeId?: string
/** When true, calendar is read-only (e.g. for booked runs). */
readOnly?: boolean
/** Called after a successful create/delete so the parent can refresh
* derived totals. */
onChange?: () => void
}
export function AbsenceCalendar({
employeeId,
periodStart,
periodEnd,
salaryRunEmployeeId,
readOnly = false,
onChange,
}: AbsenceCalendarProps) {
const periodStartDate = useMemo(() => parseISO(periodStart), [periodStart])
const periodEndDate = useMemo(() => parseISO(periodEnd), [periodEnd])
const [visibleMonth, setVisibleMonth] = useState<Date>(() => startOfMonth(periodStartDate))
const [days, setDays] = useState<AbsenceDay[]>([])
const [loading, setLoading] = useState(false)
const [editing, setEditing] = useState<{ date: string; existing?: AbsenceDay } | null>(null)
const [error, setError] = useState<string | null>(null)
// Pad to a 6-week grid starting on Monday (Swedish week).
const gridStart = startOfWeek(startOfMonth(visibleMonth), { weekStartsOn: 1 })
const loadAbsences = async () => {
setLoading(true)
setError(null)
try {
const from = format(gridStart, 'yyyy-MM-dd')
const to = format(addDays(gridStart, 41), 'yyyy-MM-dd')
const res = await fetch(
`/api/salary/employees/${employeeId}/absence?from=${from}&to=${to}`,
)
const json = await res.json()
if (!res.ok) {
throw new Error(json.error || 'Kunde inte ladda frånvaro')
}
setDays(json.data ?? [])
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadAbsences()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [employeeId, visibleMonth.getFullYear(), visibleMonth.getMonth()])
const dayMap = useMemo(() => {
const m = new Map<string, AbsenceDay[]>()
for (const d of days) {
const key = d.absence_date
const list = m.get(key) ?? []
list.push(d)
m.set(key, list)
}
return m
}, [days])
const cells = useMemo(() => {
return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i))
}, [gridStart])
const handleCellClick = (date: Date) => {
if (readOnly) return
const key = format(date, 'yyyy-MM-dd')
const existing = dayMap.get(key)?.[0] // edit first if multiple types same day
setEditing({ date: key, existing })
}
return (
<div className="rounded-md border bg-card">
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setVisibleMonth(prev => addDays(startOfMonth(prev), -1))}
aria-label="Föregående månad"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium tabular-nums">
{format(visibleMonth, 'MMMM yyyy', { locale: sv })}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setVisibleMonth(prev => addDays(endOfMonth(prev), 1))}
aria-label="Nästa månad"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{loading && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
</div>
{/* Weekday header */}
<div className="grid grid-cols-7 border-b bg-muted/40 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön'].map(d => (
<div key={d} className="px-2 py-1.5 text-center">{d}</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7">
{cells.map((date, i) => {
const key = format(date, 'yyyy-MM-dd')
const inMonth = date.getMonth() === visibleMonth.getMonth()
const inPeriod = date >= periodStartDate && date <= periodEndDate
const today = isSameDay(date, new Date())
const dayAbsences = dayMap.get(key) ?? []
return (
<button
type="button"
key={i}
onClick={() => handleCellClick(date)}
disabled={readOnly}
className={cn(
'relative flex h-20 flex-col items-start gap-0.5 border-b border-r p-1.5 text-left text-xs transition-colors',
!readOnly && 'hover:bg-accent/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
readOnly && 'cursor-default',
!inMonth && 'bg-muted/30 text-muted-foreground/60',
!inPeriod && inMonth && 'bg-muted/10',
today && 'ring-1 ring-inset ring-primary/40',
)}
>
<span className={cn('tabular-nums', today && 'font-semibold')}>
{format(date, 'd')}
</span>
{dayAbsences.length > 0 && (
<div className="mt-auto flex flex-wrap items-center gap-0.5">
{dayAbsences.map(a => {
const meta = TYPE_META[a.absence_type]
const Icon = meta.icon
return (
<span
key={a.id}
className={cn(
'inline-flex items-center gap-0.5 rounded-full px-1 py-px text-[10px] font-medium text-foreground',
meta.dotClass,
)}
title={`${meta.label} (${a.hours}h)`}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
<span>{meta.shortLabel}</span>
</span>
)
})}
</div>
)}
</button>
)
})}
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-t px-3 py-2 text-[11px] text-muted-foreground">
{TYPE_ORDER.map(t => {
const meta = TYPE_META[t]
const Icon = meta.icon
return (
<span key={t} className="inline-flex items-center gap-1">
<span className={cn('inline-flex h-3 w-3 items-center justify-center rounded-full', meta.dotClass)}>
<Icon className="h-2 w-2" aria-hidden />
</span>
<span>{meta.label}</span>
</span>
)
})}
</div>
{error && (
<div className="border-t bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
{/* Edit dialog */}
{editing && (
<AbsenceDayDialog
employeeId={employeeId}
salaryRunEmployeeId={salaryRunEmployeeId}
date={editing.date}
existing={editing.existing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null)
loadAbsences()
onChange?.()
}}
/>
)}
</div>
)
}
// ─── Dialog ────────────────────────────────────────────────────────
interface AbsenceDayDialogProps {
employeeId: string
salaryRunEmployeeId?: string
date: string
existing?: AbsenceDay
onClose: () => void
onSaved: () => void
}
function AbsenceDayDialog({
employeeId,
salaryRunEmployeeId,
date,
existing,
onClose,
onSaved,
}: AbsenceDayDialogProps) {
const [absenceType, setAbsenceType] = useState<AbsenceType>(existing?.absence_type ?? 'sick')
const [hours, setHours] = useState<string>(existing?.hours?.toString() ?? '8')
const [notes, setNotes] = useState<string>(existing?.notes ?? '')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSave = async () => {
setSubmitting(true)
setError(null)
try {
const hoursNum = parseFloat(hours)
if (!isFinite(hoursNum) || hoursNum <= 0 || hoursNum > 24) {
throw new Error('Timmar måste vara mellan 0 och 24')
}
const res = await fetch(`/api/salary/employees/${employeeId}/absence`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
absence_date: date,
absence_type: absenceType,
hours: hoursNum,
notes: notes.trim() || undefined,
salary_run_employee_id: salaryRunEmployeeId,
}),
})
const json = await res.json()
if (!res.ok) throw new Error(json.error || 'Kunde inte spara frånvaro')
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
} finally {
setSubmitting(false)
}
}
const handleDelete = async () => {
if (!existing) return
setSubmitting(true)
setError(null)
try {
const res = await fetch(
`/api/salary/employees/${employeeId}/absence?date=${date}&type=${existing.absence_type}`,
{ method: 'DELETE' },
)
const json = await res.json()
if (!res.ok) throw new Error(json.error || 'Kunde inte radera frånvaro')
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
} finally {
setSubmitting(false)
}
}
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
Frånvaro {format(parseISO(date), 'd MMMM yyyy', { locale: sv })}
</DialogTitle>
<DialogDescription>
Välj typ av frånvaro. Sjuklöneberäkning, karensavdrag och AGI-rapportering härleds automatiskt.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-xs font-medium">Typ</label>
<Select value={absenceType} onValueChange={v => setAbsenceType(v as AbsenceType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TYPE_ORDER.map(t => (
<SelectItem key={t} value={t}>{TYPE_META[t].label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium" htmlFor="absence-hours">Timmar</label>
<input
id="absence-hours"
type="number"
min={0.5}
max={24}
step={0.5}
value={hours}
onChange={(e) => setHours(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm tabular-nums shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium" htmlFor="absence-notes">Anteckning (valfri)</label>
<textarea
id="absence-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
maxLength={2000}
className="flex w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-2 text-xs text-destructive">{error}</div>
)}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<div>
{existing && (
<Button variant="outline" size="sm" onClick={handleDelete} disabled={submitting}>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Ta bort
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>
Avbryt
</Button>
<Button size="sm" onClick={handleSave} disabled={submitting}>
{submitting && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
{existing ? 'Uppdatera' : 'Lägg till'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
File diff suppressed because it is too large Load Diff
+31
View File
@@ -852,6 +852,37 @@ export const AbsenceRangeQuerySchema = z.object({
path: ['from'],
})
// ── Worked-hours per-day records (hourly employees) ─────────────────
//
// Drives base salary calculation for hourly (timanställd) employees:
// `baseSalary = hourly_rate × Σ hours`. Mirrors absence days deliberately —
// same calendar UX, half-day mixing with absence enforced by the 24h cap
// trigger. The calculator sums these per pay period at calculate time.
export const UpsertWorkedDaySchema = z.object({
work_date: isoDate,
hours: z.number().positive().max(24).default(8),
notes: z.string().max(2000).optional(),
salary_run_employee_id: uuid.optional(),
})
export const WorkedHoursRangeQuerySchema = z.object({
from: isoDate,
to: isoDate,
}).refine((data) => data.from <= data.to, {
message: '`from` måste vara före eller lika med `to`',
path: ['from'],
})
export const BatchUpsertWorkedDaysSchema = z.object({
// 100-row sanity cap: typical use is one pay period (~22 weekdays). A larger
// value usually indicates the caller is iterating wrong.
dates: z.array(isoDate).min(1).max(100),
hours: z.number().positive().max(24).default(8),
notes: z.string().max(2000).optional(),
salary_run_employee_id: uuid.optional(),
})
// ============================================================
// AI agent flow schemas
// ============================================================
+31 -20
View File
@@ -116,26 +116,35 @@ const SWEDISH_STEMS = new Set<string>([
'försäljnings', 'förskola', 'församling', 'förvaltning', 'förbund',
'förlag', 'föräldra', 'försök', 'förbrukning', 'förbättring', 'förskott',
'försening', 'förhandling', 'förbättrings',
// domain terms
// för-* derivatives that often appear in account names
'förmån', 'förmåner', 'förmedlad', 'förmedlade', 'förmedling', 'förmedlings',
'förvaltar', 'förmedla',
// -mark- / marknadsföring
'marknadsföring', 'marknadsförings', 'marknad',
// common Swedish words/prefixes
'bostadsrätt', 'rätt', 'samfällighet', 'idrott', 'fastighet', 'utbildning',
'näring', 'växel', 'värme', 'köp', 'köpa', 'inköp', 'sälja', 'säljs',
// accounting
'tjänst', 'tjänster',
// accounting terms
'kostnad', 'kostnader', 'intäkt', 'intäkter', 'avskrivning', 'avsättning',
'lön', 'lönekostnad', 'pension', 'utgående', 'ingående', 'momspliktig',
'redovisning', 'företagskonto', 'bankkonto', 'överavskrivning',
'överskott', 'underskott', 'överföring', 'överlåtelse', 'återbetalning',
'utlägg', 'utgift',
// common short prepositions and adverbs
'från', 'för', 'över', 'är', 'när', 'där', 'även', 'någon', 'något',
'många', 'själv', 'små', 'väg', 'gång', 'tjänst', 'tjänster', 'räkning',
'räntor', 'år',
// common cities
'lön', 'löner', 'lönekostnad', 'pension', 'utgående', 'ingående',
'momspliktig', 'redovisning', 'företagskonto', 'bankkonto',
'överavskrivning', 'överskott', 'underskott', 'överföring', 'överlåtelse',
'återbetalning', 'utlägg', 'utgift', 'avdrag',
// omvänd moms etc.
'omvänd', 'omvänt', 'omvända',
// 3+ letter prepositions/adverbs (skip 2-letter ones — too ambiguous)
'från', 'över', 'när', 'där', 'även', 'någon', 'något', 'många',
'själv', 'små', 'väg', 'gång', 'räkning', 'räntor', 'är',
// 'på' — short but extremely common; include explicitly
'på',
// cities
'göteborg', 'malmö', 'örebro', 'östersund', 'jönköping', 'linköping',
'norrköping', 'lidköping', 'köping', 'helsingborg', 'umeå', 'skellefteå',
'piteå', 'luleå', 'borås', 'växjö', 'östhammar', 'södertälje', 'västerås',
'härnösand', 'värnamo', 'mölndal', 'mörrum', 'mönsterås', 'färjestaden',
'eskilstuna',
// directions / common geo terms
// directions / geo
'östra', 'västra', 'södra', 'norra', 'öster', 'väster', 'söder',
// legal forms
'aktiebolag', 'handelsbolag', 'ekonomisk', 'allmännyttig',
@@ -148,19 +157,21 @@ const SWEDISH_STEMS = new Set<string>([
])
/**
* Score a candidate word.
* - 1000 if the entire word matches a known stem (highest confidence).
* - Otherwise the count of distinct stems that appear as substrings.
* Counting (not boolean-returning) is required: when the same word has
* multiple U+FFFD positions, the correct combination must outscore wrong
* combinations that still happen to contain *one* stem each.
* Score a candidate word by *length-weighted* stem matching.
*
* - Exact word match: 1_000_000 (still beats any substring sum).
* - Otherwise: sum the lengths of every stem that is a substring of the
* candidate. Length-weighting is the critical fix vs. simple counting:
* "fårmedlad" hits "år" (2 chars), "förmedlad" hits "för" (3 chars).
* Counting would tie them at 1; length-weighting gives 2 vs 3, so the
* correct candidate wins.
*/
function scoreCandidate(word: string): number {
const lower = word.toLowerCase()
if (SWEDISH_STEMS.has(lower)) return 1000
if (SWEDISH_STEMS.has(lower)) return 1_000_000
let score = 0
for (const stem of SWEDISH_STEMS) {
if (lower.includes(stem)) score++
if (lower.includes(stem)) score += stem.length
}
return score
}
@@ -0,0 +1,163 @@
import { randomUUID } from 'crypto'
import { describe, expect, it } from 'vitest'
import { seedCompany } from '@/tests/pg/fixtures'
import { getPool, withUserContext } from '@/tests/pg/setup'
/**
* RLS + 24h cap smoke for salary_worked_days. Pay-period summing has unit
* coverage in the calculate-route test; this file locks in the database-
* level invariants that mocked Supabase clients can't exercise:
* - Tenant isolation via RLS
* - Unique (employee_id, work_date)
* - 24h cap across worked + absence on the same date (allows half-day
* mixing up to 24h, blocks overflow)
*/
async function insertEmployee(params: {
userId: string
companyId: string
}): Promise<string> {
const id = randomUUID()
const pnr = '199001011234'
await getPool().query(
`INSERT INTO public.employees
(id, user_id, company_id, first_name, last_name, personnummer,
personnummer_last4, employment_start, hourly_rate, salary_type, tax_table_number)
VALUES ($1, $2, $3, 'Test', 'Person', $4, '1234', '2026-01-01', 250, 'hourly', 32)`,
[id, params.userId, params.companyId, pnr],
)
return id
}
async function insertWorkedDay(params: {
companyId: string
employeeId: string
date: string
hours?: number
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.salary_worked_days
(id, company_id, employee_id, work_date, hours)
VALUES ($1, $2, $3, $4, $5)`,
[id, params.companyId, params.employeeId, params.date, params.hours ?? 8],
)
return id
}
async function insertAbsenceDay(params: {
companyId: string
employeeId: string
date: string
hours?: number
type?: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.salary_absence_days
(id, company_id, employee_id, absence_date, absence_type, hours)
VALUES ($1, $2, $3, $4, $5, $6)`,
[id, params.companyId, params.employeeId, params.date, params.type ?? 'sick', params.hours ?? 8],
)
return id
}
describe('salary_worked_days.pg — RLS tenant isolation', () => {
it('a user only sees worked days for their own company', async () => {
const a = await seedCompany()
const b = await seedCompany()
const empA = await insertEmployee({ userId: a.userId, companyId: a.companyId })
const empB = await insertEmployee({ userId: b.userId, companyId: b.companyId })
await insertWorkedDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-15' })
await insertWorkedDay({ companyId: b.companyId, employeeId: empB, date: '2026-04-16' })
const rowsA = await withUserContext(a.userId, async (client) => {
const res = await client.query<{ company_id: string }>(
`SELECT company_id FROM public.salary_worked_days`,
)
return res.rows
})
expect(rowsA).toHaveLength(1)
expect(rowsA[0]!.company_id).toBe(a.companyId)
})
it('blocks INSERT into another tenant via WITH CHECK', async () => {
const a = await seedCompany()
const b = await seedCompany()
const empB = await insertEmployee({ userId: b.userId, companyId: b.companyId })
await expect(
withUserContext(a.userId, async (client) => {
return client.query(
`INSERT INTO public.salary_worked_days
(company_id, employee_id, work_date, hours)
VALUES ($1, $2, '2026-04-17', 8)`,
[b.companyId, empB],
)
}),
).rejects.toThrow(/row-level security/i)
})
it('enforces unique (employee_id, work_date)', async () => {
const a = await seedCompany()
const empA = await insertEmployee({ userId: a.userId, companyId: a.companyId })
await insertWorkedDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-15' })
await expect(
insertWorkedDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-15' }),
).rejects.toThrow(/duplicate key|unique/i)
})
})
describe('salary_worked_days.pg — 24h cap across worked + absence', () => {
it('allows half-day mixing (4h worked + 4h sick = 8h total)', async () => {
const a = await seedCompany()
const empA = await insertEmployee({ userId: a.userId, companyId: a.companyId })
await insertWorkedDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-20', hours: 4 })
// Should not throw — combined 4h + 4h = 8h ≤ 24h.
await expect(
insertAbsenceDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-20', hours: 4 }),
).resolves.not.toThrow()
})
it('allows up to exactly 24h combined', async () => {
const a = await seedCompany()
const empA = await insertEmployee({ userId: a.userId, companyId: a.companyId })
await insertWorkedDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-21', hours: 16 })
await expect(
insertAbsenceDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-21', hours: 8 }),
).resolves.not.toThrow()
})
it('blocks worked day that pushes total above 24h', async () => {
const a = await seedCompany()
const empA = await insertEmployee({ userId: a.userId, companyId: a.companyId })
await insertAbsenceDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-22', hours: 20 })
await expect(
insertWorkedDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-22', hours: 6 }),
).rejects.toThrow(/Total tid.*24 timmar/i)
})
it('blocks absence day that pushes total above 24h', async () => {
const a = await seedCompany()
const empA = await insertEmployee({ userId: a.userId, companyId: a.companyId })
await insertWorkedDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-23', hours: 20 })
await expect(
insertAbsenceDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-23', hours: 6 }),
).rejects.toThrow(/Total tid.*24 timmar/i)
})
it('UPDATE on existing row excludes own previous contribution', async () => {
// Bug guard: if the trigger summed including the row being updated,
// editing 8h → 6h on a day with 8h absence would falsely report 16h+8h.
const a = await seedCompany()
const empA = await insertEmployee({ userId: a.userId, companyId: a.companyId })
const wid = await insertWorkedDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-24', hours: 8 })
await insertAbsenceDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-24', hours: 8 })
await expect(
getPool().query(
`UPDATE public.salary_worked_days SET hours = 6 WHERE id = $1`,
[wid],
),
).resolves.not.toThrow()
})
})
@@ -0,0 +1,74 @@
-- Migration: salary_worked_days — per-day worked hours for hourly employees
--
-- Why this exists: hourly employees (timanställd) need per-day hour tracking
-- so payroll can derive base salary as `hourly_rate × Σ hours`. The previous
-- model relied on a single nullable `salary_run_employees.hours_worked`
-- column, but had no UI to set it after the employee was added to a run.
--
-- Mirrors salary_absence_days deliberately: same calendar UX, same RLS shape,
-- same per-day granularity. Worked time and absence time are separate domain
-- concepts (presence vs frånvaro) so they live in separate tables — combining
-- them via an enum would pollute every absence-side query (AGI Frånvarouppgift,
-- högriskskydd lookups, sjuklöneperiod derivation).
--
-- Half-day mixing is allowed: an employee can have 4h worked + 4h sick on the
-- same date. The 24-hour cap across both tables is enforced by the trigger
-- introduced in 20260512120100_worked_days_absence_conflict_trigger.sql.
CREATE TABLE public.salary_worked_days (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
-- Optional link to the pay run that already absorbed this day. Null while
-- the user marks worked days before a run exists, or for periods not yet
-- materialized into a salary run.
salary_run_employee_id UUID REFERENCES salary_run_employees(id) ON DELETE SET NULL,
work_date DATE NOT NULL,
-- Hours worked on this date. Defaults to 8.0 for a full scheduled day.
-- Allows partial days (e.g. 4h morning shift) and overtime above a normal
-- workday (up to 24h hard cap; combined with absence the trigger enforces
-- the same 24h limit across both tables).
hours NUMERIC(5, 2) NOT NULL DEFAULT 8.0
CHECK (hours > 0 AND hours <= 24),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One row per employee per date. Unlike absence (which allows multiple types
-- on the same date, e.g. half-day VAB + half-day other), worked time is a
-- single hours value — re-marking a day overwrites the existing row.
CREATE UNIQUE INDEX idx_salary_worked_days_unique
ON public.salary_worked_days (employee_id, work_date);
-- Range queries by employee+date are the dominant access pattern: pay-period
-- aggregation when the calculator sums hours for hourly employees.
CREATE INDEX idx_salary_worked_days_employee_date
ON public.salary_worked_days (employee_id, work_date);
-- Lookup by run, used when the calculator materializes line items.
CREATE INDEX idx_salary_worked_days_run
ON public.salary_worked_days (salary_run_employee_id)
WHERE salary_run_employee_id IS NOT NULL;
-- Company-level scans.
CREATE INDEX idx_salary_worked_days_company_date
ON public.salary_worked_days (company_id, work_date);
ALTER TABLE public.salary_worked_days ENABLE ROW LEVEL SECURITY;
CREATE POLICY "salary_worked_days_select" ON public.salary_worked_days
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "salary_worked_days_insert" ON public.salary_worked_days
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "salary_worked_days_update" ON public.salary_worked_days
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "salary_worked_days_delete" ON public.salary_worked_days
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE TRIGGER salary_worked_days_updated_at
BEFORE UPDATE ON public.salary_worked_days
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,83 @@
-- Migration: 24-hour cap across worked + absence days for the same employee+date
--
-- Half-day mixing is allowed: an employee can have 4h worked + 4h sick on the
-- same date. The legal/biological constraint is that a single calendar day
-- has 24 hours — combined worked + absence hours must not exceed that.
--
-- This is enforced as a trigger (not a CHECK constraint) because CHECK cannot
-- reference another table. Two BEFORE INSERT/UPDATE triggers, one on each
-- table, both call the shared function below. The function sums the OTHER
-- table's hours for the same (employee_id, date), adds NEW.hours, and raises
-- if the total exceeds 24.
--
-- For UPDATE on the same row, we exclude the row's own previous contribution
-- from the sum (otherwise editing 8h → 6h on a worked day with 8h absence
-- would falsely report 16+8 > 24). This is handled by the WHERE id <> NEW.id
-- on the same-table side.
CREATE OR REPLACE FUNCTION public.check_salary_day_hours_cap()
RETURNS TRIGGER AS $$
DECLARE
v_employee_id UUID;
v_date DATE;
v_other_total NUMERIC(6, 2);
v_same_total NUMERIC(6, 2);
v_total NUMERIC(6, 2);
BEGIN
-- Resolve the (employee, date) pair from whichever table fired the trigger.
IF TG_TABLE_NAME = 'salary_worked_days' THEN
v_employee_id := NEW.employee_id;
v_date := NEW.work_date;
SELECT COALESCE(SUM(hours), 0) INTO v_other_total
FROM public.salary_absence_days
WHERE employee_id = v_employee_id
AND absence_date = v_date;
SELECT COALESCE(SUM(hours), 0) INTO v_same_total
FROM public.salary_worked_days
WHERE employee_id = v_employee_id
AND work_date = v_date
AND id <> NEW.id;
ELSIF TG_TABLE_NAME = 'salary_absence_days' THEN
v_employee_id := NEW.employee_id;
v_date := NEW.absence_date;
SELECT COALESCE(SUM(hours), 0) INTO v_other_total
FROM public.salary_worked_days
WHERE employee_id = v_employee_id
AND work_date = v_date;
SELECT COALESCE(SUM(hours), 0) INTO v_same_total
FROM public.salary_absence_days
WHERE employee_id = v_employee_id
AND absence_date = v_date
AND id <> NEW.id;
ELSE
-- Defensive: should not be reachable given the trigger bindings below.
RETURN NEW;
END IF;
v_total := v_other_total + v_same_total + NEW.hours;
IF v_total > 24 THEN
RAISE EXCEPTION 'Total tid (arbete + frånvaro) för % får inte överstiga 24 timmar (försökte boka %, befintligt %)',
v_date, NEW.hours, (v_other_total + v_same_total)
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER salary_worked_days_24h_cap
BEFORE INSERT OR UPDATE ON public.salary_worked_days
FOR EACH ROW EXECUTE FUNCTION public.check_salary_day_hours_cap();
CREATE TRIGGER salary_absence_days_24h_cap
BEFORE INSERT OR UPDATE ON public.salary_absence_days
FOR EACH ROW EXECUTE FUNCTION public.check_salary_day_hours_cap();
NOTIFY pgrst, 'reload schema';