'use client' import { useEffect, useMemo, useRef, useState } from 'react' import { addDays, eachDayOfInterval, endOfMonth, format, isSameDay, parseISO, startOfMonth, startOfWeek, } from 'date-fns' import { enUS, sv } from 'date-fns/locale' import { Activity, Baby, CalendarPlus, ChevronLeft, ChevronRight, Clock, Heart, HeartPulse, Loader2, MinusCircle, Trash2, X, type LucideIcon, } from 'lucide-react' import { useLocale, useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { DestructiveConfirmDialog, useDestructiveConfirm, } from '@/components/ui/destructive-confirm-dialog' 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' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' // ─── 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 { /** Translation keys in the `salary_calendar` namespace. */ labelKey: string shortLabelKey: string icon: LucideIcon pillClass: string } // Absence tone comes from the semantic scale, never the raw Tailwind palette. // Every value is alpha-over-token, which is why none of these need a `dark:` // variant: the same class reads correctly on both grounds (the approach // components/ui/badge.tsx already takes). The eight types share four tones, so // fill carries a second axis: filled vs outlined separates the two types // inside a tone whose Lucide icon is identical (Heart is parental, pregnancy // and care_relative; Activity is study and other_leave). // // terracotta = sick (karens, employer cost) // ochre = caring for someone else (vab, närstående) // sage = parental and pregnancy leave // neutral = the rest (study, unpaid, other) const TYPE_META: Record = { sick: { labelKey: 'type_sick', shortLabelKey: 'type_sick_short', icon: HeartPulse, pillClass: 'bg-destructive/10 text-destructive' }, vab: { labelKey: 'type_vab', shortLabelKey: 'type_vab_short', icon: Baby, pillClass: 'bg-warning/15 text-warning-foreground' }, care_relative: { labelKey: 'type_care_relative', shortLabelKey: 'type_care_relative_short', icon: Heart, pillClass: 'border border-warning/40 text-warning-foreground' }, parental: { labelKey: 'type_parental', shortLabelKey: 'type_parental_short', icon: Heart, pillClass: 'bg-success/10 text-success' }, pregnancy: { labelKey: 'type_pregnancy', shortLabelKey: 'type_pregnancy_short', icon: Heart, pillClass: 'border border-success/40 text-success' }, study: { labelKey: 'type_study', shortLabelKey: 'type_study_short', icon: Activity, pillClass: 'bg-secondary text-secondary-foreground' }, unpaid_leave: { labelKey: 'type_unpaid_leave', shortLabelKey: 'type_unpaid_leave_short', icon: MinusCircle, pillClass: 'bg-muted text-muted-foreground' }, other_leave: { labelKey: 'type_other_leave', shortLabelKey: 'type_other_leave_short', icon: Activity, pillClass: 'border border-border text-muted-foreground' }, } 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 t = useTranslations('salary_calendar') const locale = useLocale() const { dialogProps, confirm: confirmAction } = useDestructiveConfirm() const dateLocale = locale === 'en' ? enUS : sv 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) // Map the parsed body plus the status, never `new Error(json.error)`: // the routes answer thrown errors with the canonical envelope // `{ error: { code, message } }`, and the Error constructor would // stringify that object to "[object Object]", discarding the route's // own Swedish reason. const absJson = await responses[0]!.json().catch(() => null) if (!responses[0]!.ok) { setError(getUserErrorMessage(absJson, { statusCode: responses[0]!.status })) return } setAbsences(absJson?.data ?? []) if (isHourly && responses[1]) { const wJson = await responses[1].json().catch(() => null) if (!responses[1].ok) { setError(getUserErrorMessage(wJson, { statusCode: responses[1].status })) return } setWorked(wJson?.data ?? []) } } catch (e) { setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error')) } 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 const ok = await confirmAction({ title: t('confirm_bulk_delete_title'), description: t('confirm_bulk_delete', { count: selected.size }), confirmLabel: t('delete'), variant: 'destructive', }) if (!ok) 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(() => null) setError(getUserErrorMessage(j, { statusCode: wRes.status })) return } } const aRes = await fetch( `/api/salary/employees/${employeeId}/absence?from=${date}&to=${date}`, { method: 'DELETE' }, ) if (!aRes.ok) { const j = await aRes.json().catch(() => null) setError(getUserErrorMessage(j, { statusCode: aRes.status })) return } } clearSelection() await load() onChange?.() } catch (e) { setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error')) } finally { setDeleting(false) } } return (
{/* Header */}
{format(visibleMonth, 'MMMM yyyy', { locale: dateLocale })} {loading && }
{isHourly && !readOnly && ( )}
{/* Weekday header */}
{(['wd_mon', 'wd_tue', 'wd_wed', 'wd_thu', 'wd_fri', 'wd_sat', 'wd_sun'] as const).map(d => (
{t(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 && ( <> {t('worked_hours_in_period')}{' '} {periodTotalHours} h {' · '} )} {t('hint_click')}
{/* Legend */}
{isHourly && ( {t('worked_time')} )} {TYPE_ORDER.map(type => { const meta = TYPE_META[type] const Icon = meta.icon return ( {t(meta.labelKey)} ) })}
{error && (
{error}
)} {/* Floating action bar */} {selected.size > 0 && !readOnly && (
{selected.size}{' '} {t('days_selected', { count: selected.size })}
{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 t = useTranslations('salary_calendar') 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(t('error_hours_range')) } // 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(() => null) setError(getUserErrorMessage(j, { statusCode: res.status })) return } } 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) { setError(getUserErrorMessage(json, { statusCode: res.status })) return } onSaved([]) } catch (e) { setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error')) } finally { setSubmitting(false) } } return ( !o && onClose()}> {t('bulk_worked_title')} {isClear ? t('bulk_worked_clear_desc', { count: dates.length }) : t('bulk_worked_desc', { count: dates.length })}
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" />

{t('hours_zero_hint')}