import { useState } from 'react' import { useSearchParams } from 'react-router-dom' import { api } from '../api/client' import { Spinner } from '../components/Spinner' import { Layout } from '../components/Layout' import type { AbsenceOut, AbsenceSpan, VacationBalanceOut } from '../types/absence' import { MONTHS, MONTHS_SHORT, STATUS_LABELS, STATUS_COLORS, MANAGER_ROLES, } from '../utils/calendar' import { useAbsences } from '../hooks/useAbsences' import { usePlanerView } from '../hooks/usePlanerView' import { BalanceEditModal, EditAbsenceModal, CreateAbsenceModal, QuickSickModal, } from '../components/absences/AbsenceModals' import { AbsenceCommentsModal } from '../components/absences/AbsenceCommentsModal' // ── Component ───────────────────────────────────────────────────────────────── export function AbsencesPage() { const [searchParams, setSearchParams] = useSearchParams() const [showCreate, setShowCreate] = useState(false) const [showQuickSick, setShowQuickSick] = useState(false) const todayIso = new Date().toISOString().slice(0, 10) const [quickSickForm, setQuickSickForm] = useState({ start_date: todayIso, end_date: todayIso }) const [quickSubmitting, setQuickSubmitting] = useState(false) const [quickError, setQuickError] = useState('') const [showReject, setShowReject] = useState(null) const [rejectReason, setRejectReason] = useState('') const [showCancelReq, setShowCancelReq] = useState(null) const [cancelReason, setCancelReason] = useState('') const [commentsFor, setCommentsFor] = useState(null) const [statusFilter, setStatusFilter] = useState(searchParams.get('status') ?? '') const [submitting, setSubmitting] = useState(false) const [showBalanceEdit, setShowBalanceEdit] = useState(false) const [balanceForm, setBalanceForm] = useState({ entitled_days: 0, special_days: 0, carried_over: 0 }) const [balanceSaving, setBalanceSaving] = useState(false) const [editAbsence, setEditAbsence] = useState(null) const [editForm, setEditForm] = useState({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', correction_note: '', }) const [form, setForm] = useState({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '', substitute_id: '', }) const [fzaMode, setFzaMode] = useState<'days' | 'hours'>('days') const [fzaHours, setFzaHours] = useState(4) const year = new Date().getFullYear() const { user, types, absences, total, balance, overtimeBalance, loading, error, setError, colleagues, colleagueMap, createAbsence, approve, reject, saveEdit, cancel, requestCancellation, loadColleaguesIfNeeded, typeName, typeColor, updateBalance, load, } = useAbsences(year, statusFilter) const isSickType = (typeId: string) => types.find(t => t.id === typeId)?.category === 'sick' // HR/COMPANY_ADMIN/SUPER_ADMIN dürfen Attest-Eingang markieren – MANAGER nicht. const canMarkCertificate = !!user && ['HR', 'COMPANY_ADMIN', 'SUPER_ADMIN'].includes(user.role) const todayDate = todayIso const handleQuickSick = async () => { setQuickSubmitting(true) setQuickError('') try { await api.post('/absences/quick-sick', quickSickForm) setShowQuickSick(false) setQuickSickForm({ start_date: todayIso, end_date: todayIso }) await load() } catch (e: any) { setQuickError(e?.message ?? 'Krankmeldung fehlgeschlagen.') } finally { setQuickSubmitting(false) } } const markCertificate = async (absenceId: string) => { try { await api.patch(`/absences/${absenceId}/certificate`, {}) await load() } catch (e: any) { setError(e?.message ?? 'Attest konnte nicht markiert werden.') } } const { viewMode, setViewMode, planMonth, setPlanMonth, colleagueBalances, showYearGrid, setShowYearGrid, } = usePlanerView(user, colleagues, year) const isManager = user ? MANAGER_ROLES.includes(user.role) : false const openEdit = (a: AbsenceOut) => { setEditAbsence(a) setEditForm({ type_id: a.type_id, start_date: a.start_date, end_date: a.end_date, half_day_start: a.half_day_start, half_day_end: a.half_day_end, note: a.note ?? '', correction_note: '', }) setError('') } const openCreate = async () => { setShowCreate(true) setError('') setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '', substitute_id: '' }) setFzaMode('days') setFzaHours(4) if (isManager) await loadColleaguesIfNeeded() } const handleReject = async (id: string) => { await reject(id, rejectReason, () => { setShowReject(null); setRejectReason('') }) } const handleSaveEdit = async () => { if (!editAbsence) return await saveEdit(editAbsence, editForm, isManager, () => setEditAbsence(null), setSubmitting) } const isFzaType = (typeId: string) => { const t = types.find(t => t.id === typeId) if (!t) return false const lower = t.name?.toLowerCase() ?? '' return lower.includes('fza') || lower.includes('freizeitausgleich') } const handleCreate = async () => { const useFzaHours = isFzaType(form.type_id) && fzaMode === 'hours' await createAbsence(form, () => { setShowCreate(false) setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '', substitute_id: '' }) setFzaMode('days') setFzaHours(4) }, setSubmitting, useFzaHours ? fzaHours : undefined) } const handleSaveBalance = async () => { if (!balance) return setBalanceSaving(true) try { const updated = await api.patch(`/absences/balance/${balance.user_id}?year=${year}`, balanceForm) updateBalance(updated) setShowBalanceEdit(false) } catch { /* ignore */ } finally { setBalanceSaving(false) } } // Build absence spans from local data const absenceSpans: AbsenceSpan[] = absences.map(a => ({ id: a.id, userId: a.user_id, userName: colleagueMap[a.user_id] ?? (user?.id === a.user_id ? `${user.first_name} ${user.last_name}` : a.user_id), typeName: typeName(a.type_id), color: typeColor(a.type_id), start: new Date(a.start_date), end: new Date(a.end_date), status: a.status, isHalfStart: a.half_day_start, isHalfEnd: a.half_day_end, })) const absencesInMonth = (month: number) => { const first = new Date(year, month, 1) const last = new Date(year, month + 1, 0) return absences.filter(a => new Date(a.start_date) <= last && new Date(a.end_date) >= first) } const buildAbsenceListHtml = () => { const sorted = [...absences] .filter(a => a.status !== 'cancelled') .sort((a, b) => a.start_date.localeCompare(b.start_date)) const userName = user ? `${user.first_name} ${user.last_name}` : '' const managerHeader = isManager ? 'Mitarbeiter' : '' const balanceRow = balance ? `
Grundurlaub: ${balance.entitled_days} Tage
${balance.special_days > 0 ? `
Sondertage: +${balance.special_days} Tage
` : ''} ${balance.carried_over > 0 ? `
Resturlaub ${year - 1}: +${balance.carried_over} Tage
` : ''}
Gesamt: ${balance.total_days} Tage
Verbleibend: ${balance.remaining_days} Tage
` : '' const rows = sorted.map(a => { const name = isManager ? `${colleagueMap[a.user_id] ?? '–'}` : '' const statusColor = a.status === 'approved' ? '#16a34a' : a.status === 'pending' ? '#d97706' : '#dc2626' return ` ${name} ${typeName(a.type_id)} ${new Date(a.start_date).toLocaleDateString('de-DE')} ${new Date(a.end_date).toLocaleDateString('de-DE')} ${a.working_days} ${STATUS_LABELS[a.status] ?? a.status} ${a.note ? `${a.note}` : ''} ` }).join('') const html = ` Urlaubsliste ${userName} ${year}

