Initial commit – TimeMaster Zeiterfassung & HR-Tool

Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer),
Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst.
Migrations 0001–0023 deployed auf 192.168.1.137 + .164.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
co-authored by Claude Sonnet 4.6
commit 45218cc744
177 changed files with 29145 additions and 0 deletions
+799
View File
@@ -0,0 +1,799 @@
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'
// ── 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 [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: '',
})
const year = new Date().getFullYear()
const {
user, types, absences, total, balance, overtimeBalance,
loading, error, setError, colleagues, colleagueMap,
createAbsence, approve, reject, saveEdit, cancel,
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: '' })
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 handleCreate = async () => {
await createAbsence(form, () => {
setShowCreate(false)
setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '' })
}, setSubmitting)
}
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'>Verbleibend</p>
<p className={`text-xl font-bold ${balance.remaining_days > 5 ? 'text-green-600' : balance.remaining_days > 0 ? 'text-yellow-600' : 'text-red-600'}`}>{balance.remaining_days}</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>
)}
<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.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>
)}
{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>
</>)}
{!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>
)}
</div>
</div>
{showReject === a.id && (
<div className='mt-3 flex gap-2'>
<input type='text' placeholder='Ablehnungsgrund (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>
)}
</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}
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: '' }) }}
/>
)}
{showQuickSick && (
<QuickSickModal
form={quickSickForm}
setForm={setQuickSickForm}
submitting={quickSubmitting}
error={quickError}
onSubmit={handleQuickSick}
onClose={() => { setShowQuickSick(false); setQuickError('') }}
/>
)}
</div>
</Layout>
)
}