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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user