'use client' import { useEffect, useMemo, useRef, useState } from 'react' import { addDays, eachDayOfInterval, endOfMonth, format, isSameDay, parseISO, startOfMonth, startOfWeek, } from 'date-fns' import { sv } from 'date-fns/locale' import { Activity, Baby, CalendarPlus, ChevronLeft, ChevronRight, Clock, Heart, HeartPulse, Loader2, MinusCircle, Trash2, X, 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' import type { SalaryType } from '@/types' // ─── Types ───────────────────────────────────────────────────────── type AbsenceType = | 'sick' | 'vab' | 'parental' | 'pregnancy' | 'care_relative' | 'study' | 'unpaid_leave' | 'other_leave' interface AbsenceDay { id: string absence_date: string absence_type: AbsenceType hours: number notes: string | null } interface WorkedDay { id: string work_date: string hours: number notes: string | null } interface AbsenceTypeMeta { label: string shortLabel: string icon: LucideIcon pillClass: string } const TYPE_META: Record = { sick: { label: 'Sjukfrånvaro', shortLabel: 'Sjuk', icon: HeartPulse, pillClass: 'bg-red-100 text-red-800' }, vab: { label: 'VAB', shortLabel: 'VAB', icon: Baby, pillClass: 'bg-amber-100 text-amber-800' }, parental: { label: 'Föräldraledighet', shortLabel: 'Förä.', icon: Heart, pillClass: 'bg-emerald-100 text-emerald-800' }, pregnancy: { label: 'Graviditetspenning', shortLabel: 'Grav.', icon: Heart, pillClass: 'bg-pink-100 text-pink-800' }, care_relative: { label: 'Närståendepenning', shortLabel: 'Närst.', icon: Heart, pillClass: 'bg-blue-100 text-blue-800' }, study: { label: 'Studieledig', shortLabel: 'Studie', icon: Activity, pillClass: 'bg-indigo-100 text-indigo-800' }, unpaid_leave: { label: 'Tjänstledig utan lön', shortLabel: 'Tjänstl.', icon: MinusCircle, pillClass: 'bg-slate-100 text-slate-800' }, other_leave: { label: 'Övrig ledighet', shortLabel: 'Övrigt', icon: Activity, pillClass: 'bg-zinc-100 text-zinc-800' }, } const TYPE_ORDER: AbsenceType[] = ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'unpaid_leave', 'other_leave'] // ─── Component ───────────────────────────────────────────────────── export interface SalaryCalendarProps { employeeId: string /** Hourly employees get the worked-hours overlay + actions; monthly only see absence. */ salaryType: SalaryType /** Pay period start (YYYY-MM-DD). The calendar opens on this month. */ periodStart: string /** Pay period end (YYYY-MM-DD). */ periodEnd: string /** Optional: link new rows to a specific salary run. */ salaryRunEmployeeId?: string /** Read-only mode (e.g. for booked runs). */ readOnly?: boolean /** Called after a successful create/delete so the parent can refresh totals. */ onChange?: () => void /** Live absence counts within the pay period, emitted whenever the calendar * reloads. Used so the parent can show day badges that update instantly * on save (without waiting for a recalculation snapshot). */ onAbsenceCountsChange?: (counts: { sick: number; vab: number; parental: number }) => void } export function SalaryCalendar({ employeeId, salaryType, periodStart, periodEnd, salaryRunEmployeeId, readOnly = false, onChange, onAbsenceCountsChange, }: SalaryCalendarProps) { const isHourly = salaryType === 'hourly' const periodStartDate = useMemo(() => parseISO(periodStart), [periodStart]) const periodEndDate = useMemo(() => parseISO(periodEnd), [periodEnd]) const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(periodStartDate)) const [absences, setAbsences] = useState([]) const [worked, setWorked] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [selected, setSelected] = useState>(new Set()) const anchorRef = useRef(null) const [bulkMode, setBulkMode] = useState<'worked' | 'absence' | null>(null) const [inspecting, setInspecting] = useState(null) const [deleting, setDeleting] = useState(false) const gridStart = startOfWeek(startOfMonth(visibleMonth), { weekStartsOn: 1 }) const load = async () => { setLoading(true) setError(null) try { const from = format(periodStartDate < gridStart ? periodStartDate : gridStart, 'yyyy-MM-dd') const gridEnd = addDays(gridStart, 41) const to = format(periodEndDate > gridEnd ? periodEndDate : gridEnd, 'yyyy-MM-dd') const requests: Promise[] = [ fetch(`/api/salary/employees/${employeeId}/absence?from=${from}&to=${to}`), ] if (isHourly) { requests.push(fetch(`/api/salary/employees/${employeeId}/worked-hours?from=${from}&to=${to}`)) } const responses = await Promise.all(requests) const absJson = await responses[0]!.json() if (!responses[0]!.ok) throw new Error(absJson.error || 'Kunde inte ladda frånvaro') setAbsences(absJson.data ?? []) if (isHourly && responses[1]) { const wJson = await responses[1].json() if (!responses[1].ok) throw new Error(wJson.error || 'Kunde inte ladda arbetade timmar') setWorked(wJson.data ?? []) } } catch (e) { setError(e instanceof Error ? e.message : 'Okänt fel') } finally { setLoading(false) } } useEffect(() => { load() // eslint-disable-next-line react-hooks/exhaustive-deps }, [employeeId, salaryType, visibleMonth.getFullYear(), visibleMonth.getMonth()]) const absenceMap = useMemo(() => { const m = new Map() for (const a of absences) { const list = m.get(a.absence_date) ?? [] list.push(a) m.set(a.absence_date, list) } return m }, [absences]) // Live counts within the pay period — unique dates per category. Emit so // the parent can render day badges without waiting for a recalculation. // Parental groups parental + pregnancy + care_relative to match how the // existing snapshot column lumps them. useEffect(() => { if (!onAbsenceCountsChange) return const sickDates = new Set() const vabDates = new Set() const parentalDates = new Set() for (const a of absences) { if (a.absence_date < periodStart || a.absence_date > periodEnd) continue if (a.absence_type === 'sick') sickDates.add(a.absence_date) else if (a.absence_type === 'vab') vabDates.add(a.absence_date) else if (a.absence_type === 'parental' || a.absence_type === 'pregnancy' || a.absence_type === 'care_relative') { parentalDates.add(a.absence_date) } } onAbsenceCountsChange({ sick: sickDates.size, vab: vabDates.size, parental: parentalDates.size, }) }, [absences, periodStart, periodEnd, onAbsenceCountsChange]) const workedMap = useMemo(() => { const m = new Map() for (const w of worked) m.set(w.work_date, w) return m }, [worked]) const cells = useMemo(() => { return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i)) }, [gridStart]) const periodTotalHours = useMemo(() => { return worked .filter(w => w.work_date >= periodStart && w.work_date <= periodEnd) .reduce((sum, w) => Math.round((sum + Number(w.hours)) * 100) / 100, 0) }, [worked, periodStart, periodEnd]) const handleCellClick = (date: Date, e: React.MouseEvent) => { if (readOnly) return const key = format(date, 'yyyy-MM-dd') if (e.shiftKey && anchorRef.current) { const a = parseISO(anchorRef.current) const [from, to] = a <= date ? [a, date] : [date, a] const range = eachDayOfInterval({ start: from, end: to }).map(d => format(d, 'yyyy-MM-dd')) setSelected(prev => { const next = new Set(prev) for (const k of range) next.add(k) return next }) return } setSelected(prev => { const next = new Set(prev) if (next.has(key)) next.delete(key) else next.add(key) return next }) anchorRef.current = key } const handleCellDblClick = (date: Date) => { if (readOnly) return const key = format(date, 'yyyy-MM-dd') // Only open the inspector if there's something to inspect — otherwise // it would just be an empty dialog. if (workedMap.has(key) || (absenceMap.get(key)?.length ?? 0) > 0) { setInspecting(key) } } const clearSelection = () => { setSelected(new Set()) anchorRef.current = null } const handleFillWeekdays = () => { if (readOnly || !isHourly) return const weekdays = eachDayOfInterval({ start: periodStartDate, end: periodEndDate }) .filter(d => { const dow = d.getDay() if (dow === 0 || dow === 6) return false return !workedMap.has(format(d, 'yyyy-MM-dd')) }) .map(d => format(d, 'yyyy-MM-dd')) setSelected(new Set(weekdays)) } const handleBulkDelete = async () => { if (selected.size === 0 || readOnly) return if (!confirm( `Ta bort allt (arbetad tid och frånvaro) på ${selected.size} ${selected.size === 1 ? 'dag' : 'dagar'}?`, )) return setDeleting(true) setError(null) try { // Sequential per-date so a failure on one date doesn't leave a partial // batch on the others. Selection sizes are bounded by the pay period. for (const date of selected) { if (isHourly) { const wRes = await fetch( `/api/salary/employees/${employeeId}/worked-hours?date=${date}`, { method: 'DELETE' }, ) if (!wRes.ok) { const j = await wRes.json().catch(() => ({})) throw new Error(j.error || `Kunde inte ta bort arbetad tid på ${date}`) } } const aRes = await fetch( `/api/salary/employees/${employeeId}/absence?from=${date}&to=${date}`, { method: 'DELETE' }, ) if (!aRes.ok) { const j = await aRes.json().catch(() => ({})) throw new Error(j.error || `Kunde inte ta bort frånvaro på ${date}`) } } clearSelection() await load() onChange?.() } catch (e) { setError(e instanceof Error ? e.message : 'Okänt fel') } finally { setDeleting(false) } } return (
{/* Header */}
{format(visibleMonth, 'MMMM yyyy', { locale: sv })} {loading && }
{isHourly && !readOnly && ( )}
{/* Weekday header */}
{['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön'].map(d => (
{d}
))}
{/* 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 w = workedMap.get(key) const dayAbsences = absenceMap.get(key) ?? [] const isSelected = selected.has(key) const isWeekend = date.getDay() === 0 || date.getDay() === 6 const hasContent = !!w || dayAbsences.length > 0 return ( ) })}
{/* Summary + hint */}
{isHourly && ( <> Arbetade timmar i perioden:{' '} {periodTotalHours} h {' · '} )} Klicka för att markera, shift-klicka för intervall, dubbelklicka för detaljer.
{/* Legend */}
{isHourly && ( Arbetad tid )} {TYPE_ORDER.map(t => { const meta = TYPE_META[t] const Icon = meta.icon return ( {meta.label} ) })}
{error && (
{error}
)} {/* Floating action bar */} {selected.size > 0 && !readOnly && (
{selected.size}{' '} {selected.size === 1 ? 'dag' : 'dagar'} valda
{isHourly && ( )}
)} {/* Bulk dialogs */} {bulkMode === 'worked' && ( setBulkMode(null)} onSaved={(conflicts) => { setBulkMode(null) if (conflicts.length === 0) clearSelection() else setSelected(new Set(conflicts.map(c => c.date))) load() onChange?.() }} /> )} {bulkMode === 'absence' && ( setBulkMode(null)} onSaved={(conflicts) => { setBulkMode(null) if (conflicts.length === 0) clearSelection() else setSelected(new Set(conflicts.map(c => c.date))) load() onChange?.() }} /> )} {/* Day inspector (double-click) */} {inspecting && ( setInspecting(null)} onChanged={() => { load() onChange?.() }} /> )}
) } // ─── Bulk worked-hours dialog ─────────────────────────────────────── interface BulkConflict { date: string; reason: string } interface BulkWorkedDialogProps { employeeId: string dates: string[] salaryRunEmployeeId?: string onClose: () => void onSaved: (conflicts: BulkConflict[]) => void } function BulkWorkedDialog({ employeeId, dates, salaryRunEmployeeId, onClose, onSaved, }: BulkWorkedDialogProps) { const [hours, setHours] = useState('8') const [notes, setNotes] = useState('') const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) const [conflicts, setConflicts] = useState([]) const hoursNum = parseFloat(hours) const isClear = isFinite(hoursNum) && hoursNum === 0 const handleSave = async () => { setSubmitting(true) setError(null) setConflicts([]) try { if (!isFinite(hoursNum) || hoursNum < 0 || hoursNum > 24) { throw new Error('Timmar måste vara mellan 0 och 24') } // 0 hours = "no worked time on these days" → delete any existing rows. // Avoids tripping the DB CHECK (hours > 0) and matches user intent. if (isClear) { for (const date of dates) { const res = await fetch( `/api/salary/employees/${employeeId}/worked-hours?date=${date}`, { method: 'DELETE' }, ) if (!res.ok) { const j = await res.json().catch(() => ({})) throw new Error(j.error || `Kunde inte ta bort ${date}`) } } onSaved([]) return } const res = await fetch(`/api/salary/employees/${employeeId}/worked-hours/batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dates, hours: hoursNum, notes: notes.trim() || undefined, salary_run_employee_id: salaryRunEmployeeId, }), }) const json = await res.json() if (res.status === 207) { setConflicts(json.data?.conflicts ?? []) return } if (!res.ok) throw new Error(json.error || 'Kunde inte spara timmar') onSaved([]) } catch (e) { setError(e instanceof Error ? e.message : 'Okänt fel') } finally { setSubmitting(false) } } return ( !o && onClose()}> Arbetade timmar {isClear ? `Arbetad tid tas bort för ${dates.length} ${dates.length === 1 ? 'dag' : 'dagar'}.` : `${dates.length} ${dates.length === 1 ? 'dag' : 'dagar'} markeras som arbetade. Befintliga timmar för dessa datum skrivs över.`}
setHours(e.target.value)} autoFocus 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" />

Sätt till 0 för att ta bort arbetad tid på de valda dagarna.