Files
timemaster/frontend/src/pages/DashboardPage.tsx
T
sysops 1fedd683e0 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

298 lines
11 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 } 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>
)
}