Urlaubsliste ${year}${!isManager ? ` – ${userName}` : ''}

Stand: ${new Date().toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })}

${!isManager ? balanceRow : ''} ${sorted.length === 0 ? '

Keine Einträge.

' : `${managerHeader}${rows}
AbwesenheitsartVonBisTageStatusNotiz
`} ` return html } const buildMonthHtml = () => { const list = absencesInMonth(planMonth) .filter(a => a.status !== 'cancelled' && a.status !== 'rejected') .sort((a, b) => a.start_date.localeCompare(b.start_date)) const monthName = MONTHS[planMonth] const managerHeader = isManager ? 'Mitarbeiter' : '' const remainingHeader = isManager ? 'Resturlaub' : '' const rows = list.map(a => { const name = isManager ? `${colleagueMap[a.user_id] ?? '–'}` : '' const bal = colleagueBalances[a.user_id] const remaining = isManager ? `${bal != null ? bal.remaining_days + ' Tage' : '–'}` : '' const statusColor = a.status === 'approved' ? '#16a34a' : '#d97706' return `${name}${typeName(a.type_id)}${new Date(a.start_date).toLocaleDateString('de-DE')}${new Date(a.end_date).toLocaleDateString('de-DE')}${a.working_days}${STATUS_LABELS[a.status] ?? a.status}${remaining}` }).join('') return `Urlaubsplaner ${monthName} ${year}

Urlaubsplaner – ${monthName} ${year}

Stand: ${new Date().toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })}

${list.length === 0 ? '

Keine Abwesenheiten in diesem Monat.

' : `${managerHeader}${remainingHeader}${rows}
AbwesenheitsartVonBisTageStatus
`}` } const openPdf = (html: string) => { const w = window.open('', '_blank', 'width=960,height=720') if (w) { w.document.write(html); w.document.close() } } const openPrint = (html: string) => { const w = window.open('', '_blank', 'width=960,height=720') if (w) { w.document.write(html); w.document.close(); setTimeout(() => { w.focus(); w.print() }, 250) } } if (loading) return (
) if (!user) return null const today = new Date() const todayStr = today.toDateString() return (
{/* Header */}

Abwesenheiten

{/* Tabs */}
{(['liste', 'planer'] as const).map(v => ( ))}
{viewMode === 'planer' && isManager && ( )}
{error &&
{error}
} {/* ─── LISTE ─────────────────────────────────────────────────────────── */} {viewMode === 'liste' && (<> {balance && (

Urlaubskonto {year}

{isManager && ( )}
Grundurlaub{balance.entitled_days} Tage
{balance.special_days > 0 &&
Sondertage+{balance.special_days} Tage
} {balance.carried_over > 0 && (
Resturlaub {year - 1} +{balance.carried_over} Tage
{balance.carried_over_expires_at && (
{balance.carried_over_expired ? `Verfallen am ${new Date(balance.carried_over_expires_at).toLocaleDateString('de-DE')}` : `Verfällt am ${new Date(balance.carried_over_expires_at).toLocaleDateString('de-DE')}`}
)}
)}
Gesamt{balance.total_days} Tage
{balance.used_days} genommen{balance.pending_days > 0 ? ` · ${balance.pending_days} beantragt` : ''} {balance.total_days} gesamt
{balance.pending_days > 0 &&
}

Genommen

{balance.used_days}

{balance.pending_days > 0 &&

Beantragt

{balance.pending_days}

}

Verfügbar

5 ? 'text-green-600' : balance.available_days > 0 ? 'text-yellow-600' : 'text-red-600'}`}>{balance.available_days}

{balance.carried_over_expired && balance.carried_over > 0 && (

Resturlaub verfallen

)}
)} {overtimeBalance !== null && overtimeBalance.total_hours > 0 && (

Überstunden-Konto

Angesammelt

{overtimeBalance.total_hours.toFixed(1)} h

Genommen

{overtimeBalance.taken_hours.toFixed(1)} h

Verfügbar

0 ? 'text-green-600' : 'text-red-600'}`}>{overtimeBalance.available_hours.toFixed(1)} h

)}
{[['', 'Alle'], ['pending', 'Ausstehend'], ['approved', 'Genehmigt'], ['rejected', 'Abgelehnt']].map(([val, label]) => ( ))}

Anträge {year}

{total} gesamt
{absences.length === 0 ? (

Keine Einträge

) : absences.map(a => (

{typeName(a.type_id)}

{isManager && a.user_id !== user?.id &&

{colleagueMap[a.user_id] ?? a.user_id}

}

{new Date(a.start_date).toLocaleDateString('de-DE')} – {new Date(a.end_date).toLocaleDateString('de-DE')} {' · '}{a.working_days} Arbeitstag{a.working_days !== 1 ? 'e' : ''}

{a.note &&

{a.note}

} {a.substitute_id &&

🔁 Vertretung: {colleagueMap[a.substitute_id] ?? '—'}

} {a.correction_note &&

✏️ {a.correction_note}

} {a.rejection_reason &&

Abgelehnt: {a.rejection_reason}

}
{isSickType(a.type_id) && a.certificate_received_at && ( Attest ✓ )} {isSickType(a.type_id) && !a.certificate_received_at && a.certificate_required_by && a.certificate_required_by < todayDate && ( AU überfällig )} {STATUS_LABELS[a.status] ?? a.status} {canMarkCertificate && isSickType(a.type_id) && !a.certificate_received_at && ( )} {(a.status === 'pending' || a.status === 'approved') && (isManager || a.user_id === user.id) && ( )} {isManager && a.status === 'pending' && (<> )} {isManager && a.status === 'cancellation_requested' && (<> )} {!isManager && a.status === 'pending' && a.user_id === user.id && ( )} {!isManager && a.status === 'approved' && a.user_id === user.id && ( )}
{showReject === a.id && (
setRejectReason(e.target.value)} className='flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-400' />
)} {showCancelReq === a.id && (
setCancelReason(e.target.value)} className='flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-orange-400' />
)}
))}
)} {/* ─── JAHRESPLANER ──────────────────────────────────────────────────── */} {viewMode === 'planer' && (
{/* Jahresleiste */} {(showYearGrid || !isManager) && (() => { const allYearSpans = absenceSpans.filter(s => s.status !== 'cancelled' && s.status !== 'rejected') const yearUserIds = isManager ? [...new Set([user!.id, ...allYearSpans.map(s => s.userId)])] : [user!.id] const sortedUserIds = [...yearUserIds].sort((a, b) => { if (a === user!.id) return -1 if (b === user!.id) return 1 return (colleagueMap[a] ?? '').localeCompare(colleagueMap[b] ?? '', 'de') }) return (

Jahresübersicht {year}

{MONTHS_SHORT.map((m, idx) => ( ))} {sortedUserIds.map(uid => { const name = uid === user!.id ? `${user!.first_name} ${user!.last_name}` : (colleagueMap[uid] ?? uid) const firstName = name.split(' ')[0] const lastName = name.split(' ').slice(1).join(' ') return ( {Array.from({ length: 12 }, (_, monthIdx) => { const cellAbsences = absenceSpans.filter(s => { const first = new Date(year, monthIdx, 1) const last = new Date(year, monthIdx + 1, 0) return s.userId === uid && s.start <= last && s.end >= first && s.status !== 'cancelled' && s.status !== 'rejected' }) const isActiveMonth = monthIdx === planMonth const totalDays = cellAbsences.reduce((sum, s) => { const first = new Date(year, monthIdx, 1) const last = new Date(year, monthIdx + 1, 0) const cs = s.start < first ? first : s.start const ce = s.end > last ? last : s.end return sum + Math.round((ce.getTime() - cs.getTime()) / 86400000) + 1 }, 0) return ( ) })} ) })}
{year} setPlanMonth(idx)} className={`cursor-pointer py-2 px-1 text-center text-xs font-semibold border-b border-gray-100 min-w-[52px] transition-colors select-none ${idx === planMonth ? 'text-blue-600 bg-blue-50' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'} ${idx === today.getMonth() && idx !== planMonth ? 'underline decoration-dotted decoration-blue-400' : ''} `}> {m}
{firstName} {lastName && {lastName.charAt(0)}.}
setPlanMonth(monthIdx)} className={`py-1.5 px-1 text-center cursor-pointer transition-colors ${isActiveMonth ? 'bg-blue-50' : 'hover:bg-gray-50'}`}> {cellAbsences.length === 0 ? ( · ) : (
{[...new Map(cellAbsences.map(s => [s.color, s])).values()].slice(0, 4).map(s => ( a.status === 'pending') ? 0.5 : 1 }} /> ))}
{totalDays}d
)}
) })()} {/* Monatskalender */}
{/* Header */}

