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
+256
View File
@@ -0,0 +1,256 @@
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>
)
}