Files
timemaster/frontend/src/pages/AbsencesPage.tsx
T
patrickandClaude Opus 4.8 eb8dace0fd feat(overtime): Auszahlungs-Anträge durch Mitarbeiter (opt-in pro Firma)
Mitarbeiter beantragt Überstunden-Auszahlung, HR genehmigt/lehnt ab.
Firmen-Opt-in payout_request_enabled (Default aus - nur HR-Direktbuchung).
Saldo-Abzug erst bei Genehmigung; Reject/Cancel bucht nichts.

- Migration 0042: hours_payouts.status/decided_by/decided_at/rejection_reason
  + companies.payout_request_enabled (nur Spalten, RLS unveraendert)
- Router: POST /hr/payouts/request|{id}/approve|reject|cancel; list status-Filter;
  HR-Direktbuchung bleibt (status approved)
- Frontend: PayoutRequestCard (Selbstbedienung in AbsencesPage), HR-Page
  Status-Spalte + Genehmigen/Ablehnen, CompanySettings-Toggle
- 4 pytest-Cases

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 22:43:26 +02:00

865 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { PayoutRequestCard } from '../components/PayoutRequestCard'
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<string | null>(null)
const [rejectReason, setRejectReason] = useState('')
const [showCancelReq, setShowCancelReq] = useState<string | null>(null)
const [cancelReason, setCancelReason] = useState('')
const [commentsFor, setCommentsFor] = useState<AbsenceOut | null>(null)
const [statusFilter, setStatusFilter] = useState<string>(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<AbsenceOut | null>(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<number>(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<VacationBalanceOut>(`/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 ? '<th>Mitarbeiter</th>' : ''
const balanceRow = balance ? `
<div style="display:flex;gap:24px;margin-bottom:20px;padding:12px 16px;background:#f9fafb;border-radius:8px;font-size:13px">
<div><span style="color:#6b7280">Grundurlaub:</span> <strong>${balance.entitled_days} Tage</strong></div>
${balance.special_days > 0 ? `<div><span style="color:#6b7280">Sondertage:</span> <strong>+${balance.special_days} Tage</strong></div>` : ''}
${balance.carried_over > 0 ? `<div><span style="color:#6b7280">Resturlaub ${year - 1}:</span> <strong>+${balance.carried_over} Tage</strong></div>` : ''}
<div><span style="color:#6b7280">Gesamt:</span> <strong>${balance.total_days} Tage</strong></div>
<div><span style="color:#6b7280">Verbleibend:</span> <strong style="color:${balance.remaining_days > 5 ? '#16a34a' : balance.remaining_days > 0 ? '#d97706' : '#dc2626'}">${balance.remaining_days} Tage</strong></div>
</div>` : ''
const rows = sorted.map(a => {
const name = isManager ? `<td>${colleagueMap[a.user_id] ?? ''}</td>` : ''
const statusColor = a.status === 'approved' ? '#16a34a' : a.status === 'pending' ? '#d97706' : '#dc2626'
return `<tr>
${name}
<td><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${typeColor(a.type_id)};margin-right:6px;vertical-align:middle"></span>${typeName(a.type_id)}</td>
<td>${new Date(a.start_date).toLocaleDateString('de-DE')}</td>
<td>${new Date(a.end_date).toLocaleDateString('de-DE')}</td>
<td style="text-align:right">${a.working_days}</td>
<td style="color:${statusColor}">${STATUS_LABELS[a.status] ?? a.status}</td>
${a.note ? `<td style="color:#6b7280;font-size:12px">${a.note}</td>` : '<td></td>'}
</tr>`
}).join('')
const html = `<!DOCTYPE html><html><head>
<meta charset="UTF-8">
<title>Urlaubsliste ${userName} ${year}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 28px; color: #111; }
h1 { font-size: 20px; margin: 0 0 2px; }
p.meta { font-size: 12px; color: #888; margin: 0 0 16px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { background: #f3f4f6; padding: 8px 12px; text-align: left; font-weight: 600; border-top: 2px solid #e5e7eb; border-bottom: 2px solid #e5e7eb; }
td { padding: 7px 12px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
@media print { body { padding: 10px; } }
</style>
</head><body>
<h1>Urlaubsliste ${year}${!isManager ? ` ${userName}` : ''}</h1>
<p class="meta">Stand: ${new Date().toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })}</p>
${!isManager ? balanceRow : ''}
${sorted.length === 0
? '<p style="color:#9ca3af;text-align:center;padding:32px">Keine Einträge.</p>'
: `<table>
<thead><tr>${managerHeader}<th>Abwesenheitsart</th><th>Von</th><th>Bis</th><th style="text-align:right">Tage</th><th>Status</th><th>Notiz</th></tr></thead>
<tbody>${rows}</tbody>
</table>`}
</body></html>`
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 ? '<th>Mitarbeiter</th>' : ''
const remainingHeader = isManager ? '<th style="text-align:right">Resturlaub</th>' : ''
const rows = list.map(a => {
const name = isManager ? `<td>${colleagueMap[a.user_id] ?? ''}</td>` : ''
const bal = colleagueBalances[a.user_id]
const remaining = isManager
? `<td style="text-align:right;${bal && bal.remaining_days <= 0 ? 'color:#dc2626;font-weight:700' : ''}">${bal != null ? bal.remaining_days + ' Tage' : ''}</td>`
: ''
const statusColor = a.status === 'approved' ? '#16a34a' : '#d97706'
return `<tr>${name}<td><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${typeColor(a.type_id)};margin-right:6px;vertical-align:middle"></span>${typeName(a.type_id)}</td><td>${new Date(a.start_date).toLocaleDateString('de-DE')}</td><td>${new Date(a.end_date).toLocaleDateString('de-DE')}</td><td style="text-align:right">${a.working_days}</td><td style="color:${statusColor}">${STATUS_LABELS[a.status] ?? a.status}</td>${remaining}</tr>`
}).join('')
return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Urlaubsplaner ${monthName} ${year}</title><style>body{font-family:-apple-system,sans-serif;padding:28px;color:#111}h1{font-size:22px;margin:0 0 4px}p.meta{font-size:12px;color:#888;margin:0 0 20px}table{width:100%;border-collapse:collapse;font-size:13px}th{background:#f3f4f6;padding:8px 12px;text-align:left;font-weight:600;border-top:2px solid #e5e7eb;border-bottom:2px solid #e5e7eb}td{padding:7px 12px;border-bottom:1px solid #f0f0f0;vertical-align:middle}@media print{body{padding:10px}}</style></head><body><h1>Urlaubsplaner ${monthName} ${year}</h1><p class="meta">Stand: ${new Date().toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })}</p>${list.length === 0 ? '<p style="color:#9ca3af;text-align:center;padding:32px">Keine Abwesenheiten in diesem Monat.</p>' : `<table><thead><tr>${managerHeader}<th>Abwesenheitsart</th><th>Von</th><th>Bis</th><th style="text-align:right">Tage</th><th>Status</th>${remainingHeader}</tr></thead><tbody>${rows}</tbody></table>`}</body></html>`
}
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 (
<Layout userRole='' userName=''>
<div className='flex justify-center py-20'><Spinner /></div>
</Layout>
)
if (!user) return null
const today = new Date()
const todayStr = today.toDateString()
return (
<Layout userRole={user.role} userName={`${user.first_name} ${user.last_name}`}>
<div className='space-y-6'>
{/* Header */}
<div className='flex items-center justify-between'>
<h1 className='text-2xl font-bold text-gray-900'>Abwesenheiten</h1>
<div className='flex gap-2'>
<button
onClick={() => { setQuickError(''); setShowQuickSick(true) }}
className='px-4 py-2 bg-orange-600 text-white rounded-lg text-sm font-medium hover:bg-orange-700 transition-colors'
>
🤒 Krank melden
</button>
<button onClick={openCreate} className='px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors'>
+ Antrag stellen
</button>
</div>
</div>
{/* Tabs */}
<div className='flex items-center gap-3 flex-wrap'>
<div className='flex gap-1 bg-gray-100 rounded-lg p-1 w-fit'>
{(['liste', 'planer'] as const).map(v => (
<button key={v} onClick={() => setViewMode(v)}
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${viewMode === v ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}>
{v === 'liste' ? 'Liste' : 'Jahresplaner'}
</button>
))}
</div>
{viewMode === 'planer' && isManager && (
<button
onClick={() => setShowYearGrid(v => !v)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm border transition-colors ${
showYearGrid
? 'bg-blue-50 border-blue-200 text-blue-700 hover:bg-blue-100'
: 'bg-white border-gray-200 text-gray-500 hover:bg-gray-50'
}`}
title={showYearGrid ? 'Jahresübersicht ausblenden' : 'Jahresübersicht einblenden'}
>
<svg className='w-4 h-4' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
{showYearGrid
? <path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M3 10h18M3 14h18M10 3v18M14 3v18' />
: <path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M4 6h16M4 12h16M4 18h16' />
}
</svg>
{showYearGrid ? 'Jahresübersicht' : 'Jahresübersicht'}
</button>
)}
</div>
{error && <div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{error}</div>}
{/* ─── LISTE ─────────────────────────────────────────────────────────── */}
{viewMode === 'liste' && (<>
{balance && (
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-5'>
<div className='flex items-center justify-between mb-4'>
<h2 className='text-sm font-semibold text-gray-500 uppercase tracking-wide'>Urlaubskonto {year}</h2>
{isManager && (
<button onClick={() => { setBalanceForm({ entitled_days: balance.entitled_days, special_days: balance.special_days, carried_over: balance.carried_over }); setShowBalanceEdit(true) }}
className='text-xs text-blue-600 hover:underline'>Bearbeiten</button>
)}
</div>
<div className='mb-4 p-3 bg-gray-50 rounded-lg space-y-1.5'>
<div className='flex justify-between text-sm'><span className='text-gray-500'>Grundurlaub</span><span className='font-medium text-gray-800'>{balance.entitled_days} Tage</span></div>
{balance.special_days > 0 && <div className='flex justify-between text-sm'><span className='text-gray-500'>Sondertage</span><span className='font-medium text-blue-700'>+{balance.special_days} Tage</span></div>}
{balance.carried_over > 0 && (
<div className='space-y-0.5'>
<div className='flex justify-between text-sm'>
<span className={balance.carried_over_expired ? 'text-red-500 line-through' : 'text-gray-500'}>
Resturlaub {year - 1}
</span>
<span className={`font-medium ${balance.carried_over_expired ? 'text-red-400 line-through' : 'text-purple-700'}`}>
+{balance.carried_over} Tage
</span>
</div>
{balance.carried_over_expires_at && (
<div className={`text-xs px-2 py-0.5 rounded ${balance.carried_over_expired ? 'bg-red-50 text-red-600' : 'bg-amber-50 text-amber-700'}`}>
{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')}`}
</div>
)}
</div>
)}
<div className='flex justify-between text-sm border-t border-gray-200 pt-1.5'><span className='font-medium text-gray-700'>Gesamt</span><span className='font-bold text-gray-900'>{balance.total_days} Tage</span></div>
</div>
<div className='mb-4'>
<div className='flex justify-between text-xs text-gray-500 mb-1'>
<span>{balance.used_days} genommen{balance.pending_days > 0 ? ` · ${balance.pending_days} beantragt` : ''}</span>
<span>{balance.total_days} gesamt</span>
</div>
<div className='h-2 bg-gray-100 rounded-full overflow-hidden flex'>
<div className='h-full bg-green-500' style={{ width: `${Math.min(100, balance.used_days / balance.total_days * 100)}%` }} />
{balance.pending_days > 0 && <div className='h-full bg-yellow-400' style={{ width: `${Math.min(100 - balance.used_days / balance.total_days * 100, balance.pending_days / balance.total_days * 100)}%` }} />}
</div>
</div>
<div className='grid grid-cols-3 gap-3'>
<div><p className='text-xs text-gray-500'>Genommen</p><p className='text-xl font-bold text-gray-800'>{balance.used_days}</p></div>
{balance.pending_days > 0 && <div><p className='text-xs text-gray-500'>Beantragt</p><p className='text-xl font-bold text-yellow-600'>{balance.pending_days}</p></div>}
<div>
<p className='text-xs text-gray-500'>Verfügbar</p>
<p className={`text-xl font-bold ${balance.available_days > 5 ? 'text-green-600' : balance.available_days > 0 ? 'text-yellow-600' : 'text-red-600'}`}>{balance.available_days}</p>
{balance.carried_over_expired && balance.carried_over > 0 && (
<p className='text-[11px] text-red-500'>Resturlaub verfallen</p>
)}
</div>
</div>
</div>
)}
{overtimeBalance !== null && overtimeBalance.total_hours > 0 && (
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-5'>
<h2 className='text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3'>Überstunden-Konto</h2>
<div className='grid grid-cols-3 gap-4'>
<div><p className='text-xs text-gray-500'>Angesammelt</p><p className='text-2xl font-bold text-gray-800'>{overtimeBalance.total_hours.toFixed(1)} <span className='text-sm font-normal text-gray-500'>h</span></p></div>
<div><p className='text-xs text-gray-500'>Genommen</p><p className='text-2xl font-bold text-gray-800'>{overtimeBalance.taken_hours.toFixed(1)} <span className='text-sm font-normal text-gray-500'>h</span></p></div>
<div><p className='text-xs text-gray-500'>Verfügbar</p><p className={`text-2xl font-bold ${overtimeBalance.available_hours > 0 ? 'text-green-600' : 'text-red-600'}`}>{overtimeBalance.available_hours.toFixed(1)} <span className='text-sm font-normal text-gray-500'>h</span></p></div>
</div>
</div>
)}
<PayoutRequestCard availableHours={overtimeBalance?.available_hours ?? 0} />
<div className='flex gap-2 flex-wrap'>
{[['', 'Alle'], ['pending', 'Ausstehend'], ['approved', 'Genehmigt'], ['rejected', 'Abgelehnt']].map(([val, label]) => (
<button key={val} onClick={() => { setStatusFilter(val); if (val) setSearchParams({ status: val }); else setSearchParams({}) }}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${statusFilter === val ? 'bg-blue-600 text-white' : 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'}`}>
{label}
</button>
))}
</div>
<div className='bg-white rounded-xl shadow-sm border border-gray-200'>
<div className='px-6 py-4 border-b border-gray-100 flex items-center justify-between'>
<h2 className='text-lg font-semibold text-gray-800'>Anträge {year}</h2>
<div className='flex items-center gap-3'>
<span className='text-sm text-gray-400'>{total} gesamt</span>
<button onClick={() => openPrint(buildAbsenceListHtml())}
className='flex items-center gap-1.5 px-3 py-1.5 border border-gray-200 text-gray-600 rounded-lg text-xs hover:bg-gray-50 transition-colors'
title='Drucken'>
<svg className='w-3.5 h-3.5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z' />
</svg>
Drucken
</button>
<button onClick={() => openPdf(buildAbsenceListHtml())}
className='flex items-center gap-1.5 px-3 py-1.5 border border-gray-200 text-gray-600 rounded-lg text-xs hover:bg-gray-50 transition-colors'
title='Als PDF speichern'>
<svg className='w-3.5 h-3.5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z' />
</svg>
PDF
</button>
</div>
</div>
<div className='divide-y divide-gray-50'>
{absences.length === 0 ? (
<p className='text-center text-gray-400 py-8 text-sm'>Keine Einträge</p>
) : absences.map(a => (
<div key={a.id} className='px-6 py-4'>
<div className='flex items-start justify-between gap-4'>
<div className='flex items-start gap-3'>
<div className='w-3 h-3 rounded-full mt-1.5 flex-shrink-0' style={{ backgroundColor: typeColor(a.type_id) }} />
<div>
<p className='text-sm font-medium text-gray-800'>{typeName(a.type_id)}</p>
{isManager && a.user_id !== user?.id && <p className='text-xs font-medium text-blue-600 mb-0.5'>{colleagueMap[a.user_id] ?? a.user_id}</p>}
<p className='text-xs text-gray-500'>
{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' : ''}
</p>
{a.note && <p className='text-xs text-gray-400 mt-0.5'>{a.note}</p>}
{a.substitute_id && <p className='text-xs text-gray-500 mt-0.5'>🔁 Vertretung: {colleagueMap[a.substitute_id] ?? '—'}</p>}
{a.correction_note && <p className='text-xs text-orange-500 mt-0.5'>✏️ {a.correction_note}</p>}
{a.rejection_reason && <p className='text-xs text-red-500 mt-0.5'>Abgelehnt: {a.rejection_reason}</p>}
</div>
</div>
<div className='flex items-center gap-2 flex-shrink-0'>
{isSickType(a.type_id) && a.certificate_received_at && (
<span className='text-xs px-2 py-0.5 rounded-full font-medium bg-green-100 text-green-700' title={`Eingegangen am ${new Date(a.certificate_received_at).toLocaleDateString('de-DE')}`}>
Attest
</span>
)}
{isSickType(a.type_id) && !a.certificate_received_at && a.certificate_required_by && a.certificate_required_by < todayDate && (
<span className='text-xs px-2 py-0.5 rounded-full font-medium bg-orange-100 text-orange-700' title={`Fällig seit ${new Date(a.certificate_required_by).toLocaleDateString('de-DE')}`}>
AU überfällig
</span>
)}
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[a.status] ?? 'bg-gray-100 text-gray-500'}`}>{STATUS_LABELS[a.status] ?? a.status}</span>
{canMarkCertificate && isSickType(a.type_id) && !a.certificate_received_at && (
<button
onClick={() => markCertificate(a.id)}
className='text-xs px-2 py-1 border border-green-300 text-green-700 rounded hover:bg-green-50'
title='Attest als eingegangen markieren'
>
Attest erhalten
</button>
)}
{(a.status === 'pending' || a.status === 'approved') && (isManager || a.user_id === user.id) && (
<button onClick={() => openEdit(a)} className={`text-xs px-2 py-1 border rounded ${a.status === 'approved' && !isManager ? 'border-orange-300 text-orange-600 hover:bg-orange-50' : 'border-blue-300 text-blue-600 hover:bg-blue-50'}`}>✏️</button>
)}
<button onClick={() => setCommentsFor(a)} title='Kommentare' className='text-xs px-2 py-1 border border-gray-300 text-gray-600 rounded hover:bg-gray-50'>💬</button>
{isManager && a.status === 'pending' && (<>
<button onClick={() => approve(a.id)} className='text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700'>Genehmigen</button>
<button onClick={() => { setShowReject(a.id); setRejectReason('') }} className='text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700'>Ablehnen</button>
</>)}
{a.status === 'first_approved'
&& ['HR', 'COMPANY_ADMIN', 'SUPER_ADMIN'].includes(user.role)
&& a.first_approved_by !== user.id && (<>
<button onClick={() => approve(a.id)} className='text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700' title='Finale Genehmigung'>Endgültig genehmigen</button>
<button onClick={() => { setShowReject(a.id); setRejectReason('') }} className='text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700'>Ablehnen</button>
</>)}
{isManager && a.status === 'cancellation_requested' && (<>
<button onClick={() => approve(a.id)} className='text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700' title='Stornierung genehmigen'>Storno </button>
<button onClick={() => { setShowReject(a.id); setRejectReason('') }} className='text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700' title='Stornierung ablehnen'>Storno </button>
</>)}
{!isManager && a.status === 'pending' && a.user_id === user.id && (
<button onClick={() => cancel(a.id)} className='text-xs px-2 py-1 border border-gray-300 text-gray-600 rounded hover:bg-gray-50'>Stornieren</button>
)}
{!isManager && a.status === 'approved' && a.user_id === user.id && (
<button onClick={() => { setShowCancelReq(a.id); setCancelReason('') }} className='text-xs px-2 py-1 border border-orange-300 text-orange-600 rounded hover:bg-orange-50'>Storno beantragen</button>
)}
</div>
</div>
{showReject === a.id && (
<div className='mt-3 flex gap-2'>
<input type='text' placeholder='Begründung (Pflicht)' value={rejectReason} onChange={e => 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' />
<button onClick={() => handleReject(a.id)} disabled={!rejectReason.trim()} className='px-3 py-1.5 bg-red-600 text-white text-sm rounded disabled:opacity-50'>Senden</button>
<button onClick={() => setShowReject(null)} className='px-3 py-1.5 border border-gray-300 text-gray-600 text-sm rounded'>Abbrechen</button>
</div>
)}
{showCancelReq === a.id && (
<div className='mt-3 flex gap-2'>
<input type='text' placeholder='Grund der Stornierung (optional)' value={cancelReason} onChange={e => 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' />
<button onClick={() => { requestCancellation(a.id, cancelReason); setShowCancelReq(null) }} className='px-3 py-1.5 bg-orange-600 text-white text-sm rounded'>Storno beantragen</button>
<button onClick={() => setShowCancelReq(null)} className='px-3 py-1.5 border border-gray-300 text-gray-600 text-sm rounded'>Abbrechen</button>
</div>
)}
</div>
))}
</div>
</div>
</>)}
{/* ─── JAHRESPLANER ──────────────────────────────────────────────────── */}
{viewMode === 'planer' && (
<div className='space-y-4'>
{/* 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 (
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-4'>
<p className='text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3'>Jahresübersicht {year}</p>
<div className='overflow-x-auto'>
<table className='w-full text-sm border-collapse'>
<thead>
<tr>
<th className='sticky left-0 bg-white z-10 w-28 min-w-28 text-left py-2 px-3 text-xs font-semibold text-gray-400 border-b border-gray-100'>
{year}
</th>
{MONTHS_SHORT.map((m, idx) => (
<th key={idx}
onClick={() => 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}
</th>
))}
</tr>
</thead>
<tbody>
{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 (
<tr key={uid} className='border-b border-gray-50 hover:bg-gray-50/50'>
<td className='sticky left-0 bg-white z-10 py-2 px-3 border-r border-gray-100'>
<div className='text-sm font-medium text-gray-800 truncate max-w-[100px]'>
{firstName}
{lastName && <span className='text-gray-400 font-normal ml-1 text-xs'>{lastName.charAt(0)}.</span>}
</div>
</td>
{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 (
<td key={monthIdx}
onClick={() => 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 ? (
<span className='text-gray-200 text-xs'>·</span>
) : (
<div className='flex flex-col items-center gap-0.5'>
<div className='flex gap-0.5 justify-center flex-wrap'>
{[...new Map(cellAbsences.map(s => [s.color, s])).values()].slice(0, 4).map(s => (
<span key={s.color} className='w-2 h-2 rounded-full flex-shrink-0'
style={{ backgroundColor: s.color,
opacity: cellAbsences.every(a => a.status === 'pending') ? 0.5 : 1 }} />
))}
</div>
<span className='text-[10px] text-gray-500 leading-none'>{totalDays}d</span>
</div>
)}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
})()}
{/* Monatskalender */}
<div className='bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden'>
{/* Header */}
<div className='px-5 py-3.5 border-b border-gray-100 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<button onClick={() => setPlanMonth(m => (m - 1 + 12) % 12)}
className='p-1.5 rounded-md hover:bg-gray-100 text-gray-500 transition-colors'>
<svg className='w-4 h-4' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M15 19l-7-7 7-7' /></svg>
</button>
<h2 className='text-base font-semibold text-gray-800 w-44 text-center'>
{MONTHS[planMonth]} {year}
</h2>
<button onClick={() => setPlanMonth(m => (m + 1) % 12)}
className='p-1.5 rounded-md hover:bg-gray-100 text-gray-500 transition-colors'>
<svg className='w-4 h-4' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M9 5l7 7-7 7' /></svg>
</button>
<button onClick={() => setPlanMonth(today.getMonth())}
className='ml-1 px-2.5 py-1 text-xs border border-gray-200 text-gray-600 rounded-md hover:bg-gray-50 transition-colors'>
Heute
</button>
</div>
<button onClick={() => openPrint(buildMonthHtml())}
className='flex items-center gap-1.5 px-3 py-1.5 border border-gray-200 text-gray-600 rounded-lg text-xs hover:bg-gray-50 transition-colors'
title='Drucken'>
<svg className='w-3.5 h-3.5' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z' /></svg>
Drucken
</button>
<button onClick={() => openPdf(buildMonthHtml())}
className='flex items-center gap-1.5 px-3 py-1.5 border border-gray-200 text-gray-600 rounded-lg text-xs hover:bg-gray-50 transition-colors'
title='Als PDF speichern'>
<svg className='w-3.5 h-3.5' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z' /></svg>
PDF
</button>
</div>
{/* 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 (
<p className='text-center text-gray-400 py-10 text-sm'>Keine Abwesenheiten im {MONTHS[planMonth]}</p>
)
}
return (
<div className='overflow-x-auto'>
<div style={{ minWidth: 120 + CELL_W * daysInMonth }}>
{/* Header row */}
<div className='flex border-b border-gray-100 bg-gray-50'>
<div style={{ width: 120, minWidth: 120, flexShrink: 0 }} className='sticky left-0 z-10 bg-gray-50 border-r border-gray-200' />
{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 (
<div key={idx} style={{ width: CELL_W, minWidth: CELL_W, flexShrink: 0 }}
className={`flex flex-col items-center justify-center border-r border-gray-100 last:border-r-0 py-1 ${isWeekend ? 'bg-gray-50' : ''}`}>
<span className='text-[10px] text-gray-400 leading-none mb-0.5'>{dayLabel}</span>
{isToday ? (
<span className='inline-flex items-center justify-center w-5 h-5 bg-blue-600 text-white rounded-full text-[11px] font-bold leading-none'>
{day.getDate()}
</span>
) : (
<span className={`text-[12px] font-medium leading-none ${isWeekend ? 'text-gray-400' : 'text-gray-700'}`}>
{day.getDate()}
</span>
)}
</div>
)
})}
</div>
{/* 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 (
<div key={uid} className='flex border-b border-gray-100 last:border-b-0' style={{ height: 40 }}>
<div style={{ width: 120, minWidth: 120, flexShrink: 0 }}
className='sticky left-0 z-10 bg-white/95 backdrop-blur border-r border-gray-200 flex flex-col justify-center px-3'>
<span className='text-sm font-medium text-gray-800 truncate leading-tight'>{firstName}</span>
{isManager && bal != null && (
<span className={`text-[10px] font-medium leading-tight ${bal.remaining_days <= 0 ? 'text-red-500' : bal.remaining_days <= 5 ? 'text-yellow-600' : 'text-green-600'}`}>
{bal.remaining_days} Tage
</span>
)}
</div>
<div className='relative flex-1' style={{ width: CELL_W * daysInMonth }}>
{monthDays.map((day, idx) => {
const wd = (day.getDay() + 6) % 7
const isWeekend = wd >= 5
return (
<div key={idx}
className={`absolute top-0 bottom-0 border-r border-gray-100 last:border-r-0 ${isWeekend ? 'bg-gray-50/60' : ''}`}
style={{ left: idx * CELL_W, width: CELL_W }}
/>
)
})}
{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 (
<div key={span.id}
className='absolute flex items-center overflow-hidden'
style={{
top: 4, bottom: 4, left: leftOffset, width: barWidth,
backgroundColor: span.color,
opacity: span.status === 'pending' ? 0.65 : 1,
borderRadius,
borderLeft: isContinuedLeft ? '3px solid rgba(0,0,0,0.15)' : undefined,
}}>
<span className='px-1.5 text-white text-[11px] font-medium truncate leading-none select-none'>
{span.typeName}
{span.status === 'pending' && <span className='opacity-75 ml-0.5 text-[10px]'>?</span>}
</span>
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
)
})()}
</div>
</div>
)}
{/* ─── Modals ──────────────────────────────────────────────────────── */}
{showBalanceEdit && balance && (
<BalanceEditModal
year={year}
balance={balance}
balanceForm={balanceForm}
setBalanceForm={setBalanceForm}
balanceSaving={balanceSaving}
onSave={handleSaveBalance}
onClose={() => setShowBalanceEdit(false)}
/>
)}
{editAbsence && (
<EditAbsenceModal
editAbsence={editAbsence}
editForm={editForm}
setEditForm={setEditForm}
types={types}
isManager={isManager}
submitting={submitting}
error={error}
onSave={handleSaveEdit}
onClose={() => { setEditAbsence(null); setError('') }}
/>
)}
{showCreate && (
<CreateAbsenceModal
form={form}
setForm={setForm}
types={types}
colleagues={colleagues}
isManager={isManager}
submitting={submitting}
error={error}
overtimeBalance={overtimeBalance}
fzaMode={fzaMode}
setFzaMode={setFzaMode}
fzaHours={fzaHours}
setFzaHours={setFzaHours}
isFzaType={isFzaType}
onCreate={handleCreate}
onClose={() => {
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 && (
<QuickSickModal
form={quickSickForm}
setForm={setQuickSickForm}
submitting={quickSubmitting}
error={quickError}
onSubmit={handleQuickSick}
onClose={() => { setShowQuickSick(false); setQuickError('') }}
/>
)}
{commentsFor && (
<AbsenceCommentsModal
absenceId={commentsFor.id}
onClose={() => setCommentsFor(null)}
/>
)}
</div>
</Layout>
)
}