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
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
import { useState, useCallback, useEffect } from 'react'
import { api } from '../api/client'
import type {
UserOut,
AbsenceTypeOut,
AbsenceOut,
AbsenceListResponse,
UserListItem,
VacationBalanceOut,
OvertimeBalanceOut,
} from '../types/absence'
import { MANAGER_ROLES } from '../utils/calendar'
export function useAbsences(year: number, statusFilter: string) {
const [user, setUser] = useState<UserOut | null>(null)
const [types, setTypes] = useState<AbsenceTypeOut[]>([])
const [absences, setAbsences] = useState<AbsenceOut[]>([])
const [total, setTotal] = useState(0)
const [balance, setBalance] = useState<VacationBalanceOut | null>(null)
const [overtimeBalance, setOvertimeBalance] = useState<OvertimeBalanceOut | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [colleagues, setColleagues] = useState<UserListItem[]>([])
const colleagueMap = Object.fromEntries(colleagues.map(c => [c.id, c.full_name]))
const load = useCallback(async () => {
setLoading(true)
try {
const params = statusFilter ? `?status=${statusFilter}&year=${year}` : `?year=${year}`
const [me, typeList, absList, bal, otBal] = await Promise.all([
api.get<UserOut>('/auth/me'),
api.get<AbsenceTypeOut[]>('/absence-types/'),
api.get<AbsenceListResponse>(`/absences/${params}`),
api.get<VacationBalanceOut>(`/absences/balance?year=${year}`),
api.get<OvertimeBalanceOut>('/absences/overtime-balance'),
])
setUser(me)
setTypes(typeList.filter(t => t.is_active))
setAbsences(absList.items)
setTotal(absList.total)
setBalance(bal)
setOvertimeBalance(otBal)
if (MANAGER_ROLES.includes(me.role) && colleagues.length === 0) {
try {
const res = await api.get<{ items: UserListItem[] }>('/users/?limit=500')
setColleagues(res.items)
} catch { /* ignore */ }
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [statusFilter, year]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { load() }, [load])
const createAbsence = async (
form: { type_id: string; start_date: string; end_date: string; half_day_start: boolean; half_day_end: boolean; note: string; for_user_id: string },
onSuccess: () => void,
setSubmitting: (v: boolean) => void,
) => {
if (!form.type_id || !form.start_date || !form.end_date) {
setError('Bitte alle Pflichtfelder ausfüllen')
return
}
setSubmitting(true)
setError('')
try {
await api.post('/absences/', {
type_id: form.type_id,
start_date: form.start_date,
end_date: form.end_date,
half_day_start: form.half_day_start,
half_day_end: form.half_day_end,
note: form.note || null,
for_user_id: form.for_user_id || null,
})
onSuccess()
await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Erstellen')
} finally {
setSubmitting(false)
}
}
const approve = async (id: string) => {
setError('')
try { await api.post(`/absences/${id}/approve`, {}); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
}
const reject = async (id: string, rejectReason: string, onSuccess: () => void) => {
if (!rejectReason.trim()) return
setError('')
try {
await api.post(`/absences/${id}/reject`, { rejection_reason: rejectReason })
onSuccess()
await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler')
}
}
const saveEdit = async (
editAbsence: AbsenceOut,
editForm: { type_id: string; start_date: string; end_date: string; half_day_start: boolean; half_day_end: boolean; note: string; correction_note: string },
isManager: boolean,
onSuccess: () => void,
setSubmitting: (v: boolean) => void,
) => {
if (!editForm.type_id || !editForm.start_date || !editForm.end_date) {
setError('Bitte alle Pflichtfelder ausfüllen')
return
}
if (editAbsence.status === 'approved' && !isManager && !editForm.correction_note.trim()) {
setError('Änderungsgrund ist Pflicht bei genehmigten Anträgen.')
return
}
setSubmitting(true)
setError('')
try {
await api.patch(`/absences/${editAbsence.id}`, {
type_id: editForm.type_id,
start_date: editForm.start_date,
end_date: editForm.end_date,
half_day_start: editForm.half_day_start,
half_day_end: editForm.half_day_end,
note: editForm.note || null,
correction_note: editForm.correction_note.trim() || null,
})
onSuccess()
await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
} finally {
setSubmitting(false)
}
}
const cancel = async (id: string) => {
setError('')
try { await api.del(`/absences/${id}`); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
}
const loadColleaguesIfNeeded = async () => {
if (colleagues.length === 0) {
try {
const res = await api.get<{ items: UserListItem[] }>('/users/?limit=500')
setColleagues(res.items)
} catch { /* ignore */ }
}
}
const typeName = (typeId: string) => types.find(t => t.id === typeId)?.name ?? typeId
const typeColor = (typeId: string) => types.find(t => t.id === typeId)?.color ?? '#6B7280'
const updateBalance = (updated: VacationBalanceOut) => setBalance(updated)
return {
user,
types,
absences,
total,
balance,
overtimeBalance,
loading,
error,
setError,
colleagues,
colleagueMap,
load,
createAbsence,
approve,
reject,
saveEdit,
cancel,
loadColleaguesIfNeeded,
typeName,
typeColor,
updateBalance,
}
}
+50
View File
@@ -0,0 +1,50 @@
import { useState, useCallback, useEffect } from 'react'
import { api } from '../api/client'
import type { UserOut, UserListItem, VacationBalanceOut } from '../types/absence'
import { MANAGER_ROLES } from '../utils/calendar'
export function usePlanerView(user: UserOut | null, colleagues: UserListItem[], year: number) {
const [viewMode, setViewMode] = useState<'liste' | 'planer'>('liste')
const [planMonth, setPlanMonth] = useState(new Date().getMonth())
const [colleagueBalances, setColleagueBalances] = useState<Record<string, VacationBalanceOut>>({})
const [balancesLoaded, setBalancesLoaded] = useState(false)
const [showYearGrid, setShowYearGrid] = useState<boolean>(() => {
try { return localStorage.getItem('planer_showYearGrid') !== 'false' }
catch { return true }
})
useEffect(() => {
try { localStorage.setItem('planer_showYearGrid', String(showYearGrid)) }
catch { /* ignore */ }
}, [showYearGrid])
const loadColleagueBalances = useCallback(async (list: UserListItem[]) => {
if (list.length === 0 || balancesLoaded) return
try {
const results = await Promise.allSettled(
list.map(c => api.get<VacationBalanceOut>(`/absences/balance/${c.id}?year=${year}`))
)
const map: Record<string, VacationBalanceOut> = {}
results.forEach((r, i) => { if (r.status === 'fulfilled') map[list[i].id] = r.value })
setColleagueBalances(map)
setBalancesLoaded(true)
} catch { /* ignore */ }
}, [year, balancesLoaded])
useEffect(() => {
if (viewMode === 'planer' && user && MANAGER_ROLES.includes(user.role) && colleagues.length > 0 && !balancesLoaded) {
loadColleagueBalances(colleagues)
}
}, [viewMode, user, colleagues, balancesLoaded, loadColleagueBalances])
return {
viewMode,
setViewMode,
planMonth,
setPlanMonth,
colleagueBalances,
balancesLoaded,
showYearGrid,
setShowYearGrid,
}
}