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
co-authored by Claude Sonnet 4.6
commit 45218cc744
177 changed files with 29145 additions and 0 deletions
+310
View File
@@ -0,0 +1,310 @@
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 WorkScheduleOut {
id: string
company_id: string
name: string
mon_h: number; tue_h: number; wed_h: number; thu_h: number
fri_h: number; sat_h: number; sun_h: number
valid_from: string
}
interface UserListResponse {
total: number
items: UserOut[]
}
const DAYS = [
{ key: 'mon_h', label: 'Mo' },
{ key: 'tue_h', label: 'Di' },
{ key: 'wed_h', label: 'Mi' },
{ key: 'thu_h', label: 'Do' },
{ key: 'fri_h', label: 'Fr' },
{ key: 'sat_h', label: 'Sa' },
{ key: 'sun_h', label: 'So' },
] as const
const EMPTY_FORM = { name: '', mon_h: 8, tue_h: 8, wed_h: 8, thu_h: 8, fri_h: 8, sat_h: 0, sun_h: 0, valid_from: new Date().toISOString().slice(0, 10) }
export function WorkSchedulePage() {
const [user, setUser] = useState<UserOut | null>(null)
const [schedules, setSchedules] = useState<WorkScheduleOut[]>([])
const [users, setUsers] = useState<UserOut[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editId, setEditId] = useState<string | null>(null)
const [form, setForm] = useState(EMPTY_FORM)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [assignModal, setAssignModal] = useState<string | null>(null) // scheduleId
const [assigning, setAssigning] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const [me, sched, ul] = await Promise.all([
api.get<UserOut>('/auth/me'),
api.get<WorkScheduleOut[]>('/time/schedules'),
api.get<UserListResponse>('/users/?limit=200&active_only=true'),
])
setUser(me)
setSchedules(sched)
setUsers(ul.items)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const openCreate = () => {
setEditId(null)
setForm(EMPTY_FORM)
setShowForm(true)
setError('')
}
const openEdit = (s: WorkScheduleOut) => {
setEditId(s.id)
setForm({ name: s.name, mon_h: s.mon_h, tue_h: s.tue_h, wed_h: s.wed_h, thu_h: s.thu_h, fri_h: s.fri_h, sat_h: s.sat_h, sun_h: s.sun_h, valid_from: s.valid_from })
setShowForm(true)
setError('')
}
const save = async () => {
setSaving(true)
setError('')
try {
if (editId) {
await api.patch(`/time/schedules/${editId}`, form)
} else {
await api.post('/time/schedules', form)
}
setShowForm(false)
setSuccess(editId ? 'Arbeitsplan aktualisiert.' : 'Arbeitsplan erstellt.')
await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler')
} finally {
setSaving(false)
}
}
const del = async (id: string) => {
if (!confirm('Arbeitsplan wirklich löschen?')) return
setError('')
try {
await api.del(`/time/schedules/${id}`)
setSuccess('Arbeitsplan gelöscht.')
await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler')
}
}
const assignSchedule = async (userId: string, scheduleId: string | null) => {
setAssigning(true)
try {
await api.patch(`/users/${userId}`, { work_schedule_id: scheduleId })
await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler bei Zuweisung')
} finally {
setAssigning(false)
}
}
const totalH = (s: typeof form) => [s.mon_h, s.tue_h, s.wed_h, s.thu_h, s.fri_h, s.sat_h, s.sun_h].reduce((a, b) => a + Number(b), 0)
const inputCls = 'border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
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'>
<div className='flex items-center justify-between'>
<h1 className='text-2xl font-bold text-gray-900'>Arbeitspläne</h1>
<button onClick={openCreate} className='px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700'>
+ Neuer Arbeitsplan
</button>
</div>
{error && <div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{error}</div>}
{success && <div className='bg-green-50 border border-green-200 rounded-lg p-3 text-sm text-green-700'>{success}</div>}
{/* Schedule list */}
<div className='grid gap-4'>
{schedules.length === 0 && (
<div className='bg-white rounded-xl border border-gray-200 p-8 text-center text-gray-400 text-sm'>
Noch keine Arbeitspläne. Klicke auf + Neuer Arbeitsplan".
</div>
)}
{schedules.map(s => {
const weekly = [s.mon_h, s.tue_h, s.wed_h, s.thu_h, s.fri_h, s.sat_h, s.sun_h].reduce((a, b) => a + Number(b), 0)
const assigned = users.filter(u => (u as UserOut & { work_schedule_id?: string }).work_schedule_id === s.id)
return (
<div key={s.id} className='bg-white rounded-xl shadow-sm border border-gray-200 p-5'>
<div className='flex items-start justify-between gap-4'>
<div>
<h3 className='font-semibold text-gray-800'>{s.name}</h3>
<p className='text-xs text-gray-500 mt-0.5'>Gültig ab {new Date(s.valid_from).toLocaleDateString('de-DE')} · {weekly}h/Woche</p>
</div>
<div className='flex gap-2'>
<button onClick={() => setAssignModal(s.id)} className='text-xs px-3 py-1.5 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'>
Zuweisen
</button>
<button onClick={() => openEdit(s)} className='text-xs px-3 py-1.5 border border-blue-300 rounded-lg text-blue-600 hover:bg-blue-50'>
Bearbeiten
</button>
<button onClick={() => del(s.id)} className='text-xs px-3 py-1.5 border border-red-300 rounded-lg text-red-600 hover:bg-red-50'>
Löschen
</button>
</div>
</div>
{/* Day grid */}
<div className='flex gap-2 mt-4'>
{DAYS.map(d => (
<div key={d.key} className={`flex-1 text-center rounded-lg py-2 ${Number(s[d.key]) > 0 ? 'bg-blue-50' : 'bg-gray-50'}`}>
<p className='text-xs text-gray-500'>{d.label}</p>
<p className={`text-sm font-bold ${Number(s[d.key]) > 0 ? 'text-blue-700' : 'text-gray-300'}`}>{Number(s[d.key])}h</p>
</div>
))}
</div>
{/* Assigned users */}
{assigned.length > 0 && (
<p className='text-xs text-gray-400 mt-3'>
Zugewiesen: {assigned.map(u => `${u.first_name} ${u.last_name}`).join(', ')}
</p>
)}
</div>
)
})}
</div>
{/* Users without schedule */}
{(() => {
const unassigned = users.filter(u => !(u as UserOut & { work_schedule_id?: string }).work_schedule_id)
if (unassigned.length === 0) return null
return (
<div className='bg-yellow-50 border border-yellow-200 rounded-xl p-4'>
<p className='text-sm font-medium text-yellow-800 mb-2'>Mitarbeiter ohne Arbeitsplan ({unassigned.length})</p>
<div className='flex flex-wrap gap-2'>
{unassigned.map(u => (
<span key={u.id} className='text-xs bg-white border border-yellow-200 rounded-full px-3 py-1 text-yellow-700'>
{u.first_name} {u.last_name}
</span>
))}
</div>
</div>
)
})()}
</div>
{/* Create/Edit Modal */}
{showForm && (
<div className='fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50'>
<div className='bg-white rounded-xl shadow-xl w-full max-w-lg'>
<div className='px-6 py-4 border-b border-gray-200'>
<h2 className='text-lg font-semibold text-gray-800'>{editId ? 'Arbeitsplan bearbeiten' : 'Neuer Arbeitsplan'}</h2>
</div>
<div className='px-6 py-4 space-y-4'>
{error && <p className='text-sm text-red-600'>{error}</p>}
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Name *</label>
<input type='text' value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder='z. B. Vollzeit (40h)' className={`w-full ${inputCls}`} />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-2'>Stunden pro Tag</label>
<div className='grid grid-cols-7 gap-2'>
{DAYS.map(d => (
<div key={d.key} className='text-center'>
<p className='text-xs text-gray-500 mb-1'>{d.label}</p>
<input
type='number' min={0} max={24} step={0.5}
value={form[d.key]}
onChange={e => setForm(f => ({ ...f, [d.key]: Number(e.target.value) }))}
className='w-full border border-gray-300 rounded text-sm text-center py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500'
/>
</div>
))}
</div>
<p className='text-xs text-gray-400 mt-2 text-right'>Gesamt: {totalH(form)}h/Woche</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Gültig ab</label>
<input type='date' value={form.valid_from} onChange={e => setForm(f => ({ ...f, valid_from: e.target.value }))}
className={`w-full ${inputCls}`} />
</div>
</div>
<div className='px-6 py-4 border-t border-gray-200 flex justify-end gap-2'>
<button onClick={() => setShowForm(false)} className='px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50'>
Abbrechen
</button>
<button onClick={save} disabled={saving || !form.name}
className='px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'>
{saving ? 'Speichern…' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
{/* Assign Modal */}
{assignModal && (
<div className='fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50'>
<div className='bg-white rounded-xl shadow-xl w-full max-w-sm'>
<div className='px-6 py-4 border-b border-gray-200 flex items-center justify-between'>
<h2 className='text-lg font-semibold text-gray-800'>Mitarbeiter zuweisen</h2>
<button onClick={() => setAssignModal(null)} className='text-gray-400 hover:text-gray-600 text-xl leading-none'>&times;</button>
</div>
<div className='px-6 py-4 max-h-80 overflow-y-auto divide-y divide-gray-50'>
{users.map(u => {
const userWithSchedule = u as UserOut & { work_schedule_id?: string }
const isAssigned = userWithSchedule.work_schedule_id === assignModal
return (
<div key={u.id} className='flex items-center justify-between py-2.5'>
<div>
<p className='text-sm font-medium text-gray-800'>{u.first_name} {u.last_name}</p>
<p className='text-xs text-gray-400'>{u.email}</p>
</div>
<button
onClick={() => assignSchedule(u.id, isAssigned ? null : assignModal)}
disabled={assigning}
className={`text-xs px-3 py-1.5 rounded-lg font-medium transition-colors ${
isAssigned
? 'bg-green-100 text-green-700 hover:bg-red-100 hover:text-red-700'
: 'bg-gray-100 text-gray-600 hover:bg-blue-100 hover:text-blue-700'
}`}
>
{isAssigned ? 'Zugewiesen ✓' : 'Zuweisen'}
</button>
</div>
)
})}
</div>
<div className='px-6 py-4 border-t border-gray-200 flex justify-end'>
<button onClick={() => setAssignModal(null)} className='px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700'>
Fertig
</button>
</div>
</div>
</div>
)}
</Layout>
)
}