From 8f15f986879796b2981316ceb6605fb6065dfe7a Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 12 May 2026 01:10:01 +0200 Subject: [PATCH] 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 --- app/(dashboard)/salary/employees/page.tsx | 6 +- .../runs/[id]/employees/[employeeId]/page.tsx | 58 +- .../salary/employees/[id]/absence/route.ts | 5 + .../[id]/worked-hours/batch/route.ts | 97 ++ .../employees/[id]/worked-hours/route.ts | 190 ++++ app/api/salary/runs/[id]/calculate/route.ts | 75 +- components/salary/AbsenceCalendar.tsx | 445 -------- components/salary/SalaryCalendar.tsx | 1004 +++++++++++++++++ lib/api/schemas.ts | 31 + lib/import/shared/encoding.ts | 51 +- .../__tests__/salary-worked-days.pg.test.ts | 163 +++ .../20260512120000_salary_worked_days.sql | 74 ++ ...0_worked_days_absence_conflict_trigger.sql | 83 ++ 13 files changed, 1802 insertions(+), 480 deletions(-) create mode 100644 app/api/salary/employees/[id]/worked-hours/batch/route.ts create mode 100644 app/api/salary/employees/[id]/worked-hours/route.ts delete mode 100644 components/salary/AbsenceCalendar.tsx create mode 100644 components/salary/SalaryCalendar.tsx create mode 100644 lib/salary/__tests__/salary-worked-days.pg.test.ts create mode 100644 supabase/migrations/20260512120000_salary_worked_days.sql create mode 100644 supabase/migrations/20260512120100_worked_days_absence_conflict_trigger.sql diff --git a/app/(dashboard)/salary/employees/page.tsx b/app/(dashboard)/salary/employees/page.tsx index c1e7ea9d..fa73969c 100644 --- a/app/(dashboard)/salary/employees/page.tsx +++ b/app/(dashboard)/salary/employees/page.tsx @@ -84,7 +84,7 @@ export default function EmployeesPage() { Namn Personnummer Typ - Månadslön + Lön Sysselsättningsgrad Skattetabell @@ -104,7 +104,9 @@ export default function EmployeesPage() { {EMPLOYMENT_LABELS[emp.employment_type] || emp.employment_type} - {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) : '—'} {emp.employment_degree}% diff --git a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx index 31d35a2d..05c4b6bb 100644 --- a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx @@ -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(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(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}

- @@ -158,28 +189,31 @@ export default function SalaryRunEmployeeDetailPage({ - {/* Absence calendar */} + {/* Unified calendar — worked time (for hourly) + absence on the same grid */} - Frånvaro + Tid och frånvaro

- 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.'}

-
- - - + + +
diff --git a/app/api/salary/employees/[id]/absence/route.ts b/app/api/salary/employees/[id]/absence/route.ts index 99c1cc59..597b5e3c 100644 --- a/app/api/salary/employees/[id]/absence/route.ts +++ b/app/api/salary/employees/[id]/absence/route.ts @@ -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 }) } diff --git a/app/api/salary/employees/[id]/worked-hours/batch/route.ts b/app/api/salary/employees/[id]/worked-hours/batch/route.ts new file mode 100644 index 00000000..edc54f83 --- /dev/null +++ b/app/api/salary/employees/[id]/worked-hours/batch/route.ts @@ -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 }, + ) +} diff --git a/app/api/salary/employees/[id]/worked-hours/route.ts b/app/api/salary/employees/[id]/worked-hours/route.ts new file mode 100644 index 00000000..10fef8b5 --- /dev/null +++ b/app/api/salary/employees/[id]/worked-hours/route.ts @@ -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>, + 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 } }) +} diff --git a/app/api/salary/runs/[id]/calculate/route.ts b/app/api/salary/runs/[id]/calculate/route.ts index ced8e6d5..f1871322 100644 --- a/app/api/salary/runs/[id]/calculate/route.ts +++ b/app/api/salary/runs/[id]/calculate/route.ts @@ -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, diff --git a/components/salary/AbsenceCalendar.tsx b/components/salary/AbsenceCalendar.tsx deleted file mode 100644 index 3baf82f0..00000000 --- a/components/salary/AbsenceCalendar.tsx +++ /dev/null @@ -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 = { - 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(() => startOfMonth(periodStartDate)) - const [days, setDays] = useState([]) - const [loading, setLoading] = useState(false) - const [editing, setEditing] = useState<{ date: string; existing?: AbsenceDay } | null>(null) - const [error, setError] = useState(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() - 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 ( -
- {/* Header */} -
-
- - - {format(visibleMonth, 'MMMM yyyy', { locale: sv })} - - -
- {loading && } -
- - {/* Weekday header */} -
- {['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön'].map(d => ( -
{d}
- ))} -
- - {/* Calendar grid */} -
- {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 ( - - ) - })} -
- - {/* Legend */} -
- {TYPE_ORDER.map(t => { - const meta = TYPE_META[t] - const Icon = meta.icon - return ( - - - - - {meta.label} - - ) - })} -
- - {error && ( -
- {error} -
- )} - - {/* Edit dialog */} - {editing && ( - setEditing(null)} - onSaved={() => { - setEditing(null) - loadAbsences() - onChange?.() - }} - /> - )} -
- ) -} - -// ─── 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(existing?.absence_type ?? 'sick') - const [hours, setHours] = useState(existing?.hours?.toString() ?? '8') - const [notes, setNotes] = useState(existing?.notes ?? '') - const [submitting, setSubmitting] = useState(false) - const [error, setError] = useState(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 ( - !o && onClose()}> - - - - Frånvaro {format(parseISO(date), 'd MMMM yyyy', { locale: sv })} - - - Välj typ av frånvaro. Sjuklöneberäkning, karensavdrag och AGI-rapportering härleds automatiskt. - - - -
-
- - -
- -
- - 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" - /> -
- -
- -