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