'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" />