Korrektheit der Urlaubskonten (Feature-Parität mit Urlaubsverwaltung):
- Verfall scharfgeschaltet: neuer effektiv verfügbarer Saldo (available_days)
schließt verfallenen, noch nicht verbrauchten Resturlaub aus; Konto-Warnung
beim Antrag nutzt jetzt available statt remaining. (Verfallsdatum bleibt in
company.settings, UI bereits vorhanden.)
- Anteilige Berechnung (Zwölftel) im Ein-/Austrittsjahr anhand neuer Felder
users.entry_date / exit_date; opt-in pro Firma (vacation_prorate_first_year).
- Teilzeit: Anspruch optional aus Arbeitstagen/Woche des WorkSchedule abgeleitet
(vacation_from_schedule), Basis vacation_default_days.
- _get_or_create_balance berechnet den Grundanspruch jetzt frisch
(_compute_entitlement); _carryover_expired/effective_available als Service-API,
Router delegiert.
Frontend: CompanySettingsPage (Jahresurlaub + Pro-rata- und Teilzeit-Toggles),
UsersPage (Ein-/Austrittsdatum im Edit-Modal), AbsencesPage ("Verfügbar" + Hinweis
bei verfallenem Resturlaub).
Migration 0036. 183/183 Tests grün. Deployed auf 137 + 164.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
856 lines
50 KiB
TypeScript
856 lines
50 KiB
TypeScript
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<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>
|
||
)}
|
||
|
||
<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>
|
||
</>)}
|
||
{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>
|
||
)
|
||
}
|