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
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
+297
View File
@@ -0,0 +1,297 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
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 EmployeeDashboard {
today_open: boolean
today_start: string | null
today_hours_so_far: number | null
week_hours_worked: number
week_hours_expected: number
week_overtime: number
vacation_remaining_days: number | null
vacation_used_days: number
vacation_entitled_days: number
pending_absences: number
overtime_balance_hours: number | null
schedule_name: string | null
}
interface UpcomingAbsence {
user_name: string
absence_type: string
start_date: string
end_date: string
working_days: number
}
interface CompanyDashboard {
total_employees: number
active_today: number
attendance_rate: number
month_hours_worked: number
month_hours_expected: number
month_overtime: number
pending_time_approvals: number
pending_absence_approvals: number
upcoming_absences: UpcomingAbsence[]
}
function StatCard({
label, value, sub, highlight,
}: {
label: string
value: string | number
sub?: string
highlight?: 'green' | 'red' | 'yellow'
}) {
const colors = {
green: 'border-l-4 border-green-400',
red: 'border-l-4 border-red-400',
yellow: 'border-l-4 border-yellow-400',
}
return (
<div className={`bg-white rounded-xl shadow-sm p-4 ${highlight ? colors[highlight] : 'border border-gray-100'}`}>
<p className='text-xs text-gray-500 uppercase tracking-wide font-medium'>{label}</p>
<p className='text-2xl font-bold text-gray-800 mt-1'>{value}</p>
{sub && <p className='text-xs text-gray-400 mt-0.5'>{sub}</p>}
</div>
)
}
export function DashboardPage() {
const { logout } = useAuth()
const [user, setUser] = useState<UserOut | null>(null)
const [emp, setEmp] = useState<EmployeeDashboard | null>(null)
const [comp, setComp] = useState<CompanyDashboard | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
async function load() {
try {
const [me, empDash] = await Promise.all([
api.get<UserOut>('/auth/me'),
api.get<EmployeeDashboard>('/dashboard/me'),
])
setUser(me)
setEmp(empDash)
const adminRoles = ['COMPANY_ADMIN', 'SUPER_ADMIN']
if (adminRoles.includes(me.role)) {
try {
setComp(await api.get<CompanyDashboard>('/dashboard/company'))
} catch {
// kein Admin-Zugriff still ignorieren
}
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}
load()
}, [])
if (loading) {
return (
<div className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Spinner />
</div>
)
}
if (error) {
return (
<div className='min-h-screen bg-gray-50 flex items-center justify-center'>
<div className='bg-white rounded-xl shadow p-6 text-center'>
<p className='text-red-600 font-medium'>{error}</p>
<button onClick={logout} className='mt-4 text-sm text-gray-500 underline'>
Abmelden
</button>
</div>
</div>
)
}
const overtimePositive = (emp?.week_overtime ?? 0) >= 0
return (
<Layout userRole={user?.role ?? ''} userName={`${user?.first_name ?? ''} ${user?.last_name ?? ''}`}>
<div className='space-y-8'>
{/* Heutiger Status */}
{emp && (
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium ${
emp.today_open
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-gray-100 text-gray-600 border border-gray-200'
}`}>
<span className={`h-2.5 w-2.5 rounded-full flex-shrink-0 ${
emp.today_open ? 'bg-green-500 animate-pulse' : 'bg-gray-400'
}`} />
{emp.today_open
? <>
Heute eingestempelt um {emp.today_start?.slice(0, 5) ?? '—'}
{emp.today_hours_so_far != null && (
<span className='text-green-600 font-semibold ml-1'>
· {emp.today_hours_so_far.toFixed(1)} h bisher
</span>
)}
</>
: 'Heute noch nicht eingestempelt'
}
</div>
)}
{/* Meine Übersicht */}
{emp && (
<section>
<h2 className='text-base font-semibold text-gray-700 mb-3'>
Meine Übersicht
{emp.schedule_name && (
<span className='ml-2 text-xs font-normal text-gray-400'>
· {emp.schedule_name}
</span>
)}
</h2>
<div className='grid grid-cols-2 sm:grid-cols-4 gap-3'>
<StatCard
label='Diese Woche'
value={`${emp.week_hours_worked.toFixed(1)} h`}
sub={`von ${emp.week_hours_expected.toFixed(0)} h Soll`}
/>
<StatCard
label='Überstunden'
value={`${overtimePositive ? '+' : ''}${emp.week_overtime.toFixed(1)} h`}
highlight={overtimePositive ? 'green' : 'red'}
/>
<StatCard
label='Urlaub verbleibend'
value={emp.vacation_remaining_days ?? '—'}
sub={`${emp.vacation_used_days} genutzt / ${emp.vacation_entitled_days} Tage`}
/>
{emp.overtime_balance_hours != null && (
<StatCard
label='Überstunden-Guthaben'
value={`${emp.overtime_balance_hours >= 0 ? '+' : ''}${emp.overtime_balance_hours.toFixed(1)} h`}
sub='verfügbar für Freizeitausgleich'
highlight={emp.overtime_balance_hours > 0 ? 'green' : undefined}
/>
)}
{emp.pending_absences > 0 && (
<StatCard
label='Offene Anträge'
value={emp.pending_absences}
highlight='yellow'
/>
)}
</div>
</section>
)}
{/* Unternehmens-Dashboard (nur Admin) */}
{comp && (
<section>
<h2 className='text-base font-semibold text-gray-700 mb-3'>Unternehmensübersicht</h2>
<div className='grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4'>
<StatCard label='Mitarbeiter' value={comp.total_employees} />
<StatCard
label='Heute anwesend'
value={comp.active_today}
sub={`${comp.attendance_rate.toFixed(0)} % Anwesenheitsrate`}
highlight={comp.attendance_rate >= 70 ? 'green' : 'yellow'}
/>
<StatCard
label='Monat gearbeitet'
value={`${comp.month_hours_worked.toFixed(0)} h`}
sub={`von ${comp.month_hours_expected.toFixed(0)} h Soll`}
/>
<StatCard
label='Monat Überstunden'
value={`${comp.month_overtime >= 0 ? '+' : ''}${comp.month_overtime.toFixed(1)} h`}
highlight={comp.month_overtime >= 0 ? 'green' : 'red'}
/>
</div>
{/* Ausstehende Genehmigungen */}
{(comp.pending_time_approvals > 0 || comp.pending_absence_approvals > 0) && (
<div className='flex flex-wrap gap-2 mb-4'>
{comp.pending_time_approvals > 0 && (
<Link to='/time?status=pending' className='bg-yellow-100 text-yellow-800 text-xs px-3 py-1.5 rounded-full font-medium hover:bg-yellow-200 transition-colors'>
{comp.pending_time_approvals} Zeiteinträge warten auf Genehmigung
</Link>
)}
{comp.pending_absence_approvals > 0 && (
<Link to='/absences?status=pending' className='bg-yellow-100 text-yellow-800 text-xs px-3 py-1.5 rounded-full font-medium hover:bg-yellow-200 transition-colors'>
{comp.pending_absence_approvals} Abwesenheitsanträge warten auf Genehmigung
</Link>
)}
</div>
)}
{/* Kommende Abwesenheiten */}
{comp.upcoming_absences.length > 0 && (
<div className='bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden'>
<div className='px-4 py-3 border-b border-gray-100'>
<h3 className='text-sm font-semibold text-gray-700'>
Kommende Abwesenheiten (nächste 14 Tage)
</h3>
</div>
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'>
<tr>
{['Mitarbeiter', 'Typ', 'Von', 'Bis', 'Tage'].map(h => (
<th key={h} className='px-4 py-2.5 text-left font-medium'>{h}</th>
))}
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{comp.upcoming_absences.map((a, i) => (
<tr key={i} className='hover:bg-gray-50 transition-colors'>
<td className='px-4 py-2.5 font-medium text-gray-800'>{a.user_name}</td>
<td className='px-4 py-2.5 text-gray-600'>{a.absence_type}</td>
<td className='px-4 py-2.5 text-gray-600'>{a.start_date}</td>
<td className='px-4 py-2.5 text-gray-600'>{a.end_date}</td>
<td className='px-4 py-2.5 text-gray-600'>{a.working_days}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</section>
)}
{/* API Docs Link (nur dev) */}
<div className='text-center pt-4 border-t border-gray-200'>
<a
href='/docs'
target='_blank'
rel='noreferrer'
className='text-xs text-gray-400 hover:text-blue-500 transition-colors'
>
API Dokumentation
</a>
</div>
</div>
</Layout>
)
}