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