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:
@@ -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 } })
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user