{MONTHS[planMonth]} {year}

{/* Resource-Timeline (Gantt) */} {(() => { const CELL_W = 36 const daysInMonth = new Date(year, planMonth + 1, 0).getDate() const monthDays = Array.from({ length: daysInMonth }, (_, i) => new Date(year, planMonth, i + 1)) const monthAbsenceSpans = absenceSpans.filter(s => { const first = new Date(year, planMonth, 1) const last = new Date(year, planMonth + 1, 0) return s.start <= last && s.end >= first && s.status !== 'cancelled' && s.status !== 'rejected' }) const visibleUserIds = isManager ? [...new Set([user!.id, ...monthAbsenceSpans.map(s => s.userId)])] : [user!.id] const sortedUserIds = [...visibleUserIds].sort((a, b) => { if (a === user!.id) return -1 if (b === user!.id) return 1 return (colleagueMap[a] ?? '').localeCompare(colleagueMap[b] ?? '', 'de') }) if (monthAbsenceSpans.length === 0) { return (

Keine Abwesenheiten im {MONTHS[planMonth]}

) } return (
{/* Header row */}
{monthDays.map((day, idx) => { const wd = (day.getDay() + 6) % 7 const isWeekend = wd >= 5 const isToday = day.toDateString() === todayStr const dayLabel = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'][wd] return (
{dayLabel} {isToday ? ( {day.getDate()} ) : ( {day.getDate()} )}
) })}
{/* User rows */} {sortedUserIds.map(uid => { const fullName = uid === user!.id ? `${user!.first_name} ${user!.last_name}` : (colleagueMap[uid] ?? uid) const firstName = fullName.split(' ')[0] const bal = colleagueBalances[uid] const userSpans = monthAbsenceSpans.filter(s => s.userId === uid) const first = new Date(year, planMonth, 1) const last = new Date(year, planMonth + 1, 0) return (
{firstName} {isManager && bal != null && ( {bal.remaining_days} Tage )}
{monthDays.map((day, idx) => { const wd = (day.getDay() + 6) % 7 const isWeekend = wd >= 5 return (
) })} {userSpans.map(span => { const clampedStart = span.start < first ? first : span.start const clampedEnd = span.end > last ? last : span.end const startIdx = clampedStart.getDate() - 1 const endIdx = clampedEnd.getDate() - 1 const spanDays = endIdx - startIdx + 1 const isContinuedLeft = span.start < first const isContinuedRight = span.end > last const leftOffset = startIdx * CELL_W + (isContinuedLeft ? 0 : 2) const barWidth = spanDays * CELL_W - (isContinuedLeft ? 0 : 2) - (isContinuedRight ? 0 : 2) const borderRadius = `${isContinuedLeft ? 0 : 4}px ${isContinuedRight ? 0 : 4}px ${isContinuedRight ? 0 : 4}px ${isContinuedLeft ? 0 : 4}px` return (
{span.typeName} {span.status === 'pending' && ?}
) })}
) })}
) })()}
)} {/* ─── Modals ──────────────────────────────────────────────────────── */} {showBalanceEdit && balance && ( setShowBalanceEdit(false)} /> )} {editAbsence && ( { setEditAbsence(null); setError('') }} /> )} {showCreate && ( { setShowCreate(false) setError('') setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '', substitute_id: '' }) setFzaMode('days') setFzaHours(4) }} /> )} {showQuickSick && ( { setShowQuickSick(false); setQuickError('') }} /> )} {commentsFor && ( setCommentsFor(null)} /> )}
) }