Files
timemaster/frontend/src/pages/CalendarPage.tsx
T
sysopsandClaude Sonnet 4.6 45218cc744 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>
2026-05-23 20:03:27 +02:00

257 lines
10 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 { useEffect, useState, useCallback } from 'react'
import { api } from '../api/client'
import { Spinner } from '../components/Spinner'
import { Layout } from '../components/Layout'
interface UserOut {
id: string; first_name: string; last_name: string; email: string; role: string; company_id: string
}
interface CalendarEntry {
user_id: string
user_name: string
absence_id: string
type_name: string
type_color: string
start_date: string
end_date: string
status: string
working_days: number
}
interface PublicHoliday {
id: string
date: string
name: string
}
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
function isoDate(d: Date): string {
return d.toISOString().slice(0, 10)
}
function getDaysInMonth(year: number, month: number): Date[] {
const days: Date[] = []
const d = new Date(year, month, 1)
while (d.getMonth() === month) {
days.push(new Date(d))
d.setDate(d.getDate() + 1)
}
return days
}
// Mon=0..Sun=6
function dayOfWeek(d: Date): number {
return (d.getDay() + 6) % 7
}
export function CalendarPage() {
const today = new Date()
const [user, setUser] = useState<UserOut | null>(null)
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth())
const [entries, setEntries] = useState<CalendarEntry[]>([])
const [holidays, setHolidays] = useState<PublicHoliday[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [tooltip, setTooltip] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const [me, cal, hol] = await Promise.all([
api.get<UserOut>('/auth/me'),
api.get<CalendarEntry[]>(`/absences/calendar?year=${year}&month=${month + 1}`),
api.get<PublicHoliday[]>(`/public-holidays/?year=${year}&country=DE`),
])
setUser(me)
setEntries(cal.filter(e => e.status === 'approved' || e.status === 'pending'))
setHolidays(hol)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler')
} finally {
setLoading(false)
}
}, [year, month])
useEffect(() => { load() }, [load])
const prevMonth = () => {
if (month === 0) { setMonth(11); setYear(y => y - 1) }
else setMonth(m => m - 1)
}
const nextMonth = () => {
if (month === 11) { setMonth(0); setYear(y => y + 1) }
else setMonth(m => m + 1)
}
const days = getDaysInMonth(year, month)
const firstDow = dayOfWeek(days[0]) // 0=Mon
// Absent people per day
const absentOn = (dateStr: string): CalendarEntry[] =>
entries.filter(e => e.start_date <= dateStr && e.end_date >= dateStr)
const isHoliday = (dateStr: string): string | null =>
holidays.find(h => h.date === dateStr)?.name ?? null
if (loading) return <Layout userRole='' userName=''><div className='flex justify-center py-20'><Spinner /></div></Layout>
if (!user) return null
return (
<Layout userRole={user.role} userName={`${user.first_name} ${user.last_name}`}>
<div className='space-y-6'>
{/* Header */}
<div className='flex items-center gap-4'>
<button onClick={prevMonth} className='p-2 rounded-lg border border-gray-200 hover:bg-gray-50 text-gray-600'></button>
<h1 className='text-2xl font-bold text-gray-900 min-w-[220px] text-center'>
{MONTHS[month]} {year}
</h1>
<button onClick={nextMonth} className='p-2 rounded-lg border border-gray-200 hover:bg-gray-50 text-gray-600'></button>
<button
onClick={() => { setYear(today.getFullYear()); setMonth(today.getMonth()) }}
className='ml-2 px-3 py-1.5 text-sm border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50'
>
Heute
</button>
</div>
{error && <div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{error}</div>}
{/* Calendar grid */}
<div className='bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden'>
{/* Weekday headers */}
<div className='grid grid-cols-7 border-b border-gray-100'>
{WEEKDAYS.map(d => (
<div key={d} className={`text-center text-xs font-semibold py-3 ${d === 'Sa' || d === 'So' ? 'text-gray-400' : 'text-gray-600'}`}>
{d}
</div>
))}
</div>
{/* Days */}
<div className='grid grid-cols-7'>
{/* Leading empty cells */}
{Array.from({ length: firstDow }).map((_, i) => (
<div key={`empty-${i}`} className='min-h-[100px] border-b border-r border-gray-50 bg-gray-50/30' />
))}
{days.map(day => {
const ds = isoDate(day)
const dow = dayOfWeek(day)
const isToday = ds === isoDate(today)
const isWeekend = dow >= 5
const holiday = isHoliday(ds)
const absent = absentOn(ds)
return (
<div
key={ds}
className={`min-h-[100px] border-b border-r border-gray-100 p-1.5 ${isWeekend ? 'bg-gray-50/60' : ''} ${holiday ? 'bg-amber-50/60' : ''}`}
>
<div className='flex items-center justify-between mb-1'>
<span className={`text-sm font-medium w-7 h-7 flex items-center justify-center rounded-full ${
isToday ? 'bg-blue-600 text-white' : isWeekend ? 'text-gray-400' : 'text-gray-700'
}`}>
{day.getDate()}
</span>
{holiday && (
<span
className='text-xs text-amber-600 truncate max-w-[70px] cursor-help'
title={holiday}
>
{holiday.length > 8 ? holiday.slice(0, 8) + '…' : holiday}
</span>
)}
</div>
{/* Absence chips */}
<div className='space-y-0.5'>
{absent.slice(0, 3).map(e => (
<div
key={e.absence_id}
className='text-xs px-1.5 py-0.5 rounded truncate cursor-pointer opacity-90 hover:opacity-100'
style={{ backgroundColor: e.type_color + '33', color: e.type_color, borderLeft: `3px solid ${e.type_color}` }}
title={`${e.user_name} ${e.type_name}${e.status === 'pending' ? ' (ausstehend)' : ''}`}
onMouseEnter={() => setTooltip(`${e.user_name}: ${e.type_name}`)}
onMouseLeave={() => setTooltip(null)}
>
{e.user_name.split(' ')[0]}
{e.status === 'pending' && ' ?'}
</div>
))}
{absent.length > 3 && (
<div className='text-xs text-gray-400 px-1'>+{absent.length - 3} weitere</div>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Legend */}
<div className='flex flex-wrap gap-4 text-sm text-gray-600'>
<div className='flex items-center gap-1.5'>
<div className='w-3 h-3 rounded-sm bg-amber-100 border border-amber-200' />
Feiertag
</div>
<div className='flex items-center gap-1.5'>
<div className='w-3 h-3 rounded-sm bg-gray-100 border border-gray-200' />
Wochenende
</div>
<div className='flex items-center gap-1.5 text-gray-400'>
<span className='font-medium'>Vorname ?</span>
= ausstehend
</div>
</div>
{/* Summary: who's absent this month */}
{entries.length > 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'>Abwesenheiten im {MONTHS[month]}</h2>
<div className='space-y-2'>
{Array.from(new Set(entries.map(e => e.user_id))).map(uid => {
const userEntries = entries.filter(e => e.user_id === uid)
const name = userEntries[0].user_name
return (
<div key={uid} className='flex items-start gap-3'>
<div className='w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-xs font-bold text-blue-700 flex-shrink-0'>
{name.split(' ').map(n => n[0]).join('').slice(0, 2)}
</div>
<div>
<p className='text-sm font-medium text-gray-800'>{name}</p>
<div className='flex flex-wrap gap-1.5 mt-0.5'>
{userEntries.map(e => (
<span
key={e.absence_id}
className='text-xs px-2 py-0.5 rounded-full'
style={{ backgroundColor: e.type_color + '22', color: e.type_color }}
>
{e.type_name} · {new Date(e.start_date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}
{e.start_date !== e.end_date && ` ${new Date(e.end_date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}`}
{e.status === 'pending' && ' (ausstehend)'}
</span>
))}
</div>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Tooltip overlay (hidden, just accessibility) */}
{tooltip && (
<div className='fixed bottom-4 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-sm px-4 py-2 rounded-lg shadow-lg pointer-events-none z-50'>
{tooltip}
</div>
)}
</Layout>
)
}