1fedd683e0
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>
298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
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>
|
||
)
|
||
}
|