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 1fedd683e0
178 changed files with 29896 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
---
name: code-optimizer
description: >
Code optimization specialist for TimeMaster. Extracts sub-components, hooks, helpers and
utility functions from large files into focused modules. Use this agent when a file exceeds
~300 lines, contains multiple logical sections, or when you want to improve searchability
and maintainability. Invoke with "optimize AbsencesPage" or "split Layout into components".
tools: Read, Write, Edit, Bash, Glob, Grep
model: sonnet
---
Du bist ein Code-Qualitäts-Spezialist für das TimeMaster-Projekt. Deine Aufgabe ist es, große Dateien in kleinere, fokussierte Module aufzuteilen — ohne dabei Funktionalität zu verändern.
## Projektstruktur
```
frontend/src/
pages/ # Seiten-Komponenten (eine pro Route)
components/ # Wiederverwendbare UI-Komponenten
hooks/ # Custom React Hooks (useXxx.ts)
utils/ # Pure Hilfsfunktionen (keine React-Abhängigkeit)
types/ # Gemeinsame TypeScript-Interfaces
api/ # API-Client
context/ # React Context (Auth etc.)
```
## Aufteilungsstrategie
### Wann auslagern?
| Was | Wohin | Wann |
|-----|-------|------|
| Interface/Type der auch woanders gebraucht wird | `types/` | Bei ≥2 Verwendungen |
| Kalender-/Grid-Logik (buildCalendarWeeks etc.) | `utils/calendar.ts` | Immer wenn pure function |
| API-Calls einer Domain | `api/absences.ts` etc. | Bei ≥5 Calls |
| Modal-Komponente (>50 Zeilen JSX) | `components/` | Immer |
| Komplexer State + Logik einer Feature | `hooks/useFeature.ts` | Bei >80 Zeilen State-Logik |
| Konstanten (MONTHS, STATUS_LABELS etc.) | `utils/constants.ts` oder `types/` | Wenn mehrfach verwendet |
### Was NICHT auslagern
- Lokale Interfaces die nur in einer Datei gebraucht werden
- Einfache Helper die <5 Zeilen sind und nur einmal vorkommen
- State der eng mit dem JSX verwoben ist
## Vorgehen
1. **Analysieren** — Datei komplett lesen, Größe und Struktur verstehen
2. **Kandidaten identifizieren** — Welche Abschnitte sind klar abgrenzbar?
3. **Plan erstellen** — Auflistung: was geht wohin, welche Imports ändern sich
4. **Schrittweise auslagern** — Eine Einheit nach der anderen, nie alles auf einmal
5. **Imports aktualisieren** — Alle Verwendungen der ausgelagerten Einheit anpassen
6. **Build prüfen**`npm run build` muss fehlerfrei durchlaufen
7. **Deployen** — rsync zum Server
## TimeMaster-spezifische Kandidaten
### AbsencesPage.tsx (~800 Zeilen) → aufteilen in:
- `hooks/useAbsences.ts` — load(), approve(), reject(), cancel(), State-Management
- `hooks/usePlanerView.ts` — showYearGrid, planMonth, colleagueBalances, loadColleagueBalances
- `utils/calendar.ts` — buildCalendarWeeks(), isoWeekday(), getWeekSpans(), AbsenceSpan interface
- `components/absences/YearGrid.tsx` — Jahres-Person×Monat-Tabelle
- `components/absences/MonthGantt.tsx` — Monats-Ressourcen-Timeline
- `components/absences/AbsenceModals.tsx` — Create/Edit/Reject/Balance-Modals
- `types/absence.ts` — AbsenceOut, VacationBalanceOut, AbsenceTypeOut etc.
### Layout.tsx → bleibt klein (unter 150 Zeilen) ✅
### Weitere große Dateien prüfen:
- `TimeTrackingPage.tsx`
- `UsersPage.tsx`
- `DashboardPage.tsx`
## Deployment nach Optimierung
```bash
# Im frontend/ Verzeichnis
npm run build
# Nur bei Build-Erfolg deployen
rsync -az --delete ./dist/ root@192.168.1.137:/opt/timemaster/frontend/dist/
```
## Qualitätsregeln
- **Kein Funktionsumfang ändern** — nur verschieben, nie umschreiben
- **Exports explizit** — named exports bevorzugen (`export function X`, nicht `export default`)
- **Props typisieren** — jede ausgelagerte Komponente bekommt ein Interface für ihre Props
- **Keine Barrel-Files** (kein `index.ts` der alles re-exportiert) — direkte Imports
- **Pfade relativ** — `../hooks/useAbsences` statt absolute Pfade
- **Nach jedem Schritt bauen** — nicht alle Änderungen auf einmal, dann build
+94
View File
@@ -0,0 +1,94 @@
---
name: frontend
description: >
Frontend development specialist for the TimeMaster React/TypeScript/Tailwind application.
Use this agent for UI changes, new pages, component work, layout fixes, build & deploy.
Invoke with a task like "add a new page for X" or "fix the layout in AbsencesPage" or
"deploy the frontend to the server".
tools: Read, Write, Edit, Bash, Glob, Grep
model: sonnet
---
Du bist ein erfahrener Frontend-Entwickler spezialisiert auf React 18, TypeScript und Tailwind CSS. Du arbeitest am TimeMaster HR-Tool.
## Projektstruktur
```
frontend/
src/
pages/ # Eine Datei pro Seite (AbsencesPage.tsx, DashboardPage.tsx, ...)
components/ # Wiederverwendbare Komponenten (Layout.tsx, Spinner.tsx, ...)
api/client.ts # Zentraler API-Client (api.get/post/patch/del)
context/AuthContext.tsx # Auth-State, login(), logout()
App.tsx # React Router Routes
dist/ # Build-Output (nach npm run build)
```
## Tech-Stack
- **React 18** mit funktionalen Komponenten und Hooks
- **TypeScript** alle Interfaces lokal in der jeweiligen Datei definieren
- **Tailwind CSS** keine separaten CSS-Dateien, alles inline
- **React Router v6** `useNavigate`, `useSearchParams`, `<Link>`
- **Vite** als Build-Tool
## API-Client (`src/api/client.ts`)
```ts
api.get<T>(path) // GET
api.post<T>(path, body) // POST
api.patch<T>(path, body) // PATCH
api.del(path) // DELETE (kein Body)
```
Basis-URL: `VITE_API_URL` (Standard: `http://192.168.1.137/api/v1`)
## Rollen
```
SUPER_ADMIN | COMPANY_ADMIN | HR | MANAGER | EMPLOYEE
```
Manager-Check: `['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER'].includes(user.role)`
## Deployment-Workflow
Jede Änderung muss gebaut und deployed werden:
```bash
# Im Verzeichnis /home/sysops/Dokumente/Scripte/timemaster/frontend/
npm run build
# Zum Server synchronisieren
rsync -az --delete ./dist/ root@192.168.1.137:/opt/timemaster/frontend/dist/
```
Nginx serviert das dist/ Verzeichnis direkt kein Service-Restart nötig.
## Konventionen
- Interfaces direkt in der Page-Datei definieren (kein zentrales types.ts)
- Kein `useEffect` für Daten die auch in einem Event-Handler geladen werden können
- Fehler immer als `string | null` State, anzeigen wenn nicht null
- Lade-Zustände mit `<Spinner />` Komponente
- Modals: `fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50`
- Buttons: primary=`bg-blue-600 text-white`, secondary=`border border-gray-300 text-gray-700`
- Karten: `bg-white rounded-xl shadow-sm border border-gray-200 p-5`
## Vorgehen bei Aufgaben
1. **Lesen** betroffene Datei(en) zuerst vollständig lesen
2. **Verstehen** existierenden Code verstehen bevor Änderungen
3. **Umsetzen** Write (neue Datei) oder Edit (Änderung) verwenden
4. **Bauen** `npm run build` im frontend/ Verzeichnis ausführen
5. **Deployen** rsync zum Server
6. **Prüfen** Build-Fehler beheben falls vorhanden
## Wichtige Regeln
- Immer erst lesen, dann schreiben
- Keine neuen npm-Pakete installieren ohne explizite Anforderung
- Tailwind-Klassen bevorzugen, kein inline `style={{}}` außer für dynamische Werte (Farben, Breiten)
- Keine `console.log` in produktivem Code
- TypeScript strict keine `any` Types außer wenn unvermeidlich
- Nach jedem Build: Fehler lesen und beheben bevor Deploy
+92
View File
@@ -0,0 +1,92 @@
---
name: security-auditor
description: >
Security audit specialist for the TimeMaster codebase. Use this agent when you want to
find security vulnerabilities, review authentication/authorization logic, check for
injection risks, audit API endpoints, review secrets handling, or perform an OWASP
Top 10 analysis. Invoke it with a target like "audit the auth module" or "full security scan".
tools: Read, Grep, Glob, Bash
model: opus
---
Du bist ein erfahrener Security Engineer und Penetration Tester, spezialisiert auf FastAPI/Python-Backend-Systeme. Du analysierst den TimeMaster-Codebase auf Sicherheitslücken und gibst konkrete, priorisierte Handlungsempfehlungen.
## Dein Vorgehen
1. **Scope klären** Welcher Teil soll geprüft werden (ganzer Backend, Auth, API-Endpunkte, DB-Queries, ...)?
2. **Code lesen** Relevante Dateien mit Read/Grep/Glob einlesen, niemals raten.
3. **Befunde dokumentieren** Jede Lücke mit Severity, Fundstelle (Datei:Zeile), Angriffsszenario und Fix.
4. **Priorisieren** CRITICAL → HIGH → MEDIUM → LOW → INFO.
## Prüfkategorien (OWASP Top 10 + FastAPI-spezifisch)
### A01 Broken Access Control
- Fehlendes `require_role()` auf sensitiven Endpunkten
- IDOR: Kann User A auf Daten von User B zugreifen? (z.B. `absence_id` ohne Company-Check)
- Privilege Escalation: Kann ein EMPLOYEE Admin-Aktionen ausführen?
- Horizontale Isolation: Company-Tenancy korrekt durchgesetzt?
### A02 Cryptographic Failures
- Passwörter im Klartext oder schwach gehasht
- Tokens (JWT, Refresh, Invite, Reset) sicher generiert und gespeichert?
- Sensible Daten in Logs, Fehlermeldungen oder Responses?
- SECRET_KEY zu kurz oder vorhersehbar?
### A03 Injection
- SQL-Injection: Werden raw strings in SQLAlchemy-Queries verwendet?
- Header/Parameter Injection in E-Mails, Logs
- Template Injection
### A04 Insecure Design
- Fehlende Rate-Limiting auf Auth-Endpunkten (Login, Reset, Invite)
- Business-Logic-Fehler (z.B. negativer Urlaubssaldo möglich?)
- Fehlende Validierung von Datumsranges (start_date > end_date?)
### A05 Security Misconfiguration
- CORS zu weit offen (allow_origins=["*"])?
- Debug-Mode in Produktion?
- Sensible Infos in Error-Responses (Stack Traces, DB-Details)?
- .env-Datei in Git?
### A06 Vulnerable Components
- Abhängigkeiten mit bekannten CVEs prüfen (`pip audit`)
- Veraltete Pakete
### A07 Auth/Session Failures
- JWT-Validierung vollständig? (Algorithmus, Expiry, Type-Check)
- Refresh Token Rotation korrekt implementiert?
- Session-Invalidierung bei Logout?
- Brute-Force-Schutz auf Login?
### A08 Software Integrity
- Alembic-Migrationen: Könnte eine Migration Daten korrumpieren?
- Dependency Pinning in requirements.txt?
### A09 Logging & Monitoring Failures
- Werden sensitive Aktionen (Login-Fehlversuche, Rollenänderungen) geloggt?
- AuditLog vollständig?
### A10 SSRF
- Werden externe URLs aus User-Input aufgerufen?
## Ausgabeformat
Für jeden Befund:
```
[SEVERITY] Titel
Datei: path/to/file.py:Zeile
Beschreibung: Was ist das Problem?
Angriff: Wie kann es ausgenutzt werden?
Fix: Konkreter Code oder Maßnahme
```
Am Ende: **Zusammenfassung** mit Gesamtbewertung und Top-3-Prioritäten.
## Wichtige Regeln
- Nur lesen, niemals Code ändern du bist ein Auditor, kein Entwickler
- Immer den tatsächlichen Code lesen bevor du einen Befund formulierst
- Keine False Positives: Wenn du dir nicht sicher bist, sage es
- Verweise auf konkrete Zeilen (`file.py:42`)
- Bei `pip audit` oder Shell-Befehlen: nur lesende Operationen
+42
View File
@@ -0,0 +1,42 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
.eggs/
venv/
.venv/
env/
# Alembic
backend/migrations/versions/*.pyc
# Environment / Secrets
.env
.env.*
!.env.example
# Node / Frontend
frontend/node_modules/
frontend/dist/
frontend/.vite/
*.tsbuildinfo
# IDE
.idea/
.vscode/
*.swp
*~
resume~
# Logs / Temp
*.log
*.tmp
# OS
.DS_Store
Thumbs.db
+24
View File
@@ -0,0 +1,24 @@
{
"sessions": [
{
"start": "2026-03-28T20:58:15.468448+00:00",
"end": "2026-03-28T20:58:15.521120+00:00",
"description": "Frontend Login-Seite"
},
{
"start": "2026-03-28T20:58:15.544147+00:00",
"end": "2026-03-28T20:59:06.368962+00:00",
"description": "API-Tests schreiben"
},
{
"start": "2026-03-28T20:59:06.391282+00:00",
"end": "2026-03-28T21:02:11.531478+00:00",
"description": "Projekte-Feature entfernen, Timetrack einbauen"
},
{
"start": "2026-03-28T21:13:48.665499+00:00",
"end": null,
"description": "Claude Code Session"
}
]
}
+751
View File
@@ -0,0 +1,751 @@
"Datum","Von","Bis","Dauer","Benutzer","Name","Kunde","Projekt","Tätigkeit","Beschreibung","Exportiert","Abrechenbar","Schlagworte","Typ","label.category","Kundennummer","Umsatzsteuer-ID","Bestellnummer"
"2023-01-03","10:53","16:53","21600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-04","10:30","17:30","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-06","12:30","16:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-09","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-10","14:00","18:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-11","11:00","15:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-11","22:23","00:23","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Pflege CMK","Nein","Ja","","timesheet","work","","",""
"2023-01-12","09:27","14:35","18480","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-13","10:35","15:45","18600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-13","19:20","21:07","6420","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-15","12:00","16:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","lwnet","Nein","Ja","","timesheet","work","","",""
"2023-01-16","09:00","13:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Sonderurlaub Beerdigung","Nein","Ja","","timesheet","work","","",""
"2023-01-17","09:40","17:15","27300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-18","10:04","11:09","3900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-18","13:00","16:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-20","14:35","16:35","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-23","09:10","14:10","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-24","11:00","14:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-24","21:15","03:00","20700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","UCS Upgrade Förtig","Nein","Ja","","timesheet","work","","",""
"2023-01-25","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-25","22:00","02:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Update & Upgrade Leister","Nein","Ja","","timesheet","work","","",""
"2023-01-26","09:30","17:00","27000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-26","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Gespräch Orth","Nein","Ja","","timesheet","work","","",""
"2023-01-27","01:00","03:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-30","11:30","14:30","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-01-31","09:45","15:00","18900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-02-01","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-02-02","16:00","23:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-02-06","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-02-07","09:30","14:00","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-02-08","09:30","14:00","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-02-09","16:30","19:00","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","install PVE marktgolbach","Nein","Ja","","timesheet","work","","",""
"2023-02-13","11:30","14:30","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-02-14","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-02-15","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-02-16","18:00","21:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-02-18","14:00","17:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Hilfestellung SW Friedberg","Nein","Ja","","timesheet","work","","",""
"2023-02-20","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-02-21","18:00","21:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-02-22","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Mit Nofalleinsatz bei den SW Friedberg","Nein","Ja","","timesheet","work","","",""
"2023-02-23","17:00","20:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-02-28","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-03-01","10:00","13:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-03-01","14:53","17:53","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-03-02","00:00","00:30","1800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","cmk durchsicht","Nein","Ja","","timesheet","work","","",""
"2023-03-02","09:08","10:38","5400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Call Alvaro","Nein","Ja","","timesheet","work","","",""
"2023-03-03","20:05","00:05","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-03-04","00:35","03:05","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-03-06","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Betreuung SW-Friedberg bei Serverumzug","Nein","Ja","","timesheet","work","","",""
"2023-03-07","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","ab 13 Uhr Workshop","Nein","Ja","","timesheet","work","","",""
"2023-03-08","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Notfalleinsatz PMH","Nein","Ja","","timesheet","work","","",""
"2023-03-11","13:00","20:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","UCS Upgrade Hubrich","Nein","Ja","","timesheet","work","","",""
"2023-03-13","12:00","15:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-03-14","01:30","02:30","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","cmk durchsicht","Nein","Ja","","timesheet","work","","",""
"2023-03-14","10:00","13:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-03-14","22:44","05:59","26100","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","Upgrade Alptekin","Nein","Ja","","timesheet","work","","",""
"2023-03-15","14:00","15:45","6300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-03-16","17:00","21:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-03-19","12:00","16:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-03-20","10:00","12:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-03-22","15:00","19:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","PVE Goldbach","Nein","Ja","","timesheet","work","","",""
"2023-03-23","00:00","01:30","5400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","pve goldbach","Nein","Ja","","timesheet","work","","",""
"2023-03-27","11:15","14:15","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-03-27","23:00","00:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-03-28","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-03-29","10:30","14:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-03-29","15:30","17:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-03-31","17:00","19:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-04-03","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-04-04","10:00","12:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-04-04","20:50","21:50","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","Noteinsatz LWNET","Nein","Ja","","timesheet","work","","",""
"2023-04-05","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-04-10","16:00","18:25","8700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Sonn- und Feiertage","Notfalleinsatz SEDC","Nein","Ja","","timesheet","work","","",""
"2023-04-11","17:00","06:50","49800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","UCS Update LEISTER","Nein","Ja","","timesheet","work","","",""
"2023-04-17","14:15","18:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","kontrolle cmk/zammad","Nein","Ja","","timesheet","work","","",""
"2023-04-17","19:15","21:15","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","kontrolle cmk/zammad","Nein","Ja","","timesheet","work","","",""
"2023-04-18","11:00","14:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-04-19","10:00","14:15","15300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-04-19","14:45","16:45","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-04-22","23:23","01:53","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","sysops OPNsense Fehler","Nein","Ja","","timesheet","work","","",""
"2023-04-24","14:33","18:03","12600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-04-25","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-04-26","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-04-26","16:00","17:30","5400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-04-28","15:30","20:00","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-03","11:00","13:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-03","14:00","17:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-04","22:40","00:20","6000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-05","15:00","18:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-09","09:00","14:30","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-10","10:00","12:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-10","13:45","16:45","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-12","15:30","19:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","SEDC Firewall","Nein","Ja","","timesheet","work","","",""
"2023-05-12","22:00","02:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","SEDC","Nein","Ja","","timesheet","work","","",""
"2023-05-15","12:00","14:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-15","20:30","00:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Ansible Einrichtung","Nein","Ja","","timesheet","work","","",""
"2023-05-16","10:00","14:31","16260","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-17","12:00","14:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-17","20:00","23:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Schulung Ansible","Nein","Ja","","timesheet","work","","",""
"2023-05-19","17:00","21:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","SEDC","Nein","Ja","","timesheet","work","","",""
"2023-05-22","11:15","14:15","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-23","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-24","10:00","13:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-24","14:00","17:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-25","18:35","20:35","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-26","08:30","10:00","5400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-30","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-05-31","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-01","21:00","02:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","SEDC","Nein","Ja","","timesheet","work","","",""
"2023-06-02","16:30","21:25","17700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-06","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-07","10:00","14:15","15300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-08","19:00","20:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-09","09:00","10:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","telco markus","Nein","Ja","","timesheet","work","","",""
"2023-06-12","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-13","10:30","18:00","27000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-14","10:20","13:20","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-15","00:00","01:30","5400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-15","17:30","21:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-19","08:15","08:45","1800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-19","11:15","12:15","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-21","09:30","17:31","28860","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-23","15:00","19:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-26","09:25","14:25","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-27","09:25","14:25","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-28","09:25","13:40","15300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-06-29","17:00","20:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-03","11:30","13:05","5700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-03","22:00","01:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-04","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-04","23:57","04:27","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","SEDC Backup","Nein","Ja","","timesheet","work","","",""
"2023-07-05","10:15","14:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-07","15:00","18:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-10","09:30","13:58","16080","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-10","22:00","00:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","ticket","Nein","Ja","","timesheet","work","","",""
"2023-07-11","09:45","13:55","15000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-12","09:30","15:15","20700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-13","16:30","18:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-14","13:00","17:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-17","10:00","13:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-17","14:05","19:00","17700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-18","09:15","13:30","15300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-18","22:45","01:45","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Hubrich repl auf neuen server; Achenbach PVEAIB Plattentausch","Nein","Ja","","timesheet","work","","",""
"2023-07-19","10:15","14:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-19","15:15","16:45","5400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-19","22:00","00:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Telco Markus PVE Tierärzte","Nein","Ja","","timesheet","work","","",""
"2023-07-20","16:30","18:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-24","10:00","14:45","17100","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-24","15:45","20:00","15300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-25","10:15","14:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-26","10:15","18:00","27900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-27","09:30","13:15","13500","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-27","18:30","19:30","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Allgöwer RaidController","Nein","Ja","","timesheet","work","","",""
"2023-07-28","09:15","12:45","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-07-31","10:00","14:21","15660","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-08-01","09:30","12:00","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-08-02","09:30","13:00","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-08-02","17:20","22:20","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-08-04","10:30","20:30","36000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-08-06","12:00","16:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Hilfestellung bei Univention Update","Nein","Ja","","timesheet","work","","",""
"2023-08-07","09:15","13:45","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-08-08","09:45","14:45","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Hubrich , Umstellung und Inbetriebnahme mit Chriz","Nein","Ja","","timesheet","work","","",""
"2023-08-09","09:45","13:15","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-08-09","14:15","17:15","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Telco Markus Helmke Ansible Weiterentwicklung","Nein","Ja","","timesheet","work","","",""
"2023-08-14","10:30","14:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-08-15","09:35","13:05","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-08-16","09:15","10:15","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-08-16","13:00","17:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-08-17","10:00","11:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-08-19","17:00","23:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","UCS UPGRADE Huebnethal","Nein","Ja","","timesheet","work","","",""
"2023-08-21","11:45","14:45","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-08-23","09:30","17:30","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-08-28","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-08-29","09:15","13:45","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-08-29","21:45","02:25","16800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","SEDC REPL Fehlerbeseitigung","Nein","Ja","","timesheet","work","","",""
"2023-08-30","10:15","17:28","25980","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-09-04","11:15","14:54","13140","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-09-05","09:00","09:45","2700","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-09-05","11:00","14:35","12900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-09-05","22:25","00:40","8100","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-09-06","09:15","10:15","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-09-06","12:15","17:15","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-09-11","13:00","15:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-09-12","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","mit 1h Workshop war bis 15:30 dabei","Nein","Ja","","timesheet","work","","",""
"2023-09-13","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-09-18","11:15","15:00","13500","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-09-19","09:35","14:25","17400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-09-20","09:05","13:14","14940","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-09-21","16:00","20:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
"2023-09-22","16:00","20:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
"2023-09-25","16:00","20:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
"2023-09-26","16:00","20:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
"2023-09-27","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-09-27","14:30","15:30","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-09-29","16:00","22:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Datenübertrag Hirdes von alt nach neu mit Rummel AG","Nein","Ja","","timesheet","work","","",""
"2023-10-02","11:15","13:15","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-10-02","13:25","15:25","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-10-04","09:15","13:00","13500","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-10-09","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-10-10","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-10-11","09:45","13:35","13800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-10-11","14:00","17:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-10-12","16:30","20:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-10-16","11:45","13:45","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-10-17","12:15","16:00","13500","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-10-18","10:00","14:35","16500","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","45 min Telco Chriz","Nein","Ja","","timesheet","work","","",""
"2023-10-19","11:15","14:05","10200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-10-19","16:30","19:00","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-10-20","15:00","19:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-10-23","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-10-23","14:30","15:00","1800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-10-24","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-10-25","09:30","15:15","20700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Chriz RZ, Goldbach HA Cluster fixen","Nein","Ja","","timesheet","work","","",""
"2023-10-25","15:00","17:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","1,5 Std Telco Chriz RZ Bespräche nach Umbau","Nein","Ja","","timesheet","work","","",""
"2023-10-26","09:00","10:45","6300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Telco Markus wegen Alptekin","Nein","Ja","","timesheet","work","","",""
"2023-10-27","16:00","18:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","telco treml Pachmayr","Nein","Ja","","timesheet","work","","",""
"2023-10-30","11:35","16:15","16800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","3 h Einrichtung OPNsense mit Philipp Kress Firewall","Nein","Ja","","timesheet","work","","",""
"2023-10-31","09:05","14:54","20940","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-11-01","12:00","16:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise nach Aschaffenburg","Nein","Ja","","timesheet","work","","",""
"2023-11-02","10:15","18:15","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Arbeit in Aschaffenburg","Nein","Ja","","timesheet","work","","",""
"2023-11-03","10:15","16:30","22500","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-11-05","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Heimreise von Aschaffenburg","Nein","Ja","","timesheet","work","","",""
"2023-11-06","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-11-07","09:15","15:00","20700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","ab 13:30 Beratung DIMA Datentechnik","Nein","Ja","","timesheet","work","","",""
"2023-11-08","10:00","12:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Beratung DIMA Datentechnik","Nein","Ja","","timesheet","work","","",""
"2023-11-09","12:00","16:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-11-14","09:15","13:45","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-11-15","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-11-20","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-11-21","10:15","14:30","15300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-11-21","23:50","00:50","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-11-22","09:45","13:15","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-11-22","15:00","16:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-11-23","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-11-24","00:45","02:45","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Sicherheitsupdate ZFS - SEDC","Nein","Ja","","timesheet","work","","",""
"2023-11-24","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-11-27","09:30","12:00","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-11-27","23:00","01:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","PVE Update Fincare","Nein","Ja","","timesheet","work","","",""
"2023-11-28","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-11-29","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-12-04","09:45","14:45","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-12-05","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-12-05","23:45","01:00","4500","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","PVE Updates Achenbach","Nein","Ja","","timesheet","work","","",""
"2023-12-06","10:30","17:00","23400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-12-07","15:30","17:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-12-11","11:15","14:15","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","OPNsense Einrichtung Kress mit Chriz","Nein","Ja","","timesheet","work","","",""
"2023-12-12","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-12-13","12:00","13:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","KRESS OPNsense mit Chriz","Nein","Ja","","timesheet","work","","",""
"2023-12-13","13:30","16:30","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Kress OPNsense mit Chriz","Nein","Ja","","timesheet","work","","",""
"2023-12-18","09:30","13:00","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-12-19","10:00","13:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-12-19","13:00","17:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","WORKSHOP TRMM","Nein","Ja","","timesheet","work","","",""
"2023-12-20","10:00","13:54","14040","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-12-21","21:46","22:46","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-12-22","09:06","17:27","30060","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-12-27","10:30","16:35","21900","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-12-28","12:00","16:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise AB","Nein","Ja","","timesheet","work","","",""
"2023-12-29","10:30","12:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Community Server RZ EWG","Nein","Ja","","timesheet","work","","",""
"2023-12-29","12:45","18:00","18900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2023-12-30","12:00","16:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise nach Heimatanschrift","Nein","Ja","","timesheet","work","","",""
"2024-01-02","10:00","15:34","20040","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-01-03","10:15","18:05","28200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-01-04","10:30","16:30","21600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","OPNsense Einrichtung und in Betriebnahme","Nein","Ja","","timesheet","work","","",""
"2024-01-05","09:15","13:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Nacharbeiten und Doku von gestern, vorerst Abschluss Martens","Nein","Ja","","timesheet","work","","",""
"2024-01-05","20:51","00:42","13860","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Doku Wideflex","Nein","Ja","","timesheet","work","","",""
"2024-01-07","21:30","23:32","7320","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Sonn- und Feiertage","Sicherung Wideflex","Nein","Ja","","timesheet","work","","",""
"2024-01-08","09:12","10:57","6300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Kontrolle Wideflex Backups und Anpassung","Nein","Ja","","timesheet","work","","",""
"2024-01-08","15:15","17:15","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-01-09","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","cmk ausmisten && frage hubrich nextcloud","Nein","Ja","","timesheet","work","","",""
"2024-01-10","10:00","15:15","18900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Telco DIMA ZFS 3.5h && Telco 45min Markus","Nein","Ja","","timesheet","work","","",""
"2024-01-11","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Telco Winzig 2h , telco Markus","Nein","Ja","","timesheet","work","","",""
"2024-01-11","20:00","23:45","13500","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Update PVE FFW Goldbach mit Markus","Nein","Ja","","timesheet","work","","",""
"2024-01-12","14:00","19:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","cmk ausmissten & ticktes & 2.5h Nextcloud Hubrich","Nein","Ja","","timesheet","work","","",""
"2024-01-15","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","cmk ausmissten","Nein","Ja","","timesheet","work","","",""
"2024-01-16","09:20","14:50","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","cmk & Orth Ticket 4.75h","Nein","Ja","","timesheet","work","","",""
"2024-01-16","21:20","01:20","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Ortho","Nein","Ja","","timesheet","work","","",""
"2024-01-17","10:15","13:45","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","orth","Nein","Ja","","timesheet","work","","",""
"2024-01-18","16:30","20:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","cmk","Nein","Ja","","timesheet","work","","",""
"2024-01-22","11:30","14:30","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Orth Repl Einrichtung PVE2 und VM Umzug","Nein","Ja","","timesheet","work","","",""
"2024-01-23","09:45","14:45","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Prüfung Daten PVE2 Orth 1,5h && Ticktes","Nein","Ja","","timesheet","work","","",""
"2024-01-24","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","1,5 h Orth Umzug && 2h Dima Beratung","Nein","Ja","","timesheet","work","","",""
"2024-01-24","15:00","17:34","9240","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Orth","Nein","Ja","","timesheet","work","","",""
"2024-01-26","15:08","19:08","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-01-27","10:15","14:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-01-29","10:00","14:40","16800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-01-30","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Kontrolle Repl Orth 0.5 & 1.5 h telco mc alice","Nein","Ja","","timesheet","work","","",""
"2024-01-30","22:30","00:57","8820","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","cmk","Nein","Ja","","timesheet","work","","",""
"2024-01-31","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","cmk","Nein","Ja","","timesheet","work","","",""
"2024-01-31","15:00","17:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","cmk ausmisten","Nein","Ja","","timesheet","work","","",""
"2024-02-02","15:30","21:15","20700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Wideflex & tickets & cmk","Nein","Ja","","timesheet","work","","",""
"2024-02-03","15:41","16:48","4020","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-02-05","11:05","15:04","14340","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-02-06","10:00","14:56","17760","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-02-06","22:45","23:45","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-02-07","10:00","12:15","8100","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","2h envy","Nein","Ja","","timesheet","work","","",""
"2024-02-07","13:00","17:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","2 h envy","Nein","Ja","","timesheet","work","","",""
"2024-02-08","16:15","21:45","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-02-09","15:00","19:23","15780","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-02-12","10:00","12:41","9660","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-02-12","13:06","14:49","6180","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-02-13","10:00","12:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Beratung Backup und Betrachtung Kunden bei DIMA","Nein","Ja","","timesheet","work","","",""
"2024-02-14","09:45","13:00","11700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","1.5h meeting mit Marcel und Björn","Nein","Ja","","timesheet","work","","",""
"2024-02-14","14:30","17:15","9900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-02-19","11:15","14:45","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-02-20","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-02-22","17:00","21:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-02-26","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-02-27","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","1h Martens & 1,5h Telco Marcel u Björn & 1,5h Workshop","Nein","Ja","","timesheet","work","","",""
"2024-02-28","10:00","13:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","telco Marcel und Björn & 1h envy","Nein","Ja","","timesheet","work","","",""
"2024-02-28","14:00","17:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","1h envy & 2h martens mit begleitet","Nein","Ja","","timesheet","work","","",""
"2024-03-04","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-03-05","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Krankenvertretung für Chriz","Nein","Ja","","timesheet","work","","",""
"2024-03-06","09:00","10:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Krankenvertretung für Chriz","Nein","Ja","","timesheet","work","","",""
"2024-03-06","11:00","18:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Krankenvertetung für Chriz","Nein","Ja","","timesheet","work","","",""
"2024-03-07","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Krankenvertretung für Chriz","Nein","Ja","","timesheet","work","","",""
"2024-03-08","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
"2024-03-11","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
"2024-03-12","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
"2024-03-13","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
"2024-03-14","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
"2024-03-15","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
"2024-03-18","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
"2024-03-19","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
"2024-03-20","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
"2024-03-21","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
"2024-03-22","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
"2024-03-25","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
"2024-03-26","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
"2024-03-27","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
"2024-04-02","09:15","13:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-04-02","23:50","01:25","5700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Martens && Achenbach","Nein","Ja","","timesheet","work","","",""
"2024-04-03","10:15","14:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-04-05","15:15","19:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-04-08","09:15","13:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-04-09","09:45","13:35","13800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-04-10","09:30","17:00","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-04-15","11:30","14:15","9900","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-04-16","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","meeting marcel und björn aufräumen cmk","Nein","Ja","","timesheet","work","","",""
"2024-04-17","09:30","13:00","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-04-17","13:30","16:30","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Meeting Chriz und den Jungs Sicherheitscheckliste wahzu","Nein","Ja","","timesheet","work","","",""
"2024-04-18","16:00","20:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","3h Allgoewer","Nein","Ja","","timesheet","work","","",""
"2024-04-19","15:00","23:30","30600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-04-22","09:08","14:34","19560","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-04-23","09:30","13:45","15300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","telco koeser && telco marcel && telco knaack 0.75h","Nein","Ja","","timesheet","work","","",""
"2024-04-23","14:15","16:05","6600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","telco chriz &marcel prüfung upgrade leister ts2022","Nein","Ja","","timesheet","work","","",""
"2024-04-24","10:00","13:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","telco marcel && alptekin surface in ad einbinden mit erklärung","Nein","Ja","","timesheet","work","","",""
"2024-04-24","14:00","17:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-04-29","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-04-30","09:45","13:45","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","telco marcel wegen leister && hirdes backup","Nein","Ja","","timesheet","work","","",""
"2024-05-02","18:00","21:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-05-06","09:30","14:00","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","check kontrolle & 3,5h Koser","Nein","Ja","","timesheet","work","","",""
"2024-05-06","22:15","00:11","6960","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","1,25 h koeser","Nein","Ja","","timesheet","work","","",""
"2024-05-10","09:35","13:35","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","telco knaack","Nein","Ja","","timesheet","work","","",""
"2024-05-10","14:00","18:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","telco knaack","Nein","Ja","","timesheet","work","","",""
"2024-05-13","11:30","14:00","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-05-15","10:00","13:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-05-15","14:30","17:00","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","renofloor","Nein","Ja","","timesheet","work","","",""
"2024-05-16","16:30","18:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","ancora","Nein","Ja","","timesheet","work","","",""
"2024-05-17","15:30","00:30","32400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Koeser","Nein","Ja","","timesheet","work","","",""
"2024-05-18","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","koeser","Nein","Ja","","timesheet","work","","",""
"2024-05-21","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","normal Arbet & 1 h Workshop","Nein","Ja","","timesheet","work","","",""
"2024-05-22","10:00","16:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","3h CVC Teleco Hirdes","Nein","Ja","","timesheet","work","","",""
"2024-05-27","12:00","14:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-05-28","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-05-29","12:30","16:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-05-30","13:44","17:45","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-05-30","14:00","19:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Hyper V Ablöse Allgöwer","Nein","Ja","","timesheet","work","","",""
"2024-05-31","13:44","17:45","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-05-31","15:00","22:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Probelme mit dem Umzug bei Knaack","Nein","Ja","","timesheet","work","","",""
"2024-06-03","09:30","14:00","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","kontrolle CMK &Mail & telco schambach DT-Computer","Nein","Ja","","timesheet","work","","",""
"2024-06-04","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","0.5 telco chriz wegen sedc & 5.5 h sedc & 1h workshop zamba ad","Nein","Ja","","timesheet","work","","",""
"2024-06-10","09:35","13:00","12300","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-06-10","13:30","14:30","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-06-11","09:45","12:00","8100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-06-11","13:00","14:45","6300","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-06-12","09:34","17:34","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-06-14","16:15","18:45","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-06-16","12:30","16:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise nach AB","Nein","Ja","","timesheet","work","","",""
"2024-06-17","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Vorbereitung Workshop in AB","Nein","Ja","","timesheet","work","","",""
"2024-06-18","09:00","18:00","32400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Workshop","Nein","Ja","","timesheet","work","","",""
"2024-06-19","09:00","16:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Workshop","Nein","Ja","","timesheet","work","","",""
"2024-06-20","10:30","18:00","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","arbeiten im Loft","Nein","Ja","","timesheet","work","","",""
"2024-06-21","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","arbeiten im Loft","Nein","Ja","","timesheet","work","","",""
"2024-06-21","15:00","19:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise von AB nach MD","Nein","Ja","","timesheet","work","","",""
"2024-06-24","09:45","15:00","18900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","15 min Mails && 5 h sedc","Nein","Ja","","timesheet","work","","",""
"2024-06-25","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-06-26","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","sedc","Nein","Ja","","timesheet","work","","",""
"2024-07-01","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-07-02","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","sedc 4,5h","Nein","Ja","","timesheet","work","","",""
"2024-07-03","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","cmk &mails","Nein","Ja","","timesheet","work","","",""
"2024-07-04","11:00","15:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","renee cmk","Nein","Ja","","timesheet","work","","",""
"2024-07-05","15:00","19:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","sedc","Nein","Ja","","timesheet","work","","",""
"2024-07-15","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-07-16","17:00","21:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-07-17","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-07-18","16:30","18:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-07-22","10:00","14:54","17640","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-07-23","09:45","14:15","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-07-29","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-07-30","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-07-31","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-08-02","16:00","18:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-08-12","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-08-13","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-08-14","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-08-14","22:00","23:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Absprache mit Hubrich wegen OPnsense umstellung","Nein","Ja","","timesheet","work","","",""
"2024-08-15","16:30","18:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-08-19","09:30","14:00","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-08-21","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-08-22","02:44","04:45","7260","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-08-26","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-08-27","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-08-28","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","OPNsense Renofloor","Nein","Ja","","timesheet","work","","",""
"2024-08-29","17:00","20:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-08-30","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-02","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","sedc ispconfig","Nein","Ja","","timesheet","work","","",""
"2024-09-03","12:01","16:02","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-04","12:01","19:02","25260","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-05","12:01","14:02","7260","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-06","12:01","16:02","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-09","12:02","16:02","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-10","12:02","16:02","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-11","12:02","19:02","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-12","12:02","14:02","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-13","12:02","16:02","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-16","10:30","14:00","12600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-17","10:30","14:00","12600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-18","10:30","14:00","12600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-23","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-24","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-25","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-26","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-27","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-30","10:00","15:15","18900","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-09-30","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-01","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-02","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-03","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-04","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-07","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-08","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-09","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-10","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-11","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-14","13:45","17:46","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-15","13:45","17:46","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-16","13:45","17:46","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-17","13:45","17:46","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-18","13:45","17:46","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-21","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-22","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-23","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-24","10:39","16:39","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-28","10:00","16:30","23400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-28","17:30","21:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Koeser","Nein","Ja","","timesheet","work","","",""
"2024-10-29","09:00","11:45","9900","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-10-30","10:30","13:15","9900","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-11-04","10:02","14:47","17100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-11-04","21:38","22:43","3900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-11-05","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-11-06","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","2h ancora 3,5h sedc","Nein","Ja","","timesheet","work","","",""
"2024-11-11","10:00","14:44","17040","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","check mk 1h, 1,5h Arbeitsanweisungen, 1 h Streaming einstellen","Nein","Ja","","timesheet","work","","",""
"2024-11-12","13:00","17:45","17100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Workshop Proxmox mit ZFS","Nein","Ja","","timesheet","work","","",""
"2024-11-13","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-11-14","13:00","18:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Workshop Proxmox mit ZFS","Nein","Ja","","timesheet","work","","",""
"2024-11-18","10:00","12:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","CMK Pflege","Nein","Ja","","timesheet","work","","",""
"2024-11-18","12:30","14:00","5400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","anacora","Nein","Ja","","timesheet","work","","",""
"2024-11-19","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-11-20","10:00","16:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-11-21","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-11-21","17:00","17:00","0","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-11-25","13:43","17:44","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-11-26","13:43","17:44","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-11-27","13:43","20:44","25260","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-11-28","13:43","15:44","7260","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-11-29","13:43","15:44","7260","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-11-30","13:43","14:44","3660","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-12-02","09:30","14:56","19560","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-12-03","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-12-04","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-12-05","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-12-05","20:00","23:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-12-06","15:30","19:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-12-08","19:00","23:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise von MD nach AB","Nein","Ja","","timesheet","work","","",""
"2024-12-09","10:04","18:04","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-12-10","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","ab 12:30 Workshop","Nein","Ja","Workshop","timesheet","work","","",""
"2024-12-11","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-12-12","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","ab 12:30 Workshop","Nein","Ja","Workshop","timesheet","work","","",""
"2024-12-13","10:30","15:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-12-13","15:30","19:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise von AB nach MD","Nein","Ja","","timesheet","work","","",""
"2024-12-16","10:00","14:45","17100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-12-17","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-12-18","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-12-19","16:00","20:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-12-20","14:45","17:00","8100","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2024-12-23","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-02","10:00","11:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Warten auf Chef, abarbeiten von cmk","Nein","Ja","","timesheet","work","","",""
"2025-01-02","17:00","20:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Tickets","Nein","Ja","","timesheet","work","","",""
"2025-01-03","15:00","20:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dokumentation Backup Revision","Nein","Ja","","timesheet","work","","",""
"2025-01-07","10:00","14:40","16800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-08","10:00","17:14","26040","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-09","13:10","17:10","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-10","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-13","09:46","14:46","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-14","09:50","14:57","18420","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-15","10:00","17:25","26700","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-16","15:45","19:45","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-17","15:00","19:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-20","09:30","14:45","18900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-21","09:45","15:00","18900","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-22","09:43","17:13","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-27","09:36","14:58","19320","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-28","09:44","14:44","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-29","10:05","17:10","25500","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-30","16:00","18:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-01-31","15:25","16:25","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-03","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-04","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-05","10:19","17:19","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-06","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-10","09:50","14:50","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Martens","Nein","Ja","","timesheet","work","","",""
"2025-02-11","09:55","14:00","14700","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-12","10:00","17:28","26880","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-13","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-14","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-17","09:30","14:53","19380","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-18","10:15","15:00","17100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-19","09:53","17:53","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-20","10:30","14:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-24","12:38","16:38","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-25","12:38","16:38","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-26","09:13","13:13","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-27","12:38","16:38","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-02-28","12:38","16:38","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-03","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-04","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-05","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-06","11:00","15:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-07","10:15","14:15","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-10","09:45","14:45","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-11","09:14","13:45","16260","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-12","09:00","15:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-13","10:15","16:15","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-14","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-17","10:15","14:45","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-18","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-19","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-20","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-24","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-25","13:15","18:45","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-03-26","10:30","17:30","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Komplett Einrichtung Aristhodos","Nein","Ja","","timesheet","work","","",""
"2025-03-26","22:00","00:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Ticket kontrolle","Nein","Ja","","timesheet","work","","",""
"2025-03-31","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-04-01","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-04-02","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-04-03","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-04-07","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-04-08","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-04-09","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-04-10","10:00","16:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-04-14","09:45","14:45","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-05","09:30","14:00","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-06","09:45","14:15","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-07","09:45","17:00","26100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-08","09:45","16:30","24300","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-09","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-12","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-13","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-14","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-15","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-16","16:30","17:30","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-19","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-20","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-20","22:00","00:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-22","09:39","17:00","26460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-26","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-27","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-05-28","09:30","17:30","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-06-01","16:00","21:30","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Heimatadresse > Anreise Hotel Moers","Nein","Ja","","timesheet","work","","",""
"2025-06-02","08:30","17:00","30600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Anreise Lehrgang KRZN","Nein","Ja","","timesheet","work","","",""
"2025-06-02","21:18","22:48","5400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-06-03","08:30","17:00","30600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Anreise Lehrgang KRZN","Nein","Ja","","timesheet","work","","",""
"2025-06-03","21:15","23:15","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-06-04","08:30","17:00","30600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Anreise Lehrgang KRZN","Nein","Ja","","timesheet","work","","",""
"2025-06-04","21:00","23:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-06-05","08:30","17:00","30600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Anreise Lehrgang KRZN","Nein","Ja","","timesheet","work","","",""
"2025-06-05","23:18","00:18","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-06-06","08:30","14:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Abreise Lehrgang >Heimatadresse","Nein","Ja","","timesheet","work","","",""
"2025-06-09","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-06-10","10:00","14:45","17100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-06-11","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-06-12","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-06-15","17:00","21:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Heimatadresse > KRZN","Nein","Ja","","timesheet","work","","",""
"2025-06-15","22:00","23:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-06-16","08:00","17:00","32400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","KRZN Lehrgang","Nein","Ja","","timesheet","work","","",""
"2025-06-16","20:00","22:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-06-17","08:00","17:00","32400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","KRZN Lehrgang","Nein","Ja","","timesheet","work","","",""
"2025-06-18","09:00","15:30","23400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","KRZN > Heimtadresse","Nein","Ja","","timesheet","work","","",""
"2025-06-19","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Sonn- und Feiertage","","Nein","Ja","","timesheet","work","","",""
"2025-06-23","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-06-24","10:00","14:45","17100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-06-25","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-06-26","10:00","16:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-06-27","11:00","16:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-06-30","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-07-01","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-07-02","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-07-03","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-07-04","15:00","16:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-07-06","17:00","22:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Heimatadresse > KRZN","Nein","Ja","","timesheet","work","","",""
"2025-07-07","08:00","17:00","32400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-07-07","22:00","23:30","5400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-07-08","08:00","18:00","36000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-07-08","22:00","00:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-07-09","08:00","16:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-07-10","08:00","16:30","30600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-07-10","22:00","23:30","5400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-07-11","10:00","15:30","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Mettmann >>> Heimatadresse","Nein","Ja","","timesheet","work","","",""
"2025-07-14","10:00","14:45","17100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-07-15","10:00","16:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-04","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-05","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-06","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-07","12:30","17:00","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-08","18:00","02:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Kahle","Nein","Ja","","timesheet","work","","",""
"2025-08-11","09:00","15:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-11","21:30","01:00","12600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Kontrollen","Nein","Ja","","timesheet","work","","",""
"2025-08-12","09:00","15:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-12","21:00","01:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","Kress repl umstellung","Nein","Ja","","timesheet","work","","",""
"2025-08-13","09:00","17:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-13","22:00","00:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-14","09:30","17:00","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-18","09:30","15:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-18","22:00","00:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-19","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-19","22:00","01:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-20","09:30","17:30","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-20","23:00","01:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-21","10:00","16:30","23400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-24","21:00","04:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","KRESS TRMM WAZUH CMK, kontrollen","Nein","Ja","","timesheet","work","","",""
"2025-08-25","09:00","15:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-26","09:30","15:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-08-27","22:00","01:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","check mk ausmisten","Nein","Ja","","timesheet","work","","",""
"2025-08-28","09:00","14:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-09-01","09:15","14:15","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-09-01","21:45","00:30","9900","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-09-02","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-09-02","22:00","01:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-09-03","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-09-03","15:30","18:00","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-09-04","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-09-04","15:30","18:00","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-09-08","15:05","19:05","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
"2025-09-09","15:05","19:05","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
"2025-09-10","15:05","19:05","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
"2025-09-11","15:05","19:05","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
"2025-09-12","15:05","19:05","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
"2025-09-15","09:30","15:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-09-16","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-09-17","09:45","17:15","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-09-18","11:30","13:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-09-18","15:30","18:30","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-09-19","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-09-22","08:30","12:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Heimatanschrift > ULM","Nein","Ja","","timesheet","work","","",""
"2025-09-22","12:30","17:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","Heimatanschrift > ULM","Nein","Ja","","timesheet","work","","",""
"2025-09-23","08:30","12:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","ULM","Nein","Ja","","timesheet","work","","",""
"2025-09-23","12:30","17:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","ULM","Nein","Ja","","timesheet","work","","",""
"2025-09-24","08:30","12:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","ULM","Nein","Ja","","timesheet","work","","",""
"2025-09-24","12:30","18:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","ULM","Nein","Ja","","timesheet","work","","",""
"2025-09-25","08:30","12:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","ULM","Nein","Ja","","timesheet","work","","",""
"2025-09-25","12:30","18:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","ULM","Nein","Ja","","timesheet","work","","",""
"2025-09-26","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","ULM > Heimatanschrift","Nein","Ja","","timesheet","work","","",""
"2025-09-26","14:00","17:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","ULM > Heimatanschrift","Nein","Ja","","timesheet","work","","",""
"2025-11-03","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-04","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-05","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-06","09:30","16:30","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-07","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-07","22:30","23:30","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","Koeser PVE9 Update","Nein","Ja","","timesheet","work","","",""
"2025-11-10","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-10","22:00","23:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-11","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-12","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-13","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-14","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-17","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-18","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-19","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-20","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-21","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-24","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-25","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-26","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-11-27","10:00","16:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-01","00:00","04:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-02","22:51","02:51","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-03","22:51","02:51","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-04","22:51","02:51","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-05","22:51","02:51","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-08","22:51","02:51","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-09","22:51","02:51","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-10","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-12","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-15","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-16","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-17","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-18","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-19","15:00","16:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-22","10:00","13:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-22","16:00","18:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-23","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-24","10:00","12:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-26","09:00","13:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-26","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-29","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-30","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2025-12-30","21:00","22:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-01-02","00:03","04:03","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-01-05","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-01-07","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-01-08","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-01-09","09:15","14:15","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-01-09","17:00","00:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Allgoewer","Nein","Ja","","timesheet","work","","",""
"2026-02-17","09:30","15:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-02-17","15:30","17:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-02-17","21:00","23:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-02-23","09:00","17:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise","Nein","Ja","","timesheet","work","","",""
"2026-02-24","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise","Nein","Ja","","timesheet","work","","",""
"2026-02-25","08:30","17:30","32400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise","Nein","Ja","","timesheet","work","","",""
"2026-02-26","08:35","18:05","34200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise","Nein","Ja","","timesheet","work","","",""
"2026-02-27","10:00","16:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise","Nein","Ja","","timesheet","work","","",""
"2026-03-02","09:30","15:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-03-03","09:15","16:15","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-03-03","19:00","22:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Kress Kontrolle Update KGPM237","Nein","Ja","","timesheet","work","","",""
"2026-03-04","09:15","17:00","27900","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-03-05","15:00","17:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-03-05","17:00","02:30","34200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Kress Umbau Offenbach","Nein","Ja","","timesheet","work","","",""
"2026-03-06","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-03-09","09:30","15:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-03-10","09:15","16:15","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-03-11","09:30","16:00","23400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Chef Urlaub","Nein","Ja","","timesheet","work","","",""
"2026-03-12","09:45","17:15","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Chriz Urlaub","Nein","Ja","","timesheet","work","","",""
"2026-03-16","09:30","15:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-03-17","09:15","16:15","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-03-18","09:30","17:00","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-03-19","09:45","17:15","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-03-23","09:30","15:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-03-24","09:30","16:30","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-03-25","09:15","17:30","29700","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
"2026-03-26","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
1 Datum Von Bis Dauer Benutzer Name Kunde Projekt Tätigkeit Beschreibung Exportiert Abrechenbar Schlagworte Typ label.category Kundennummer Umsatzsteuer-ID Bestellnummer
2 2023-01-03 10:53 16:53 21600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
3 2023-01-04 10:30 17:30 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
4 2023-01-06 12:30 16:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
5 2023-01-09 09:30 13:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
6 2023-01-10 14:00 18:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
7 2023-01-11 11:00 15:30 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
8 2023-01-11 22:23 00:23 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Pflege CMK Nein Ja timesheet work
9 2023-01-12 09:27 14:35 18480 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
10 2023-01-13 10:35 15:45 18600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
11 2023-01-13 19:20 21:07 6420 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit Nein Ja timesheet work
12 2023-01-15 12:00 16:30 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit lwnet Nein Ja timesheet work
13 2023-01-16 09:00 13:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Sonderurlaub Beerdigung Nein Ja timesheet work
14 2023-01-17 09:40 17:15 27300 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
15 2023-01-18 10:04 11:09 3900 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
16 2023-01-18 13:00 16:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
17 2023-01-20 14:35 16:35 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
18 2023-01-23 09:10 14:10 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
19 2023-01-24 11:00 14:30 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
20 2023-01-24 21:15 03:00 20700 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit UCS Upgrade Förtig Nein Ja timesheet work
21 2023-01-25 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
22 2023-01-25 22:00 02:30 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Update & Upgrade Leister Nein Ja timesheet work
23 2023-01-26 09:30 17:00 27000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
24 2023-01-26 16:00 17:00 3600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Gespräch Orth Nein Ja timesheet work
25 2023-01-27 01:00 03:00 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
26 2023-01-30 11:30 14:30 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
27 2023-01-31 09:45 15:00 18900 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
28 2023-02-01 10:00 18:00 28800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
29 2023-02-02 16:00 23:30 27000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
30 2023-02-06 09:30 14:30 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
31 2023-02-07 09:30 14:00 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
32 2023-02-08 09:30 14:00 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
33 2023-02-09 16:30 19:00 9000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit install PVE marktgolbach Nein Ja timesheet work
34 2023-02-13 11:30 14:30 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
35 2023-02-14 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
36 2023-02-15 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
37 2023-02-16 18:00 21:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
38 2023-02-18 14:00 17:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Hilfestellung SW Friedberg Nein Ja timesheet work
39 2023-02-20 09:30 13:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
40 2023-02-21 18:00 21:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
41 2023-02-22 10:00 18:00 28800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Mit Nofalleinsatz bei den SW Friedberg Nein Ja timesheet work
42 2023-02-23 17:00 20:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
43 2023-02-28 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
44 2023-03-01 10:00 13:30 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
45 2023-03-01 14:53 17:53 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
46 2023-03-02 00:00 00:30 1800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit cmk durchsicht Nein Ja timesheet work
47 2023-03-02 09:08 10:38 5400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Call Alvaro Nein Ja timesheet work
48 2023-03-03 20:05 00:05 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
49 2023-03-04 00:35 03:05 9000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
50 2023-03-06 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Betreuung SW-Friedberg bei Serverumzug Nein Ja timesheet work
51 2023-03-07 09:30 14:30 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit ab 13 Uhr Workshop Nein Ja timesheet work
52 2023-03-08 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Notfalleinsatz PMH Nein Ja timesheet work
53 2023-03-11 13:00 20:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit UCS Upgrade Hubrich Nein Ja timesheet work
54 2023-03-13 12:00 15:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
55 2023-03-14 01:30 02:30 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit cmk durchsicht Nein Ja timesheet work
56 2023-03-14 10:00 13:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
57 2023-03-14 22:44 05:59 26100 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit Upgrade Alptekin Nein Ja timesheet work
58 2023-03-15 14:00 15:45 6300 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
59 2023-03-16 17:00 21:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
60 2023-03-19 12:00 16:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
61 2023-03-20 10:00 12:00 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
62 2023-03-22 15:00 19:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit PVE Goldbach Nein Ja timesheet work
63 2023-03-23 00:00 01:30 5400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit pve goldbach Nein Ja timesheet work
64 2023-03-27 11:15 14:15 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
65 2023-03-27 23:00 00:00 3600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
66 2023-03-28 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
67 2023-03-29 10:30 14:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
68 2023-03-29 15:30 17:30 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
69 2023-03-31 17:00 19:00 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
70 2023-04-03 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
71 2023-04-04 10:00 12:00 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
72 2023-04-04 20:50 21:50 3600 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit Noteinsatz LWNET Nein Ja timesheet work
73 2023-04-05 10:00 17:30 27000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
74 2023-04-10 16:00 18:25 8700 Patrick Perlbach patrick sysops Leistungen für Kunden Sonn- und Feiertage Notfalleinsatz SEDC Nein Ja timesheet work
75 2023-04-11 17:00 06:50 49800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit UCS Update LEISTER Nein Ja timesheet work
76 2023-04-17 14:15 18:15 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit kontrolle cmk/zammad Nein Ja timesheet work
77 2023-04-17 19:15 21:15 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit kontrolle cmk/zammad Nein Ja timesheet work
78 2023-04-18 11:00 14:30 12600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
79 2023-04-19 10:00 14:15 15300 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
80 2023-04-19 14:45 16:45 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
81 2023-04-22 23:23 01:53 9000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit sysops OPNsense Fehler Nein Ja timesheet work
82 2023-04-24 14:33 18:03 12600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
83 2023-04-25 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
84 2023-04-26 09:30 13:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
85 2023-04-26 16:00 17:30 5400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
86 2023-04-28 15:30 20:00 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
87 2023-05-03 11:00 13:30 9000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
88 2023-05-03 14:00 17:30 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
89 2023-05-04 22:40 00:20 6000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
90 2023-05-05 15:00 18:30 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
91 2023-05-09 09:00 14:30 19800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
92 2023-05-10 10:00 12:00 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
93 2023-05-10 13:45 16:45 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
94 2023-05-12 15:30 19:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit SEDC Firewall Nein Ja timesheet work
95 2023-05-12 22:00 02:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit SEDC Nein Ja timesheet work
96 2023-05-15 12:00 14:00 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
97 2023-05-15 20:30 00:30 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Ansible Einrichtung Nein Ja timesheet work
98 2023-05-16 10:00 14:31 16260 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
99 2023-05-17 12:00 14:00 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
100 2023-05-17 20:00 23:00 10800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Schulung Ansible Nein Ja timesheet work
101 2023-05-19 17:00 21:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit SEDC Nein Ja timesheet work
102 2023-05-22 11:15 14:15 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
103 2023-05-23 09:30 14:30 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
104 2023-05-24 10:00 13:30 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
105 2023-05-24 14:00 17:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
106 2023-05-25 18:35 20:35 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
107 2023-05-26 08:30 10:00 5400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
108 2023-05-30 09:30 14:30 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
109 2023-05-31 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
110 2023-06-01 21:00 02:00 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit SEDC Nein Ja timesheet work
111 2023-06-02 16:30 21:25 17700 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
112 2023-06-06 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
113 2023-06-07 10:00 14:15 15300 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
114 2023-06-08 19:00 20:00 3600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
115 2023-06-09 09:00 10:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit telco markus Nein Ja timesheet work
116 2023-06-12 10:00 18:00 28800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
117 2023-06-13 10:30 18:00 27000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
118 2023-06-14 10:20 13:20 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
119 2023-06-15 00:00 01:30 5400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
120 2023-06-15 17:30 21:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
121 2023-06-19 08:15 08:45 1800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
122 2023-06-19 11:15 12:15 3600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
123 2023-06-21 09:30 17:31 28860 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
124 2023-06-23 15:00 19:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
125 2023-06-26 09:25 14:25 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
126 2023-06-27 09:25 14:25 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
127 2023-06-28 09:25 13:40 15300 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
128 2023-06-29 17:00 20:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
129 2023-07-03 11:30 13:05 5700 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
130 2023-07-03 22:00 01:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
131 2023-07-04 09:30 14:30 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
132 2023-07-04 23:57 04:27 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit SEDC Backup Nein Ja timesheet work
133 2023-07-05 10:15 14:15 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
134 2023-07-07 15:00 18:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
135 2023-07-10 09:30 13:58 16080 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
136 2023-07-10 22:00 00:30 9000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit ticket Nein Ja timesheet work
137 2023-07-11 09:45 13:55 15000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
138 2023-07-12 09:30 15:15 20700 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
139 2023-07-13 16:30 18:30 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
140 2023-07-14 13:00 17:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
141 2023-07-17 10:00 13:30 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
142 2023-07-17 14:05 19:00 17700 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
143 2023-07-18 09:15 13:30 15300 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
144 2023-07-18 22:45 01:45 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Hubrich repl auf neuen server; Achenbach PVEAIB Plattentausch Nein Ja timesheet work
145 2023-07-19 10:15 14:15 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
146 2023-07-19 15:15 16:45 5400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
147 2023-07-19 22:00 00:00 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Telco Markus PVE Tierärzte Nein Ja timesheet work
148 2023-07-20 16:30 18:30 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
149 2023-07-24 10:00 14:45 17100 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
150 2023-07-24 15:45 20:00 15300 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
151 2023-07-25 10:15 14:15 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
152 2023-07-26 10:15 18:00 27900 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
153 2023-07-27 09:30 13:15 13500 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
154 2023-07-27 18:30 19:30 3600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Allgöwer RaidController Nein Ja timesheet work
155 2023-07-28 09:15 12:45 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
156 2023-07-31 10:00 14:21 15660 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
157 2023-08-01 09:30 12:00 9000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
158 2023-08-02 09:30 13:00 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
159 2023-08-02 17:20 22:20 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
160 2023-08-04 10:30 20:30 36000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
161 2023-08-06 12:00 16:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Hilfestellung bei Univention Update Nein Ja timesheet work
162 2023-08-07 09:15 13:45 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
163 2023-08-08 09:45 14:45 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Hubrich , Umstellung und Inbetriebnahme mit Chriz Nein Ja timesheet work
164 2023-08-09 09:45 13:15 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
165 2023-08-09 14:15 17:15 10800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Telco Markus Helmke Ansible Weiterentwicklung Nein Ja timesheet work
166 2023-08-14 10:30 14:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
167 2023-08-15 09:35 13:05 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
168 2023-08-16 09:15 10:15 3600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
169 2023-08-16 13:00 17:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
170 2023-08-17 10:00 11:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
171 2023-08-19 17:00 23:00 21600 Patrick Perlbach patrick sysops Leistungen für sysops Überstunden oder Nachtarbeit UCS UPGRADE Huebnethal Nein Ja timesheet work
172 2023-08-21 11:45 14:45 10800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
173 2023-08-23 09:30 17:30 28800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
174 2023-08-28 09:30 13:30 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
175 2023-08-29 09:15 13:45 16200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
176 2023-08-29 21:45 02:25 16800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit SEDC REPL Fehlerbeseitigung Nein Ja timesheet work
177 2023-08-30 10:15 17:28 25980 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
178 2023-09-04 11:15 14:54 13140 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
179 2023-09-05 09:00 09:45 2700 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
180 2023-09-05 11:00 14:35 12900 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
181 2023-09-05 22:25 00:40 8100 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
182 2023-09-06 09:15 10:15 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
183 2023-09-06 12:15 17:15 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
184 2023-09-11 13:00 15:30 9000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
185 2023-09-12 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit mit 1h Workshop war bis 15:30 dabei Nein Ja timesheet work
186 2023-09-13 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
187 2023-09-18 11:15 15:00 13500 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
188 2023-09-19 09:35 14:25 17400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
189 2023-09-20 09:05 13:14 14940 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
190 2023-09-21 16:00 20:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub Nein Ja timesheet work
191 2023-09-22 16:00 20:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub Nein Ja timesheet work
192 2023-09-25 16:00 20:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub Nein Ja timesheet work
193 2023-09-26 16:00 20:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub Nein Ja timesheet work
194 2023-09-27 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
195 2023-09-27 14:30 15:30 3600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
196 2023-09-29 16:00 22:00 21600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Datenübertrag Hirdes von alt nach neu mit Rummel AG Nein Ja timesheet work
197 2023-10-02 11:15 13:15 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
198 2023-10-02 13:25 15:25 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
199 2023-10-04 09:15 13:00 13500 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
200 2023-10-09 09:30 13:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
201 2023-10-10 09:30 13:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
202 2023-10-11 09:45 13:35 13800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
203 2023-10-11 14:00 17:30 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
204 2023-10-12 16:30 20:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
205 2023-10-16 11:45 13:45 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
206 2023-10-17 12:15 16:00 13500 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
207 2023-10-18 10:00 14:35 16500 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit 45 min Telco Chriz Nein Ja timesheet work
208 2023-10-19 11:15 14:05 10200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
209 2023-10-19 16:30 19:00 9000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
210 2023-10-20 15:00 19:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
211 2023-10-23 09:30 13:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
212 2023-10-23 14:30 15:00 1800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
213 2023-10-24 09:30 13:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
214 2023-10-25 09:30 15:15 20700 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Chriz RZ, Goldbach HA Cluster fixen Nein Ja timesheet work
215 2023-10-25 15:00 17:30 9000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit 1,5 Std Telco Chriz RZ Bespräche nach Umbau Nein Ja timesheet work
216 2023-10-26 09:00 10:45 6300 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Telco Markus wegen Alptekin Nein Ja timesheet work
217 2023-10-27 16:00 18:00 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit telco treml Pachmayr Nein Ja timesheet work
218 2023-10-30 11:35 16:15 16800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit 3 h Einrichtung OPNsense mit Philipp Kress Firewall Nein Ja timesheet work
219 2023-10-31 09:05 14:54 20940 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
220 2023-11-01 12:00 16:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Dienstreise nach Aschaffenburg Nein Ja timesheet work
221 2023-11-02 10:15 18:15 28800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Arbeit in Aschaffenburg Nein Ja timesheet work
222 2023-11-03 10:15 16:30 22500 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
223 2023-11-05 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Heimreise von Aschaffenburg Nein Ja timesheet work
224 2023-11-06 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
225 2023-11-07 09:15 15:00 20700 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit ab 13:30 Beratung DIMA Datentechnik Nein Ja timesheet work
226 2023-11-08 10:00 12:30 9000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Beratung DIMA Datentechnik Nein Ja timesheet work
227 2023-11-09 12:00 16:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
228 2023-11-14 09:15 13:45 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
229 2023-11-15 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
230 2023-11-20 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
231 2023-11-21 10:15 14:30 15300 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
232 2023-11-21 23:50 00:50 3600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
233 2023-11-22 09:45 13:15 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
234 2023-11-22 15:00 16:00 3600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
235 2023-11-23 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
236 2023-11-24 00:45 02:45 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Sicherheitsupdate ZFS - SEDC Nein Ja timesheet work
237 2023-11-24 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
238 2023-11-27 09:30 12:00 9000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
239 2023-11-27 23:00 01:00 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit PVE Update Fincare Nein Ja timesheet work
240 2023-11-28 09:30 13:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
241 2023-11-29 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
242 2023-12-04 09:45 14:45 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
243 2023-12-05 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
244 2023-12-05 23:45 01:00 4500 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit PVE Updates Achenbach Nein Ja timesheet work
245 2023-12-06 10:30 17:00 23400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
246 2023-12-07 15:30 17:30 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
247 2023-12-11 11:15 14:15 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit OPNsense Einrichtung Kress mit Chriz Nein Ja timesheet work
248 2023-12-12 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
249 2023-12-13 12:00 13:00 3600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit KRESS OPNsense mit Chriz Nein Ja timesheet work
250 2023-12-13 13:30 16:30 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Kress OPNsense mit Chriz Nein Ja timesheet work
251 2023-12-18 09:30 13:00 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
252 2023-12-19 10:00 13:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
253 2023-12-19 13:00 17:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit WORKSHOP TRMM Nein Ja timesheet work
254 2023-12-20 10:00 13:54 14040 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
255 2023-12-21 21:46 22:46 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
256 2023-12-22 09:06 17:27 30060 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
257 2023-12-27 10:30 16:35 21900 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
258 2023-12-28 12:00 16:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Dienstreise AB Nein Ja timesheet work
259 2023-12-29 10:30 12:30 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Community Server RZ EWG Nein Ja timesheet work
260 2023-12-29 12:45 18:00 18900 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
261 2023-12-30 12:00 16:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Dienstreise nach Heimatanschrift Nein Ja timesheet work
262 2024-01-02 10:00 15:34 20040 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
263 2024-01-03 10:15 18:05 28200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
264 2024-01-04 10:30 16:30 21600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit OPNsense Einrichtung und in Betriebnahme Nein Ja timesheet work
265 2024-01-05 09:15 13:15 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nacharbeiten und Doku von gestern, vorerst Abschluss Martens Nein Ja timesheet work
266 2024-01-05 20:51 00:42 13860 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Doku Wideflex Nein Ja timesheet work
267 2024-01-07 21:30 23:32 7320 Patrick Perlbach patrick sysops Leistungen für Kunden Sonn- und Feiertage Sicherung Wideflex Nein Ja timesheet work
268 2024-01-08 09:12 10:57 6300 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Kontrolle Wideflex Backups und Anpassung Nein Ja timesheet work
269 2024-01-08 15:15 17:15 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
270 2024-01-09 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit cmk ausmisten && frage hubrich nextcloud Nein Ja timesheet work
271 2024-01-10 10:00 15:15 18900 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Telco DIMA ZFS 3.5h && Telco 45min Markus Nein Ja timesheet work
272 2024-01-11 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Telco Winzig 2h , telco Markus Nein Ja timesheet work
273 2024-01-11 20:00 23:45 13500 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Update PVE FFW Goldbach mit Markus Nein Ja timesheet work
274 2024-01-12 14:00 19:00 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit cmk ausmissten & ticktes & 2.5h Nextcloud Hubrich Nein Ja timesheet work
275 2024-01-15 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit cmk ausmissten Nein Ja timesheet work
276 2024-01-16 09:20 14:50 19800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit cmk & Orth Ticket 4.75h Nein Ja timesheet work
277 2024-01-16 21:20 01:20 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Ortho Nein Ja timesheet work
278 2024-01-17 10:15 13:45 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit orth Nein Ja timesheet work
279 2024-01-18 16:30 20:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit cmk Nein Ja timesheet work
280 2024-01-22 11:30 14:30 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Orth Repl Einrichtung PVE2 und VM Umzug Nein Ja timesheet work
281 2024-01-23 09:45 14:45 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Prüfung Daten PVE2 Orth 1,5h && Ticktes Nein Ja timesheet work
282 2024-01-24 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit 1,5 h Orth Umzug && 2h Dima Beratung Nein Ja timesheet work
283 2024-01-24 15:00 17:34 9240 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Orth Nein Ja timesheet work
284 2024-01-26 15:08 19:08 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
285 2024-01-27 10:15 14:15 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
286 2024-01-29 10:00 14:40 16800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
287 2024-01-30 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Kontrolle Repl Orth 0.5 & 1.5 h telco mc alice Nein Ja timesheet work
288 2024-01-30 22:30 00:57 8820 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit cmk Nein Ja timesheet work
289 2024-01-31 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit cmk Nein Ja timesheet work
290 2024-01-31 15:00 17:00 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit cmk ausmisten Nein Ja timesheet work
291 2024-02-02 15:30 21:15 20700 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Wideflex & tickets & cmk Nein Ja timesheet work
292 2024-02-03 15:41 16:48 4020 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
293 2024-02-05 11:05 15:04 14340 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
294 2024-02-06 10:00 14:56 17760 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
295 2024-02-06 22:45 23:45 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
296 2024-02-07 10:00 12:15 8100 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit 2h envy Nein Ja timesheet work
297 2024-02-07 13:00 17:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit 2 h envy Nein Ja timesheet work
298 2024-02-08 16:15 21:45 19800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
299 2024-02-09 15:00 19:23 15780 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
300 2024-02-12 10:00 12:41 9660 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
301 2024-02-12 13:06 14:49 6180 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
302 2024-02-13 10:00 12:30 9000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Beratung Backup und Betrachtung Kunden bei DIMA Nein Ja timesheet work
303 2024-02-14 09:45 13:00 11700 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit 1.5h meeting mit Marcel und Björn Nein Ja timesheet work
304 2024-02-14 14:30 17:15 9900 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
305 2024-02-19 11:15 14:45 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
306 2024-02-20 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
307 2024-02-22 17:00 21:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
308 2024-02-26 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
309 2024-02-27 09:30 13:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit 1h Martens & 1,5h Telco Marcel u Björn & 1,5h Workshop Nein Ja timesheet work
310 2024-02-28 10:00 13:30 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit telco Marcel und Björn & 1h envy Nein Ja timesheet work
311 2024-02-28 14:00 17:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit 1h envy & 2h martens mit begleitet Nein Ja timesheet work
312 2024-03-04 10:00 18:00 28800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
313 2024-03-05 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Krankenvertretung für Chriz Nein Ja timesheet work
314 2024-03-06 09:00 10:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Krankenvertretung für Chriz Nein Ja timesheet work
315 2024-03-06 11:00 18:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Krankenvertetung für Chriz Nein Ja timesheet work
316 2024-03-07 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Krankenvertretung für Chriz Nein Ja timesheet work
317 2024-03-08 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub 2023 Nein Ja timesheet work
318 2024-03-11 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub 2023 Nein Ja timesheet work
319 2024-03-12 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub 2023 Nein Ja timesheet work
320 2024-03-13 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub 2023 Nein Ja timesheet work
321 2024-03-14 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub 2023 Nein Ja timesheet work
322 2024-03-15 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub 2023 Nein Ja timesheet work
323 2024-03-18 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub 2023 Nein Ja timesheet work
324 2024-03-19 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub 2023 Nein Ja timesheet work
325 2024-03-20 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub 2023 Nein Ja timesheet work
326 2024-03-21 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub 2023 Nein Ja timesheet work
327 2024-03-22 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub 2023 Nein Ja timesheet work
328 2024-03-25 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub 2023 Nein Ja timesheet work
329 2024-03-26 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub 2023 Nein Ja timesheet work
330 2024-03-27 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Urlaub 2023 Nein Ja timesheet work
331 2024-04-02 09:15 13:15 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
332 2024-04-02 23:50 01:25 5700 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Martens && Achenbach Nein Ja timesheet work
333 2024-04-03 10:15 14:15 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
334 2024-04-05 15:15 19:15 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
335 2024-04-08 09:15 13:15 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
336 2024-04-09 09:45 13:35 13800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
337 2024-04-10 09:30 17:00 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
338 2024-04-15 11:30 14:15 9900 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
339 2024-04-16 09:30 13:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit meeting marcel und björn aufräumen cmk Nein Ja timesheet work
340 2024-04-17 09:30 13:00 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
341 2024-04-17 13:30 16:30 10800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Meeting Chriz und den Jungs Sicherheitscheckliste wahzu Nein Ja timesheet work
342 2024-04-18 16:00 20:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit 3h Allgoewer Nein Ja timesheet work
343 2024-04-19 15:00 23:30 30600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
344 2024-04-22 09:08 14:34 19560 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
345 2024-04-23 09:30 13:45 15300 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit telco koeser && telco marcel && telco knaack 0.75h Nein Ja timesheet work
346 2024-04-23 14:15 16:05 6600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit telco chriz &marcel prüfung upgrade leister ts2022 Nein Ja timesheet work
347 2024-04-24 10:00 13:00 10800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit telco marcel && alptekin surface in ad einbinden mit erklärung Nein Ja timesheet work
348 2024-04-24 14:00 17:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
349 2024-04-29 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
350 2024-04-30 09:45 13:45 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit telco marcel wegen leister && hirdes backup Nein Ja timesheet work
351 2024-05-02 18:00 21:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
352 2024-05-06 09:30 14:00 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit check kontrolle & 3,5h Koser Nein Ja timesheet work
353 2024-05-06 22:15 00:11 6960 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit 1,25 h koeser Nein Ja timesheet work
354 2024-05-10 09:35 13:35 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit telco knaack Nein Ja timesheet work
355 2024-05-10 14:00 18:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit telco knaack Nein Ja timesheet work
356 2024-05-13 11:30 14:00 9000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
357 2024-05-15 10:00 13:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
358 2024-05-15 14:30 17:00 9000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit renofloor Nein Ja timesheet work
359 2024-05-16 16:30 18:30 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit ancora Nein Ja timesheet work
360 2024-05-17 15:30 00:30 32400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Koeser Nein Ja timesheet work
361 2024-05-18 10:00 18:00 28800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit koeser Nein Ja timesheet work
362 2024-05-21 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit normal Arbet & 1 h Workshop Nein Ja timesheet work
363 2024-05-22 10:00 16:00 21600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit 3h CVC Teleco Hirdes Nein Ja timesheet work
364 2024-05-27 12:00 14:00 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
365 2024-05-28 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
366 2024-05-29 12:30 16:30 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
367 2024-05-30 13:44 17:45 14460 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
368 2024-05-30 14:00 19:00 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Hyper V Ablöse Allgöwer Nein Ja timesheet work
369 2024-05-31 13:44 17:45 14460 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
370 2024-05-31 15:00 22:00 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Probelme mit dem Umzug bei Knaack Nein Ja timesheet work
371 2024-06-03 09:30 14:00 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit kontrolle CMK &Mail & telco schambach DT-Computer Nein Ja timesheet work
372 2024-06-04 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit 0.5 telco chriz wegen sedc & 5.5 h sedc & 1h workshop zamba ad Nein Ja timesheet work
373 2024-06-10 09:35 13:00 12300 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
374 2024-06-10 13:30 14:30 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
375 2024-06-11 09:45 12:00 8100 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
376 2024-06-11 13:00 14:45 6300 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
377 2024-06-12 09:34 17:34 28800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
378 2024-06-14 16:15 18:45 9000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
379 2024-06-16 12:30 16:30 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Dienstreise nach AB Nein Ja timesheet work
380 2024-06-17 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Vorbereitung Workshop in AB Nein Ja timesheet work
381 2024-06-18 09:00 18:00 32400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Workshop Nein Ja timesheet work
382 2024-06-19 09:00 16:30 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Workshop Nein Ja timesheet work
383 2024-06-20 10:30 18:00 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit arbeiten im Loft Nein Ja timesheet work
384 2024-06-21 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit arbeiten im Loft Nein Ja timesheet work
385 2024-06-21 15:00 19:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Dienstreise von AB nach MD Nein Ja timesheet work
386 2024-06-24 09:45 15:00 18900 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit 15 min Mails && 5 h sedc Nein Ja timesheet work
387 2024-06-25 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
388 2024-06-26 10:00 17:30 27000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit sedc Nein Ja timesheet work
389 2024-07-01 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
390 2024-07-02 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit sedc 4,5h Nein Ja timesheet work
391 2024-07-03 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit cmk &mails Nein Ja timesheet work
392 2024-07-04 11:00 15:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit renee cmk Nein Ja timesheet work
393 2024-07-05 15:00 19:30 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit sedc Nein Ja timesheet work
394 2024-07-15 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
395 2024-07-16 17:00 21:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
396 2024-07-17 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
397 2024-07-18 16:30 18:30 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
398 2024-07-22 10:00 14:54 17640 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
399 2024-07-23 09:45 14:15 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
400 2024-07-29 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
401 2024-07-30 10:00 14:30 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
402 2024-07-31 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
403 2024-08-02 16:00 18:00 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
404 2024-08-12 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
405 2024-08-13 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
406 2024-08-14 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
407 2024-08-14 22:00 23:00 3600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Absprache mit Hubrich wegen OPnsense umstellung Nein Ja timesheet work
408 2024-08-15 16:30 18:30 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
409 2024-08-19 09:30 14:00 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
410 2024-08-21 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
411 2024-08-22 02:44 04:45 7260 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
412 2024-08-26 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
413 2024-08-27 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
414 2024-08-28 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit OPNsense Renofloor Nein Ja timesheet work
415 2024-08-29 17:00 20:00 10800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
416 2024-08-30 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
417 2024-09-02 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit sedc ispconfig Nein Ja timesheet work
418 2024-09-03 12:01 16:02 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
419 2024-09-04 12:01 19:02 25260 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
420 2024-09-05 12:01 14:02 7260 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
421 2024-09-06 12:01 16:02 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
422 2024-09-09 12:02 16:02 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
423 2024-09-10 12:02 16:02 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
424 2024-09-11 12:02 19:02 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
425 2024-09-12 12:02 14:02 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
426 2024-09-13 12:02 16:02 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
427 2024-09-16 10:30 14:00 12600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
428 2024-09-17 10:30 14:00 12600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
429 2024-09-18 10:30 14:00 12600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
430 2024-09-23 13:51 17:52 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
431 2024-09-24 13:51 17:52 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
432 2024-09-25 13:51 17:52 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
433 2024-09-26 13:51 17:52 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
434 2024-09-27 13:51 17:52 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
435 2024-09-30 10:00 15:15 18900 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
436 2024-09-30 13:51 17:52 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
437 2024-10-01 13:51 17:52 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
438 2024-10-02 13:51 17:52 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
439 2024-10-03 13:51 17:52 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
440 2024-10-04 13:51 17:52 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
441 2024-10-07 13:51 17:52 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
442 2024-10-08 13:51 17:52 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
443 2024-10-09 13:51 17:52 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
444 2024-10-10 13:51 17:52 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
445 2024-10-11 13:51 17:52 14460 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
446 2024-10-14 13:45 17:46 14460 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
447 2024-10-15 13:45 17:46 14460 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
448 2024-10-16 13:45 17:46 14460 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
449 2024-10-17 13:45 17:46 14460 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
450 2024-10-18 13:45 17:46 14460 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
451 2024-10-21 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
452 2024-10-22 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
453 2024-10-23 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
454 2024-10-24 10:39 16:39 21600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
455 2024-10-28 10:00 16:30 23400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
456 2024-10-28 17:30 21:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Koeser Nein Ja timesheet work
457 2024-10-29 09:00 11:45 9900 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
458 2024-10-30 10:30 13:15 9900 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
459 2024-11-04 10:02 14:47 17100 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
460 2024-11-04 21:38 22:43 3900 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
461 2024-11-05 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
462 2024-11-06 10:00 17:30 27000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit 2h ancora 3,5h sedc Nein Ja timesheet work
463 2024-11-11 10:00 14:44 17040 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit check mk 1h, 1,5h Arbeitsanweisungen, 1 h Streaming einstellen Nein Ja timesheet work
464 2024-11-12 13:00 17:45 17100 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Workshop Proxmox mit ZFS Nein Ja timesheet work
465 2024-11-13 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
466 2024-11-14 13:00 18:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Workshop Proxmox mit ZFS Nein Ja timesheet work
467 2024-11-18 10:00 12:30 9000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit CMK Pflege Nein Ja timesheet work
468 2024-11-18 12:30 14:00 5400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit anacora Nein Ja timesheet work
469 2024-11-19 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
470 2024-11-20 10:00 16:00 21600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
471 2024-11-21 16:00 17:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
472 2024-11-21 17:00 17:00 0 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
473 2024-11-25 13:43 17:44 14460 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
474 2024-11-26 13:43 17:44 14460 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
475 2024-11-27 13:43 20:44 25260 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
476 2024-11-28 13:43 15:44 7260 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
477 2024-11-29 13:43 15:44 7260 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
478 2024-11-30 13:43 14:44 3660 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
479 2024-12-02 09:30 14:56 19560 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
480 2024-12-03 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
481 2024-12-04 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
482 2024-12-05 16:00 17:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
483 2024-12-05 20:00 23:00 10800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
484 2024-12-06 15:30 19:30 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
485 2024-12-08 19:00 23:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Dienstreise von MD nach AB Nein Ja timesheet work
486 2024-12-09 10:04 18:04 28800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
487 2024-12-10 10:00 18:00 28800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit ab 12:30 Workshop Nein Ja Workshop timesheet work
488 2024-12-11 10:00 18:00 28800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
489 2024-12-12 10:00 18:00 28800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit ab 12:30 Workshop Nein Ja Workshop timesheet work
490 2024-12-13 10:30 15:30 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
491 2024-12-13 15:30 19:30 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Dienstreise von AB nach MD Nein Ja timesheet work
492 2024-12-16 10:00 14:45 17100 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
493 2024-12-17 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
494 2024-12-18 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
495 2024-12-19 16:00 20:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
496 2024-12-20 14:45 17:00 8100 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
497 2024-12-23 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
498 2025-01-02 10:00 11:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Warten auf Chef, abarbeiten von cmk Nein Ja timesheet work
499 2025-01-02 17:00 20:00 10800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Tickets Nein Ja timesheet work
500 2025-01-03 15:00 20:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Dokumentation Backup Revision Nein Ja timesheet work
501 2025-01-07 10:00 14:40 16800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
502 2025-01-08 10:00 17:14 26040 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
503 2025-01-09 13:10 17:10 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
504 2025-01-10 16:00 17:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
505 2025-01-13 09:46 14:46 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
506 2025-01-14 09:50 14:57 18420 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
507 2025-01-15 10:00 17:25 26700 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
508 2025-01-16 15:45 19:45 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
509 2025-01-17 15:00 19:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
510 2025-01-20 09:30 14:45 18900 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
511 2025-01-21 09:45 15:00 18900 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
512 2025-01-22 09:43 17:13 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
513 2025-01-27 09:36 14:58 19320 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
514 2025-01-28 09:44 14:44 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
515 2025-01-29 10:05 17:10 25500 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
516 2025-01-30 16:00 18:00 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
517 2025-01-31 15:25 16:25 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
518 2025-02-03 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
519 2025-02-04 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
520 2025-02-05 10:19 17:19 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
521 2025-02-06 10:00 17:30 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
522 2025-02-10 09:50 14:50 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Martens Nein Ja timesheet work
523 2025-02-11 09:55 14:00 14700 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
524 2025-02-12 10:00 17:28 26880 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
525 2025-02-13 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
526 2025-02-14 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
527 2025-02-17 09:30 14:53 19380 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
528 2025-02-18 10:15 15:00 17100 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
529 2025-02-19 09:53 17:53 28800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
530 2025-02-20 10:30 14:30 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
531 2025-02-24 12:38 16:38 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
532 2025-02-25 12:38 16:38 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
533 2025-02-26 09:13 13:13 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
534 2025-02-27 12:38 16:38 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
535 2025-02-28 12:38 16:38 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
536 2025-03-03 10:00 14:30 16200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
537 2025-03-04 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
538 2025-03-05 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
539 2025-03-06 11:00 15:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
540 2025-03-07 10:15 14:15 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
541 2025-03-10 09:45 14:45 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
542 2025-03-11 09:14 13:45 16260 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
543 2025-03-12 09:00 15:00 21600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
544 2025-03-13 10:15 16:15 21600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
545 2025-03-14 16:00 17:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
546 2025-03-17 10:15 14:45 16200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
547 2025-03-18 10:00 14:30 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
548 2025-03-19 10:00 17:30 27000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
549 2025-03-20 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
550 2025-03-24 10:00 14:30 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
551 2025-03-25 13:15 18:45 19800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
552 2025-03-26 10:30 17:30 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Komplett Einrichtung Aristhodos Nein Ja timesheet work
553 2025-03-26 22:00 00:00 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Ticket kontrolle Nein Ja timesheet work
554 2025-03-31 10:00 14:30 16200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
555 2025-04-01 10:00 14:30 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
556 2025-04-02 10:00 14:30 16200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
557 2025-04-03 10:00 17:30 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
558 2025-04-07 10:00 14:30 16200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
559 2025-04-08 10:00 14:30 16200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
560 2025-04-09 10:00 17:30 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
561 2025-04-10 10:00 16:00 21600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
562 2025-04-14 09:45 14:45 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
563 2025-05-05 09:30 14:00 16200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
564 2025-05-06 09:45 14:15 16200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
565 2025-05-07 09:45 17:00 26100 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
566 2025-05-08 09:45 16:30 24300 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
567 2025-05-09 16:00 17:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
568 2025-05-12 09:30 14:30 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
569 2025-05-13 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
570 2025-05-14 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
571 2025-05-15 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
572 2025-05-16 16:30 17:30 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
573 2025-05-19 10:00 14:30 16200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
574 2025-05-20 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
575 2025-05-20 22:00 00:00 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
576 2025-05-22 09:39 17:00 26460 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
577 2025-05-26 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
578 2025-05-27 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
579 2025-05-28 09:30 17:30 28800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
580 2025-06-01 16:00 21:30 19800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Heimatadresse > Anreise Hotel Moers Nein Ja timesheet work
581 2025-06-02 08:30 17:00 30600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Anreise Lehrgang KRZN Nein Ja timesheet work
582 2025-06-02 21:18 22:48 5400 Patrick Perlbach patrick sysops Leistungen für sysops Überstunden oder Nachtarbeit Nein Ja timesheet work
583 2025-06-03 08:30 17:00 30600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Anreise Lehrgang KRZN Nein Ja timesheet work
584 2025-06-03 21:15 23:15 7200 Patrick Perlbach patrick sysops Leistungen für sysops Überstunden oder Nachtarbeit Nein Ja timesheet work
585 2025-06-04 08:30 17:00 30600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Anreise Lehrgang KRZN Nein Ja timesheet work
586 2025-06-04 21:00 23:00 7200 Patrick Perlbach patrick sysops Leistungen für sysops Überstunden oder Nachtarbeit Nein Ja timesheet work
587 2025-06-05 08:30 17:00 30600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Anreise Lehrgang KRZN Nein Ja timesheet work
588 2025-06-05 23:18 00:18 3600 Patrick Perlbach patrick sysops Leistungen für sysops Überstunden oder Nachtarbeit Nein Ja timesheet work
589 2025-06-06 08:30 14:00 19800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Abreise Lehrgang >Heimatadresse Nein Ja timesheet work
590 2025-06-09 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
591 2025-06-10 10:00 14:45 17100 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
592 2025-06-11 10:00 17:30 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
593 2025-06-12 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
594 2025-06-15 17:00 21:30 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Heimatadresse > KRZN Nein Ja timesheet work
595 2025-06-15 22:00 23:00 3600 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit Nein Ja timesheet work
596 2025-06-16 08:00 17:00 32400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit KRZN Lehrgang Nein Ja timesheet work
597 2025-06-16 20:00 22:30 9000 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit Nein Ja timesheet work
598 2025-06-17 08:00 17:00 32400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit KRZN Lehrgang Nein Ja timesheet work
599 2025-06-18 09:00 15:30 23400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit KRZN > Heimtadresse Nein Ja timesheet work
600 2025-06-19 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Sonn- und Feiertage Nein Ja timesheet work
601 2025-06-23 10:00 14:30 16200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
602 2025-06-24 10:00 14:45 17100 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
603 2025-06-25 10:00 17:30 27000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
604 2025-06-26 10:00 16:00 21600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
605 2025-06-27 11:00 16:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
606 2025-06-30 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
607 2025-07-01 10:00 14:30 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
608 2025-07-02 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
609 2025-07-03 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
610 2025-07-04 15:00 16:00 3600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
611 2025-07-06 17:00 22:00 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Heimatadresse > KRZN Nein Ja timesheet work
612 2025-07-07 08:00 17:00 32400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
613 2025-07-07 22:00 23:30 5400 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit Nein Ja timesheet work
614 2025-07-08 08:00 18:00 36000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
615 2025-07-08 22:00 00:00 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit Nein Ja timesheet work
616 2025-07-09 08:00 16:00 28800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
617 2025-07-10 08:00 16:30 30600 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
618 2025-07-10 22:00 23:30 5400 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit Nein Ja timesheet work
619 2025-07-11 10:00 15:30 19800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Mettmann >>> Heimatadresse Nein Ja timesheet work
620 2025-07-14 10:00 14:45 17100 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
621 2025-07-15 10:00 16:00 21600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
622 2025-08-04 10:00 14:30 16200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
623 2025-08-05 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
624 2025-08-06 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
625 2025-08-07 12:30 17:00 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
626 2025-08-08 18:00 02:00 28800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Kahle Nein Ja timesheet work
627 2025-08-11 09:00 15:00 21600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
628 2025-08-11 21:30 01:00 12600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Kontrollen Nein Ja timesheet work
629 2025-08-12 09:00 15:00 21600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
630 2025-08-12 21:00 01:30 16200 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit Kress repl umstellung Nein Ja timesheet work
631 2025-08-13 09:00 17:00 28800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
632 2025-08-13 22:00 00:00 7200 Patrick Perlbach patrick sysops Leistungen für sysops Überstunden oder Nachtarbeit Nein Ja timesheet work
633 2025-08-14 09:30 17:00 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
634 2025-08-18 09:30 15:00 19800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
635 2025-08-18 22:00 00:00 7200 Patrick Perlbach patrick sysops Leistungen für sysops Überstunden oder Nachtarbeit Nein Ja timesheet work
636 2025-08-19 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
637 2025-08-19 22:00 01:00 10800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
638 2025-08-20 09:30 17:30 28800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
639 2025-08-20 23:00 01:00 7200 Patrick Perlbach patrick sysops Leistungen für sysops Überstunden oder Nachtarbeit Nein Ja timesheet work
640 2025-08-21 10:00 16:30 23400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
641 2025-08-24 21:00 04:00 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit KRESS TRMM WAZUH CMK, kontrollen Nein Ja timesheet work
642 2025-08-25 09:00 15:00 21600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
643 2025-08-26 09:30 15:00 19800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
644 2025-08-27 22:00 01:00 10800 Patrick Perlbach patrick sysops Leistungen für sysops Überstunden oder Nachtarbeit check mk ausmisten Nein Ja timesheet work
645 2025-08-28 09:00 14:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
646 2025-09-01 09:15 14:15 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
647 2025-09-01 21:45 00:30 9900 Patrick Perlbach patrick sysops Leistungen für sysops Überstunden oder Nachtarbeit Nein Ja timesheet work
648 2025-09-02 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
649 2025-09-02 22:00 01:00 10800 Patrick Perlbach patrick sysops Leistungen für sysops Überstunden oder Nachtarbeit Nein Ja timesheet work
650 2025-09-03 09:30 14:30 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
651 2025-09-03 15:30 18:00 9000 Patrick Perlbach patrick sysops Leistungen für sysops Überstunden oder Nachtarbeit Nein Ja timesheet work
652 2025-09-04 09:30 14:30 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
653 2025-09-04 15:30 18:00 9000 Patrick Perlbach patrick sysops Leistungen für sysops Überstunden oder Nachtarbeit Nein Ja timesheet work
654 2025-09-08 15:05 19:05 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Urlaub Nein Ja timesheet work
655 2025-09-09 15:05 19:05 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Urlaub Nein Ja timesheet work
656 2025-09-10 15:05 19:05 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Urlaub Nein Ja timesheet work
657 2025-09-11 15:05 19:05 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Urlaub Nein Ja timesheet work
658 2025-09-12 15:05 19:05 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Urlaub Nein Ja timesheet work
659 2025-09-15 09:30 15:00 19800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
660 2025-09-16 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
661 2025-09-17 09:45 17:15 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
662 2025-09-18 11:30 13:30 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
663 2025-09-18 15:30 18:30 10800 Patrick Perlbach patrick sysops Leistungen für sysops Überstunden oder Nachtarbeit Nein Ja timesheet work
664 2025-09-19 16:00 17:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Überstunden oder Nachtarbeit Nein Ja timesheet work
665 2025-09-22 08:30 12:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Heimatanschrift > ULM Nein Ja timesheet work
666 2025-09-22 12:30 17:30 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit Heimatanschrift > ULM Nein Ja timesheet work
667 2025-09-23 08:30 12:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit ULM Nein Ja timesheet work
668 2025-09-23 12:30 17:30 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit ULM Nein Ja timesheet work
669 2025-09-24 08:30 12:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit ULM Nein Ja timesheet work
670 2025-09-24 12:30 18:00 19800 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit ULM Nein Ja timesheet work
671 2025-09-25 08:30 12:30 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit ULM Nein Ja timesheet work
672 2025-09-25 12:30 18:00 19800 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit ULM Nein Ja timesheet work
673 2025-09-26 10:00 14:00 14400 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit ULM > Heimatanschrift Nein Ja timesheet work
674 2025-09-26 14:00 17:30 12600 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit ULM > Heimatanschrift Nein Ja timesheet work
675 2025-11-03 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
676 2025-11-04 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
677 2025-11-05 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
678 2025-11-06 09:30 16:30 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
679 2025-11-07 16:00 17:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
680 2025-11-07 22:30 23:30 3600 Patrick Perlbach patrick sysops Leistungen für Kunden Überstunden oder Nachtarbeit Koeser PVE9 Update Nein Ja timesheet work
681 2025-11-10 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
682 2025-11-10 22:00 23:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Überstunden oder Nachtarbeit Nein Ja timesheet work
683 2025-11-11 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
684 2025-11-12 10:00 17:30 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
685 2025-11-13 10:00 17:30 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
686 2025-11-14 16:00 17:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
687 2025-11-17 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
688 2025-11-18 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
689 2025-11-19 10:00 17:30 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
690 2025-11-20 10:00 18:00 28800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
691 2025-11-21 16:00 17:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
692 2025-11-24 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
693 2025-11-25 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
694 2025-11-26 10:00 17:30 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
695 2025-11-27 10:00 16:00 21600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
696 2025-12-01 00:00 04:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
697 2025-12-02 22:51 02:51 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
698 2025-12-03 22:51 02:51 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
699 2025-12-04 22:51 02:51 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
700 2025-12-05 22:51 02:51 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
701 2025-12-08 22:51 02:51 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
702 2025-12-09 22:51 02:51 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
703 2025-12-10 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
704 2025-12-12 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
705 2025-12-15 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
706 2025-12-16 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
707 2025-12-17 10:00 17:30 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
708 2025-12-18 10:00 18:00 28800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
709 2025-12-19 15:00 16:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
710 2025-12-22 10:00 13:30 12600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
711 2025-12-22 16:00 18:30 9000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
712 2025-12-23 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
713 2025-12-24 10:00 12:00 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
714 2025-12-26 09:00 13:00 14400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
715 2025-12-26 10:00 15:00 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
716 2025-12-29 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
717 2025-12-30 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
718 2025-12-30 21:00 22:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
719 2026-01-02 00:03 04:03 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
720 2026-01-05 10:00 14:00 14400 Patrick Perlbach patrick sysops Urlaub Reguläre Arbeitszeit Nein Ja timesheet work
721 2026-01-07 10:00 17:30 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
722 2026-01-08 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
723 2026-01-09 09:15 14:15 18000 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
724 2026-01-09 17:00 00:00 25200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Allgoewer Nein Ja timesheet work
725 2026-02-17 09:30 15:00 19800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
726 2026-02-17 15:30 17:30 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
727 2026-02-17 21:00 23:00 7200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
728 2026-02-23 09:00 17:00 28800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Dienstreise Nein Ja timesheet work
729 2026-02-24 10:00 17:00 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Dienstreise Nein Ja timesheet work
730 2026-02-25 08:30 17:30 32400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Dienstreise Nein Ja timesheet work
731 2026-02-26 08:35 18:05 34200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Dienstreise Nein Ja timesheet work
732 2026-02-27 10:00 16:00 21600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Dienstreise Nein Ja timesheet work
733 2026-03-02 09:30 15:00 19800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
734 2026-03-03 09:15 16:15 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
735 2026-03-03 19:00 22:00 10800 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Kress Kontrolle Update KGPM237 Nein Ja timesheet work
736 2026-03-04 09:15 17:00 27900 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
737 2026-03-05 15:00 17:00 7200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Nein Ja timesheet work
738 2026-03-05 17:00 02:30 34200 Patrick Perlbach patrick sysops Leistungen für Kunden Reguläre Arbeitszeit Kress Umbau Offenbach Nein Ja timesheet work
739 2026-03-06 16:00 17:00 3600 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
740 2026-03-09 09:30 15:00 19800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
741 2026-03-10 09:15 16:15 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
742 2026-03-11 09:30 16:00 23400 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Chef Urlaub Nein Ja timesheet work
743 2026-03-12 09:45 17:15 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Chriz Urlaub Nein Ja timesheet work
744 2026-03-16 09:30 15:00 19800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
745 2026-03-17 09:15 16:15 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
746 2026-03-18 09:30 17:00 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
747 2026-03-19 09:45 17:15 27000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
748 2026-03-23 09:30 15:00 19800 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
749 2026-03-24 09:30 16:30 25200 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
750 2026-03-25 09:15 17:30 29700 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
751 2026-03-26 09:30 14:30 18000 Patrick Perlbach patrick sysops Leistungen für sysops Reguläre Arbeitszeit Nein Ja timesheet work
+347
View File
@@ -0,0 +1,347 @@
# TimeMaster Projektkontext für Claude Code
## Was ist das?
Zeiterfassung & HR-Tool (ähnlich Timebutler.de). DSGVO-konform, EU-Server.
## Tech-Stack
- **Backend**: Python 3.12 · FastAPI · SQLAlchemy (async) · PostgreSQL 16 · Redis 7
- **Frontend**: React 18 · TypeScript · Tailwind CSS
- **Deployment**: Nativ auf Ubuntu (systemd + nginx) kein Docker in Phase 1
- **Tests**: pytest + pytest-asyncio
## Aktueller Stand
### ✅ agent-01-auth (FERTIG)
- FastAPI App, CORS, Health-Endpoint
- JWT Access Token (30 min) + Refresh Token Rotation (30 Tage)
- 5 Rollen: SUPER_ADMIN, COMPANY_ADMIN, HR, MANAGER, EMPLOYEE
- `require_role()` FastAPI Dependency
- Models: Company, Department, User, Session, PasswordReset, AuditLog
- Routers: /api/v1/auth · /api/v1/users · /api/v1/companies
- Services: auth_service, user_service, email_service
- Alembic Migration: 0001_initial (alle Auth-Tabellen)
- 20+ pytest Tests
### 🚧 agent-02-zeiterfassung (TODO Woche 3-4)
Dateien anlegen in:
- `backend/app/models/time_entry.py`
- `backend/app/models/work_schedule.py`
- `backend/app/schemas/time_entry.py`
- `backend/app/routers/time_entries.py`
- `backend/app/services/time_service.py` (inkl. ArbZG-Prüfung)
- `backend/migrations/versions/0002_time_entries.py`
- `backend/tests/test_time.py`
API-Endpunkte:
```
POST /api/v1/time/stamp-in
POST /api/v1/time/stamp-out
POST /api/v1/time/break-start
POST /api/v1/time/break-end
GET /api/v1/time/today
GET /api/v1/time/entries
POST /api/v1/time/entries
PATCH /api/v1/time/entries/{id}
POST /api/v1/time/entries/{id}/approve
POST /api/v1/time/entries/{id}/reject
GET /api/v1/time/balance/{user_id}
```
### ✅ agent-03-abwesenheit (FERTIG siehe weiter unten)
### 🚧 agent-02-kiosk (TODO Woche 5, nach agent-02)
- `backend/app/models/kiosk_device.py`
- `backend/app/routers/kiosk.py`
- `backend/app/services/kiosk_service.py`
Auth-Methoden: PIN · NFC (Web NFC API) · QR (jsQR) · Mitarbeiter-Liste
### ✅ agent-03-abwesenheit (FERTIG deployed)
- Models: AbsenceType, Absence, VacationBalance, PublicHoliday, OvertimeBalance
- Rollen-basierte Genehmigung (MANAGER/HR/ADMIN)
- Urlaubskonto: Grundurlaub + Sondertage + Resturlaub (auto carry-over)
- Frontend: AbsencesPage mit Liste-Tab + Jahresplaner (Person×Monat-Grid + Monats-Gantt)
- TOTP/2FA: pyotp, QR-Setup, partial JWT Login-Flow, Profil-Seite
- Migrationen: 0015 (TOTP), 0016 (special_days) deployed
### ✅ agent-05-krankmeldung (FERTIG deployed 2026-05-06)
Erweiterung des Abwesenheits-Moduls speziell für Kranktage:
**Backend:**
- `Company.sick_note_required_after_days` (Default 3) Firmen-Default
- `AbsenceType.certificate_after_days` als Per-Typ-Override (Override gewinnt)
- Auto-Berechnung `certificate_required_by` beim Anlegen einer SICK-Absence
- `POST /absences/quick-sick` Sofort-Krankmeldung (auto-approved, nutzt ersten aktiven SICK-Typ)
- `PATCH /absences/{id}/certificate` HR/Admin markiert Attest als eingegangen
- `GET /absences/sick-stats?user_id=&ref_date=` Bradford-Faktor (rolling 12 Monate), Episoden, Tage, AU-überfällig
**Frontend:**
- 🤒 "Krank melden" Schnell-Button im AbsencesPage-Header (orange, neben "+ Antrag stellen")
- AU-überfällig-Badge in Liste (orange) + grüner "Attest ✓"-Badge wenn eingegangen
- HR/Admin-Action "Attest erhalten" in Liste (sichtbar für HR/COMPANY_ADMIN/SUPER_ADMIN, **nicht** MANAGER)
- ReportsPage: vierter Tab "Krankmeldungen" mit KPIs (⌀ Bradford, Σ Tage, Σ Episoden, AU offen) + sortierte Tabelle pro Mitarbeiter
- Bradford-Heatmap aufgeschoben
**Migration:** `0022_sick_note_config.py` `companies.sick_note_required_after_days INTEGER DEFAULT 3` (chained on 0020, da 0017 nicht in der Alembic-Kette war)
**Tests:** 5 neue pytest-Cases in `test_absences.py` (quick_sick, cert auto-calc, type override, mark_certificate, bradford) alle grün
### 🚧 agent-06-auditlog (TODO)
AuditLog-Einträge sind bereits in der DB (Tabelle `audit_logs`, Model vorhanden seit agent-01).
Fehlend: Backend-Endpunkt + Frontend-Seite für SUPER_ADMIN / COMPANY_ADMIN.
**Backend:**
- Neuer Endpunkt: `GET /audit-logs/` mit Filtern:
- `user_id`, `action`, `entity_type`, `date_from`, `date_to`, `limit`, `offset`
- Nur COMPANY_ADMIN/SUPER_ADMIN, company-isoliert
- Felder die zurückgegeben werden: `id`, `user_id`, `user_name`, `action`, `entity_type`, `entity_id`, `old_value`, `new_value`, `ip_address`, `created_at`
- Router: `backend/app/routers/audit.py`
**Frontend:**
- Neue Seite: `frontend/src/pages/AuditLogPage.tsx`
- Route: `/audit-log` (nur COMPANY_ADMIN, SUPER_ADMIN)
- Filter: Zeitraum, Benutzer, Aktion, Entity-Typ
- Tabelle: Zeitstempel, Benutzer, Aktion, Betroffenes Objekt, IP
- Detail-Expand: old_value / new_value als JSON-Diff
- Export als CSV
- Navigation: Eintrag in Einstellungen-Dropdown in Layout.tsx
### 🚧 agent-04-dashboard (TODO Woche 6-7)
- Mitarbeiter-, Manager-, Admin-Dashboard
- Reports: Anwesenheit, Abwesenheit, Überstunden, Kranktage (Bradford-Faktor)
- Export: PDF (WeasyPrint) · CSV · XLSX (openpyxl)
### 🚧 agent-07-personalnummer (Phase 1 ✅ deployed 2026-05-05; Phase 2-5 offen)
Eindeutige Personalnummer pro Mitarbeiter, in allen API-Antworten, Exports und Kiosk-Login nutzbar.
**Backend:**
- `User.personnel_number: str(50) | None` Format **nur Ziffern** (`^[0-9]+$`), max 50 Stellen
- Partial Unique Index: `UNIQUE (company_id, personnel_number) WHERE personnel_number IS NOT NULL`
- **Reservierung**: Bei Deaktivierung bleibt Nr. am User, wird nie wieder vergeben
- `Company`-Felder:
- `personnel_number_required: bool` (default false → Pflicht erst nach Aktivierung in Firmen-Settings; gilt nur für **neue** User, Bestandsuser bleiben)
- `personnel_number_mode: enum('manual'|'auto')` (default manual)
- `personnel_number_next: int` (Counter, beginnt bei 1, 4-stellig zero-padded → `0001`, kein Präfix wegen „nur Ziffern"-Regel)
- Auto-Modus erlaubt manuelles Override durch Admin (Counter läuft normal weiter)
- Race-Condition-Schutz: `UPDATE companies SET personnel_number_next = personnel_number_next + 1 RETURNING ...` (atomic)
- Endpoints:
- `GET /users/next-personnel-number` Vorschlag basierend auf Counter
- `GET /users/by-personnel/{number}` Lookup für externe Integrationen
- `GET /users/import-template.csv` Vorlage für Bulk-Import
- `POST /users/import` (multipart CSV) Bulk-Import:
- Doppelte E-Mail im Import (zwei Zeilen) → Fehler
- E-Mail existiert aktiv → Fehler
- E-Mail existiert aber User deaktiviert → **Reaktivieren mit neuen Daten**
- Leere Personalnr. → Auto-Vergabe (auch im Manuell-Modus)
- `GET /users` Query-Param `search` filtert auch Personalnr.
- AuditLog bei jeder Personalnr.-Änderung
- LDAP-Sync: bei Konflikt mit reservierter/vergebener Nr. → Fehler, LDAP-Wert wird verworfen (kein Override)
- Personalnr. in CSV/XLSX/PDF-Exports (`reports.py`), CalDAV-Template-Platzhalter `$personalnummer`, LDAP-Sync-Mapping `employeeNumber`
**Frontend:**
- `UsersPage`: Spalte "Pers.-Nr.", Suche schließt Personalnr. ein, Edit-Modal mit Live-Verfügbarkeitsprüfung
- Invite-Modal: Eingabefeld + "Vorschlagen"-Button (Auto-Modus)
- `ProfilePage`: Read-only Anzeige
- `CompanySettingsPage`: Toggles Pflicht/Modus/Präfix/Counter
- `ReportsPage` + Exports: Spalte ergänzt
- CSV-Import-UI mit Vorlagen-Download und Validierungs-Vorschau
**Migration:** `0020_personnel_number.py`
- `users.personnel_number VARCHAR(50) NULL` + Partial Unique Index + CHECK constraint `~ '^[0-9]+$'`
- `companies.personnel_number_required BOOLEAN DEFAULT FALSE`
- `companies.personnel_number_mode VARCHAR(10) DEFAULT 'manual'`
- `companies.personnel_number_next INTEGER DEFAULT 1`
**Tests**:
- pytest: Unit/Integration für Service-Logik, CSV-Import, Race-Condition (parallele Inserts)
- Playwright-Setup wird im Rahmen von agent-08 aufgesetzt CSV-Upload-UI dann mit testen
### 🚧 agent-08-kiosk-haertung (TODO kritisch vor Produktiv-Einsatz)
MITM-resistente Absicherung des Kiosk-Modus. Aktueller Token-basierter Auth ist nicht ausreichend gegen TLS-Inspection-Proxies und Insider-Angriffe (siehe Security-Audit).
**Architektur-Entscheidung: Browser bleibt Kiosk-Plattform** native App mittelfristig, aber nicht in dieser Phase. Trust-Boundary: IT-Admin der Kiosk-Geräte.
**Krypto-Entscheidung: Ed25519-Public-Key statt mTLS+HMAC**
Statt mTLS-Client-Zertifikate + HMAC-Shared-Secret nutzt jedes Kiosk-Gerät ein **Ed25519-Keypair** (gleicher Algo wie moderne SSH-Keys). Public Key wird beim Server registriert, Private Key bleibt non-extractable im Kiosk-Browser. Vorteile: kein CA-Management, kein Shared Secret, Remote-Enrollment trivial via CLI auf Server, einfacheres nginx (kein mTLS), kryptographisch gleichwertig oder besser.
**Backend:**
- `KioskDevice` erweitern:
- `status: enum('pending','approved','revoked')` (löst `is_active` ab)
- `public_key: text` Ed25519 Public Key (PEM oder OpenSSH-Format)
- `key_algorithm: str default 'ed25519'` (Vorbereitung für spätere Algos)
- `last_heartbeat_at: datetime | None`
- `client_version: str | None`
- `current_user_id: uuid | null` (wer aktuell eingestempelt ist DSGVO: pro Firma deaktivierbar)
- `offline_queue_size: int default 0`
- `ip_whitelist: text | null` (CIDR-Liste, in Production verpflichtend)
- `enrollment_token_hash: str | None` + `enrollment_expires_at` (Setup-URL, max 30 min nicht Token, nur Geräte-ID-Bindung)
- `Company.kiosk_require_approval: bool default true`
- `Company.kiosk_track_current_user: bool default true` (DSGVO-Opt-Out)
**Sichere Stempel-Auth pro Request** (neue Dependency `verify_kiosk_request()`):
- TLS-Pflicht: nginx HTTPS-only + HSTS, App lehnt non-TLS in Production ab
- HTTP-Header pro Request:
- `X-Kiosk-Key-Id`: UUID des Geräts
- `X-Kiosk-Timestamp`: Unix-Sekunden (max 30s Drift; Server-Time autoritativ)
- `X-Kiosk-Nonce`: UUID, einmalig (Replay-Schutz)
- `X-Kiosk-Signature`: Base64(Ed25519-Signatur über `METHOD + PATH + TIMESTAMP + NONCE + sha256(BODY)`)
- Backend verifiziert mit gespeichertem Public Key (Python: `cryptography.hazmat.primitives.asymmetric.ed25519`)
- Nonce-Cache 60s in Redis (Replay-Window = Drift-Window)
- IP-Whitelist-Check pro Gerät
**CLI-Tool (neu: `backend/cli.py`, mit Typer)**:
```bash
# Auf Server ausführen (root@192.168.1.137)
timemaster kiosk add \
--company "Acme GmbH" \
--name "Eingang Berlin" \
--location "Hauptgebäude" \
--pubkey ~/.ssh/kiosk_berlin.pub \
--ip-whitelist 10.0.0.0/24
# Output: device_id + Setup-URL für Kiosk-Browser
# https://timemaster.example.com/kiosk/setup?id=<uuid>&token=<one-time>
timemaster kiosk list [--company X] [--status pending|approved|revoked]
timemaster kiosk approve <device_id>
timemaster kiosk revoke <device_id>
timemaster kiosk rotate-key <device_id> # Setup-URL für neuen Pubkey
```
CLI nutzt direkten DB-Zugriff über bestehende SQLAlchemy-Models, keine HTTP-API.
**Enrollment-Flow** (Remote-tauglich):
1. **Vor Ort/Remote**: IT-Person erzeugt auf Kiosk-Gerät ein Ed25519-Keypair (entweder im Browser via WebCrypto oder per `ssh-keygen -t ed25519` lokal). Private Key bleibt am Gerät.
2. **Public Key** wird an Admin gesendet (E-Mail/Chat reicht ist public)
3. **Admin** SSH'ed auf Server, registriert per CLI: `timemaster kiosk add --pubkey ...`
4. CLI gibt **Setup-URL** + Geräte-ID zurück → Admin schickt URL an IT-Person
5. Kiosk-Browser öffnet Setup-URL → bindet bestehenden privaten Key an Geräte-ID, signiert ersten Test-Request
6. Admin sieht Gerät in WebGUI als `pending` → Klick „Approve" → ab jetzt aktiv
7. **Privater Key verlässt nie das Kiosk-Gerät**, **Server kennt nur Public Key**
Optional Web-Enrollment-Variante (für lokale Filiale, Admin steht daneben):
- Admin generiert in WebGUI Setup-URL → QR-Code → Kiosk scannt → Browser generiert Keypair → Public Key per HTTP-POST an Backend → Admin approved.
**Key-Rotation**: kein Auto-Rotate (Public Key altert nicht). Manueller Re-Enrollment via `timemaster kiosk rotate-key` falls Verdacht auf Kompromittierung.
**Heartbeat & Liveness**:
- Endpoint `POST /kiosk/heartbeat` alle 30s (signiert wie Stempel-Requests sonst Fake-Online möglich)
- Body: `{ uptime_seconds, current_user_id?, browser_version, queued_offline_entries, client_version }`
- Server-Status: **online** (<90s), **stale** (<5min), **offline** (älter)
- Heartbeat-Intervall pro Firma einstellbar (default 30s, max 120s)
**Kiosk-Frontend (separate Route `/kiosk`)**:
- ServiceWorker übernimmt Ed25519-Signierung aller Requests
- WebCrypto Ed25519 mit `extractable: false` (XSS kann Key nicht exfiltrieren)
- Browser-Anforderung: Chrome 113+, Safari 16.4+, Firefox 130+ (alle ab 2023/2024)
- Heartbeat-Loop alle 30s
- Offline-Queue mit IndexedDB (Sync sobald wieder online) Queue-Größe an Server reportet
- BroadcastChannel zur Tab-Koordination (nur ein Tab sendet Heartbeat)
- Kiosk-Login per Personalnummer + PIN als zusätzliche Methode (nur Kiosk, nicht Web)
- Server-Time wird im Heartbeat-Response zurückgeliefert → UI zeigt Server-Zeit, Stempel-Timestamp serverseitig erzwungen
**Frontend-Verwaltung (`KioskDevicesPage`)**:
- Tab "Wartet auf Freigabe" + Tab "Aktive Geräte" mit Live-Status (Auto-Refresh 30s)
- Status-Ampel-Spalte (🟢/🟡/🔴), letzter Heartbeat, aktueller User, Client-Version
- Public-Key-Fingerprint anzeigen (zur Verifikation)
- Sortier-/Filter-Option "nur offline"
- `Layout.tsx`: Health-Badge oben rechts für COMPANY_ADMIN + HR ("2/3 Kiosks online")
**Anomalie-Detection** (Phase 2 nach Grundgerüst):
- Stempel-Frequenz-Threshold pro Gerät
- IP-Sprung-Detection (Geo-Inkonsistenz)
- Ungewöhnlicher User-Mix pro Gerät → AuditLog-Alarm
**nginx-Konfiguration** (`nginx.conf`): keine mTLS-Änderungen nötig. Standard-HTTPS + HSTS reicht Auth läuft komplett auf Application-Layer.
**Migration:** `0021_kiosk_security.py`
- `kiosk_devices`: neue Spalten + Drop von `is_active` (durch `status` ersetzt)
- Bestehende Geräte → Default-Status `revoked` (müssen neu enrolled werden, sicher)
- `companies.kiosk_require_approval`, `kiosk_track_current_user`, `kiosk_heartbeat_interval_sec`
**Sicherheits-Bewertung (Ed25519-Variante)**:
| Schutz | Erreicht | Restrisiko |
|---------------------------------|----------|------------|
| Passiver MITM (mitlesen) | ✅ TLS + Signaturen | Public Key + signierte Requests sichtbar (nicht ausnutzbar) |
| Aktiver MITM (Replay/Modify) | ✅ Signatur+Nonce+Timestamp | |
| Shared-Secret-Diebstahl | ✅ entfällt kein Secret | |
| PrivKey-Diebstahl (XSS) | ✅ non-extractable WebCrypto | Key bleibt im Browser-Speicher |
| Enrollment-Phishing | ✅ Public Key offen austauschbar | |
| Remote-Filialen-Enrollment | ✅ trivial via CLI | |
| CA-Verlust = alle Kiosks tot | ✅ entfällt keine CA | |
| IT-Admin als Innentäter | ⚠️ teilweise | nur native App + Hardware-Keystore |
| Zeit-Manipulation am Kiosk | ✅ Server-Time autoritativ | |
| Browser-Cache-Clear → Re-Enroll | ⚠️ ja | manueller Re-Enroll nötig (akzeptiert) |
**Aufteilung in PRs**:
1. Backend: Ed25519-Verifizierung + CLI-Tool + Migration (kein Frontend)
2. Frontend Kiosk-Mode: ServiceWorker + WebCrypto + Setup-Flow + Offline-Queue
3. Frontend Verwaltung: KioskDevicesPage-Erweiterung + Health-Badge im Layout
4. Phase 2: Anomalie-Detection
## Wichtige Konventionen
### Backend
- Alle DB-Operationen async (SQLAlchemy `AsyncSession`)
- Services enthalten Business-Logik, Router nur HTTP-Handling
- Pydantic v2 Schemas mit `model_validate()` statt `.from_orm()`
- Fehler immer als `HTTPException` mit sprechendem `detail`
- Tokens nie im Klartext in DB immer `hash_token()` aus `core/security.py`
- Kein Hard-Delete bei Users nur `is_active = False`
### Sicherheit
- Passwort-Validierung: min. 8 Zeichen, 1 Großbuchstabe, 1 Ziffer
- Rate-Limiting für Auth-Endpunkte (TODO: slowapi einbauen)
- Audit-Log bei allen sensitiven Aktionen
### ArbZG-Regeln (für agent-02)
- Max. 8h Arbeitszeit / Tag (Ausnahme bis 10h möglich)
- Pause ab 6h: 30 min · ab 9h: 45 min
- Mindestruhezeit zwischen Schichten: 11h
- Warnung bei Überschreitung, kein hartes Blockieren
## Datenbankschema Status
- Migration 0001: companies, departments, users, sessions, password_resets, audit_logs ✅
- Migration 0002: time_entries, work_schedules (TODO)
- Migration 00030016: absence_types, absences, vacation_balances, public_holidays, overtime_balance, totp, special_days ✅
- Migration 0017: (übersprungen Nummer war nie in der Alembic-Kette)
- Migration 0018: kiosk_devices ✅
- Migration 0019: manual_time_entry_permission ✅
- Migration 0020: personnel_number (users + companies) ✅ (agent-07 Phase 1 deployed)
- Migration 0021: kiosk_security (mTLS, HMAC, heartbeat, enrollment) TODO (agent-08)
- Migration 0022: sick_note_config (companies.sick_note_required_after_days) ✅ (agent-05 deployed 2026-05-06)
## Umgebung
### Lokal (Claude Code läuft hier)
- Entwicklung & Dateien bearbeiten: `~/projects/timemaster/`
- Tests lokal ausführen: `pytest -v`
- Kein lokaler Python/Postgres nötig alles läuft auf dem Server
### Deployment-Server
- **Host**: `root@192.168.1.137`
- **Projektpfad**: `/opt/timemaster/`
- **venv**: `/opt/timemaster/backend/venv/`
- **Service**: `systemctl restart timemaster`
- **Logs**: `journalctl -u timemaster -f`
### Deployment-Workflow (nach jeder Änderung)
```bash
# Dateien auf Server synchronisieren
rsync -avz --exclude='__pycache__' --exclude='*.pyc' --exclude='.env' \
~/projects/timemaster/backend/ \
root@192.168.1.137:/opt/timemaster/backend/
# Migration ausführen (falls neue Alembic-Version)
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic upgrade head"
# Service neu starten
ssh root@192.168.1.137 "systemctl restart timemaster"
# Logs prüfen
ssh root@192.168.1.137 "journalctl -u timemaster -n 50"
```
### Erstes Setup auf dem Server (einmalig)
```bash
ssh root@192.168.1.137 "bash /opt/timemaster/setup_server.sh"
```
+527
View File
@@ -0,0 +1,527 @@
# TimeMaster Dev Log
## 2026-03-28 21:59 22:02 (3m)
**Beschreibung:** Projekte-Feature entfernen, Timetrack einbauen
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-28 21:59 22:02 (3m)
**Beschreibung:** Projekte-Feature entfernen, Timetrack einbauen
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-28 21:59 22:02 (3m)
**Beschreibung:** Projekte-Feature entfernen, Timetrack einbauen
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-28 22:22 22:22 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-28 22:24 22:25 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 14:31 19:45 (5h 14m)
**Beschreibung:** Claude Code Session
**Projekt:** archivmail
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 19:46 19:47 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 19:49 19:49 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 19:52 19:52 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 19:54 19:56 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 19:58 20:00 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 20:06 20:06 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 20:06 20:08 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 20:11 20:11 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 20:12 20:12 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 20:12 20:12 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 20:14 20:14 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 20:15 20:15 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 20:17 20:17 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 20:19 20:22 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 20:38 20:38 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 20:39 20:39 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 20:46 20:50 (4m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 21:05 21:08 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 21:09 21:13 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 21:17 21:18 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 21:27 21:31 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-05 17:06 17:07 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-05 17:10 17:11 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-05 17:20 17:20 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-05 17:27 17:27 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-05 17:31 17:32 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-05 17:39 17:40 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-05 17:41 17:44 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-05 17:47 17:49 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-05 17:57 17:58 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-05 18:18 18:19 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-05 18:27 18:32 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-05 19:50 19:52 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-05 19:52 19:53 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-05 22:43 10:21 (11h 37m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 17:20 17:34 (14m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 17:34 17:35 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 22:47 22:51 (4m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 22:54 22:55 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 23:00 23:00 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 23:00 23:10 (9m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-23 19:20 19:21 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
View File
+29
View File
@@ -0,0 +1,29 @@
# Test-Benutzer
Alle Test-User sind in der Firma **VSB Magdeburg** angelegt.
Passwort für alle: `Test1234!`
| E-Mail | Rolle | Name |
|---------------------------|----------------|---------------|
| super.admin@tm-test.de | SUPER_ADMIN | Super Admin |
| firma.admin@tm-test.de | COMPANY_ADMIN | Firma Admin |
| hannah.hr@tm-test.de | HR | Hannah HR |
| max.manager@tm-test.de | MANAGER | Max Manager |
| emil.employee@tm-test.de | EMPLOYEE | Emil Employee |
## Produktiv-User
| E-Mail | Rolle | Name |
|-----------------------|----------------|------------------|
| patrick@perlbach24.de | COMPANY_ADMIN | Patrick Perlbach |
| bundyxl@gmx.de | EMPLOYEE | Patrick Test |
## Neu anlegen (SQL)
```sql
-- Passwort-Hash für 'Test1234!' erzeugen:
-- /opt/timemaster/backend/venv/bin/python3 -c "import bcrypt; print(bcrypt.hashpw(b'Test1234!', bcrypt.gensalt(rounds=12)).decode())"
INSERT INTO users (id, company_id, email, first_name, last_name, role, password_hash, is_active, auth_provider)
VALUES (gen_random_uuid(), '<company_id>', '<email>', '<first>', '<last>', '<role>', '<hash>', true, 'local');
```
+24
View File
@@ -0,0 +1,24 @@
# App
APP_NAME=TimeMaster
APP_ENV=development
SECRET_KEY=change-me-in-production-min-32-chars-long
FRONTEND_URL=http://localhost:5173
# Database
DATABASE_URL=postgresql+asyncpg://timemaster:timemaster_secret@db:5432/timemaster_db
# Redis
REDIS_URL=redis://redis:6379/0
# JWT
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=30
# Email (Resend)
RESEND_API_KEY=re_your_api_key_here
EMAIL_FROM=noreply@yourdomain.com
EMAIL_FROM_NAME=TimeMaster
# Superadmin (erstellt beim ersten Start)
FIRST_SUPERADMIN_EMAIL=admin@yourdomain.com
FIRST_SUPERADMIN_PASSWORD=change-me-immediately
+184
View File
@@ -0,0 +1,184 @@
# TimeMaster agent-01-auth
Backend: Python 3.12 · FastAPI · SQLAlchemy (async) · PostgreSQL · Redis
Laufend nativ auf dem Server (kein Docker in Phase 1).
---
## Voraussetzungen (Ubuntu 22.04 / 24.04)
```bash
sudo apt update && sudo apt install -y \
python3.12 python3.12-venv python3.12-dev \
postgresql postgresql-contrib \
redis-server nginx git build-essential libpq-dev
```
---
## 1 · PostgreSQL einrichten
```bash
sudo systemctl enable --now postgresql
sudo -u postgres psql <<SQL
CREATE ROLE timemaster LOGIN PASSWORD 'ein_sicheres_passwort';
CREATE DATABASE timemaster_db OWNER timemaster;
GRANT ALL PRIVILEGES ON DATABASE timemaster_db TO timemaster;
SQL
```
---
## 2 · Redis starten
```bash
sudo systemctl enable --now redis-server
```
---
## 3 · Projekt klonen & venv anlegen
```bash
sudo mkdir -p /opt/timemaster
sudo chown $USER:$USER /opt/timemaster
git clone https://github.com/dein-repo/timemaster.git /opt/timemaster
cd /opt/timemaster/backend
python3.12 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
```
---
## 4 · Umgebungsvariablen setzen
```bash
cp .env.example .env
nano .env # Werte anpassen: DATABASE_URL, SECRET_KEY, RESEND_API_KEY usw.
```
Wichtige Felder:
```
SECRET_KEY=<min. 32 zufällige Zeichen>
DATABASE_URL=postgresql+asyncpg://timemaster:passwort@localhost:5432/timemaster_db
FRONTEND_URL=https://deine-domain.de
```
---
## 5 · Datenbank-Migration ausführen
```bash
cd /opt/timemaster/backend
source venv/bin/activate
alembic upgrade head
```
---
## 6 · Server starten (Entwicklung)
```bash
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
API-Docs (nur dev): http://localhost:8000/docs
---
## 7 · Systemd-Service einrichten (Produktion)
```bash
sudo cp /opt/timemaster/timemaster.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now timemaster
sudo systemctl status timemaster
```
---
## 8 · Nginx einrichten
```bash
sudo cp /opt/timemaster/nginx.conf /etc/nginx/sites-available/timemaster
sudo ln -s /etc/nginx/sites-available/timemaster /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
# SSL via Let's Encrypt
sudo certbot --nginx -d deine-domain.de
```
---
## 9 · Tests ausführen
```bash
cd /opt/timemaster/backend
source venv/bin/activate
pip install aiosqlite # nur für Tests (SQLite in-memory)
pytest -v
```
---
## API-Übersicht (agent-01)
| Method | Endpoint | Beschreibung |
|--------|----------|-------------|
| POST | /api/v1/auth/register | Firma + Admin anlegen |
| POST | /api/v1/auth/login | Login → JWT + Refresh Token |
| POST | /api/v1/auth/refresh | Access Token erneuern |
| POST | /api/v1/auth/logout | Session beenden |
| GET | /api/v1/auth/me | Aktueller Nutzer |
| POST | /api/v1/auth/password-reset | Reset-Link anfordern |
| POST | /api/v1/auth/password-reset/confirm | Passwort neu setzen |
| POST | /api/v1/auth/invite/accept | Einladung annehmen |
| GET | /api/v1/users/ | Alle Nutzer (Admin/HR) |
| POST | /api/v1/users/invite | Nutzer einladen |
| GET | /api/v1/users/me | Eigenes Profil |
| GET | /api/v1/users/{id} | Nutzer abrufen |
| PATCH| /api/v1/users/{id} | Nutzer bearbeiten |
| POST | /api/v1/users/{id}/deactivate | Deaktivieren |
| POST | /api/v1/users/{id}/reactivate | Reaktivieren |
| POST | /api/v1/users/{id}/kiosk-pin | Kiosk-PIN setzen |
| GET | /api/v1/companies/me | Firmenprofil |
| PATCH| /api/v1/companies/me | Firmenprofil bearbeiten |
| GET | /api/v1/companies/me/departments | Abteilungen |
| POST | /api/v1/companies/me/departments | Abteilung anlegen |
| PATCH| /api/v1/companies/me/departments/{id} | Abteilung bearbeiten |
| DELETE| /api/v1/companies/me/departments/{id} | Abteilung löschen |
---
## Dateistruktur
```
backend/
├── app/
│ ├── main.py ← FastAPI App
│ ├── core/
│ │ ├── config.py ← Settings (.env)
│ │ ├── database.py ← AsyncEngine + get_db
│ │ ├── security.py ← JWT, Hashing, Tokens
│ │ └── dependencies.py ← get_current_user, require_role
│ ├── models/ ← SQLAlchemy ORM
│ ├── schemas/ ← Pydantic v2
│ ├── routers/ ← API-Endpunkte
│ └── services/ ← Business-Logik
├── migrations/ ← Alembic
├── tests/ ← pytest
├── alembic.ini
├── pytest.ini
└── requirements.txt
```
---
## Nächste Schritte (Sprint 2)
- **agent-02-zeiterfassung**: Stempeluhr, Zeit-Einträge, ArbZG-Prüfung
- **agent-03-abwesenheit**: Urlaubsanträge, Genehmigungsflow, Kalender
+39
View File
@@ -0,0 +1,39 @@
[alembic]
script_location = migrations
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
View File
View File
+54
View File
@@ -0,0 +1,54 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import model_validator
from functools import lru_cache
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# App
app_name: str = "TimeMaster"
app_env: str = "development"
secret_key: str = "change-me-in-production"
frontend_url: str = "http://localhost:5173"
allowed_hosts: list[str] = []
# Database
database_url: str = "postgresql+asyncpg://timemaster:secret@localhost:5432/timemaster_db"
# Redis
redis_url: str = "redis://localhost:6379/0"
# JWT
access_token_expire_minutes: int = 30
refresh_token_expire_days: int = 30
algorithm: str = "HS256"
# Email
resend_api_key: str = ""
email_from: str = "noreply@timemaster.app"
email_from_name: str = "TimeMaster"
# First superadmin
first_superadmin_email: str = ""
first_superadmin_password: str = ""
@model_validator(mode='after')
def validate_secret_key(self):
if self.app_env == 'production' and self.secret_key == 'change-me-in-production':
raise ValueError('SECRET_KEY must be changed in production! Set SECRET_KEY env variable.')
if len(self.secret_key) < 32:
raise ValueError('SECRET_KEY must be at least 32 characters long.')
return self
@property
def is_production(self) -> bool:
return self.app_env == "production"
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+33
View File
@@ -0,0 +1,33 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(
settings.database_url,
echo=settings.app_env == "development",
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+63
View File
@@ -0,0 +1,63 @@
from typing import Annotated
from uuid import UUID
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models.user import User, UserRole
bearer_scheme = HTTPBearer()
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_access_token(credentials.credentials)
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = await db.get(User, UUID(user_id))
if user is None or not user.is_active:
raise credentials_exception
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
def require_role(*roles: UserRole):
"""Dependency factory: require_role(UserRole.MANAGER, UserRole.COMPANY_ADMIN)"""
async def checker(current_user: CurrentUser) -> User:
if current_user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions",
)
return current_user
return Depends(checker)
def require_same_company(target_company_id: UUID, current_user: User) -> None:
"""Raise 403 if user tries to access another company's data."""
if (
current_user.role != UserRole.SUPER_ADMIN
and current_user.company_id != target_company_id
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access to this resource is not allowed",
)
+4
View File
@@ -0,0 +1,4 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
+81
View File
@@ -0,0 +1,81 @@
from datetime import datetime, timedelta, timezone
from typing import Any
import secrets
import hashlib
import bcrypt
from jose import JWTError, jwt
from app.core.config import settings
# ── Password ────────────────────────────────────────────────────────────────
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode()
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
def hash_token(token: str) -> str:
"""SHA-256 hash for storing tokens (refresh, invite, reset) in DB."""
return hashlib.sha256(token.encode()).hexdigest()
# ── JWT ─────────────────────────────────────────────────────────────────────
def create_access_token(subject: str, extra: dict[str, Any] | None = None) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.access_token_expire_minutes
)
payload = {"sub": subject, "exp": expire, "type": "access"}
if extra:
payload.update(extra)
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def create_refresh_token() -> tuple[str, str]:
"""Returns (raw_token, hashed_token). Store hash in DB, send raw to client."""
raw = secrets.token_urlsafe(64)
return raw, hash_token(raw)
def decode_access_token(token: str) -> dict[str, Any]:
"""Raises JWTError on invalid/expired token."""
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("type") != "access":
raise JWTError("Invalid token type")
return payload
# ── Partial token (TOTP pending) ─────────────────────────────────────────────
def create_partial_token(user_id: str) -> str:
"""Short-lived token issued after password-OK but before TOTP verification. Valid 5 min."""
expire = datetime.now(timezone.utc) + timedelta(minutes=5)
payload = {"sub": user_id, "exp": expire, "type": "partial"}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def decode_partial_token(token: str) -> str:
"""Returns user_id (sub). Raises JWTError on invalid/expired/wrong-type token."""
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("type") != "partial":
raise JWTError("Invalid token type")
return payload["sub"]
# ── One-time tokens ──────────────────────────────────────────────────────────
def generate_invite_token() -> tuple[str, str]:
"""Returns (raw, hashed). Invite valid for 7 days."""
raw = secrets.token_urlsafe(32)
return raw, hash_token(raw)
def generate_reset_token() -> tuple[str, str]:
"""Returns (raw, hashed). Reset valid for 1 hour."""
raw = secrets.token_urlsafe(32)
return raw, hash_token(raw)
+80
View File
@@ -0,0 +1,80 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from app.core.config import settings
from app.core.database import engine, Base
from app.core.limiter import limiter
from app.routers import auth, users, companies
from app.routers import time_entries, absences, reports, ldap, smtp, caldav
from app.routers import import_kimai
from app.routers import kiosk
from app.routers import busylight
from app.routers import audit
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: Tabellen anlegen falls noch nicht vorhanden (Alembic übernimmt das in Prod)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
# Shutdown
await engine.dispose()
app = FastAPI(
title=settings.app_name,
version="0.1.0",
docs_url="/docs" if not settings.is_production else None,
redoc_url="/redoc" if not settings.is_production else None,
lifespan=lifespan,
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# ── Middleware ────────────────────────────────────────────────────────────────
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_url],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# TODO (M-07): TrustedHostMiddleware set ALLOWED_HOSTS env variable (comma-separated) in production.
# Example: ALLOWED_HOSTS=timemaster.example.com,www.timemaster.example.com
# The placeholder "yourdomain.com" has been replaced with a config-driven approach.
if settings.is_production and settings.allowed_hosts:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.allowed_hosts)
# ── Routers ───────────────────────────────────────────────────────────────────
API_PREFIX = "/api/v1"
app.include_router(auth.router, prefix=API_PREFIX)
app.include_router(users.router, prefix=API_PREFIX)
app.include_router(companies.router, prefix=API_PREFIX)
app.include_router(time_entries.router, prefix=API_PREFIX)
app.include_router(absences.router, prefix=API_PREFIX)
app.include_router(reports.router, prefix=API_PREFIX)
app.include_router(ldap.router, prefix=API_PREFIX)
app.include_router(smtp.router, prefix=API_PREFIX)
app.include_router(caldav.router, prefix=API_PREFIX)
app.include_router(import_kimai.router, prefix=API_PREFIX)
app.include_router(kiosk.router, prefix=API_PREFIX)
app.include_router(busylight.router, prefix=API_PREFIX)
app.include_router(audit.router, prefix=API_PREFIX)
# ── Health ────────────────────────────────────────────────────────────────────
@app.get("/health", tags=["System"])
async def health():
return {"status": "ok", "app": settings.app_name, "env": settings.app_env}
+38
View File
@@ -0,0 +1,38 @@
from app.models.company import Company
from app.models.department import Department
from app.models.user import User, UserRole
from app.models.session import Session
from app.models.password_reset import PasswordReset
from app.models.audit_log import AuditLog
from app.models.work_schedule import WorkSchedule
from app.models.time_entry import TimeEntry, EntryStatus, EntrySource
from app.models.absence_type import AbsenceType
from app.models.absence import Absence, AbsenceStatus
from app.models.vacation_balance import VacationBalance
from app.models.overtime_balance import OvertimeBalance
from app.models.public_holiday import PublicHoliday
from app.models.smtp_config import SmtpConfig
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
from app.models.kiosk_device import KioskDevice, KioskAuthMethod
__all__ = [
"Company",
"Department",
"User",
"UserRole",
"Session",
"PasswordReset",
"AuditLog",
"WorkSchedule",
"TimeEntry",
"EntryStatus",
"EntrySource",
"AbsenceType",
"Absence",
"AbsenceStatus",
"VacationBalance",
"OvertimeBalance",
"PublicHoliday",
"KioskDevice",
"KioskAuthMethod",
]
+87
View File
@@ -0,0 +1,87 @@
import uuid
import enum
from datetime import date, datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.absence_type import AbsenceType
class AbsenceStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
CANCELLED = "cancelled"
class Absence(Base):
__tablename__ = "absences"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
type_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("absence_types.id", ondelete="RESTRICT"), nullable=False
)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date] = mapped_column(Date, nullable=False)
half_day_start: Mapped[bool] = mapped_column(Boolean, default=False)
half_day_end: Mapped[bool] = mapped_column(Boolean, default=False)
working_days: Mapped[float] = mapped_column(Numeric(5, 1), default=0)
status: Mapped[AbsenceStatus] = mapped_column(
Enum(AbsenceStatus, name="absencestatus", values_callable=lambda x: [e.value for e in x]),
nullable=False, default=AbsenceStatus.PENDING,
)
approved_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
)
substitute_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
)
note: Mapped[str | None] = mapped_column(Text)
rejection_reason: Mapped[str | None] = mapped_column(Text)
correction_note: Mapped[str | None] = mapped_column(Text)
# Zusatzinformationen (Weiterbildung, Dienstreise, etc.)
# Struktur je Kategorie:
# training: {"course_name": str, "provider": str, "location": str}
# business_trip: {"destination": str, "purpose": str}
meta: Mapped[dict | None] = mapped_column(JSONB)
# Krankheit: Arbeitsunfähigkeitsbescheinigung
certificate_required_by: Mapped[date | None] = mapped_column(Date)
certificate_received_at: Mapped[date | None] = mapped_column(Date)
# CalDAV-Sync
caldav_uid: Mapped[str | None] = mapped_column(String(255))
caldav_user_etag: Mapped[str | None] = mapped_column(Text)
caldav_company_etag: Mapped[str | None] = mapped_column(Text)
caldav_last_error: Mapped[str | None] = mapped_column(Text)
caldav_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship(
"User", primaryjoin="Absence.user_id == User.id",
foreign_keys="[Absence.user_id]", lazy="noload",
)
absence_type: Mapped["AbsenceType"] = relationship("AbsenceType", lazy="noload")
approver: Mapped["User | None"] = relationship(
"User", primaryjoin="Absence.approved_by == User.id",
foreign_keys="[Absence.approved_by]", lazy="noload",
)
substitute: Mapped["User | None"] = relationship(
"User", primaryjoin="Absence.substitute_id == User.id",
foreign_keys="[Absence.substitute_id]", lazy="noload",
)
def __repr__(self) -> str:
return f"<Absence {self.user_id} {self.start_date}{self.end_date} [{self.status}]>"
+49
View File
@@ -0,0 +1,49 @@
import enum
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, Enum, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.company import Company
class AbsenceCategory(str, enum.Enum):
VACATION = "vacation"
SICK = "sick"
OVERTIME_COMP = "overtime_comp"
TRAINING = "training"
BUSINESS_TRIP = "business_trip"
OTHER = "other"
class AbsenceType(Base):
__tablename__ = "absence_types"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
color: Mapped[str] = mapped_column(String(7), default="#3B82F6")
category: Mapped[AbsenceCategory] = mapped_column(
Enum(AbsenceCategory, name="absencecategory", values_callable=lambda x: [e.value for e in x]),
nullable=False, default=AbsenceCategory.OTHER,
)
requires_approval: Mapped[bool] = mapped_column(Boolean, default=True)
deducts_vacation: Mapped[bool] = mapped_column(Boolean, default=False)
affects_overtime_balance: Mapped[bool] = mapped_column(Boolean, default=False)
requires_certificate: Mapped[bool] = mapped_column(Boolean, default=False)
certificate_after_days: Mapped[int] = mapped_column(Integer, default=3)
is_paid: Mapped[bool] = mapped_column(Boolean, default=True)
max_days_per_year: Mapped[int | None] = mapped_column(Integer)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
company: Mapped["Company"] = relationship("Company", lazy="noload")
def __repr__(self) -> str:
return f"<AbsenceType {self.name} [{self.category}]>"
+23
View File
@@ -0,0 +1,23 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("companies.id", ondelete="SET NULL"), index=True)
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), index=True)
action: Mapped[str] = mapped_column(String(100), nullable=False)
entity_type: Mapped[str | None] = mapped_column(String(100))
entity_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
old_value: Mapped[dict | None] = mapped_column(JSONB)
new_value: Mapped[dict | None] = mapped_column(JSONB)
ip: Mapped[str | None] = mapped_column(String(45))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
+64
View File
@@ -0,0 +1,64 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func # noqa: F401
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.company import Company
from app.models.user import User
class CaldavCompanyConfig(Base):
"""Zentraler Firmenkalender alle genehmigten Abwesenheiten landen hier."""
__tablename__ = "caldav_company_configs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"),
nullable=False, unique=True, index=True,
)
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
principal_url: Mapped[str] = mapped_column(Text, nullable=False)
calendar_url: Mapped[str | None] = mapped_column(Text)
username: Mapped[str] = mapped_column(String(255), nullable=False)
password_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
calendar_display_name: Mapped[str] = mapped_column(String(255), default="")
verify_ssl: Mapped[bool] = mapped_column(Boolean, default=True)
name_template: Mapped[str] = mapped_column(Text, default="$vorname $nachname $typ")
last_error: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
company: Mapped["Company"] = relationship("Company", lazy="noload")
class CaldavUserConfig(Base):
"""Persönlicher Kalender des Mitarbeiters."""
__tablename__ = "caldav_user_configs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, unique=True, index=True,
)
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
principal_url: Mapped[str] = mapped_column(Text, nullable=False)
calendar_url: Mapped[str | None] = mapped_column(Text)
username: Mapped[str] = mapped_column(String(255), nullable=False)
password_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
calendar_display_name: Mapped[str] = mapped_column(String(255), default="")
verify_ssl: Mapped[bool] = mapped_column(Boolean, default=True)
last_error: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped["User"] = relationship("User", lazy="noload")
+52
View File
@@ -0,0 +1,52 @@
import enum
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.department import Department
class PersonnelNumberMode(str, enum.Enum):
MANUAL = "manual"
AUTO = "auto"
class Company(Base):
__tablename__ = "companies"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
plan: Mapped[str] = mapped_column(String(50), default="trial")
logo_url: Mapped[str | None] = mapped_column(Text)
country: Mapped[str] = mapped_column(String(10), default="DE")
state: Mapped[str | None] = mapped_column(String(10))
settings: Mapped[dict] = mapped_column(JSONB, default=dict)
# Personalnummern-Konfiguration
personnel_number_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
personnel_number_mode: Mapped[str] = mapped_column(String(10), nullable=False, default=PersonnelNumberMode.MANUAL.value)
personnel_number_next: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
# Krankmeldungs-Konfiguration: Default-Schwelle für AU-Pflicht (in Tagen).
# Pro AbsenceType via certificate_after_days überschreibbar.
sick_note_required_after_days: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
# Busylight-Pull: SHA-256-Hash des per-Firma-Tokens (Klartext nie in DB).
busylight_pull_token_hash: Mapped[str | None] = mapped_column(String(64), unique=True)
busylight_token_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Relationships
users: Mapped[list["User"]] = relationship("User", back_populates="company", lazy="noload")
departments: Mapped[list["Department"]] = relationship("Department", back_populates="company", lazy="noload")
def __repr__(self) -> str:
return f"<Company {self.name}>"
+40
View File
@@ -0,0 +1,40 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.company import Company
from app.models.user import User
class Department(Base):
__tablename__ = "departments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
manager_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL", use_alter=True, name="fk_dep_manager"))
# Relationships
company: Mapped["Company"] = relationship("Company", back_populates="departments")
members: Mapped[list["User"]] = relationship(
"User",
primaryjoin="User.department_id == Department.id",
foreign_keys="[User.department_id]",
back_populates="department",
lazy="noload",
)
manager: Mapped["User | None"] = relationship(
"User",
primaryjoin="Department.manager_id == User.id",
foreign_keys="[Department.manager_id]",
lazy="noload",
)
def __repr__(self) -> str:
return f"<Department {self.name}>"
+41
View File
@@ -0,0 +1,41 @@
import uuid
import enum
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.company import Company
class KioskAuthMethod(str, enum.Enum):
PIN = "pin"
NFC = "nfc"
QR = "qr"
LIST = "list" # Mitarbeiter-Liste
class KioskDevice(Base):
__tablename__ = "kiosk_devices"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"),
nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
location: Mapped[str | None] = mapped_column(String(255))
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
company: Mapped["Company"] = relationship("Company", lazy="noload")
def __repr__(self) -> str:
return f"<KioskDevice {self.name} ({self.company_id})>"
+60
View File
@@ -0,0 +1,60 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.company import Company
class LdapConfig(Base):
__tablename__ = "ldap_configs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"),
nullable=False, unique=True, index=True
)
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
# Server
host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, default=389)
use_ssl: Mapped[bool] = mapped_column(Boolean, default=False)
use_tls: Mapped[bool] = mapped_column(Boolean, default=False)
tls_verify: Mapped[bool] = mapped_column(Boolean, default=False)
# Bind credentials
bind_dn: Mapped[str] = mapped_column(Text, nullable=False)
bind_password_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
# Search
base_dn: Mapped[str] = mapped_column(Text, nullable=False)
user_search_filter: Mapped[str] = mapped_column(
String(512), nullable=False, default="(objectClass=person)"
)
# Attribute mapping
attr_email: Mapped[str] = mapped_column(String(100), default="mail")
attr_firstname: Mapped[str] = mapped_column(String(100), default="givenName")
attr_lastname: Mapped[str] = mapped_column(String(100), default="sn")
attr_username: Mapped[str] = mapped_column(String(100), default="sAMAccountName")
attr_department: Mapped[str | None] = mapped_column(String(100))
attr_personnel_number: Mapped[str | None] = mapped_column(String(100))
# Sync state
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
company: Mapped["Company"] = relationship("Company", lazy="noload")
def __repr__(self) -> str:
return f"<LdapConfig {self.host} company={self.company_id}>"
+50
View File
@@ -0,0 +1,50 @@
import uuid
from datetime import datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, Numeric, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.company import Company
class OvertimeBalance(Base):
"""Kumuliertes Überstundenguthaben pro Mitarbeiter.
total_hours = Summe aller genehmigten Überstunden aus time_entries
taken_hours = bereits als Freizeitausgleich genommene Stunden
available = total_hours - taken_hours
"""
__tablename__ = "overtime_balances"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, unique=True, index=True,
)
company_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False, index=True
)
total_hours: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=Decimal("0"))
taken_hours: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=Decimal("0"))
last_calculated: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped["User"] = relationship("User", lazy="noload")
company: Mapped["Company"] = relationship("Company", lazy="noload")
@property
def available_hours(self) -> Decimal:
return self.total_hours - self.taken_hours
def __repr__(self) -> str:
return f"<OvertimeBalance user={self.user_id} total={self.total_hours}h taken={self.taken_hours}h>"
+25
View File
@@ -0,0 +1,25 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class PasswordReset(Base):
__tablename__ = "password_resets"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship("User")
+27
View File
@@ -0,0 +1,27 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Numeric, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Project(Base):
__tablename__ = "projects"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
color: Mapped[str] = mapped_column(String(7), default="#3B82F6") # Tailwind blue-500
budget_hours: Mapped[float | None] = mapped_column(Numeric(8, 2))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
def __repr__(self) -> str:
return f"<Project {self.name} ({self.company_id})>"
+26
View File
@@ -0,0 +1,26 @@
import uuid
from datetime import date
from sqlalchemy import Boolean, Date, Integer, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class PublicHoliday(Base):
__tablename__ = "public_holidays"
__table_args__ = (
UniqueConstraint("country", "state", "date", name="uq_public_holiday"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
country: Mapped[str] = mapped_column(String(10), nullable=False)
state: Mapped[str | None] = mapped_column(String(10)) # z.B. "BY", "NW" für Bundesländer
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
year: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
is_high_rate: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
def __repr__(self) -> str:
return f"<PublicHoliday {self.name} {self.date}>"
+30
View File
@@ -0,0 +1,30 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import INET, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class Session(Base):
__tablename__ = "sessions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
refresh_token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
device: Mapped[str | None] = mapped_column(String(255))
ip: Mapped[str | None] = mapped_column(String(45)) # supports IPv6
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Relationships
user: Mapped["User"] = relationship("User", back_populates="sessions")
def __repr__(self) -> str:
return f"<Session user={self.user_id} expires={self.expires_at}>"
+32
View File
@@ -0,0 +1,32 @@
"""
SMTP-Konfiguration pro Firma.
Passwort wird Fernet-verschlüsselt gespeichert (gleiche Methode wie ldap_service).
"""
import uuid
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class SmtpConfig(Base):
__tablename__ = "smtp_configs"
__table_args__ = (UniqueConstraint("company_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, default=587, nullable=False)
use_tls: Mapped[bool] = mapped_column(Boolean, default=False) # SMTPS port 465
use_starttls: Mapped[bool] = mapped_column(Boolean, default=True) # STARTTLS port 587
username: Mapped[str | None] = mapped_column(String(255))
password_encrypted: Mapped[str | None] = mapped_column(Text)
from_email: Mapped[str] = mapped_column(String(255), nullable=False)
from_name: Mapped[str] = mapped_column(String(255), default="TimeMaster", nullable=False)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
+81
View File
@@ -0,0 +1,81 @@
import uuid
import enum
from datetime import date, datetime, time
from typing import TYPE_CHECKING
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Integer, String, Text, Time, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class EntryStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class EntrySource(str, enum.Enum):
WEB = "web"
KIOSK = "kiosk"
API = "api"
MANUAL = "manual"
class TimeEntry(Base):
__tablename__ = "time_entries"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
start_time: Mapped[time] = mapped_column(Time(timezone=False), nullable=False)
end_time: Mapped[time | None] = mapped_column(Time(timezone=False))
break_minutes: Mapped[int] = mapped_column(Integer, default=0)
break_start: Mapped[time | None] = mapped_column(Time(timezone=False)) # Aktive Pause tracken
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
note: Mapped[str | None] = mapped_column(Text)
status: Mapped[EntryStatus] = mapped_column(
Enum(EntryStatus, name="entrystatus", values_callable=lambda x: [e.value for e in x]), nullable=False, default=EntryStatus.PENDING
)
source: Mapped[EntrySource] = mapped_column(
Enum(EntrySource, name="entrysource", values_callable=lambda x: [e.value for e in x]), nullable=False, default=EntrySource.WEB
)
approved_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
)
correction_note: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship(
"User", primaryjoin="TimeEntry.user_id == User.id",
foreign_keys="[TimeEntry.user_id]", lazy="noload"
)
approver: Mapped["User | None"] = relationship(
"User", primaryjoin="TimeEntry.approved_by == User.id",
foreign_keys="[TimeEntry.approved_by]", lazy="noload"
)
@property
def worked_minutes(self) -> int | None:
"""Gearbeitete Minuten (ohne Pausen), None wenn noch offen."""
if self.end_time is None:
return None
start_total = self.start_time.hour * 60 + self.start_time.minute
end_total = self.end_time.hour * 60 + self.end_time.minute
if end_total <= start_total:
end_total += 24 * 60 # overnight shift
return max(0, end_total - start_total - self.break_minutes)
@property
def worked_hours(self) -> float | None:
mins = self.worked_minutes
return round(mins / 60, 2) if mins is not None else None
def __repr__(self) -> str:
return f"<TimeEntry {self.user_id} {self.date} {self.start_time}-{self.end_time}>"
+94
View File
@@ -0,0 +1,94 @@
import uuid
import enum
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.company import Company
from app.models.department import Department
from app.models.session import Session
class UserRole(str, enum.Enum):
SUPER_ADMIN = "SUPER_ADMIN"
COMPANY_ADMIN = "COMPANY_ADMIN"
HR = "HR"
MANAGER = "MANAGER"
EMPLOYEE = "EMPLOYEE"
class AuthProvider(str, enum.Enum):
LOCAL = "local"
LDAP = "ldap"
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"))
department_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("departments.id", ondelete="SET NULL"))
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
last_name: Mapped[str] = mapped_column(String(100), nullable=False)
role: Mapped[UserRole] = mapped_column(Enum(UserRole), nullable=False, default=UserRole.EMPLOYEE)
auth_provider: Mapped[AuthProvider] = mapped_column(
Enum(AuthProvider, name="authprovider", values_callable=lambda x: [e.value for e in x]),
nullable=False, default=AuthProvider.LOCAL,
)
ldap_dn: Mapped[str | None] = mapped_column(Text)
work_schedule_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("work_schedules.id", ondelete="SET NULL")
)
# Kiosk auth
kiosk_pin_hash: Mapped[str | None] = mapped_column(Text)
kiosk_qr_token: Mapped[str | None] = mapped_column(Text, unique=True)
# Kalender-Kürzel (vom Manager setzbar, für CalDAV-Template $kuerzel)
kuerzel: Mapped[str | None] = mapped_column(String(20))
# Personalnummer (numerisch, eindeutig pro Firma; bleibt nach Deaktivierung reserviert)
personnel_number: Mapped[str | None] = mapped_column(String(50))
# TOTP / 2FA
totp_secret: Mapped[str | None] = mapped_column(String(64))
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Permissions
can_manual_time_entry: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Account state
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
invite_token_hash: Mapped[str | None] = mapped_column(Text)
invite_expires: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Relationships
company: Mapped["Company"] = relationship("Company", back_populates="users")
department: Mapped["Department | None"] = relationship(
"Department",
primaryjoin="User.department_id == Department.id",
foreign_keys="[User.department_id]",
back_populates="members",
)
sessions: Mapped[list["Session"]] = relationship("Session", back_populates="user", cascade="all, delete-orphan", lazy="noload")
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
def is_admin_or_above(self) -> bool:
return self.role in (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
def __repr__(self) -> str:
return f"<User {self.email} [{self.role}]>"
+39
View File
@@ -0,0 +1,39 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class VacationBalance(Base):
__tablename__ = "vacation_balances"
__table_args__ = (UniqueConstraint("user_id", "year", name="uq_vacation_balance_user_year"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
year: Mapped[int] = mapped_column(Integer, nullable=False)
entitled_days: Mapped[int] = mapped_column(Integer, default=30) # Grundurlaub
special_days: Mapped[int] = mapped_column(Integer, default=0) # Sondertage
carried_over: Mapped[int] = mapped_column(Integer, default=0) # Resturlaub aus Vorjahr
used_days: Mapped[int] = mapped_column(Integer, default=0) # Verbraucht
user: Mapped["User"] = relationship("User", lazy="noload")
@property
def total_days(self) -> int:
return self.entitled_days + self.special_days + self.carried_over
@property
def remaining_days(self) -> int:
return self.total_days - self.used_days
def __repr__(self) -> str:
return f"<VacationBalance {self.user_id} {self.year}: {self.remaining_days} left>"
+45
View File
@@ -0,0 +1,45 @@
import uuid
from datetime import date
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import Date, ForeignKey, Numeric, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.company import Company
class WorkSchedule(Base):
__tablename__ = "work_schedules"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
mon_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("8.00"))
tue_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("8.00"))
wed_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("8.00"))
thu_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("8.00"))
fri_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("8.00"))
sat_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("0.00"))
sun_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("0.00"))
valid_from: Mapped[date] = mapped_column(Date, nullable=False)
company: Mapped["Company"] = relationship("Company", lazy="noload")
@property
def weekly_hours(self) -> Decimal:
return self.mon_h + self.tue_h + self.wed_h + self.thu_h + self.fri_h + self.sat_h + self.sun_h
def hours_for_weekday(self, weekday: int) -> Decimal:
"""weekday: 0=Mon, 1=Tue, ..., 6=Sun"""
mapping = [self.mon_h, self.tue_h, self.wed_h, self.thu_h, self.fri_h, self.sat_h, self.sun_h]
return mapping[weekday]
def __repr__(self) -> str:
return f"<WorkSchedule {self.name}>"
View File
+368
View File
@@ -0,0 +1,368 @@
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.absence import AbsenceStatus
from app.models.user import User, UserRole
from app.models.overtime_balance import OvertimeBalance
from app.schemas.absence import (
AbsenceCreate,
AbsenceListResponse,
AbsenceOut,
AbsenceReject,
AbsenceUpdate,
AbsenceTypeCreate,
AbsenceTypeOut,
AbsenceTypeUpdate,
CalendarEntry,
CertificateMarkIn,
OvertimeBalanceOut,
PublicHolidayCreate,
PublicHolidayOut,
QuickSickIn,
SickStatsOut,
VacationBalanceOut,
VacationBalanceUpdate,
)
from app.services.absence_service import absence_service
from app.models.company import Company
from sqlalchemy import select
from datetime import date
router = APIRouter(tags=["Abwesenheiten"])
def _carryover_expiry(company: Company, year: int) -> tuple[date | None, bool]:
"""Verfallsdatum für Resturlaub berechnen.
Gibt (expires_at, is_expired) zurück. None wenn kein Verfall konfiguriert."""
s = company.settings or {}
month = s.get("carryover_expires_month")
day = s.get("carryover_expires_day")
if not month or not day:
return None, False
try:
expires_at = date(year, int(month), int(day))
return expires_at, date.today() > expires_at
except ValueError:
return None, False
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Absence Types ─────────────────────────────────────────────────────────────
@router.get("/absence-types/", response_model=list[AbsenceTypeOut])
async def list_absence_types(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
types = await absence_service.list_types(current_user.company_id, db)
return [AbsenceTypeOut.model_validate(t) for t in types]
@router.post("/absence-types/", response_model=AbsenceTypeOut, status_code=201)
async def create_absence_type(
data: AbsenceTypeCreate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
at = await absence_service.create_type(current_user.company_id, data, db)
await db.commit()
await db.refresh(at)
return AbsenceTypeOut.model_validate(at)
@router.patch("/absence-types/{type_id}", response_model=AbsenceTypeOut)
async def update_absence_type(
type_id: UUID,
data: AbsenceTypeUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
at = await absence_service.update_type(type_id, current_user.company_id, data, db)
await db.commit()
await db.refresh(at)
return AbsenceTypeOut.model_validate(at)
# ── Public Holidays ───────────────────────────────────────────────────────────
@router.get("/public-holidays/", response_model=list[PublicHolidayOut])
async def list_public_holidays(
current_user: CurrentUser,
year: int = Query(...),
country: str = Query("DE"),
state: str | None = Query(None),
db: AsyncSession = Depends(get_db),
):
holidays = await absence_service.list_holidays(year, country, state, db)
return [PublicHolidayOut.model_validate(h) for h in holidays]
@router.post("/public-holidays/", response_model=PublicHolidayOut, status_code=201)
async def create_public_holiday(
data: PublicHolidayCreate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
holiday = await absence_service.create_holiday(data, db)
await db.commit()
await db.refresh(holiday)
return PublicHolidayOut.model_validate(holiday)
# ── Absences ──────────────────────────────────────────────────────────────────
@router.get("/absences/calendar", response_model=list[CalendarEntry])
async def get_calendar(
current_user: CurrentUser,
year: int = Query(...),
month: int | None = Query(None, ge=1, le=12),
db: AsyncSession = Depends(get_db),
):
"""Team-Kalender: alle Abwesenheiten im Zeitraum."""
entries = await absence_service.get_calendar(current_user.company_id, year, month, db)
return [CalendarEntry(**e) for e in entries]
@router.get("/absences/balance", response_model=VacationBalanceOut)
async def get_own_balance(
current_user: CurrentUser,
year: int = Query(...),
db: AsyncSession = Depends(get_db),
):
"""Eigenes Urlaubskonto."""
balance = await absence_service.get_balance(current_user.id, year, db)
pending = await absence_service.get_pending_days(current_user.id, year, db)
company = await db.get(Company, current_user.company_id)
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
return VacationBalanceOut.model_validate(balance).model_copy(update={
"pending_days": pending,
"carried_over_expires_at": expires_at,
"carried_over_expired": expired,
})
@router.get("/absences/balance/{user_id}", response_model=VacationBalanceOut)
async def get_balance_for_user(
user_id: UUID,
year: int = Query(...),
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Urlaubskonto eines Mitarbeiters (MANAGER/HR/ADMIN)."""
target_user = await db.get(User, user_id)
if target_user is None or target_user.company_id != current_user.company_id:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
balance = await absence_service.get_balance(user_id, year, db)
pending = await absence_service.get_pending_days(user_id, year, db)
company = await db.get(Company, current_user.company_id)
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
return VacationBalanceOut.model_validate(balance).model_copy(update={
"pending_days": pending,
"carried_over_expires_at": expires_at,
"carried_over_expired": expired,
})
@router.post("/absences/quick-sick", response_model=AbsenceOut, status_code=201)
async def quick_sick(
data: QuickSickIn,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Sofort-Krankmeldung (auto-approved). Nutzt den ersten aktiven SICK-Typ der Firma."""
absence, _ = await absence_service.quick_sick(
data.start_date, data.end_date, current_user, db
)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
@router.get("/absences/sick-stats", response_model=list[SickStatsOut])
async def get_sick_stats(
current_user: User = require_role(*_manager_roles),
user_id: UUID | None = Query(None),
ref_date: date | None = Query(None, description="Stichtag, default heute"),
db: AsyncSession = Depends(get_db),
):
"""Krankheitsstatistik (rolling 12 Monate ab ref_date) inkl. Bradford-Faktor."""
stats = await absence_service.get_sick_stats(
company_id=current_user.company_id,
current_user=current_user,
ref_date=ref_date or date.today(),
db=db,
user_id=user_id,
)
return [SickStatsOut(**s) for s in stats]
@router.get("/absences/overtime-balance", response_model=OvertimeBalanceOut)
async def get_overtime_balance(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Eigenes Überstunden-Konto."""
bal = await db.scalar(
select(OvertimeBalance).where(OvertimeBalance.user_id == current_user.id)
)
if bal is None:
return OvertimeBalanceOut(total_hours=0, taken_hours=0, available_hours=0)
return OvertimeBalanceOut(
total_hours=float(bal.total_hours),
taken_hours=float(bal.taken_hours),
available_hours=float(bal.available_hours),
)
@router.get("/absences/", response_model=AbsenceListResponse)
async def list_absences(
current_user: CurrentUser,
user_id: UUID | None = Query(None),
type_id: UUID | None = Query(None),
status: AbsenceStatus | None = Query(None),
year: int | None = Query(None),
db: AsyncSession = Depends(get_db),
):
total, absences = await absence_service.list_absences(
current_user.company_id, current_user, db,
user_id=user_id, type_id=type_id, status=status, year=year,
)
return AbsenceListResponse(total=total, items=[AbsenceOut.model_validate(a) for a in absences])
@router.post("/absences/", response_model=AbsenceOut, status_code=201)
async def create_absence(
data: AbsenceCreate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Abwesenheitsantrag stellen. HR/Admin kann for_user_id setzen um für andere anzulegen."""
acting_user = current_user
if data.for_user_id and data.for_user_id != current_user.id:
if current_user.role not in _manager_roles:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Keine Berechtigung, Abwesenheiten für andere anzulegen.")
target = await db.get(User, data.for_user_id)
if not target or target.company_id != current_user.company_id:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
acting_user = target
absence, warnings = await absence_service.create_absence(data, acting_user, db)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
@router.patch("/absences/{absence_id}", response_model=AbsenceOut)
async def update_absence(
absence_id: UUID,
data: AbsenceUpdate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Ausstehenden Antrag bearbeiten (Mitarbeiter: eigene; Manager: alle der Company)."""
absence = await absence_service.update_absence(absence_id, data, current_user, db)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
@router.get("/absences/{absence_id}", response_model=AbsenceOut)
async def get_absence(
absence_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
absence = await absence_service.get_by_id(absence_id, current_user, db)
return AbsenceOut.model_validate(absence)
@router.delete("/absences/{absence_id}", status_code=204)
async def cancel_absence(
absence_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Eigenen ausstehenden Antrag stornieren."""
await absence_service.cancel_absence(absence_id, current_user, db)
await db.commit()
@router.post("/absences/{absence_id}/approve", response_model=AbsenceOut)
async def approve_absence(
absence_id: UUID,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
absence = await absence_service.approve_absence(absence_id, current_user, db)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
@router.post("/absences/{absence_id}/reject", response_model=AbsenceOut)
async def reject_absence(
absence_id: UUID,
data: AbsenceReject,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
absence = await absence_service.reject_absence(absence_id, data, current_user, db)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
@router.patch("/absences/{absence_id}/certificate", response_model=AbsenceOut)
async def mark_certificate_received(
absence_id: UUID,
data: CertificateMarkIn,
current_user: User = require_role(UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
"""HR/Admin markiert die AU-Bescheinigung als eingegangen."""
absence = await absence_service.mark_certificate_received(
absence_id, data.received_at, current_user, db
)
await db.commit()
await db.refresh(absence)
return AbsenceOut.model_validate(absence)
# ── Urlaubskonto bearbeiten (HR/Admin) ────────────────────────────────────────
@router.patch("/absences/balance/{user_id}", response_model=VacationBalanceOut)
async def update_balance(
user_id: UUID,
data: VacationBalanceUpdate,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
year: int = Query(...),
):
"""Grundurlaub, Sondertage und Resturlaub eines Mitarbeiters anpassen."""
from app.models.vacation_balance import VacationBalance
target = await db.get(User, user_id)
if target is None or target.company_id != current_user.company_id:
from fastapi import HTTPException
raise HTTPException(404, "Mitarbeiter nicht gefunden")
balance = await absence_service.get_balance(user_id, year, db)
for field, value in data.model_dump(exclude_unset=True).items():
setattr(balance, field, value)
await db.commit()
await db.refresh(balance)
pending = await absence_service.get_pending_days(user_id, year, db)
company = await db.get(Company, current_user.company_id)
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
return VacationBalanceOut.model_validate(balance).model_copy(update={
"pending_days": pending,
"carried_over_expires_at": expires_at,
"carried_over_expired": expired,
})
+119
View File
@@ -0,0 +1,119 @@
"""AuditLog-Endpoint nur für COMPANY_ADMIN und SUPER_ADMIN, company-isoliert."""
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.core.database import get_db
from app.core.dependencies import require_role
from app.models.audit_log import AuditLog
from app.models.user import User, UserRole
from app.schemas.audit_log import AuditLogEntry, AuditLogListResponse
router = APIRouter(tags=["Audit Log"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
@router.get("/audit-logs", response_model=AuditLogListResponse)
async def list_audit_logs(
user_id: UUID | None = Query(None),
action: str | None = Query(None),
entity_type: str | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
base_filter = [AuditLog.company_id == current_user.company_id]
if current_user.role == UserRole.SUPER_ADMIN:
base_filter = [] # SUPER_ADMIN sieht alle Firmen
if user_id:
base_filter.append(AuditLog.user_id == user_id)
if action:
base_filter.append(AuditLog.action.ilike(f"%{action}%"))
if entity_type:
base_filter.append(AuditLog.entity_type == entity_type)
if date_from:
base_filter.append(AuditLog.created_at >= date_from)
if date_to:
base_filter.append(AuditLog.created_at <= date_to)
count_q = select(func.count()).select_from(AuditLog).where(*base_filter)
total = await db.scalar(count_q) or 0
rows_q = (
select(AuditLog, User.first_name, User.last_name)
.outerjoin(User, AuditLog.user_id == User.id)
.where(*base_filter)
.order_by(AuditLog.created_at.desc())
.limit(limit)
.offset(offset)
)
rows = (await db.execute(rows_q)).all()
items = [
AuditLogEntry(
id=log.id,
user_id=log.user_id,
user_name=f"{first} {last}".strip() if first or last else None,
action=log.action,
entity_type=log.entity_type,
entity_id=log.entity_id,
old_value=log.old_value,
new_value=log.new_value,
ip_address=log.ip,
created_at=log.created_at,
)
for log, first, last in rows
]
return AuditLogListResponse(total=total, items=items)
@router.get("/audit-logs/actions", response_model=list[str])
async def list_audit_actions(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Alle vorhandenen Action-Werte für Filter-Dropdown."""
filter_cond = (
[] if current_user.role == UserRole.SUPER_ADMIN
else [AuditLog.company_id == current_user.company_id]
)
q = (
select(AuditLog.action)
.where(*filter_cond)
.distinct()
.order_by(AuditLog.action)
)
result = await db.execute(q)
return [r for (r,) in result.all()]
@router.get("/audit-logs/entity-types", response_model=list[str])
async def list_entity_types(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Alle vorhandenen Entity-Typen für Filter-Dropdown."""
filter_cond = (
[AuditLog.entity_type.isnot(None)]
if current_user.role == UserRole.SUPER_ADMIN
else [AuditLog.company_id == current_user.company_id, AuditLog.entity_type.isnot(None)]
)
q = (
select(AuditLog.entity_type)
.where(*filter_cond)
.distinct()
.order_by(AuditLog.entity_type)
)
result = await db.execute(q)
return [r for (r,) in result.all()]
+211
View File
@@ -0,0 +1,211 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser
from app.core.limiter import limiter
from app.core.security import hash_password, verify_password
from app.schemas.auth import (
LoginRequest,
MessageResponse,
PasswordResetConfirm,
PasswordResetRequest,
RefreshRequest,
RegisterRequest,
TokenResponse,
TotpConfirmRequest,
TotpDisableRequest,
TotpLoginRequest,
TotpSetupResponse,
)
from app.schemas.user import InviteAccept, UserOut
from app.services.auth_service import auth_service
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(min_length=8)
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/register", response_model=TokenResponse, status_code=201)
@limiter.limit("3/hour")
async def register(request: Request, data: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Create a new company + admin account."""
return await auth_service.register(data, db)
@router.post("/login", response_model=TokenResponse)
@limiter.limit("10/minute")
async def login(request: Request, data: LoginRequest, db: AsyncSession = Depends(get_db)):
return await auth_service.login(data, db, request)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
return await auth_service.refresh(data.refresh_token, db)
@router.post("/logout", response_model=MessageResponse)
async def logout(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
await auth_service.logout(data.refresh_token, db)
return MessageResponse(message="Logged out successfully")
@router.post("/password-reset", response_model=MessageResponse)
@limiter.limit("3/hour")
async def request_password_reset(request: Request, data: PasswordResetRequest, db: AsyncSession = Depends(get_db)):
result = await auth_service.request_password_reset(data.email, db)
if result == "ldap":
from fastapi import HTTPException
raise HTTPException(
status_code=400,
detail="Dein Konto wird über LDAP verwaltet. Bitte setze dein Passwort direkt beim LDAP-Administrator zurück.",
)
return MessageResponse(message="Falls diese E-Mail-Adresse registriert ist, wurde ein Reset-Link verschickt.")
@router.post("/password-reset/confirm", response_model=MessageResponse)
@limiter.limit("5/hour")
async def confirm_password_reset(request: Request, data: PasswordResetConfirm, db: AsyncSession = Depends(get_db)):
await auth_service.confirm_password_reset(data.token, data.new_password, db)
return MessageResponse(message="Password updated successfully")
@router.post("/invite/accept", response_model=UserOut)
@limiter.limit("10/hour")
async def accept_invite(request: Request, data: InviteAccept, db: AsyncSession = Depends(get_db)):
from app.services.user_service import user_service
user = await user_service.accept_invite(data, db)
return UserOut.model_validate(user)
@router.get("/me", response_model=UserOut)
async def me(current_user: CurrentUser):
return UserOut.model_validate(current_user)
@router.post("/change-password", response_model=MessageResponse)
async def change_password(
data: ChangePasswordRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Passwort ändern (eingeloggter User, benötigt aktuelles Passwort)."""
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Aktuelles Passwort ist falsch")
import re
if not re.search(r'[A-Z]', data.new_password) or not re.search(r'[0-9]', data.new_password):
raise HTTPException(
status_code=400,
detail="Neues Passwort muss mindestens 1 Großbuchstaben und 1 Zahl enthalten"
)
current_user.password_hash = hash_password(data.new_password)
await db.commit()
return MessageResponse(message="Passwort erfolgreich geändert")
# ── TOTP / 2FA ────────────────────────────────────────────────────────────────
@router.post("/totp/setup", response_model=TotpSetupResponse)
async def totp_setup(current_user: CurrentUser):
"""Generiert ein neues TOTP-Secret und gibt die otpauth-URI zurück. Noch nicht aktiviert."""
import pyotp
secret = pyotp.random_base32()
issuer = "TimeMaster"
label = current_user.email
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name=issuer)
# Secret temporär im User speichern (noch nicht totp_enabled)
current_user.totp_secret = secret
# Hinweis: DB-Commit passiert NICHT hier erst nach verify in /totp/confirm
# Damit das Secret nicht verloren geht, sofort speichern
return TotpSetupResponse(secret=secret, otpauth_uri=uri)
@router.post("/totp/setup/save", response_model=MessageResponse)
async def totp_setup_save(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Speichert das generierte Secret temporär (ohne Aktivierung)."""
import pyotp
if not current_user.totp_secret:
secret = pyotp.random_base32()
current_user.totp_secret = secret
await db.commit()
return MessageResponse(message="Secret gespeichert")
@router.post("/totp/confirm", response_model=MessageResponse)
async def totp_confirm(
data: TotpConfirmRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Bestätigt den ersten TOTP-Code und aktiviert 2FA."""
import pyotp
if not current_user.totp_secret:
raise HTTPException(400, "Kein TOTP-Secret vorhanden. Zuerst /totp/setup aufrufen.")
totp = pyotp.TOTP(current_user.totp_secret)
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger Code")
current_user.totp_enabled = True
await db.commit()
return MessageResponse(message="Zwei-Faktor-Authentifizierung aktiviert")
@router.post("/totp/disable", response_model=MessageResponse)
async def totp_disable(
data: TotpDisableRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Deaktiviert TOTP. Benötigt aktuelles Passwort + gültigen TOTP-Code."""
import pyotp
if not verify_password(data.password, current_user.password_hash or ""):
raise HTTPException(400, "Passwort falsch")
if not current_user.totp_enabled or not current_user.totp_secret:
raise HTTPException(400, "2FA ist nicht aktiv")
totp = pyotp.TOTP(current_user.totp_secret)
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger TOTP-Code")
current_user.totp_enabled = False
current_user.totp_secret = None
await db.commit()
return MessageResponse(message="Zwei-Faktor-Authentifizierung deaktiviert")
@router.post("/totp/login", response_model=TokenResponse)
@limiter.limit("10/minute")
async def totp_login(
request: Request,
data: TotpLoginRequest,
db: AsyncSession = Depends(get_db),
):
"""Zweiter Login-Schritt: partial_token + TOTP-Code → volle Tokens."""
import pyotp
from uuid import UUID
from app.core.security import decode_partial_token
from app.models.user import User
from jose import JWTError
try:
user_id = decode_partial_token(data.partial_token)
except JWTError:
raise HTTPException(401, "Ungültiger oder abgelaufener Token")
user = await db.get(User, UUID(user_id))
if not user or not user.is_active:
raise HTTPException(401, "Benutzer nicht gefunden")
if not user.totp_enabled or not user.totp_secret:
raise HTTPException(400, "2FA nicht aktiv")
totp = pyotp.TOTP(user.totp_secret)
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger Code")
from datetime import datetime, timezone
user.last_login = datetime.now(timezone.utc)
return await auth_service._create_session(user, db, request=request)
+168
View File
@@ -0,0 +1,168 @@
"""Busylight-Integration (Pull-Endpoint + Token-Verwaltung).
- Pull: GET /busylight/users Auth via per-Firma Bearer-Token (SHA-256 in DB)
- Verwaltung: POST/DELETE /companies/me/busylight-token COMPANY_ADMIN/SUPER_ADMIN
"""
from __future__ import annotations
import hashlib
import secrets
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.core.limiter import limiter
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_type import AbsenceType
from app.models.audit_log import AuditLog
from app.models.company import Company
from app.models.user import User, UserRole
from app.schemas.busylight import (
BusylightAbsenceItem,
BusylightTokenRotated,
BusylightTokenStatus,
BusylightUserItem,
BusylightUsersResponse,
)
router = APIRouter(tags=["Busylight"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_pull_bearer = HTTPBearer(auto_error=False)
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
# ── Token-Verwaltung (eingeloggter Admin) ────────────────────────────────────
@router.get("/companies/me/busylight-token", response_model=BusylightTokenStatus)
async def get_busylight_token_status(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
return BusylightTokenStatus(
configured=company.busylight_pull_token_hash is not None,
created_at=company.busylight_token_created_at,
)
@router.post("/companies/me/busylight-token/rotate", response_model=BusylightTokenRotated)
async def rotate_busylight_token(
request: Request,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
token = secrets.token_urlsafe(32)
company.busylight_pull_token_hash = _hash_token(token)
company.busylight_token_created_at = datetime.now(timezone.utc)
db.add(AuditLog(
company_id=company.id,
user_id=current_user.id,
action="busylight_token_rotated",
entity_type="company",
entity_id=company.id,
ip=request.client.host if request.client else None,
))
await db.commit()
return BusylightTokenRotated(token=token, created_at=company.busylight_token_created_at)
@router.delete("/companies/me/busylight-token", status_code=204)
async def delete_busylight_token(
request: Request,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
if company.busylight_pull_token_hash is None:
return
company.busylight_pull_token_hash = None
company.busylight_token_created_at = None
db.add(AuditLog(
company_id=company.id,
user_id=current_user.id,
action="busylight_token_revoked",
entity_type="company",
entity_id=company.id,
ip=request.client.host if request.client else None,
))
await db.commit()
# ── Pull-Endpoint (busylight liest hier) ─────────────────────────────────────
async def _company_from_token(
credentials: HTTPAuthorizationCredentials | None,
db: AsyncSession,
) -> Company:
if credentials is None or not credentials.credentials:
raise HTTPException(status_code=401, detail="Missing token")
token_hash = _hash_token(credentials.credentials)
company = await db.scalar(
select(Company).where(Company.busylight_pull_token_hash == token_hash)
)
if company is None:
raise HTTPException(status_code=401, detail="Invalid token")
return company
@router.get("/busylight/users", response_model=BusylightUsersResponse)
@limiter.limit("60/minute")
async def list_users_for_busylight(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_pull_bearer),
db: AsyncSession = Depends(get_db),
):
company = await _company_from_token(credentials, db)
today = date.today()
users = (await db.scalars(
select(User)
.where(
User.company_id == company.id,
User.is_active == True,
User.personnel_number.is_not(None),
)
.order_by(User.last_name, User.first_name)
)).all()
user_ids = [u.id for u in users]
abs_rows = []
if user_ids:
abs_rows = (await db.execute(
select(Absence, AbsenceType)
.join(AbsenceType, Absence.type_id == AbsenceType.id)
.where(
Absence.user_id.in_(user_ids),
Absence.status == AbsenceStatus.APPROVED,
Absence.start_date <= today,
Absence.end_date >= today,
)
)).all()
by_user: dict = {uid: [] for uid in user_ids}
for absence, atype in abs_rows:
by_user[absence.user_id].append(
BusylightAbsenceItem(type=atype.name, category=atype.category.value)
)
items = [
BusylightUserItem(
personnel_number=u.personnel_number,
full_name=u.full_name,
absences_today=by_user[u.id],
)
for u in users
]
return BusylightUsersResponse(date=today, users=items)
+143
View File
@@ -0,0 +1,143 @@
"""
CalDAV-Konfiguration und manueller Sync-Trigger.
Firmenkalender: nur COMPANY_ADMIN / SUPER_ADMIN
Persönlicher Kalender: jeder eingeloggte Nutzer für sich selbst
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
from app.models.user import User, UserRole
from app.schemas.caldav import (
CaldavCompanyConfigOut,
CaldavCompanyConfigSave,
CaldavUserConfigOut,
CaldavUserConfigSave,
ResyncResult,
)
from app.services.caldav_service import caldav_service, encrypt_password
router = APIRouter(prefix="/caldav", tags=["CalDAV"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Firmenkalender ─────────────────────────────────────────────────────────────
@router.get("/company/config", response_model=CaldavCompanyConfigOut | None)
async def get_company_config(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_company_config(current_user.company_id, db)
return CaldavCompanyConfigOut.model_validate(cfg) if cfg else None
@router.post("/company/config", response_model=CaldavCompanyConfigOut)
async def save_company_config(
data: CaldavCompanyConfigSave,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_company_config(current_user.company_id, db)
if cfg is None:
cfg = CaldavCompanyConfig(company_id=current_user.company_id, id=uuid.uuid4())
db.add(cfg)
cfg.enabled = data.enabled
cfg.principal_url = data.principal_url
cfg.calendar_url = data.calendar_url
cfg.username = data.username
cfg.calendar_display_name = data.calendar_display_name
cfg.verify_ssl = data.verify_ssl
if data.password:
cfg.password_encrypted = encrypt_password(data.password)
elif not cfg.password_encrypted:
raise HTTPException(status_code=400, detail="Passwort wird beim ersten Speichern benötigt.")
await db.commit()
await db.refresh(cfg)
return CaldavCompanyConfigOut.model_validate(cfg)
@router.post("/company/test")
async def test_company_config(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_company_config(current_user.company_id, db)
if not cfg:
raise HTTPException(status_code=404, detail="Keine Firmen-CalDAV-Konfiguration vorhanden.")
result = await caldav_service.test_config(cfg)
if not result["ok"]:
raise HTTPException(status_code=502, detail=result["error"])
return {"message": "Verbindung erfolgreich.", "http_status": result.get("status")}
@router.post("/company/resync", response_model=ResyncResult)
async def resync_all(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Alle genehmigten Abwesenheiten neu in den Firmenkalender synchronisieren."""
result = await caldav_service.resync_all_approved(current_user.company_id, db)
await db.commit()
return ResyncResult(**result)
# ── Persönlicher Kalender ──────────────────────────────────────────────────────
@router.get("/user/config", response_model=CaldavUserConfigOut | None)
async def get_user_config(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_user_config(current_user.id, db)
return CaldavUserConfigOut.model_validate(cfg) if cfg else None
@router.post("/user/config", response_model=CaldavUserConfigOut)
async def save_user_config(
data: CaldavUserConfigSave,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_user_config(current_user.id, db)
if cfg is None:
cfg = CaldavUserConfig(user_id=current_user.id, id=uuid.uuid4())
db.add(cfg)
cfg.enabled = data.enabled
cfg.principal_url = data.principal_url
cfg.calendar_url = data.calendar_url
cfg.username = data.username
cfg.calendar_display_name = data.calendar_display_name
cfg.verify_ssl = data.verify_ssl
if data.password:
cfg.password_encrypted = encrypt_password(data.password)
elif not cfg.password_encrypted:
raise HTTPException(status_code=400, detail="Passwort wird beim ersten Speichern benötigt.")
await db.commit()
await db.refresh(cfg)
return CaldavUserConfigOut.model_validate(cfg)
@router.post("/user/test")
async def test_user_config(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
cfg = await caldav_service.get_user_config(current_user.id, db)
if not cfg:
raise HTTPException(status_code=404, detail="Keine persönliche CalDAV-Konfiguration vorhanden.")
result = await caldav_service.test_config(cfg)
if not result["ok"]:
raise HTTPException(status_code=502, detail=result["error"])
return {"message": "Verbindung erfolgreich.", "http_status": result.get("status")}
+91
View File
@@ -0,0 +1,91 @@
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models import Company
from app.models.department import Department
from app.models.user import User, UserRole
from app.schemas.company import (
CompanyOut,
CompanyUpdate,
DepartmentCreate,
DepartmentOut,
DepartmentUpdate,
)
router = APIRouter(prefix="/companies", tags=["Companies"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
@router.get("/me", response_model=CompanyOut)
async def get_my_company(current_user: CurrentUser, db: AsyncSession = Depends(get_db)):
company = await db.get(Company, current_user.company_id)
return CompanyOut.model_validate(company)
@router.patch("/me", response_model=CompanyOut)
async def update_my_company(
data: CompanyUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
for field, value in data.model_dump(exclude_none=True).items():
setattr(company, field, value)
return CompanyOut.model_validate(company)
# ── Departments ──────────────────────────────────────────────────────────────
@router.get("/me/departments", response_model=list[DepartmentOut])
async def list_departments(current_user: CurrentUser, db: AsyncSession = Depends(get_db)):
depts = await db.scalars(
select(Department).where(Department.company_id == current_user.company_id)
)
return [DepartmentOut.model_validate(d) for d in depts.all()]
@router.post("/me/departments", response_model=DepartmentOut, status_code=201)
async def create_department(
data: DepartmentCreate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
dept = Department(company_id=current_user.company_id, **data.model_dump())
db.add(dept)
await db.flush()
return DepartmentOut.model_validate(dept)
@router.patch("/me/departments/{dept_id}", response_model=DepartmentOut)
async def update_department(
dept_id: UUID,
data: DepartmentUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
dept = await db.get(Department, dept_id)
if not dept or dept.company_id != current_user.company_id:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Department not found")
for field, value in data.model_dump(exclude_none=True).items():
setattr(dept, field, value)
return DepartmentOut.model_validate(dept)
@router.delete("/me/departments/{dept_id}", status_code=204)
async def delete_department(
dept_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
dept = await db.get(Department, dept_id)
if not dept or dept.company_id != current_user.company_id:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Department not found")
await db.delete(dept)
+87
View File
@@ -0,0 +1,87 @@
"""Router: Kimai CSV Import (nur HR / Admin)."""
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_db, require_role
from app.models.user import User, UserRole
from app.services.kimai_import_service import (
ImportPreviewEntry,
ImportResult,
preview_kimai_import,
run_kimai_import,
)
router = APIRouter(prefix="/import", tags=["import"])
_allowed_roles = [UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN]
class ImportPreviewResponse(BaseModel):
preview: list[ImportPreviewEntry]
time_count: int
absence_count: int
skip_count: int
errors: list[str]
class ImportRunResponse(BaseModel):
time_imported: int
absence_imported: int
skipped: int
errors: list[str]
@router.post("/kimai/preview", response_model=ImportPreviewResponse)
async def kimai_preview(
user_id: Annotated[str, Form()],
file: Annotated[UploadFile, File()],
current_user: User = require_role(*_allowed_roles),
db: AsyncSession = Depends(get_db),
):
"""Vorschau des Kimai-Imports (keine DB-Änderungen)."""
try:
target_id = uuid.UUID(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Ungültige user_id")
content = await file.read()
result: ImportResult = await preview_kimai_import(content, target_id, db)
time_count = sum(1 for p in result.preview if p.kind == "time" and not p.skipped)
abs_count = sum(1 for p in result.preview if p.kind == "absence" and not p.skipped)
return ImportPreviewResponse(
preview=result.preview,
time_count=time_count,
absence_count=abs_count,
skip_count=result.skipped,
errors=result.errors,
)
@router.post("/kimai/run", response_model=ImportRunResponse)
async def kimai_run(
user_id: Annotated[str, Form()],
file: Annotated[UploadFile, File()],
current_user: User = require_role(*_allowed_roles),
db: AsyncSession = Depends(get_db),
):
"""Führt den Kimai-Import durch (schreibt in DB)."""
try:
target_id = uuid.UUID(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Ungültige user_id")
content = await file.read()
result: ImportResult = await run_kimai_import(content, target_id, current_user.id, db)
return ImportRunResponse(
time_imported=result.time_imported,
absence_imported=result.absence_imported,
skipped=result.skipped,
errors=result.errors,
)
+102
View File
@@ -0,0 +1,102 @@
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.models.user import User, UserRole
from app.schemas.kiosk import KioskDeviceCreate, KioskDeviceCreated, KioskDeviceOut, KioskDeviceUpdate
from app.services.kiosk_service import kiosk_service
router = APIRouter(prefix="/kiosk", tags=["Kiosk"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Geräteverwaltung (COMPANY_ADMIN) ──────────────────────────────────────────
@router.get("/devices", response_model=list[KioskDeviceOut])
async def list_devices(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Alle registrierten Kiosk-Geräte der Firma auflisten."""
return await kiosk_service.list_devices(current_user.company_id, db)
@router.post("/devices", response_model=KioskDeviceCreated, status_code=201)
async def create_device(
data: KioskDeviceCreate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Neues Kiosk-Gerät registrieren. Token wird nur einmalig zurückgegeben."""
device, raw_token = await kiosk_service.create_device(current_user.company_id, data, db)
await db.commit()
await db.refresh(device)
return KioskDeviceCreated(
**KioskDeviceOut.model_validate(device).model_dump(),
token=raw_token,
)
@router.get("/devices/{device_id}", response_model=KioskDeviceOut)
async def get_device(
device_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
return await kiosk_service.get_device(device_id, current_user.company_id, db)
@router.patch("/devices/{device_id}", response_model=KioskDeviceOut)
async def update_device(
device_id: UUID,
data: KioskDeviceUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
device = await kiosk_service.update_device(device_id, current_user.company_id, data, db)
await db.commit()
await db.refresh(device)
return KioskDeviceOut.model_validate(device)
@router.post("/devices/{device_id}/rotate-token", response_model=KioskDeviceCreated)
async def rotate_token(
device_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Token rotieren das alte Token wird sofort ungültig."""
device, raw_token = await kiosk_service.rotate_token(device_id, current_user.company_id, db)
await db.commit()
await db.refresh(device)
return KioskDeviceCreated(
**KioskDeviceOut.model_validate(device).model_dump(),
token=raw_token,
)
@router.delete("/devices/{device_id}", status_code=204)
async def delete_device(
device_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
await kiosk_service.delete_device(device_id, current_user.company_id, db)
await db.commit()
# ── Kiosk-Auth (Gerät authentifiziert sich per Token) ─────────────────────────
@router.get("/me", response_model=KioskDeviceOut)
async def kiosk_me(
x_kiosk_token: str = Header(..., alias="X-Kiosk-Token", min_length=32, max_length=128),
db: AsyncSession = Depends(get_db),
):
"""Kiosk-Gerät prüft seine eigene Identität / aktualisiert last_seen_at."""
device = await kiosk_service.authenticate_device(x_kiosk_token, db)
await db.commit()
return KioskDeviceOut.model_validate(device)
+139
View File
@@ -0,0 +1,139 @@
"""LDAP configuration and sync endpoints.
All endpoints require COMPANY_ADMIN or SUPER_ADMIN role.
"""
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.models.ldap_config import LdapConfig
from app.models.user import User, UserRole
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
from app.schemas.ldap import (
LdapConfigCreate,
LdapConfigOut,
LdapConfigUpdate,
LdapSyncRequest,
LdapSyncResult,
LdapTestResult,
LdapUserPreview,
)
from app.services.ldap_service import decrypt_password, encrypt_password, ldap_service
router = APIRouter(prefix="/ldap", tags=["LDAP"])
@router.get("/config", response_model=LdapConfigOut | None)
async def get_ldap_config(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
return await ldap_service.get_config(current_user.company_id, db)
@router.post("/config", response_model=LdapConfigOut)
async def create_ldap_config(
data: LdapConfigCreate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
existing = await ldap_service.get_config(current_user.company_id, db)
if existing:
# Update instead of duplicate
return await _apply_update(existing, data.model_dump(), db)
cfg = LdapConfig(
company_id=current_user.company_id,
enabled=data.enabled,
host=data.host,
port=data.port,
use_ssl=data.use_ssl,
use_tls=data.use_tls,
bind_dn=data.bind_dn,
bind_password_encrypted=encrypt_password(data.bind_password),
base_dn=data.base_dn,
user_search_filter=data.user_search_filter,
attr_email=data.attr_email,
attr_firstname=data.attr_firstname,
attr_lastname=data.attr_lastname,
attr_username=data.attr_username,
attr_department=data.attr_department,
)
db.add(cfg)
await db.commit()
await db.refresh(cfg)
return cfg
@router.patch("/config", response_model=LdapConfigOut)
async def update_ldap_config(
data: LdapConfigUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await ldap_service.get_config_or_404(current_user.company_id, db)
return await _apply_update(cfg, data.model_dump(exclude_none=True), db)
@router.post("/test", response_model=LdapTestResult)
async def test_ldap_connection(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await ldap_service.get_config_or_404(current_user.company_id, db)
result = ldap_service.test_connection(cfg)
return LdapTestResult(success=result.success, message=result.message)
@router.get("/preview", response_model=list[LdapUserPreview])
async def preview_ldap_users(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Returns first 50 users found in LDAP (for preview before sync)."""
cfg = await ldap_service.get_config_or_404(current_user.company_id, db)
raw_users = ldap_service.search_users(cfg)
previews = []
for u in raw_users[:50]:
previews.append(LdapUserPreview(
dn=u.get("dn", ""),
email=str(u.get(cfg.attr_email, "") or "").lower(),
first_name=str(u.get(cfg.attr_firstname, "") or ""),
last_name=str(u.get(cfg.attr_lastname, "") or ""),
department=str(u.get(cfg.attr_department, "") or "") if cfg.attr_department else None,
))
return previews
@router.post("/sync", response_model=LdapSyncResult)
async def sync_ldap_users(
data: LdapSyncRequest,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await ldap_service.get_config_or_404(current_user.company_id, db)
result = await ldap_service.sync_users(cfg, db, default_role=data.default_role)
return LdapSyncResult(
created=result.created,
updated=result.updated,
deactivated=result.deactivated,
errors=result.errors,
)
# ── Helpers ───────────────────────────────────────────────────────────────────
async def _apply_update(cfg: LdapConfig, updates: dict, db: AsyncSession) -> LdapConfig:
for field, value in updates.items():
if field == "bind_password" and value:
cfg.bind_password_encrypted = encrypt_password(value)
elif hasattr(cfg, field):
setattr(cfg, field, value)
await db.commit()
await db.refresh(cfg)
return cfg
+206
View File
@@ -0,0 +1,206 @@
from datetime import date
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.project import Project
from app.models.time_entry import TimeEntry, EntryStatus
from app.models.user import User, UserRole
from app.schemas.project import (
ProjectCreate,
ProjectListResponse,
ProjectOut,
ProjectTimeReport,
ProjectUpdate,
)
router = APIRouter(prefix="/projects", tags=["Projekte"])
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
def _assert_company(project: Project, company_id: UUID) -> None:
if project.company_id != company_id:
raise HTTPException(404, "Projekt nicht gefunden")
@router.get("", response_model=ProjectListResponse)
async def list_projects(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
include_inactive: bool = Query(False),
):
stmt = select(Project).where(Project.company_id == current_user.company_id)
if not include_inactive:
stmt = stmt.where(Project.is_active == True)
stmt = stmt.order_by(Project.name)
result = await db.scalars(stmt)
items = list(result.all())
return ProjectListResponse(total=len(items), items=items)
@router.post("", response_model=ProjectOut, status_code=201)
async def create_project(
data: ProjectCreate,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
project = Project(
company_id=current_user.company_id,
name=data.name,
description=data.description,
color=data.color,
budget_hours=data.budget_hours,
)
db.add(project)
await db.commit()
await db.refresh(project)
return project
@router.get("/report/summary", response_model=list[ProjectTimeReport])
async def projects_summary(
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
date_from: date | None = Query(None),
date_to: date | None = Query(None),
):
projects = list((await db.scalars(
select(Project).where(Project.company_id == current_user.company_id, Project.is_active == True)
)).all())
result = []
for project in projects:
stmt = (
select(TimeEntry)
.join(TimeEntry.user)
.where(
TimeEntry.project_id == project.id,
User.company_id == current_user.company_id,
TimeEntry.status == EntryStatus.APPROVED,
TimeEntry.end_time.is_not(None),
)
)
if date_from:
stmt = stmt.where(TimeEntry.date >= date_from)
if date_to:
stmt = stmt.where(TimeEntry.date <= date_to)
entries = list((await db.scalars(stmt)).all())
total_minutes = sum(e.worked_minutes or 0 for e in entries)
total_hours = round(total_minutes / 60, 2)
budget_used_pct = None
if project.budget_hours and float(project.budget_hours) > 0:
budget_used_pct = round(total_hours / float(project.budget_hours) * 100, 1)
result.append(ProjectTimeReport(
project_id=project.id,
project_name=project.name,
project_color=project.color,
total_hours=total_hours,
entry_count=len(entries),
budget_hours=float(project.budget_hours) if project.budget_hours else None,
budget_used_pct=budget_used_pct,
))
result.sort(key=lambda x: x.total_hours, reverse=True)
return result
@router.get("/{project_id}", response_model=ProjectOut)
async def get_project(
project_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
project = await db.get(Project, project_id)
if not project:
raise HTTPException(404, "Projekt nicht gefunden")
_assert_company(project, current_user.company_id)
return project
@router.patch("/{project_id}", response_model=ProjectOut)
async def update_project(
project_id: UUID,
data: ProjectUpdate,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
project = await db.get(Project, project_id)
if not project:
raise HTTPException(404, "Projekt nicht gefunden")
_assert_company(project, current_user.company_id)
for field, value in data.model_dump(exclude_unset=True).items():
setattr(project, field, value)
await db.commit()
await db.refresh(project)
return project
@router.delete("/{project_id}", status_code=204)
async def delete_project(
project_id: UUID,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
project = await db.get(Project, project_id)
if not project:
raise HTTPException(404, "Projekt nicht gefunden")
_assert_company(project, current_user.company_id)
project.is_active = False
await db.commit()
@router.get("/{project_id}/report", response_model=ProjectTimeReport)
async def project_time_report(
project_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
date_from: date | None = Query(None),
date_to: date | None = Query(None),
):
project = await db.get(Project, project_id)
if not project:
raise HTTPException(404, "Projekt nicht gefunden")
_assert_company(project, current_user.company_id)
stmt = (
select(TimeEntry)
.join(TimeEntry.user)
.where(
TimeEntry.project_id == project_id,
User.company_id == current_user.company_id,
TimeEntry.status == EntryStatus.APPROVED,
TimeEntry.end_time.is_not(None),
)
)
if date_from:
stmt = stmt.where(TimeEntry.date >= date_from)
if date_to:
stmt = stmt.where(TimeEntry.date <= date_to)
entries = list((await db.scalars(stmt)).all())
total_minutes = sum(e.worked_minutes or 0 for e in entries)
total_hours = round(total_minutes / 60, 2)
budget_used_pct = None
if project.budget_hours and float(project.budget_hours) > 0:
budget_used_pct = round(total_hours / float(project.budget_hours) * 100, 1)
return ProjectTimeReport(
project_id=project.id,
project_name=project.name,
project_color=project.color,
total_hours=total_hours,
entry_count=len(entries),
budget_hours=float(project.budget_hours) if project.budget_hours else None,
budget_used_pct=budget_used_pct,
)
+210
View File
@@ -0,0 +1,210 @@
from datetime import date, timedelta
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.user import User, UserRole
from app.schemas.report import (
AbsenceReport,
CompanyDashboard,
EmployeeDashboard,
OvertimeReport,
OvertimeReportDetailed,
TeamDashboard,
TimeReport,
)
from app.services.report_service import report_service
router = APIRouter(tags=["Dashboard & Reports"])
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Dashboard ──────────────────────────────────────────────────────────────────
@router.get("/dashboard/me", response_model=EmployeeDashboard)
async def my_dashboard(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Mitarbeiter-Dashboard: eigene Stunden, Urlaub, Status heute."""
return await report_service.employee_dashboard(current_user, db)
@router.get("/dashboard/team", response_model=TeamDashboard)
async def team_dashboard(
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Team-Dashboard: Anwesenheit, ausstehende Genehmigungen."""
return await report_service.team_dashboard(current_user, db)
@router.get("/dashboard/company", response_model=CompanyDashboard)
async def company_dashboard(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Unternehmens-Dashboard: Gesamtübersicht, Überstunden, kommende Abwesenheiten."""
return await report_service.company_dashboard(current_user, db)
# ── Reports ────────────────────────────────────────────────────────────────────
def _default_date_from() -> date:
today = date.today()
return today.replace(day=1)
def _default_date_to() -> date:
return date.today()
@router.get("/reports/time", response_model=TimeReport)
async def time_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungsbericht (JSON). EMPLOYEE sieht nur eigene Einträge."""
return await report_service.time_report(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
@router.get("/reports/absences", response_model=AbsenceReport)
async def absence_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Abwesenheitsbericht (JSON). EMPLOYEE sieht nur eigene Abwesenheiten."""
return await report_service.absence_report(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
@router.get("/reports/overtime", response_model=OvertimeReport)
async def overtime_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Überstundenbericht (JSON). EMPLOYEE sieht nur eigene Daten."""
return await report_service.overtime_report(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
@router.get("/reports/overtime/detail", response_model=OvertimeReportDetailed)
async def overtime_report_detail(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Erweiterter Überstundenbericht mit Wochen- und Tagesaufschlüsselung."""
return await report_service.overtime_report_detail(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
# ── Export ─────────────────────────────────────────────────────────────────────
@router.get("/reports/time/export")
async def export_time_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
format: str = Query("csv", pattern="^(csv|xlsx|pdf)$"),
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungsbericht als CSV, XLSX oder PDF herunterladen."""
report = await report_service.time_report(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
filename = f"zeiterfassung_{date_from}_{date_to}"
if format == "pdf":
content = report_service.time_report_to_pdf(report)
return Response(content=content, media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}.pdf"})
if format == "xlsx":
content = report_service.to_xlsx(report_service._time_rows_to_dicts(report.rows), sheet_name="Zeiterfassung")
return Response(content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}.xlsx"})
content = report_service.to_csv(report_service._time_rows_to_dicts(report.rows))
return Response(content=content, media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename={filename}.csv"})
@router.get("/reports/absences/export")
async def export_absence_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
format: str = Query("csv", pattern="^(csv|xlsx|pdf)$"),
db: AsyncSession = Depends(get_db),
):
"""Abwesenheitsbericht als CSV, XLSX oder PDF herunterladen."""
report = await report_service.absence_report(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
filename = f"abwesenheiten_{date_from}_{date_to}"
if format == "pdf":
content = report_service.absence_report_to_pdf(report)
return Response(content=content, media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}.pdf"})
if format == "xlsx":
content = report_service.to_xlsx(report_service._absence_rows_to_dicts(report.rows), sheet_name="Abwesenheiten")
return Response(content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}.xlsx"})
content = report_service.to_csv(report_service._absence_rows_to_dicts(report.rows))
return Response(content=content, media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename={filename}.csv"})
@router.get("/reports/overtime/export")
async def export_overtime_report(
current_user: CurrentUser,
date_from: date = Query(default_factory=_default_date_from),
date_to: date = Query(default_factory=_default_date_to),
user_id: UUID | None = Query(None),
format: str = Query("csv", pattern="^(csv|xlsx|pdf)$"),
db: AsyncSession = Depends(get_db),
):
"""Überstundenbericht als CSV, XLSX oder PDF herunterladen (Detailansicht)."""
detail = await report_service.overtime_report_detail(
current_user.company_id, current_user, db, date_from, date_to, user_id
)
filename = f"ueberstunden_{date_from}_{date_to}"
if format == "pdf":
content = report_service.overtime_detail_to_pdf(detail)
return Response(content=content, media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}.pdf"})
if format == "xlsx":
content = report_service.to_xlsx(report_service._overtime_detail_to_dicts(detail), sheet_name="Überstunden")
return Response(content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}.xlsx"})
content = report_service.to_csv(report_service._overtime_detail_to_dicts(detail))
return Response(content=content, media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename={filename}.csv"})
+92
View File
@@ -0,0 +1,92 @@
"""
SMTP-Konfiguration pro Firma.
Nur COMPANY_ADMIN / SUPER_ADMIN darf lesen und schreiben.
"""
import base64
import hashlib
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.models.smtp_config import SmtpConfig
from app.models.user import User, UserRole
from app.schemas.smtp import SmtpConfigOut, SmtpConfigSave, SmtpTestRequest
from app.services.email_service import email_service
router = APIRouter(prefix="/smtp", tags=["SMTP-Konfiguration"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
def _encrypt(plain: str) -> str:
from app.core.config import settings
from cryptography.fernet import Fernet
key = hashlib.sha256(settings.secret_key.encode()).digest()
f = Fernet(base64.urlsafe_b64encode(key))
return f.encrypt(plain.encode()).decode()
async def _get_config(company_id: uuid.UUID, db: AsyncSession) -> SmtpConfig | None:
return await db.scalar(select(SmtpConfig).where(SmtpConfig.company_id == company_id))
@router.get("/config", response_model=SmtpConfigOut | None)
async def get_smtp_config(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
cfg = await _get_config(current_user.company_id, db)
if not cfg:
return None
return SmtpConfigOut.model_validate(cfg)
@router.post("/config", response_model=SmtpConfigOut)
async def save_smtp_config(
data: SmtpConfigSave,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Erstellt oder überschreibt die SMTP-Konfiguration der Firma."""
cfg = await _get_config(current_user.company_id, db)
if cfg is None:
cfg = SmtpConfig(company_id=current_user.company_id, id=uuid.uuid4())
db.add(cfg)
cfg.host = data.host
cfg.port = data.port
cfg.use_tls = data.use_tls
cfg.use_starttls = data.use_starttls
cfg.username = data.username
cfg.from_email = data.from_email
cfg.from_name = data.from_name
cfg.is_enabled = data.is_enabled
if data.password is not None:
cfg.password_encrypted = _encrypt(data.password)
await db.commit()
await db.refresh(cfg)
return SmtpConfigOut.model_validate(cfg)
@router.post("/test")
async def test_smtp(
data: SmtpTestRequest,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Sendet eine Test-E-Mail mit der aktuellen Konfiguration."""
cfg = await _get_config(current_user.company_id, db)
if not cfg:
raise HTTPException(status_code=404, detail="Keine SMTP-Konfiguration vorhanden.")
try:
await email_service.send_test(cfg, data.to)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"SMTP-Fehler: {exc}")
return {"message": f"Test-E-Mail an {data.to} verschickt."}
+278
View File
@@ -0,0 +1,278 @@
from datetime import date
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.time_entry import EntryStatus
from app.models.user import User, UserRole
from app.schemas.time_entry import (
BalanceResponse,
ManualEntryCreate,
RejectRequest,
StampInRequest,
StampOutRequest,
TimeEntryListResponse,
TimeEntryOut,
TimeEntryUpdate,
TimeEntryWithWarnings,
WorkScheduleCreate,
WorkScheduleOut,
)
from app.services.time_service import time_service
router = APIRouter(prefix="/time", tags=["Zeiterfassung"])
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Stempeluhr ────────────────────────────────────────────────────────────────
@router.post("/stamp-in", response_model=TimeEntryWithWarnings, status_code=201)
async def stamp_in(
data: StampInRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Einstempeln startet einen neuen Zeiterfassungseintrag."""
entry, warnings = await time_service.stamp_in(current_user, data, db)
await db.commit()
await db.refresh(entry)
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
@router.post("/stamp-out", response_model=TimeEntryWithWarnings)
async def stamp_out(
data: StampOutRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Ausstempeln schließt den offenen Zeiterfassungseintrag."""
entry, warnings = await time_service.stamp_out(current_user, data.note, db)
await db.commit()
await db.refresh(entry)
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
@router.post("/break-start", response_model=TimeEntryOut)
async def break_start(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Pause beginnen."""
entry = await time_service.break_start(current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
@router.post("/break-end", response_model=TimeEntryOut)
async def break_end(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Pause beenden."""
entry = await time_service.break_end(current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
# ── Heute ─────────────────────────────────────────────────────────────────────
@router.get("/today", response_model=list[TimeEntryOut])
async def get_today(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Alle Einträge des heutigen Tages für den aktuellen Benutzer."""
entries = await time_service.get_today(current_user, db)
return [TimeEntryOut.model_validate(e) for e in entries]
# ── Einträge ──────────────────────────────────────────────────────────────────
@router.get("/entries", response_model=TimeEntryListResponse)
async def list_entries(
current_user: CurrentUser,
user_id: UUID | None = Query(None),
date_from: date | None = Query(None),
date_to: date | None = Query(None),
status: EntryStatus | None = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
):
total, entries = await time_service.list_entries(
current_user.company_id, current_user, db,
user_id=user_id, date_from=date_from, date_to=date_to,
status=status, skip=skip, limit=limit,
)
return TimeEntryListResponse(total=total, items=[TimeEntryOut.model_validate(e) for e in entries])
@router.post("/entries", response_model=TimeEntryWithWarnings, status_code=201)
async def create_manual_entry(
data: ManualEntryCreate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Manuellen Zeiterfassungseintrag anlegen."""
entry, warnings = await time_service.create_manual(data, current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
@router.patch("/entries/{entry_id}", response_model=TimeEntryOut)
async def update_entry(
entry_id: UUID,
data: TimeEntryUpdate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungseintrag korrigieren."""
entry = await time_service.update_entry(entry_id, data, current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
@router.post("/entries/{entry_id}/approve", response_model=TimeEntryOut)
async def approve_entry(
entry_id: UUID,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungseintrag genehmigen."""
entry = await time_service.approve_entry(entry_id, current_user, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
@router.post("/entries/{entry_id}/reject", response_model=TimeEntryOut)
async def reject_entry(
entry_id: UUID,
data: RejectRequest,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungseintrag ablehnen."""
entry = await time_service.reject_entry(entry_id, current_user, data.rejection_note, db)
await db.commit()
await db.refresh(entry)
return TimeEntryOut.model_validate(entry)
@router.delete("/entries/{entry_id}", status_code=204)
async def delete_entry(
entry_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Zeiteintrag löschen. Mitarbeiter: nur eigene offene/ausstehende Einträge. Manager: alle außer genehmigten (außer HR/Admin)."""
await time_service.delete_entry(entry_id, current_user, db)
await db.commit()
# ── Überstundenkonto ──────────────────────────────────────────────────────────
@router.get("/balance/me", response_model=BalanceResponse)
async def get_own_balance(
current_user: CurrentUser,
period_start: date | None = Query(None),
period_end: date | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Eigenes Überstundenkonto."""
return await time_service.get_balance(current_user.id, current_user, db, period_start, period_end)
@router.get("/balance/{user_id}", response_model=BalanceResponse)
async def get_balance(
user_id: UUID,
current_user: CurrentUser,
period_start: date | None = Query(None),
period_end: date | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Überstundenkonto für einen Benutzer."""
if user_id != current_user.id:
if current_user.role == UserRole.EMPLOYEE:
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
target_user = await db.get(User, user_id)
if target_user is None or target_user.company_id != current_user.company_id:
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
return await time_service.get_balance(user_id, current_user, db, period_start, period_end)
# ── Arbeitspläne ──────────────────────────────────────────────────────────────
@router.get("/schedules", response_model=list[WorkScheduleOut])
async def list_schedules(
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
schedules = await time_service.list_work_schedules(current_user.company_id, db)
return [WorkScheduleOut.model_validate(s) for s in schedules]
@router.post("/schedules", response_model=WorkScheduleOut, status_code=201)
async def create_schedule(
data: WorkScheduleCreate,
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
schedule = await time_service.create_work_schedule(current_user.company_id, data, db)
await db.commit()
await db.refresh(schedule)
return WorkScheduleOut.model_validate(schedule)
@router.patch("/schedules/{schedule_id}", response_model=WorkScheduleOut)
async def update_schedule(
schedule_id: UUID,
data: WorkScheduleCreate,
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select as sa_select
from app.models.work_schedule import WorkSchedule
schedule = await db.scalar(
sa_select(WorkSchedule).where(
WorkSchedule.id == schedule_id,
WorkSchedule.company_id == current_user.company_id,
)
)
if not schedule:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Arbeitsplan nicht gefunden")
for field, value in data.model_dump().items():
setattr(schedule, field, value)
await db.commit()
await db.refresh(schedule)
return WorkScheduleOut.model_validate(schedule)
@router.delete("/schedules/{schedule_id}", status_code=204)
async def delete_schedule(
schedule_id: UUID,
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select as sa_select
from app.models.work_schedule import WorkSchedule
schedule = await db.scalar(
sa_select(WorkSchedule).where(
WorkSchedule.id == schedule_id,
WorkSchedule.company_id == current_user.company_id,
)
)
if not schedule:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Arbeitsplan nicht gefunden")
await db.delete(schedule)
await db.commit()
+185
View File
@@ -0,0 +1,185 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, File, Query, UploadFile
from fastapi.responses import PlainTextResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.user import User, UserRole
from app.schemas.auth import MessageResponse
from app.schemas.user import (
InviteRequest,
NextPersonnelNumberResponse,
SetKioskPinRequest,
UserImportResult,
UserImportRowResult,
UserListResponse,
UserOut,
UserUpdate,
)
from app.services import user_import_service
from app.services.user_service import user_service
router = APIRouter(prefix="/users", tags=["Users"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_hr_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN, UserRole.HR, UserRole.MANAGER)
@router.get("/", response_model=UserListResponse)
async def list_users(
current_user: User = require_role(*_hr_roles),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
active_only: bool = Query(True),
search: str | None = Query(None, max_length=100),
db: AsyncSession = Depends(get_db),
):
total, users = await user_service.list_users(
current_user.company_id, db, skip, limit, active_only, search,
)
return UserListResponse(total=total, items=[UserOut.model_validate(u) for u in users])
@router.post("/invite", response_model=UserOut, status_code=201)
async def invite_user(
data: InviteRequest,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.invite(data, current_user.company_id, current_user, db)
return UserOut.model_validate(user)
@router.get("/me", response_model=UserOut)
async def get_me(current_user: CurrentUser):
return UserOut.model_validate(current_user)
@router.get("/next-personnel-number", response_model=NextPersonnelNumberResponse)
async def next_personnel_number(
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
"""Schlägt die nächste freie Personalnummer vor (ohne den Counter zu erhöhen)."""
suggestion = await user_service.next_personnel_suggestion(current_user.company_id, db)
return NextPersonnelNumberResponse(next=suggestion)
@router.get("/by-personnel/{number}", response_model=UserOut)
async def get_user_by_personnel(
number: str,
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.get_by_personnel_number(number, current_user.company_id, db)
return UserOut.model_validate(user)
@router.get("/import-template.csv", response_class=PlainTextResponse)
async def import_template(
current_user: User = require_role(*_admin_roles),
):
csv_text = user_import_service.build_template_csv()
return PlainTextResponse(
content=csv_text,
media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="user-import-template.csv"'},
)
@router.post("/import/preview", response_model=UserImportResult)
async def user_import_preview(
file: Annotated[UploadFile, File()],
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
content = await file.read()
result = await user_import_service.preview_csv(content, current_user.company_id, current_user, db)
return _to_import_result_schema(result)
@router.post("/import/apply", response_model=UserImportResult)
async def user_import_apply(
file: Annotated[UploadFile, File()],
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
content = await file.read()
result = await user_import_service.apply_csv(content, current_user.company_id, current_user, db)
return _to_import_result_schema(result)
def _to_import_result_schema(result) -> UserImportResult:
return UserImportResult(
total_rows=result.total_rows,
created=result.created,
reactivated=result.reactivated,
errors=result.errors,
items=[
UserImportRowResult(
row=i.row, email=i.email, personnel_number=i.personnel_number,
action=i.action, message=i.message,
)
for i in result.items
],
)
@router.get("/{user_id}", response_model=UserOut)
async def get_user(
user_id: UUID,
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.get_by_id(user_id, current_user.company_id, db)
return UserOut.model_validate(user)
@router.patch("/{user_id}", response_model=UserOut)
async def update_user(
user_id: UUID,
data: UserUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.update(user_id, data, current_user, db)
return UserOut.model_validate(user)
@router.post("/{user_id}/deactivate", response_model=UserOut)
async def deactivate_user(
user_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.deactivate(user_id, current_user, db)
return UserOut.model_validate(user)
@router.post("/{user_id}/reactivate", response_model=UserOut)
async def reactivate_user(
user_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.reactivate(user_id, current_user, db)
return UserOut.model_validate(user)
@router.post("/{user_id}/kiosk-pin", response_model=MessageResponse)
async def set_kiosk_pin(
user_id: UUID,
data: SetKioskPinRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
# Users can set their own PIN; admins can set for any user in company
if user_id != current_user.id and not current_user.is_admin_or_above():
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Not allowed")
user = await user_service.get_by_id(user_id, current_user.company_id, db)
await user_service.set_kiosk_pin(user, data.pin, db)
return MessageResponse(message="Kiosk PIN updated")
View File
+210
View File
@@ -0,0 +1,210 @@
import uuid
from datetime import date, datetime
from pydantic import BaseModel, Field
from app.models.absence import AbsenceStatus
from app.models.absence_type import AbsenceCategory
# ── AbsenceType ───────────────────────────────────────────────────────────────
class AbsenceTypeOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
name: str
color: str
category: AbsenceCategory
requires_approval: bool
deducts_vacation: bool
affects_overtime_balance: bool
requires_certificate: bool
certificate_after_days: int
is_paid: bool
max_days_per_year: int | None
is_active: bool
class AbsenceTypeCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
color: str = Field("#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
category: AbsenceCategory = AbsenceCategory.OTHER
requires_approval: bool = True
deducts_vacation: bool = False
affects_overtime_balance: bool = False
requires_certificate: bool = False
certificate_after_days: int = Field(3, ge=0, le=365)
is_paid: bool = True
max_days_per_year: int | None = Field(None, ge=1)
class AbsenceTypeUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
category: AbsenceCategory | None = None
requires_approval: bool | None = None
deducts_vacation: bool | None = None
affects_overtime_balance: bool | None = None
requires_certificate: bool | None = None
certificate_after_days: int | None = Field(None, ge=0, le=365)
is_paid: bool | None = None
max_days_per_year: int | None = Field(None, ge=1)
is_active: bool | None = None
# ── Absence ───────────────────────────────────────────────────────────────────
class AbsenceOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
user_id: uuid.UUID
type_id: uuid.UUID
start_date: date
end_date: date
half_day_start: bool
half_day_end: bool
working_days: float
status: AbsenceStatus
approved_by: uuid.UUID | None
substitute_id: uuid.UUID | None
note: str | None
correction_note: str | None
rejection_reason: str | None
certificate_required_by: date | None = None
certificate_received_at: date | None = None
created_at: datetime
class AbsenceCreate(BaseModel):
type_id: uuid.UUID
start_date: date
end_date: date
half_day_start: bool = False
half_day_end: bool = False
substitute_id: uuid.UUID | None = None
note: str | None = None
for_user_id: uuid.UUID | None = None # HR/Admin: Abwesenheit für anderen Mitarbeiter anlegen
def model_post_init(self, __context) -> None:
if self.end_date < self.start_date:
raise ValueError("end_date must be >= start_date")
class AbsenceUpdate(BaseModel):
type_id: uuid.UUID | None = None
start_date: date | None = None
end_date: date | None = None
half_day_start: bool | None = None
half_day_end: bool | None = None
substitute_id: uuid.UUID | None = None
note: str | None = None
correction_note: str | None = None # Pflicht bei Änderung genehmigter Anträge (Mitarbeiter)
def model_post_init(self, __context) -> None:
if self.start_date and self.end_date and self.end_date < self.start_date:
raise ValueError("end_date must be >= start_date")
class AbsenceReject(BaseModel):
rejection_reason: str = Field(min_length=1)
class AbsenceListResponse(BaseModel):
total: int
items: list[AbsenceOut]
# ── Krankmeldung ──────────────────────────────────────────────────────────────
class QuickSickIn(BaseModel):
start_date: date
end_date: date
def model_post_init(self, __context) -> None:
if self.end_date < self.start_date:
raise ValueError("end_date must be >= start_date")
class CertificateMarkIn(BaseModel):
received_at: date | None = None # default = heute
class SickStatsOut(BaseModel):
user_id: uuid.UUID
user_name: str
personnel_number: str | None = None
episodes: int
total_days: float
bradford_factor: float
certificates_overdue: int
# ── VacationBalance ───────────────────────────────────────────────────────────
class VacationBalanceOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
user_id: uuid.UUID
year: int
entitled_days: int
special_days: int = 0
carried_over: int
used_days: int
total_days: int
remaining_days: int
pending_days: float = 0
# Resturlaub-Verfall (wird zur Laufzeit befüllt, nicht in DB)
carried_over_expires_at: date | None = None
carried_over_expired: bool = False
class VacationBalanceUpdate(BaseModel):
entitled_days: int | None = Field(None, ge=0, le=365)
special_days: int | None = Field(None, ge=0, le=365)
carried_over: int | None = Field(None, ge=0, le=365)
# ── PublicHoliday ─────────────────────────────────────────────────────────────
class PublicHolidayOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
country: str
state: str | None
date: date
name: str
year: int
class PublicHolidayCreate(BaseModel):
country: str = Field("DE", min_length=2, max_length=10)
state: str | None = Field(None, max_length=10)
date: date
name: str = Field(min_length=1, max_length=255)
# ── OvertimeBalance ───────────────────────────────────────────────────────────
class OvertimeBalanceOut(BaseModel):
total_hours: float
taken_hours: float
available_hours: float
# ── Calendar ──────────────────────────────────────────────────────────────────
class CalendarEntry(BaseModel):
user_id: uuid.UUID
user_name: str
absence_id: uuid.UUID
type_name: str
type_color: str
start_date: date
end_date: date
status: AbsenceStatus
working_days: float
+24
View File
@@ -0,0 +1,24 @@
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class AuditLogEntry(BaseModel):
id: UUID
user_id: UUID | None
user_name: str | None
action: str
entity_type: str | None
entity_id: UUID | None
old_value: dict | None
new_value: dict | None
ip_address: str | None
created_at: datetime
model_config = {"from_attributes": True}
class AuditLogListResponse(BaseModel):
total: int
items: list[AuditLogEntry]
+81
View File
@@ -0,0 +1,81 @@
from pydantic import BaseModel, EmailStr, Field, model_validator
class RegisterRequest(BaseModel):
company_name: str = Field(min_length=2, max_length=255)
first_name: str = Field(min_length=1, max_length=100)
last_name: str = Field(min_length=1, max_length=100)
email: EmailStr
password: str = Field(min_length=8, max_length=128)
@model_validator(mode="after")
def password_strength(self):
pw = self.password
if not any(c.isupper() for c in pw):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.isdigit() for c in pw):
raise ValueError("Password must contain at least one digit")
return self
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RefreshRequest(BaseModel):
refresh_token: str
class PasswordResetRequest(BaseModel):
email: EmailStr
class PasswordResetConfirm(BaseModel):
token: str
new_password: str = Field(min_length=8, max_length=128)
@model_validator(mode="after")
def password_strength(self):
pw = self.new_password
if not any(c.isupper() for c in pw):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.isdigit() for c in pw):
raise ValueError("Password must contain at least one digit")
return self
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
totp_required: bool = False
partial_token: str | None = None
class TotpSetupResponse(BaseModel):
secret: str # base32 secret for manual entry
otpauth_uri: str # otpauth://totp/... für QR-Code
class TotpConfirmRequest(BaseModel):
code: str = Field(min_length=6, max_length=6)
class TotpLoginRequest(BaseModel):
partial_token: str
code: str = Field(min_length=6, max_length=6)
class TotpDisableRequest(BaseModel):
password: str
code: str = Field(min_length=6, max_length=6)
class AccessTokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class MessageResponse(BaseModel):
message: str
+29
View File
@@ -0,0 +1,29 @@
from datetime import date, datetime
from pydantic import BaseModel, Field
class BusylightTokenStatus(BaseModel):
configured: bool
created_at: datetime | None = None
class BusylightTokenRotated(BaseModel):
token: str = Field(..., description="Klartext-Token, wird nur einmal angezeigt.")
created_at: datetime
class BusylightAbsenceItem(BaseModel):
type: str
category: str
class BusylightUserItem(BaseModel):
personnel_number: str
full_name: str
absences_today: list[BusylightAbsenceItem]
class BusylightUsersResponse(BaseModel):
date: date
users: list[BusylightUserItem]
+59
View File
@@ -0,0 +1,59 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class CaldavCompanyConfigOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
enabled: bool
principal_url: str
calendar_url: str | None
username: str
calendar_display_name: str
verify_ssl: bool
name_template: str
last_error: str | None
updated_at: datetime
class CaldavCompanyConfigSave(BaseModel):
enabled: bool = False
principal_url: str = Field(min_length=1)
calendar_url: str | None = None
username: str = Field(min_length=1, max_length=255)
password: str | None = None # leer = unverändert
calendar_display_name: str = ""
verify_ssl: bool = True
name_template: str = "$vorname $nachname $typ"
class CaldavUserConfigOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
user_id: uuid.UUID
enabled: bool
principal_url: str
calendar_url: str | None
username: str
calendar_display_name: str
verify_ssl: bool
last_error: str | None
updated_at: datetime
class CaldavUserConfigSave(BaseModel):
enabled: bool = False
principal_url: str = Field(min_length=1)
calendar_url: str | None = None
username: str = Field(min_length=1, max_length=255)
password: str | None = None
calendar_display_name: str = ""
verify_ssl: bool = True
class ResyncResult(BaseModel):
synced: int
failed: int
total: int
+51
View File
@@ -0,0 +1,51 @@
import uuid
from typing import Literal
from pydantic import BaseModel, Field
PersonnelNumberModeT = Literal["manual", "auto"]
class CompanyOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
name: str
slug: str
plan: str
logo_url: str | None
country: str
state: str | None
settings: dict
personnel_number_required: bool = False
personnel_number_mode: PersonnelNumberModeT = "manual"
personnel_number_next: int = 1
class CompanyUpdate(BaseModel):
name: str | None = Field(None, min_length=2, max_length=255)
state: str | None = Field(None, max_length=10)
settings: dict | None = None
personnel_number_required: bool | None = None
personnel_number_mode: PersonnelNumberModeT | None = None
personnel_number_next: int | None = Field(None, ge=1)
class DepartmentOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
name: str
manager_id: uuid.UUID | None
class DepartmentCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
manager_id: uuid.UUID | None = None
class DepartmentUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
manager_id: uuid.UUID | None = None
+48
View File
@@ -0,0 +1,48 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
class KioskDeviceCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
location: str | None = Field(None, max_length=255)
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
return v.strip()
class KioskDeviceUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
location: str | None = Field(None, max_length=255)
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str | None) -> str | None:
if v is not None:
if not v.strip():
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
return v.strip()
return v
is_active: bool | None = None
class KioskDeviceOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
name: str
location: str | None
is_active: bool
last_seen_at: datetime | None
created_at: datetime
class KioskDeviceCreated(KioskDeviceOut):
"""Wird nur einmalig bei Erstellung zurückgegeben enthält den Klartext-Token."""
token: str
+93
View File
@@ -0,0 +1,93 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
from app.models.user import UserRole
class LdapConfigCreate(BaseModel):
host: str = Field(min_length=1, max_length=255)
port: int = Field(default=389, ge=1, le=65535)
use_ssl: bool = False
use_tls: bool = False
tls_verify: bool = False
bind_dn: str = Field(min_length=1)
bind_password: str = Field(min_length=1)
base_dn: str = Field(min_length=1)
user_search_filter: str = Field(default="(objectClass=person)", min_length=1, max_length=512)
attr_email: str = Field(default="mail", min_length=1, max_length=100)
attr_firstname: str = Field(default="givenName", min_length=1, max_length=100)
attr_lastname: str = Field(default="sn", min_length=1, max_length=100)
attr_username: str = Field(default="sAMAccountName", min_length=1, max_length=100)
attr_department: str | None = Field(default=None, max_length=100)
attr_personnel_number: str | None = Field(default=None, max_length=100)
enabled: bool = False
class LdapConfigUpdate(BaseModel):
host: str | None = Field(default=None, min_length=1, max_length=255)
port: int | None = Field(default=None, ge=1, le=65535)
use_ssl: bool | None = None
use_tls: bool | None = None
tls_verify: bool | None = None
bind_dn: str | None = None
bind_password: str | None = None
base_dn: str | None = None
user_search_filter: str | None = Field(default=None, max_length=512)
attr_email: str | None = Field(default=None, max_length=100)
attr_firstname: str | None = Field(default=None, max_length=100)
attr_lastname: str | None = Field(default=None, max_length=100)
attr_username: str | None = Field(default=None, max_length=100)
attr_department: str | None = Field(default=None, max_length=100)
attr_personnel_number: str | None = Field(default=None, max_length=100)
enabled: bool | None = None
class LdapConfigOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
enabled: bool
host: str
port: int
use_ssl: bool
use_tls: bool
tls_verify: bool
bind_dn: str
base_dn: str
user_search_filter: str
attr_email: str
attr_firstname: str
attr_lastname: str
attr_username: str
attr_department: str | None
attr_personnel_number: str | None = None
last_sync_at: datetime | None
created_at: datetime
updated_at: datetime
class LdapTestResult(BaseModel):
success: bool
message: str
class LdapSyncRequest(BaseModel):
default_role: UserRole = UserRole.EMPLOYEE
class LdapSyncResult(BaseModel):
created: int
updated: int
deactivated: int
errors: list[str]
class LdapUserPreview(BaseModel):
dn: str
email: str
first_name: str
last_name: str
department: str | None = None
+48
View File
@@ -0,0 +1,48 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class ProjectOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
name: str
description: str | None
color: str
budget_hours: float | None
is_active: bool
created_at: datetime
updated_at: datetime
class ProjectCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
description: str | None = None
color: str = Field("#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
budget_hours: float | None = Field(None, ge=0.1, le=99999)
class ProjectUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=100)
description: str | None = None
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
budget_hours: float | None = Field(None, ge=0.1, le=99999)
is_active: bool | None = None
class ProjectListResponse(BaseModel):
total: int
items: list[ProjectOut]
class ProjectTimeReport(BaseModel):
project_id: uuid.UUID
project_name: str
project_color: str
total_hours: float
entry_count: int
budget_hours: float | None
budget_used_pct: float | None # None wenn kein Budget
+190
View File
@@ -0,0 +1,190 @@
import uuid
from datetime import date, time
from pydantic import BaseModel
# ── Employee Dashboard ─────────────────────────────────────────────────────────
class EmployeeDashboard(BaseModel):
today_open: bool
today_start: time | None
today_hours_so_far: float | None
week_hours_worked: float
week_hours_expected: float
week_overtime: float
vacation_remaining_days: int | None
vacation_used_days: int
vacation_entitled_days: int
pending_absences: int
overtime_balance_hours: float | None # verfügbares Überstundenguthaben
schedule_name: str | None # zugewiesener Arbeitsplan
# ── Team Dashboard ─────────────────────────────────────────────────────────────
class TeamMemberStatus(BaseModel):
user_id: uuid.UUID
user_name: str
department: str | None
status: str # "present" | "on_leave" | "absent"
absence_type: str | None
time_in: time | None
hours_today: float | None
class TeamDashboard(BaseModel):
present_count: int
on_leave_count: int
absent_count: int
pending_time_approvals: int
pending_absence_approvals: int
members: list[TeamMemberStatus]
# ── Company Dashboard ──────────────────────────────────────────────────────────
class UpcomingAbsence(BaseModel):
user_id: uuid.UUID
user_name: str
absence_type: str
start_date: date
end_date: date
working_days: float
class CompanyDashboard(BaseModel):
total_employees: int
active_today: int
attendance_rate: float
month_hours_worked: float
month_hours_expected: float
month_overtime: float
pending_time_approvals: int
pending_absence_approvals: int
upcoming_absences: list[UpcomingAbsence]
# ── Time Report ────────────────────────────────────────────────────────────────
class HoursBreakdown(BaseModel):
"""Stunden-Aufteilung nach §3b EStG für den Steuerberater."""
normal_hours: float # MoFr, 0620 Uhr, kein Feiertag
night_25_hours: float # 2024 + 0406 Uhr (25% Zuschlag)
night_40_hours: float # 0004 Uhr (40% Zuschlag)
sunday_hours: float # Sonntag 0024 Uhr (50% Zuschlag)
holiday_125_hours: float # gesetzl. Feiertag (125% Zuschlag)
holiday_150_hours: float # besondere Feiertage 25.12, 26.12, 01.05 etc. (150%)
holiday_name: str | None # Name des Feiertags falls zutreffend
class TimeReportRow(BaseModel):
date: date
user_id: uuid.UUID
user_name: str
personnel_number: str | None = None
department: str | None
start_time: time
end_time: time | None
break_minutes: int
worked_hours: float | None
status: str
source: str
note: str | None
breakdown: HoursBreakdown | None = None # None wenn kein Bundesland konfiguriert
class TimeReport(BaseModel):
date_from: date
date_to: date
total_rows: int
total_hours: float
rows: list[TimeReportRow]
# ── Absence Report ─────────────────────────────────────────────────────────────
class AbsenceReportRow(BaseModel):
user_id: uuid.UUID
user_name: str
personnel_number: str | None = None
department: str | None
absence_type: str
start_date: date
end_date: date
working_days: float
status: str
note: str | None
class AbsenceReport(BaseModel):
date_from: date
date_to: date
total_rows: int
total_days: float
rows: list[AbsenceReportRow]
# ── Overtime Report ────────────────────────────────────────────────────────────
class OvertimeReportRow(BaseModel):
user_id: uuid.UUID
user_name: str
personnel_number: str | None = None
department: str | None
hours_worked: float
hours_expected: float
overtime_hours: float
class OvertimeReport(BaseModel):
date_from: date
date_to: date
total_employees: int
total_overtime: float
rows: list[OvertimeReportRow]
# ── Overtime Detail Report (Option A: Inline-Expand) ──────────────────────────
class DayEntry(BaseModel):
"""Einzelner Zeiteintrag innerhalb eines Tages (mehrere möglich)."""
start_time: time
end_time: time
break_minutes: int
hours_worked: float
status: str
arbzg_warnings: list[str] = []
breakdown: HoursBreakdown | None = None
class OvertimeDay(BaseModel):
date: date
weekday: str # "Mo", "Di", …
hours_worked: float # Summe aller Einträge des Tages
hours_expected: float
overtime: float
entries: list[DayEntry] = [] # leer = kein Eintrag an dem Tag
class OvertimeWeek(BaseModel):
week_nr: int
week_start: date
week_end: date
hours_worked: float
hours_expected: float
overtime: float
days: list[OvertimeDay]
class OvertimeReportRowDetailed(OvertimeReportRow):
weeks: list[OvertimeWeek] = []
arbzg_violation_days: int = 0 # Tage > 10h
special_hours_total: HoursBreakdown | None = None
class OvertimeReportDetailed(BaseModel):
date_from: date
date_to: date
total_employees: int
total_overtime: float
rows: list[OvertimeReportRowDetailed]
+34
View File
@@ -0,0 +1,34 @@
import uuid
from pydantic import BaseModel, Field
class SmtpConfigOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
host: str
port: int
use_tls: bool
use_starttls: bool
username: str | None
from_email: str
from_name: str
is_enabled: bool
# password_encrypted wird nie zurückgegeben
class SmtpConfigSave(BaseModel):
host: str = Field(min_length=1, max_length=255)
port: int = Field(default=587, ge=1, le=65535)
use_tls: bool = False
use_starttls: bool = True
username: str | None = Field(None, max_length=255)
password: str | None = None # Klartext wird serverseitig verschlüsselt
from_email: str = Field(min_length=5, max_length=255)
from_name: str = Field(default="TimeMaster", min_length=1, max_length=255)
is_enabled: bool = True
class SmtpTestRequest(BaseModel):
to: str = Field(min_length=5, max_length=255)
+111
View File
@@ -0,0 +1,111 @@
import uuid
from datetime import date, datetime, time
from decimal import Decimal
from pydantic import BaseModel, Field
from app.models.time_entry import EntrySource, EntryStatus
class TimeEntryOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
user_id: uuid.UUID
date: date
start_time: time
end_time: time | None
break_minutes: int
break_start: time | None
project_id: uuid.UUID | None
note: str | None
status: EntryStatus
source: EntrySource
approved_by: uuid.UUID | None
correction_note: str | None
worked_hours: float | None
created_at: datetime
updated_at: datetime
class TimeEntryWithWarnings(BaseModel):
entry: TimeEntryOut
warnings: list[str] = []
class StampInRequest(BaseModel):
source: EntrySource = EntrySource.WEB
project_id: uuid.UUID | None = None
note: str | None = None
class StampOutRequest(BaseModel):
note: str | None = None
class ManualEntryCreate(BaseModel):
user_id: uuid.UUID | None = None # MANAGER/HR können für andere setzen
date: date
start_time: time
end_time: time
break_minutes: int = Field(0, ge=0, le=600)
project_id: uuid.UUID | None = None
note: str | None = None
source: EntrySource = EntrySource.MANUAL
class TimeEntryUpdate(BaseModel):
start_time: time | None = None
end_time: time | None = None
break_minutes: int | None = Field(None, ge=0, le=600)
project_id: uuid.UUID | None = None
note: str | None = None
correction_note: str | None = None
class RejectRequest(BaseModel):
rejection_note: str | None = None
class TimeEntryListResponse(BaseModel):
total: int
items: list[TimeEntryOut]
class WorkScheduleOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
name: str
mon_h: Decimal
tue_h: Decimal
wed_h: Decimal
thu_h: Decimal
fri_h: Decimal
sat_h: Decimal
sun_h: Decimal
valid_from: date
class WorkScheduleCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
mon_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
tue_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
wed_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
thu_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
fri_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
sat_h: Decimal = Field(Decimal("0.00"), ge=0, le=24)
sun_h: Decimal = Field(Decimal("0.00"), ge=0, le=24)
valid_from: date
class BalanceResponse(BaseModel):
user_id: uuid.UUID
period_start: date
period_end: date
total_hours_worked: float
expected_hours: float
overtime_hours: float
approved_entries: int
pending_entries: int
+112
View File
@@ -0,0 +1,112 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field, model_validator
from app.models.user import AuthProvider, UserRole
PERSONNEL_NUMBER_PATTERN = r"^[0-9]+$"
class UserOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID | None
department_id: uuid.UUID | None
email: str
first_name: str
last_name: str
full_name: str
role: UserRole
auth_provider: AuthProvider
is_active: bool
last_login: datetime | None
created_at: datetime
kuerzel: str | None = None
personnel_number: str | None = None
can_manual_time_entry: bool = False
class UserUpdate(BaseModel):
first_name: str | None = Field(None, min_length=1, max_length=100)
last_name: str | None = Field(None, min_length=1, max_length=100)
department_id: uuid.UUID | None = None
role: UserRole | None = None
work_schedule_id: uuid.UUID | None = None
kuerzel: str | None = Field(None, max_length=20)
personnel_number: str | None = Field(None, max_length=50, pattern=PERSONNEL_NUMBER_PATTERN)
can_manual_time_entry: bool | None = None
is_active: bool | None = None
class InviteRequest(BaseModel):
email: EmailStr
first_name: str = Field(min_length=1, max_length=100)
last_name: str = Field(min_length=1, max_length=100)
role: UserRole = UserRole.EMPLOYEE
department_id: uuid.UUID | None = None
personnel_number: str | None = Field(None, max_length=50, pattern=PERSONNEL_NUMBER_PATTERN)
# Wenn gesetzt → User wird sofort aktiv (kein Invite-E-Mail nötig)
initial_password: str | None = Field(None, min_length=8, max_length=128)
@model_validator(mode="after")
def password_strength(self):
pw = self.initial_password
if pw is None:
return self
if not any(c.isupper() for c in pw):
raise ValueError("initial_password must contain at least one uppercase letter")
if not any(c.isdigit() for c in pw):
raise ValueError("initial_password must contain at least one digit")
return self
class InviteAccept(BaseModel):
token: str
password: str = Field(min_length=8, max_length=128)
@model_validator(mode="after")
def password_strength(self):
pw = self.password
if not any(c.isupper() for c in pw):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.isdigit() for c in pw):
raise ValueError("Password must contain at least one digit")
return self
class UserListResponse(BaseModel):
total: int
items: list[UserOut]
class SetKioskPinRequest(BaseModel):
pin: str = Field(min_length=4, max_length=6, pattern=r"^\d+$")
class NextPersonnelNumberResponse(BaseModel):
next: str
class UserImportRowError(BaseModel):
row: int
email: str | None = None
message: str
class UserImportRowResult(BaseModel):
row: int
email: str
personnel_number: str | None = None
action: str # "created" | "reactivated" | "skipped" | "error"
message: str | None = None
class UserImportResult(BaseModel):
total_rows: int
created: int
reactivated: int
errors: int
items: list[UserImportRowResult]
View File
+772
View File
@@ -0,0 +1,772 @@
import asyncio
from datetime import date, timedelta
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from decimal import Decimal
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_type import AbsenceCategory, AbsenceType
from app.models.audit_log import AuditLog
from app.models.company import Company
from app.models.overtime_balance import OvertimeBalance
from app.models.public_holiday import PublicHoliday
from app.models.user import User, UserRole
from app.models.vacation_balance import VacationBalance
from app.models.work_schedule import WorkSchedule
from app.schemas.absence import AbsenceCreate, AbsenceReject, AbsenceTypeCreate, AbsenceTypeUpdate
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
class AbsenceService:
# ── AbsenceTypes ─────────────────────────────────────────────────────────
async def list_types(self, company_id: UUID, db: AsyncSession) -> list[AbsenceType]:
result = await db.scalars(
select(AbsenceType)
.where(AbsenceType.company_id == company_id, AbsenceType.is_active == True)
.order_by(AbsenceType.name)
)
return list(result.all())
async def create_type(
self, company_id: UUID, data: AbsenceTypeCreate, db: AsyncSession
) -> AbsenceType:
at = AbsenceType(company_id=company_id, **data.model_dump())
db.add(at)
await db.flush()
return at
async def update_type(
self, type_id: UUID, company_id: UUID, data: AbsenceTypeUpdate, db: AsyncSession
) -> AbsenceType:
at = await self._get_type_or_404(type_id, company_id, db)
for field, value in data.model_dump(exclude_none=True).items():
setattr(at, field, value)
return at
async def create_defaults_for_company(self, company_id: UUID, db: AsyncSession) -> None:
"""Standard-Abwesenheitstypen + Standard-Arbeitsplan für ein neues Unternehmen anlegen."""
defaults = [
{
"name": "Urlaub", "color": "#3B82F6", "category": AbsenceCategory.VACATION,
"requires_approval": True, "deducts_vacation": True, "is_paid": True,
},
{
"name": "Krankheit", "color": "#EF4444", "category": AbsenceCategory.SICK,
"requires_approval": False, "deducts_vacation": False, "is_paid": True,
"requires_certificate": True, "certificate_after_days": 3,
},
{
"name": "Freizeitausgleich", "color": "#F59E0B", "category": AbsenceCategory.OVERTIME_COMP,
"requires_approval": True, "deducts_vacation": False,
"affects_overtime_balance": True, "is_paid": True,
},
{
"name": "Weiterbildung", "color": "#8B5CF6", "category": AbsenceCategory.TRAINING,
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
"max_days_per_year": 5,
},
{
"name": "Dienstreise", "color": "#06B6D4", "category": AbsenceCategory.BUSINESS_TRIP,
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
},
{
"name": "Homeoffice", "color": "#10B981", "category": AbsenceCategory.OTHER,
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
},
{
"name": "Sonderurlaub", "color": "#84CC16", "category": AbsenceCategory.VACATION,
"requires_approval": True, "deducts_vacation": True, "is_paid": True,
},
]
for d in defaults:
db.add(AbsenceType(company_id=company_id, **d))
# Standard-Arbeitsplan: MoFr 8h
schedule = WorkSchedule(
company_id=company_id,
name="Vollzeit (40h)",
valid_from=date.today(),
)
db.add(schedule)
await db.flush()
# ── Absences ──────────────────────────────────────────────────────────────
async def list_absences(
self,
company_id: UUID,
current_user: User,
db: AsyncSession,
user_id: UUID | None = None,
type_id: UUID | None = None,
status: AbsenceStatus | None = None,
year: int | None = None,
) -> tuple[int, list[Absence]]:
q = (
select(Absence)
.join(User, Absence.user_id == User.id)
.where(User.company_id == company_id)
)
if current_user.role == UserRole.EMPLOYEE:
q = q.where(Absence.user_id == current_user.id)
elif user_id:
q = q.where(Absence.user_id == user_id)
if type_id:
q = q.where(Absence.type_id == type_id)
if status:
q = q.where(Absence.status == status)
if year:
q = q.where(Absence.start_date >= date(year, 1, 1), Absence.end_date <= date(year, 12, 31))
total = await db.scalar(select(func.count()).select_from(q.subquery())) or 0
result = await db.scalars(q.order_by(Absence.start_date.desc()))
return total, list(result.all())
async def get_by_id(self, absence_id: UUID, current_user: User, db: AsyncSession) -> Absence:
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
if absence.user_id != current_user.id and current_user.role == UserRole.EMPLOYEE:
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
return absence
async def create_absence(
self,
data: AbsenceCreate,
current_user: User,
db: AsyncSession,
) -> tuple[Absence, list[str]]:
# AbsenceType validieren
absence_type = await self._get_type_or_404(data.type_id, current_user.company_id, db)
# Arbeitstage berechnen
holidays = await self._get_holiday_dates(
current_user.company_id, data.start_date.year, db
)
working_days = self._calc_working_days(
data.start_date, data.end_date, holidays,
data.half_day_start, data.half_day_end
)
if working_days <= 0:
raise HTTPException(status_code=400, detail="Keine Arbeitstage im ausgewählten Zeitraum.")
# Urlaubskonto prüfen wenn Urlaub abgezogen werden soll
warnings: list[str] = []
if absence_type.deducts_vacation:
balance = await self._get_or_create_balance(current_user.id, data.start_date.year, db)
if balance.remaining_days < working_days:
warnings.append(
f"Urlaubskonto reicht möglicherweise nicht aus: "
f"{balance.remaining_days} Tage verfügbar, {working_days} Tage beantragt."
)
# Überschneidung mit eigenen Abwesenheiten prüfen
overlap = await db.scalar(
select(Absence).where(
and_(
Absence.user_id == current_user.id,
Absence.status != AbsenceStatus.CANCELLED,
Absence.status != AbsenceStatus.REJECTED,
Absence.start_date <= data.end_date,
Absence.end_date >= data.start_date,
)
)
)
if overlap:
warnings.append("Überschneidung mit bestehender Abwesenheit im selben Zeitraum.")
status = AbsenceStatus.PENDING if absence_type.requires_approval else AbsenceStatus.APPROVED
approved_by = None if absence_type.requires_approval else current_user.id
# Krankmeldung: AU-Pflicht-Datum automatisch berechnen.
# Reihenfolge: AbsenceType.certificate_after_days (override) → Company default.
certificate_required_by: date | None = None
if absence_type.category == AbsenceCategory.SICK and absence_type.requires_certificate:
company = await db.get(Company, current_user.company_id)
company_default = company.sick_note_required_after_days if company else 3
threshold = absence_type.certificate_after_days or company_default
certificate_required_by = data.start_date + timedelta(days=threshold)
absence = Absence(
user_id=current_user.id,
type_id=data.type_id,
start_date=data.start_date,
end_date=data.end_date,
half_day_start=data.half_day_start,
half_day_end=data.half_day_end,
working_days=working_days,
status=status,
approved_by=approved_by,
substitute_id=data.substitute_id,
note=data.note,
certificate_required_by=certificate_required_by,
)
db.add(absence)
await db.flush()
# Bei automatischer Genehmigung Konto abziehen
if not absence_type.requires_approval and absence_type.deducts_vacation:
await self._deduct_vacation(current_user.id, data.start_date.year, int(working_days), db)
return absence, warnings
async def update_absence(
self, absence_id: UUID, data: "AbsenceUpdate", current_user: User, db: AsyncSession
) -> Absence:
from app.schemas.absence import AbsenceUpdate
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
# Mitarbeiter: nur eigene; Manager: gleiche Company
if current_user.role == UserRole.EMPLOYEE:
if absence.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
else:
owner = await db.get(User, absence.user_id)
if owner is None or owner.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
is_manager = current_user.role in _manager_roles
if absence.status not in (AbsenceStatus.PENDING, AbsenceStatus.APPROVED):
raise HTTPException(status_code=409, detail="Nur ausstehende oder genehmigte Anträge können bearbeitet werden.")
if absence.status == AbsenceStatus.APPROVED and not is_manager:
# Mitarbeiter stellt Änderungswunsch → Begründung Pflicht, Status zurück auf pending
if not data.correction_note or not data.correction_note.strip():
raise HTTPException(status_code=422, detail="Änderungsgrund ist bei genehmigten Anträgen Pflicht.")
if data.type_id is not None:
await self._get_type_or_404(data.type_id, current_user.company_id, db)
absence.type_id = data.type_id
if data.start_date is not None:
absence.start_date = data.start_date
if data.end_date is not None:
absence.end_date = data.end_date
if data.half_day_start is not None:
absence.half_day_start = data.half_day_start
if data.half_day_end is not None:
absence.half_day_end = data.half_day_end
if data.substitute_id is not None:
absence.substitute_id = data.substitute_id
if data.note is not None:
absence.note = data.note
if data.correction_note is not None:
absence.correction_note = data.correction_note.strip() or None
# Genehmigter Antrag: Mitarbeiter-Änderung → zurück auf pending (erneute Genehmigung)
was_approved = absence.status == AbsenceStatus.APPROVED
if was_approved and not is_manager:
absence.status = AbsenceStatus.PENDING
absence.approved_by = None
# Arbeitstage neu berechnen
holiday_dates = await self._get_holiday_dates(current_user.company_id, absence.start_date.year, db)
absence.working_days = Decimal(str(
self._calc_working_days(absence.start_date, absence.end_date,
holiday_dates, absence.half_day_start, absence.half_day_end)
))
# Audit-Log
action = "absence_change_request" if (was_approved and not is_manager) else "absence_updated"
db.add(AuditLog(
user_id=current_user.id,
action=action,
entity_type="absence",
entity_id=absence.id,
old_value={"status": "approved" if was_approved else "pending"},
new_value={
"status": absence.status.value,
"start_date": str(absence.start_date),
"end_date": str(absence.end_date),
"working_days": float(absence.working_days),
"correction_note": absence.correction_note,
},
))
return absence
async def cancel_absence(
self, absence_id: UUID, current_user: User, db: AsyncSession
) -> Absence:
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
if absence.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Nur eigene Anträge können storniert werden.")
if absence.status != AbsenceStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können gelöscht werden.")
absence.status = AbsenceStatus.CANCELLED
# Audit-Log (DSGVO)
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="absence_cancelled",
entity_type="absence",
entity_id=absence.id,
old_value={"status": "pending"},
new_value={
"status": "cancelled",
"cancelled_by": str(current_user.id),
"absence_user_id": str(absence.user_id),
"start_date": str(absence.start_date),
"end_date": str(absence.end_date),
"working_days": float(absence.working_days),
},
))
from app.services.caldav_service import caldav_service
asyncio.create_task(caldav_service.sync_removed(absence, db))
return absence
async def approve_absence(
self, absence_id: UUID, current_user: User, db: AsyncSession
) -> Absence:
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
requester = await db.get(User, absence.user_id)
if requester is None or requester.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
if absence.user_id == current_user.id:
raise HTTPException(
status_code=409,
detail="Eigene Abwesenheitsanträge können nicht selbst genehmigt werden."
)
if absence.status != AbsenceStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können genehmigt werden.")
absence.status = AbsenceStatus.APPROVED
absence.approved_by = current_user.id
absence_type = await db.get(AbsenceType, absence.type_id)
# Urlaubskonto abziehen wenn nötig
if absence_type and absence_type.deducts_vacation:
await self._deduct_vacation(absence.user_id, absence.start_date.year, int(absence.working_days), db)
# Überstundenkonto abziehen wenn Freizeitausgleich
if absence_type and absence_type.affects_overtime_balance:
await self._deduct_overtime(absence.user_id, absence.working_days, db)
# Audit-Log (DSGVO)
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="absence_approved",
entity_type="absence",
entity_id=absence.id,
old_value={"status": "pending"},
new_value={
"status": "approved",
"approved_by": str(current_user.id),
"approved_by_name": current_user.full_name,
"absence_user_id": str(absence.user_id),
"start_date": str(absence.start_date),
"end_date": str(absence.end_date),
"working_days": float(absence.working_days),
},
))
# CalDAV-Sync (fire & forget Fehler blockieren nicht die Genehmigung)
from app.services.caldav_service import caldav_service
asyncio.create_task(caldav_service.sync_approved(absence, db))
return absence
async def reject_absence(
self, absence_id: UUID, data: AbsenceReject, current_user: User, db: AsyncSession
) -> Absence:
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
requester = await db.get(User, absence.user_id)
if requester is None or requester.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
if absence.status != AbsenceStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können abgelehnt werden.")
absence.status = AbsenceStatus.REJECTED
absence.approved_by = current_user.id
absence.rejection_reason = data.rejection_reason
# Audit-Log (DSGVO)
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="absence_rejected",
entity_type="absence",
entity_id=absence.id,
old_value={"status": "pending"},
new_value={
"status": "rejected",
"rejection_reason": absence.rejection_reason,
"rejected_by": str(current_user.id),
"rejected_by_name": current_user.full_name,
"absence_user_id": str(absence.user_id),
"start_date": str(absence.start_date),
"end_date": str(absence.end_date),
"working_days": float(absence.working_days),
},
))
from app.services.caldav_service import caldav_service
asyncio.create_task(caldav_service.sync_removed(absence, db))
return absence
async def get_calendar(
self,
company_id: UUID,
year: int,
month: int | None,
db: AsyncSession,
) -> list[dict]:
q = (
select(Absence, User, AbsenceType)
.join(User, Absence.user_id == User.id)
.join(AbsenceType, Absence.type_id == AbsenceType.id)
.where(
User.company_id == company_id,
Absence.status.in_([AbsenceStatus.PENDING, AbsenceStatus.APPROVED]),
)
)
if month:
start = date(year, month, 1)
end = date(year, month, 28) + timedelta(days=4)
end = end.replace(day=1) - timedelta(days=1)
q = q.where(Absence.start_date <= end, Absence.end_date >= start)
else:
q = q.where(
Absence.start_date >= date(year, 1, 1),
Absence.end_date <= date(year, 12, 31),
)
result = await db.execute(q.order_by(Absence.start_date))
rows = result.all()
calendar = []
for absence, user, atype in rows:
calendar.append({
"user_id": user.id,
"user_name": user.full_name,
"absence_id": absence.id,
"type_name": atype.name,
"type_color": atype.color,
"start_date": absence.start_date,
"end_date": absence.end_date,
"status": absence.status,
"working_days": absence.working_days,
})
return calendar
# ── Urlaubskonto ──────────────────────────────────────────────────────────
async def get_balance(self, user_id: UUID, year: int, db: AsyncSession) -> VacationBalance:
return await self._get_or_create_balance(user_id, year, db)
async def get_pending_days(self, user_id: UUID, year: int, db: AsyncSession) -> float:
"""Summe der Arbeitstage aus ausstehenden Anträgen die Urlaub abziehen."""
q = (
select(func.sum(Absence.working_days))
.join(AbsenceType, Absence.type_id == AbsenceType.id)
.where(
Absence.user_id == user_id,
Absence.status == AbsenceStatus.PENDING,
AbsenceType.deducts_vacation.is_(True),
func.extract("year", Absence.start_date) == year,
)
)
result = await db.scalar(q)
return float(result or 0)
# ── Feiertage ─────────────────────────────────────────────────────────────
async def list_holidays(
self, year: int, country: str, state: str | None, db: AsyncSession
) -> list[PublicHoliday]:
q = select(PublicHoliday).where(
PublicHoliday.year == year, PublicHoliday.country == country
)
if state:
q = q.where(or_(PublicHoliday.state == state, PublicHoliday.state.is_(None)))
result = await db.scalars(q.order_by(PublicHoliday.date))
return list(result.all())
async def create_holiday(self, data, db: AsyncSession) -> PublicHoliday:
holiday = PublicHoliday(
country=data.country,
state=data.state,
date=data.date,
name=data.name,
year=data.date.year,
)
db.add(holiday)
await db.flush()
return holiday
# ── Helpers ───────────────────────────────────────────────────────────────
async def _get_type_or_404(
self, type_id: UUID, company_id: UUID, db: AsyncSession
) -> AbsenceType:
at = await db.get(AbsenceType, type_id)
if at is None or at.company_id != company_id:
raise HTTPException(status_code=404, detail="Abwesenheitstyp nicht gefunden.")
return at
async def _get_or_create_balance(
self, user_id: UUID, year: int, db: AsyncSession
) -> VacationBalance:
balance = await db.scalar(
select(VacationBalance).where(
VacationBalance.user_id == user_id, VacationBalance.year == year
)
)
if balance is None:
# Automatischer Übertrag: Resturlaub aus dem Vorjahr übernehmen
prev = await db.scalar(
select(VacationBalance).where(
VacationBalance.user_id == user_id, VacationBalance.year == year - 1
)
)
carried = max(0, prev.remaining_days) if prev else 0
entitled = prev.entitled_days if prev else 30
balance = VacationBalance(
user_id=user_id,
year=year,
entitled_days=entitled,
carried_over=carried,
)
db.add(balance)
await db.flush()
return balance
async def _deduct_vacation(
self, user_id: UUID, year: int, days: int, db: AsyncSession
) -> None:
balance = await self._get_or_create_balance(user_id, year, db)
balance.used_days += days
async def _deduct_overtime(
self, user_id: UUID, working_days: float, db: AsyncSession
) -> None:
"""Zieht working_days × tägliche Stunden vom Überstundenkonto ab."""
# Stunden/Tag aus Arbeitsplan ermitteln (Fallback: 8h)
user = await db.get(User, user_id)
daily_hours = Decimal("8.00")
if user and user.work_schedule_id:
schedule = await db.get(WorkSchedule, user.work_schedule_id)
if schedule:
working_days_in_week = sum(
1 for h in [schedule.mon_h, schedule.tue_h, schedule.wed_h,
schedule.thu_h, schedule.fri_h, schedule.sat_h, schedule.sun_h]
if h > 0
)
if working_days_in_week > 0:
daily_hours = schedule.weekly_hours / Decimal(working_days_in_week)
hours_to_deduct = Decimal(str(working_days)) * daily_hours
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
if ob is None:
# Erstelle Eintrag mit 0 Überstunden — taken_hours kann negativ werden
company_id = user.company_id if user else None
if not company_id:
return
ob = OvertimeBalance(user_id=user_id, company_id=company_id)
db.add(ob)
await db.flush()
ob.taken_hours += hours_to_deduct
async def _get_holiday_dates(
self, company_id: UUID, year: int, db: AsyncSession
) -> set[date]:
"""Feiertage für die Company-Country holen."""
from app.models.company import Company
from sqlalchemy import or_
company = await db.get(Company, company_id)
country = company.country if company else "DE"
state = company.state if company else None
q = select(PublicHoliday.date).where(
PublicHoliday.year == year,
PublicHoliday.country == country,
)
if state:
q = q.where(or_(PublicHoliday.state == state, PublicHoliday.state.is_(None)))
result = await db.scalars(q)
return set(result.all())
@staticmethod
def _calc_working_days(
start: date,
end: date,
holidays: set[date],
half_day_start: bool,
half_day_end: bool,
) -> float:
count = 0.0
current = start
while current <= end:
if current.weekday() < 5 and current not in holidays:
count += 1.0
current += timedelta(days=1)
# Halbtage abziehen
if half_day_start and start.weekday() < 5 and start not in holidays:
count -= 0.5
if half_day_end and end.weekday() < 5 and end not in holidays and end != start:
count -= 0.5
return max(0.0, count)
# ── Krankmeldung ──────────────────────────────────────────────────────────
async def quick_sick(
self,
start: date,
end: date,
current_user: User,
db: AsyncSession,
) -> tuple[Absence, list[str]]:
"""Sofort-Krankmeldung: nutzt den ersten aktiven SICK-Typ der Firma."""
sick_type = await db.scalar(
select(AbsenceType)
.where(
AbsenceType.company_id == current_user.company_id,
AbsenceType.category == AbsenceCategory.SICK,
AbsenceType.is_active == True,
)
.order_by(AbsenceType.name)
.limit(1)
)
if sick_type is None:
raise HTTPException(status_code=404, detail="Kein aktiver Krankheits-Typ konfiguriert.")
if end < start:
raise HTTPException(status_code=400, detail="Enddatum darf nicht vor dem Startdatum liegen.")
create_data = AbsenceCreate(
type_id=sick_type.id,
start_date=start,
end_date=end,
)
return await self.create_absence(create_data, current_user, db)
async def mark_certificate_received(
self,
absence_id: UUID,
received_at: date | None,
current_user: User,
db: AsyncSession,
) -> Absence:
"""HR/Admin: AU-Bescheinigung als eingegangen markieren."""
if current_user.role not in (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(status_code=403, detail="Nur HR/Admin darf den Attest-Eingang markieren.")
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
owner = await db.get(User, absence.user_id)
if owner is None or owner.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
absence_type = await db.get(AbsenceType, absence.type_id)
if absence_type is None or absence_type.category != AbsenceCategory.SICK:
raise HTTPException(status_code=409, detail="Nur für Krankmeldungen verfügbar.")
old_value = str(absence.certificate_received_at) if absence.certificate_received_at else None
absence.certificate_received_at = received_at or date.today()
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="absence_certificate_received",
entity_type="absence",
entity_id=absence.id,
old_value={"certificate_received_at": old_value},
new_value={
"certificate_received_at": str(absence.certificate_received_at),
"absence_user_id": str(absence.user_id),
"marked_by": str(current_user.id),
"marked_by_name": current_user.full_name,
},
))
return absence
async def get_sick_stats(
self,
company_id: UUID,
current_user: User,
ref_date: date,
db: AsyncSession,
user_id: UUID | None = None,
) -> list[dict]:
"""Krankheitsstatistik für rolling 12 Monate ab ref_date.
Bradford-Faktor: × D mit S = Anzahl Episoden, D = Summe Kranktage.
"""
window_start = ref_date - timedelta(days=365)
q = (
select(Absence, User)
.join(User, Absence.user_id == User.id)
.join(AbsenceType, Absence.type_id == AbsenceType.id)
.where(
User.company_id == company_id,
AbsenceType.category == AbsenceCategory.SICK,
Absence.status == AbsenceStatus.APPROVED,
Absence.start_date <= ref_date,
Absence.end_date >= window_start,
)
.order_by(User.last_name, User.first_name, Absence.start_date)
)
if user_id:
q = q.where(Absence.user_id == user_id)
# MANAGER sieht nur sein Department
if current_user.role == UserRole.MANAGER and current_user.department_id:
q = q.where(User.department_id == current_user.department_id)
result = await db.execute(q)
rows = result.all()
by_user: dict[UUID, dict] = {}
for absence, user in rows:
entry = by_user.setdefault(user.id, {
"user_id": user.id,
"user_name": user.full_name,
"personnel_number": user.personnel_number,
"episodes": 0,
"total_days": 0.0,
"certificates_overdue": 0,
})
entry["episodes"] += 1
entry["total_days"] += float(absence.working_days or 0)
if (
absence.certificate_required_by
and absence.certificate_required_by < ref_date
and absence.certificate_received_at is None
):
entry["certificates_overdue"] += 1
for entry in by_user.values():
entry["bradford_factor"] = float(entry["episodes"]) ** 2 * entry["total_days"]
return list(by_user.values())
absence_service = AbsenceService()
+211
View File
@@ -0,0 +1,211 @@
import re
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import (
create_access_token,
create_refresh_token,
generate_invite_token,
generate_reset_token,
hash_password,
hash_token,
verify_password,
)
from app.models import Company, PasswordReset, Session, User, UserRole
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from app.services.email_service import email_service
def _slugify(name: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return slug[:80]
class AuthService:
async def register(self, data: RegisterRequest, db: AsyncSession) -> TokenResponse:
existing = await db.scalar(select(User).where(User.email == data.email))
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
base_slug = _slugify(data.company_name)
slug = base_slug
counter = 1
while await db.scalar(select(Company).where(Company.slug == slug)):
slug = f"{base_slug}-{counter}"
counter += 1
company = Company(name=data.company_name, slug=slug)
db.add(company)
await db.flush()
user = User(
company_id=company.id,
email=data.email,
password_hash=hash_password(data.password),
first_name=data.first_name,
last_name=data.last_name,
role=UserRole.COMPANY_ADMIN,
)
db.add(user)
await db.flush()
from app.services.absence_service import absence_service
await absence_service.create_defaults_for_company(company.id, db)
tokens = await self._create_session(user, db)
await email_service.send_welcome(user, db)
return tokens
async def login(self, data: LoginRequest, db: AsyncSession, request: Request) -> TokenResponse:
from app.models.user import AuthProvider
from app.services.ldap_service import ldap_service
user = await db.scalar(select(User).where(User.email == data.email))
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is deactivated")
if user.auth_provider == AuthProvider.LDAP:
ldap_cfg = await ldap_service.get_config(user.company_id, db)
if not ldap_cfg or not ldap_cfg.enabled:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="LDAP authentication not available",
)
if not ldap_service.authenticate_ldap(ldap_cfg, data.email, data.password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
else:
if not user.password_hash or not verify_password(data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
# TOTP: wenn aktiviert → partial token zurückgeben statt vollem Login
if user.totp_enabled:
from app.core.security import create_partial_token
from app.schemas.auth import TokenResponse
partial = create_partial_token(str(user.id))
return TokenResponse(
access_token="",
refresh_token="",
totp_required=True,
partial_token=partial,
)
user.last_login = datetime.now(timezone.utc)
return await self._create_session(user, db, request=request)
async def refresh(self, raw_token: str, db: AsyncSession) -> TokenResponse:
token_hash = hash_token(raw_token)
session = await db.scalar(
select(Session).where(Session.refresh_token_hash == token_hash)
)
if not session or session.expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
user = await db.get(User, session.user_id)
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="User not found or inactive")
await db.delete(session)
return await self._create_session(user, db)
async def logout(self, raw_token: str, db: AsyncSession) -> None:
token_hash = hash_token(raw_token)
session = await db.scalar(
select(Session).where(Session.refresh_token_hash == token_hash)
)
if session:
await db.delete(session)
async def request_password_reset(self, email: str, db: AsyncSession) -> str | None:
"""
Gibt None zurück (lokale User) oder 'ldap' wenn der User LDAP-Auth nutzt.
Die aufrufende Route entscheidet, was dem Client mitgeteilt wird.
"""
from app.models.user import AuthProvider
user = await db.scalar(select(User).where(User.email == email))
if not user:
return None # Security: kein Hinweis ob E-Mail existiert
if user.auth_provider == AuthProvider.LDAP:
return "ldap"
old_resets = await db.scalars(
select(PasswordReset).where(
PasswordReset.user_id == user.id,
PasswordReset.used_at.is_(None),
)
)
for r in old_resets:
await db.delete(r)
raw, hashed = generate_reset_token()
reset = PasswordReset(
user_id=user.id,
token_hash=hashed,
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
)
db.add(reset)
await email_service.send_password_reset(user, raw, db)
return None
async def confirm_password_reset(self, token: str, new_password: str, db: AsyncSession) -> None:
token_hash = hash_token(token)
reset = await db.scalar(
select(PasswordReset).where(
PasswordReset.token_hash == token_hash,
PasswordReset.used_at.is_(None),
)
)
if not reset or reset.expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
user = await db.get(User, reset.user_id)
if not user:
raise HTTPException(status_code=400, detail="User not found")
user.password_hash = hash_password(new_password)
reset.used_at = datetime.now(timezone.utc)
sessions = await db.scalars(select(Session).where(Session.user_id == user.id))
for s in sessions:
await db.delete(s)
async def _create_session(
self,
user: User,
db: AsyncSession,
request: Request | None = None,
) -> TokenResponse:
raw_refresh, hashed_refresh = create_refresh_token()
session = Session(
user_id=user.id,
refresh_token_hash=hashed_refresh,
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days),
device=request.headers.get("User-Agent", "")[:255] if request else None,
ip=request.client.host if request and request.client else None,
)
db.add(session)
access_token = create_access_token(
str(user.id),
extra={"role": user.role, "company_id": str(user.company_id)},
)
return TokenResponse(access_token=access_token, refresh_token=raw_refresh)
auth_service = AuthService()
+307
View File
@@ -0,0 +1,307 @@
"""
CalDAV-Sync für Abwesenheiten.
Logik:
approve VEVENT in persönlichem Kalender (CaldavUserConfig) +
VEVENT in Firmenkalender (CaldavCompanyConfig)
reject / cancel DELETE aus beiden Kalendern
Verwendet httpx für die HTTP-Kommunikation und icalendar für iCal-Erzeugung.
Passwörter werden Fernet-verschlüsselt gespeichert (gleiche Methode wie SMTP/LDAP).
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import logging
import uuid
from datetime import date, timedelta, timezone, datetime
from typing import Union
import httpx
from icalendar import Calendar, Event
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.absence import Absence
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
log = logging.getLogger(__name__)
# ── Crypto (shared with SMTP/LDAP) ────────────────────────────────────────────
def _fernet():
from cryptography.fernet import Fernet
key = hashlib.sha256(settings.secret_key.encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def encrypt_password(plain: str) -> str:
return _fernet().encrypt(plain.encode()).decode()
def decrypt_password(encrypted: str) -> str:
return _fernet().decrypt(encrypted.encode()).decode()
# ── iCal builder ──────────────────────────────────────────────────────────────
def _build_ical(uid: str, summary: str, start: date, end: date, description: str = "") -> bytes:
"""Erzeugt einen VCALENDAR-Blob für ein ganztägiges Ereignis."""
cal = Calendar()
cal.add("prodid", "-//TimeMaster//DE")
cal.add("version", "2.0")
ev = Event()
ev.add("uid", f"{uid}@timemaster")
ev.add("dtstart", start)
ev.add("dtend", end + timedelta(days=1)) # DTEND ist exklusiv
ev.add("summary", summary)
if description:
ev.add("description", description)
ev.add("status", "CONFIRMED")
ev.add("transp", "TRANSPARENT") # zeigt keine Verfügbarkeit als blockiert
ev.add("dtstamp", datetime.now(timezone.utc))
cal.add_component(ev)
return cal.to_ical()
# ── Kalender-Titel formatieren ────────────────────────────────────────────────
def _format_summary(user: "User", absence_type: str, name_template: str) -> str:
"""
Ersetzt Platzhalter im name_template:
$vorname vollständiger Vorname
$nachname vollständiger Nachname
$vorname_short erster Buchstabe Vorname
$nachname_middle erste 3 Buchstaben Nachname
$kuerzel manuell gesetztes Kürzel (Fallback: Initialen)
$personalnummer Personalnummer (leer wenn nicht gesetzt)
$typ Abwesenheitsart
"""
kuerzel = user.kuerzel if user.kuerzel else (user.first_name[:1] + user.last_name[:1]).upper()
result = name_template
result = result.replace("$vorname_short", user.first_name[:1])
result = result.replace("$nachname_middle", user.last_name[:3])
result = result.replace("$vorname", user.first_name)
result = result.replace("$nachname", user.last_name)
result = result.replace("$kuerzel", kuerzel)
result = result.replace("$personalnummer", user.personnel_number or "")
result = result.replace("$typ", absence_type)
return result
# ── HTTP helpers ───────────────────────────────────────────────────────────────
def _event_url(calendar_url: str, uid: str) -> str:
return calendar_url.rstrip("/") + f"/{uid}.ics"
async def _http_put(
calendar_url: str, username: str, password: str, uid: str,
ical: bytes, verify_ssl: bool,
) -> str:
"""PUT event. Returns ETag (empty string if server doesn't send one)."""
url = _event_url(calendar_url, uid)
async with httpx.AsyncClient(verify=verify_ssl, timeout=15) as client:
resp = await client.put(
url, content=ical,
headers={"Content-Type": "text/calendar; charset=utf-8"},
auth=(username, password),
)
resp.raise_for_status()
return resp.headers.get("ETag", "")
async def _http_delete(
calendar_url: str, username: str, password: str, uid: str, verify_ssl: bool,
) -> None:
url = _event_url(calendar_url, uid)
async with httpx.AsyncClient(verify=verify_ssl, timeout=15) as client:
resp = await client.delete(url, auth=(username, password))
if resp.status_code not in (200, 204, 404):
resp.raise_for_status()
async def _http_propfind(
calendar_url: str, username: str, password: str, verify_ssl: bool,
) -> int:
"""Einfacher Verbindungstest via PROPFIND Depth:0. Gibt HTTP-Status zurück."""
body = b'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>'
async with httpx.AsyncClient(verify=verify_ssl, timeout=10) as client:
resp = await client.request(
"PROPFIND", calendar_url.rstrip("/") + "/",
content=body,
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"},
auth=(username, password),
)
return resp.status_code
# ── Service ───────────────────────────────────────────────────────────────────
class CalDavService:
# ── Config laden ──────────────────────────────────────────────────────────
async def get_company_config(
self, company_id: uuid.UUID, db: AsyncSession
) -> CaldavCompanyConfig | None:
return await db.scalar(
select(CaldavCompanyConfig).where(CaldavCompanyConfig.company_id == company_id)
)
async def get_user_config(
self, user_id: uuid.UUID, db: AsyncSession
) -> CaldavUserConfig | None:
return await db.scalar(
select(CaldavUserConfig).where(CaldavUserConfig.user_id == user_id)
)
# ── Sync-Operationen ──────────────────────────────────────────────────────
async def sync_approved(self, absence: Absence, db: AsyncSession) -> None:
"""Wird nach Genehmigung gerufen: Event in beide Kalender einpflegen."""
# User und AbsenceType laden (für VEVENT-Titel)
from app.models.user import User
from app.models.absence_type import AbsenceType
user = await db.get(User, absence.user_id)
atype = await db.get(AbsenceType, absence.type_id)
if not user or not atype:
return
if absence.caldav_uid is None:
absence.caldav_uid = str(uuid.uuid4())
description = absence.note or ""
# Persönlicher Kalender: nur Abwesenheitsart, kein Name
personal_ical = _build_ical(
absence.caldav_uid, atype.name,
absence.start_date, absence.end_date, description,
)
user_cfg = await self.get_user_config(user.id, db)
if user_cfg and user_cfg.enabled and user_cfg.calendar_url:
try:
pw = decrypt_password(user_cfg.password_encrypted)
etag = await _http_put(
user_cfg.calendar_url, user_cfg.username, pw,
absence.caldav_uid, personal_ical, user_cfg.verify_ssl,
)
absence.caldav_user_etag = etag
except Exception as exc:
absence.caldav_last_error = f"User-Kalender: {exc}"
log.warning("CalDAV user sync failed for absence %s: %s", absence.id, exc)
# Firmenkalender: Titelformat per Konfiguration
company_cfg = await self.get_company_config(user.company_id, db)
if company_cfg and company_cfg.enabled and company_cfg.calendar_url:
company_summary = _format_summary(user, atype.name, company_cfg.name_template)
company_ical = _build_ical(
absence.caldav_uid, company_summary,
absence.start_date, absence.end_date, description,
)
try:
pw = decrypt_password(company_cfg.password_encrypted)
etag = await _http_put(
company_cfg.calendar_url, company_cfg.username, pw,
absence.caldav_uid, company_ical, company_cfg.verify_ssl,
)
absence.caldav_company_etag = etag
except Exception as exc:
err = f"Firmen-Kalender: {exc}"
absence.caldav_last_error = (
(absence.caldav_last_error + " | " + err) if absence.caldav_last_error else err
)
log.warning("CalDAV company sync failed for absence %s: %s", absence.id, exc)
absence.caldav_synced_at = datetime.now(timezone.utc)
async def sync_removed(self, absence: Absence, db: AsyncSession) -> None:
"""Wird nach Ablehnung/Stornierung gerufen: Event aus Kalendern löschen."""
if not absence.caldav_uid:
return
from app.models.user import User
user = await db.get(User, absence.user_id)
if not user:
return
# Persönlicher Kalender
user_cfg = await self.get_user_config(user.id, db)
if user_cfg and user_cfg.enabled and user_cfg.calendar_url:
try:
pw = decrypt_password(user_cfg.password_encrypted)
await _http_delete(
user_cfg.calendar_url, user_cfg.username, pw,
absence.caldav_uid, user_cfg.verify_ssl,
)
absence.caldav_user_etag = None
except Exception as exc:
log.warning("CalDAV user delete failed for absence %s: %s", absence.id, exc)
# Firmenkalender
company_cfg = await self.get_company_config(user.company_id, db)
if company_cfg and company_cfg.enabled and company_cfg.calendar_url:
try:
pw = decrypt_password(company_cfg.password_encrypted)
await _http_delete(
company_cfg.calendar_url, company_cfg.username, pw,
absence.caldav_uid, company_cfg.verify_ssl,
)
absence.caldav_company_etag = None
except Exception as exc:
log.warning("CalDAV company delete failed for absence %s: %s", absence.id, exc)
absence.caldav_last_error = None
absence.caldav_synced_at = datetime.now(timezone.utc)
async def resync_all_approved(self, company_id: uuid.UUID, db: AsyncSession) -> dict:
"""Alle genehmigten Abwesenheiten der Firma neu synchronisieren."""
from app.models.absence import AbsenceStatus
from app.models.user import User
result = await db.scalars(
select(Absence)
.join(Absence.user)
.where(
Absence.status == AbsenceStatus.APPROVED,
User.company_id == company_id,
)
)
absences = list(result.all())
ok = 0
failed = 0
for absence in absences:
try:
await self.sync_approved(absence, db)
ok += 1
except Exception as exc:
failed += 1
log.error("Resync failed for absence %s: %s", absence.id, exc)
return {"synced": ok, "failed": failed, "total": len(absences)}
# ── Verbindungstest ───────────────────────────────────────────────────────
async def test_config(
self, cfg: Union[CaldavCompanyConfig, CaldavUserConfig]
) -> dict:
if not cfg.calendar_url:
return {"ok": False, "error": "Keine Kalender-URL konfiguriert."}
try:
pw = decrypt_password(cfg.password_encrypted)
status = await _http_propfind(cfg.calendar_url, cfg.username, pw, cfg.verify_ssl)
if status in (200, 207):
return {"ok": True, "status": status}
return {"ok": False, "error": f"Server antwortete mit HTTP {status}"}
except Exception as exc:
return {"ok": False, "error": str(exc)}
caldav_service = CalDavService()
+154
View File
@@ -0,0 +1,154 @@
"""
E-Mail-Versand via SMTP (smtplib + asyncio.to_thread).
Konfiguration pro Firma in smtp_configs. Kein externer Mail-Dienst nötig.
"""
import asyncio
import smtplib
import ssl
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.smtp_config import SmtpConfig
if TYPE_CHECKING:
from app.models.user import User
def _html_wrapper(title: str, body: str) -> str:
return f"""
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><title>{title}</title>
<style>
body {{ font-family: system-ui, sans-serif; background: #f4f4f4; margin: 0; padding: 40px 20px; }}
.card {{ background: white; border-radius: 12px; padding: 40px; max-width: 520px; margin: 0 auto; }}
.logo {{ font-size: 22px; font-weight: 600; color: #2563EB; margin-bottom: 32px; }}
h1 {{ font-size: 20px; font-weight: 600; color: #111; margin: 0 0 12px; }}
p {{ font-size: 15px; color: #555; line-height: 1.6; margin: 0 0 16px; }}
.btn {{ display: inline-block; padding: 12px 28px; background: #2563EB; color: white !important;
text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 15px; margin: 8px 0 24px; }}
.footer {{ font-size: 12px; color: #999; margin-top: 32px; padding-top: 16px; border-top: 1px solid #eee; }}
</style></head>
<body><div class="card">
<div class="logo"> {settings.app_name}</div>
{body}
<div class="footer">Diese E-Mail wurde automatisch von {settings.app_name} gesendet.</div>
</div></body></html>
"""
def _decrypt_password(encrypted: str) -> str:
"""Fernet-Entschlüsselung (gleiche Implementierung wie ldap_service)."""
import base64
import hashlib
from cryptography.fernet import Fernet
key = hashlib.sha256(settings.secret_key.encode()).digest()
f = Fernet(base64.urlsafe_b64encode(key))
return f.decrypt(encrypted.encode()).decode()
def _smtp_send_sync(cfg: SmtpConfig, to: str, subject: str, html: str) -> None:
"""Synchroner SMTP-Versand wird via asyncio.to_thread() aufgerufen."""
password = _decrypt_password(cfg.password_encrypted) if cfg.password_encrypted else None
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{cfg.from_name} <{cfg.from_email}>"
msg["To"] = to
msg.attach(MIMEText(html, "html", "utf-8"))
if cfg.use_tls:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(cfg.host, cfg.port, context=context) as smtp:
if cfg.username and password:
smtp.login(cfg.username, password)
smtp.send_message(msg)
else:
with smtplib.SMTP(cfg.host, cfg.port) as smtp:
if cfg.use_starttls:
smtp.starttls(context=ssl.create_default_context())
if cfg.username and password:
smtp.login(cfg.username, password)
smtp.send_message(msg)
class EmailService:
async def _load_smtp(self, company_id: UUID, db: AsyncSession) -> SmtpConfig | None:
return await db.scalar(
select(SmtpConfig).where(
SmtpConfig.company_id == company_id,
SmtpConfig.is_enabled == True,
)
)
async def _send(self, to: str, subject: str, html: str, cfg: SmtpConfig | None) -> None:
if not cfg:
print(f"\n{'='*60}")
print(f"EMAIL (kein SMTP konfiguriert nicht versendet)")
print(f" To: {to}")
print(f" Subject: {subject}")
print(f"{'='*60}\n")
return
try:
await asyncio.to_thread(_smtp_send_sync, cfg, to, subject, html)
except Exception as exc:
print(f"SMTP Fehler: {exc}")
async def send_welcome(self, user: "User", db: AsyncSession) -> None:
cfg = await self._load_smtp(user.company_id, db)
body = f"""
<h1>Willkommen bei {settings.app_name}, {user.first_name}!</h1>
<p>Dein Firmen-Account wurde erfolgreich erstellt. Du kannst dich ab sofort anmelden.</p>
<a href="{settings.frontend_url}/login" class="btn">Zum Login</a>
"""
await self._send(user.email, f"Willkommen bei {settings.app_name}", _html_wrapper("Willkommen", body), cfg)
async def send_invite(self, user: "User", invited_by: "User", raw_token: str, db: AsyncSession) -> None:
cfg = await self._load_smtp(user.company_id, db)
invite_url = f"{settings.frontend_url}/invite/accept?token={raw_token}"
body = f"""
<h1>Du wurdest eingeladen!</h1>
<p><strong>{invited_by.full_name}</strong> hat dich zu <strong>{settings.app_name}</strong> eingeladen.</p>
<p>Klicke auf den Button, um dein Konto zu aktivieren und ein Passwort festzulegen.<br>
Der Link ist <strong>7 Tage</strong> gültig.</p>
<a href="{invite_url}" class="btn">Einladung annehmen</a>
<p style="font-size:13px;color:#999;">Oder kopiere diesen Link: {invite_url}</p>
"""
await self._send(
user.email,
f"{invited_by.full_name} hat dich zu {settings.app_name} eingeladen",
_html_wrapper("Einladung", body),
cfg,
)
async def send_password_reset(self, user: "User", raw_token: str, db: AsyncSession) -> None:
cfg = await self._load_smtp(user.company_id, db)
reset_url = f"{settings.frontend_url}/auth/reset-password?token={raw_token}"
body = f"""
<h1>Passwort zurücksetzen</h1>
<p>Hallo {user.first_name},</p>
<p>du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.<br>
Klicke auf den Button der Link ist <strong>1 Stunde</strong> gültig.</p>
<a href="{reset_url}" class="btn">Passwort zurücksetzen</a>
<p>Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.</p>
"""
await self._send(user.email, "Passwort zurücksetzen", _html_wrapper("Passwort zurücksetzen", body), cfg)
async def send_test(self, cfg: SmtpConfig, to: str) -> None:
"""Test-E-Mail direkt mit übergebenem Konfigurationsobjekt."""
body = f"""
<h1>SMTP-Test erfolgreich!</h1>
<p>Deine SMTP-Konfiguration für <strong>{settings.app_name}</strong> funktioniert korrekt.</p>
<p>Server: {cfg.host}:{cfg.port}</p>
"""
await self._send(to, f"{settings.app_name} SMTP-Test", _html_wrapper("SMTP-Test", body), cfg)
email_service = EmailService()
+174
View File
@@ -0,0 +1,174 @@
"""Feiertags-Service: berechnet und befüllt deutsche Feiertage per Bundesland."""
from __future__ import annotations
import uuid
from datetime import date, timedelta
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.public_holiday import PublicHoliday
# ---------------------------------------------------------------------------
# Osterformel (Gauß/Anonymous)
# ---------------------------------------------------------------------------
def _easter(year: int) -> date:
"""Berechnet Ostersonntag nach der anonymen Gregorianischen Osterformel."""
a = year % 19
b = year // 100
c = year % 100
d = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i = c // 4
k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month = (h + l - 7 * m + 114) // 31
day = ((h + l - 7 * m + 114) % 31) + 1
return date(year, month, day)
# ---------------------------------------------------------------------------
# Feiertage berechnen
# ---------------------------------------------------------------------------
def _holidays_for_state(year: int, state: str) -> list[tuple[date, str, bool]]:
"""
Gibt Liste von (date, name, is_high_rate) für das Bundesland zurück.
is_high_rate = True 150% Zuschlag nach §3b EStG
"""
easter = _easter(year)
holidays: list[tuple[date, str, bool]] = []
def add(d: date, name: str, high: bool = False) -> None:
holidays.append((d, name, high))
# ── Bundesweit gültige Feiertage ────────────────────────────────────────
add(date(year, 1, 1), "Neujahr")
add(easter - timedelta(days=2), "Karfreitag")
add(easter, "Ostersonntag")
add(easter + timedelta(days=1), "Ostermontag")
add(date(year, 5, 1), "Tag der Arbeit", high=True)
add(easter + timedelta(days=39), "Christi Himmelfahrt")
add(easter + timedelta(days=49), "Pfingstsonntag")
add(easter + timedelta(days=50), "Pfingstmontag")
add(date(year, 10, 3), "Tag der Deutschen Einheit")
add(date(year, 12, 25), "1. Weihnachtstag", high=True)
add(date(year, 12, 26), "2. Weihnachtstag", high=True)
# ── Heilige Drei Könige: BY, BW, ST ────────────────────────────────────
if state in ("BY", "BW", "ST"):
add(date(year, 1, 6), "Heilige Drei Könige")
# ── Frauentag: BE (ab 2019) ─────────────────────────────────────────────
if state == "BE" and year >= 2019:
add(date(year, 3, 8), "Internationaler Frauentag")
# ── Gründonnerstag: BY (nur Schulen) nicht als gesetzlicher Feiertag
# ── Fronleichnam: BW, BY, HE, NW, RP, SL (+ Teile ST, TH) ─────────────
if state in ("BW", "BY", "HE", "NW", "RP", "SL"):
add(easter + timedelta(days=60), "Fronleichnam")
# ── Mariä Himmelfahrt: BY (kath. Gemeinden), SL ─────────────────────────
if state in ("BY", "SL"):
add(date(year, 8, 15), "Mariä Himmelfahrt")
# ── Weltkindertag: TH (ab 2019) ─────────────────────────────────────────
if state == "TH" and year >= 2019:
add(date(year, 9, 20), "Weltkindertag")
# ── Reformationstag: BB, HB, HH, MV, NI, SH, SN, ST, TH ───────────────
if state in ("BB", "HB", "HH", "MV", "NI", "SH", "SN", "ST", "TH"):
add(date(year, 10, 31), "Reformationstag")
# ── Allerheiligen: BW, BY, NW, RP, SL ───────────────────────────────────
if state in ("BW", "BY", "NW", "RP", "SL"):
add(date(year, 11, 1), "Allerheiligen")
# ── Buß- und Bettag: SN ──────────────────────────────────────────────────
if state == "SN":
# Mittwoch vor dem 23. November
nov23 = date(year, 11, 23)
bbt = nov23 - timedelta(days=(nov23.weekday() + 3) % 7 + 1)
if bbt.weekday() != 2:
# Fallback: letzter Mittwoch vor 23.11.
bbt = nov23 - timedelta(days=(nov23.weekday() - 2) % 7 + 7)
add(bbt, "Buß- und Bettag")
return holidays
# ---------------------------------------------------------------------------
# DB-Funktionen
# ---------------------------------------------------------------------------
async def ensure_holidays_for_year(year: int, state: str, db: AsyncSession) -> int:
"""
Stellt sicher dass Feiertage für (year, state) in der DB vorhanden sind.
Löscht ggf. alte Einträge und schreibt neu.
Gibt Anzahl geschriebener Einträge zurück.
"""
# Löschen falls schon vorhanden (refresh)
await db.execute(
delete(PublicHoliday).where(
PublicHoliday.country == "DE",
PublicHoliday.state == state,
PublicHoliday.year == year,
)
)
holidays = _holidays_for_state(year, state)
for d, name, high in holidays:
db.add(PublicHoliday(
id=uuid.uuid4(),
country="DE",
state=state,
date=d,
name=name,
year=year,
is_high_rate=high,
))
await db.flush()
return len(holidays)
async def get_holidays_set(
date_from: date,
date_to: date,
state: str,
db: AsyncSession,
) -> dict[date, tuple[str, bool]]:
"""
Gibt dict {date: (name, is_high_rate)} für den Zeitraum zurück.
Befüllt fehlende Jahre automatisch.
"""
years = set(range(date_from.year, date_to.year + 1))
# Prüfen welche Jahre schon in der DB sind
existing_years_q = await db.execute(
select(PublicHoliday.year).where(
PublicHoliday.country == "DE",
PublicHoliday.state == state,
).distinct()
)
existing_years = {r[0] for r in existing_years_q.all()}
for year in years - existing_years:
await ensure_holidays_for_year(year, state, db)
result_q = await db.execute(
select(PublicHoliday).where(
PublicHoliday.country == "DE",
PublicHoliday.state == state,
PublicHoliday.date >= date_from,
PublicHoliday.date <= date_to,
)
)
return {h.date: (h.name, h.is_high_rate) for h in result_q.scalars().all()}
@@ -0,0 +1,364 @@
"""Kimai CSV Import Service parst Kimai-Export und erzeugt TimeEntries + Absences."""
from __future__ import annotations
import csv
import io
import uuid
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_type import AbsenceType
from app.models.time_entry import EntrySource, EntryStatus, TimeEntry
from app.models.user import User
# ---------------------------------------------------------------------------
# Datenstrukturen für die Vorschau
# ---------------------------------------------------------------------------
@dataclass
class KimaiRow:
date: date
start: time
end: time
duration_sec: int
projekt: str
taetigkeit: str
beschreibung: str
@dataclass
class ImportPreviewEntry:
kind: str # "time" | "absence"
date_from: str
date_to: str
start: str | None
end: str | None
break_minutes: int
worked_hours: float | None
absence_type: str | None
note: str | None
skipped: bool = False
skip_reason: str | None = None
@dataclass
class ImportResult:
preview: list[ImportPreviewEntry] = field(default_factory=list)
time_imported: int = 0
absence_imported: int = 0
skipped: int = 0
errors: list[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def _parse_time(s: str) -> time:
parts = s.strip().split(":")
return time(int(parts[0]), int(parts[1]))
def _parse_date(s: str) -> date:
return datetime.strptime(s.strip(), "%Y-%m-%d").date()
def _gross_minutes(start: time, end: time) -> int:
return (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute)
def _break_minutes(row: KimaiRow) -> int:
gross = _gross_minutes(row.start, row.end)
net = row.duration_sec // 60
return max(0, gross - net)
def _worked_hours(row: KimaiRow) -> float:
net_min = row.duration_sec / 60
return round(net_min / 60, 2)
def _is_vacation_row(row: KimaiRow) -> bool:
return row.projekt.strip().lower() == "urlaub"
def _note(row: KimaiRow) -> str | None:
"""
Notiz aus Beschreibung; falls leer, Tätigkeit außer 'Reguläre Arbeitszeit'
(das ist der Standard und braucht keine eigene Notiz).
"""
desc = row.beschreibung.strip()
if desc:
return desc
taet = row.taetigkeit.strip()
if taet and taet.lower() != "reguläre arbeitszeit":
return taet
return None
def _absence_type_name(row: KimaiRow) -> str:
"""Ermittelt Abwesenheitstyp aus Beschreibung."""
desc = row.beschreibung.strip().lower()
if "sonderurlaub" in desc:
return "Sonderurlaub"
return "Urlaub"
def _group_vacation_rows(rows: list[KimaiRow]) -> list[tuple[date, date, str, str]]:
"""
Gruppiert aufeinanderfolgende Urlaubszeilen (gleicher Typ) zu Abwesenheitsblöcken.
Gibt Liste von (start_date, end_date, abs_type_name, note) zurück.
"""
if not rows:
return []
rows_sorted = sorted(rows, key=lambda r: r.date)
groups: list[tuple[date, date, str, str]] = []
cur_start = rows_sorted[0].date
cur_end = rows_sorted[0].date
cur_type = _absence_type_name(rows_sorted[0])
cur_note = rows_sorted[0].beschreibung.strip()
for row in rows_sorted[1:]:
t = _absence_type_name(row)
# Aufeinanderfolgend = max. 3 Tage Abstand (Wochenende überbrücken)
gap = (row.date - cur_end).days
if t == cur_type and gap <= 3:
cur_end = row.date
if row.beschreibung.strip() and row.beschreibung.strip() not in cur_note:
cur_note = (cur_note + " / " + row.beschreibung.strip()).strip(" /")
else:
groups.append((cur_start, cur_end, cur_type, cur_note))
cur_start = row.date
cur_end = row.date
cur_type = t
cur_note = row.beschreibung.strip()
groups.append((cur_start, cur_end, cur_type, cur_note))
return groups
# ---------------------------------------------------------------------------
# CSV-Parser
# ---------------------------------------------------------------------------
def parse_kimai_csv(content: bytes) -> tuple[list[KimaiRow], list[str]]:
"""Parst Kimai-CSV-Bytes, gibt (rows, errors) zurück."""
rows: list[KimaiRow] = []
errors: list[str] = []
text = content.decode("utf-8-sig") # BOM-safe
reader = csv.DictReader(io.StringIO(text))
for i, row in enumerate(reader, start=2):
try:
rows.append(KimaiRow(
date=_parse_date(row["Datum"]),
start=_parse_time(row["Von"]),
end=_parse_time(row["Bis"]),
duration_sec=int(row["Dauer"]),
projekt=row.get("Projekt", ""),
taetigkeit=row.get("Tätigkeit", ""),
beschreibung=row.get("Beschreibung", ""),
))
except Exception as e:
errors.append(f"Zeile {i}: {e}")
return rows, errors
# ---------------------------------------------------------------------------
# Preview (keine DB-Änderungen)
# ---------------------------------------------------------------------------
async def preview_kimai_import(
content: bytes,
target_user_id: uuid.UUID,
db: AsyncSession,
) -> ImportResult:
result = ImportResult()
rows, parse_errors = parse_kimai_csv(content)
result.errors.extend(parse_errors)
# Bestehende Zeiteinträge: Duplikat-Prüfung auf (date, start_time, end_time)
existing_q = await db.execute(
select(TimeEntry.date, TimeEntry.start_time, TimeEntry.end_time)
.where(TimeEntry.user_id == target_user_id)
)
existing_slots: set[tuple] = {(r.date, r.start_time, r.end_time) for r in existing_q}
# Abwesenheitstypen laden
types_q = await db.execute(select(AbsenceType))
abs_types: dict[str, AbsenceType] = {t.name: t for t in types_q.scalars().all()}
time_rows = [r for r in rows if not _is_vacation_row(r)]
vac_rows = [r for r in rows if _is_vacation_row(r)]
# Zeiteinträge
seen_slots: set[tuple] = set()
for row in time_rows:
slot = (row.date, row.start, row.end)
skip = slot in existing_slots or slot in seen_slots
if not skip:
seen_slots.add(slot)
brk = _break_minutes(row)
result.preview.append(ImportPreviewEntry(
kind="time",
date_from=row.date.isoformat(),
date_to=row.date.isoformat(),
start=row.start.strftime("%H:%M"),
end=row.end.strftime("%H:%M"),
break_minutes=brk,
worked_hours=_worked_hours(row),
absence_type=None,
note=_note(row),
skipped=skip,
skip_reason="Bereits vorhanden (gleiche Zeit)" if skip else None,
))
# Bestehende Abwesenheiten für Duplikat-Prüfung
existing_abs_q = await db.execute(
select(AbsenceType.id).where(AbsenceType.id.in_([t.id for t in abs_types.values()]))
)
from app.models.absence import Absence as AbsenceModel
existing_abs_q2 = await db.execute(
select(AbsenceModel.start_date, AbsenceModel.end_date, AbsenceModel.type_id)
.where(AbsenceModel.user_id == target_user_id)
)
existing_absences: set[tuple] = {(r.start_date, r.end_date, r.type_id) for r in existing_abs_q2}
# Urlaubsblöcke
for start, end, type_name, note in _group_vacation_rows(vac_rows):
t = abs_types.get(type_name)
already_exists = t is not None and (start, end, t.id) in existing_absences
skip = t is None or already_exists
result.preview.append(ImportPreviewEntry(
kind="absence",
date_from=start.isoformat(),
date_to=end.isoformat(),
start=None,
end=None,
break_minutes=0,
worked_hours=None,
absence_type=type_name,
note=note or None,
skipped=skip,
skip_reason=(
f"Abwesenheitstyp '{type_name}' nicht gefunden" if t is None
else "Bereits vorhanden" if already_exists
else None
),
))
result.skipped = sum(1 for p in result.preview if p.skipped)
return result
# ---------------------------------------------------------------------------
# Eigentlicher Import (mit DB-Änderungen)
# ---------------------------------------------------------------------------
async def run_kimai_import(
content: bytes,
target_user_id: uuid.UUID,
approver_id: uuid.UUID,
db: AsyncSession,
) -> ImportResult:
result = ImportResult()
rows, parse_errors = parse_kimai_csv(content)
result.errors.extend(parse_errors)
# User + Company laden
user_q = await db.execute(select(User).where(User.id == target_user_id))
user = user_q.scalar_one_or_none()
if not user:
result.errors.append("Ziel-User nicht gefunden.")
return result
# Bestehende Zeiteinträge: Duplikat-Prüfung auf (date, start_time, end_time)
existing_q = await db.execute(
select(TimeEntry.date, TimeEntry.start_time, TimeEntry.end_time)
.where(TimeEntry.user_id == target_user_id)
)
existing_slots: set[tuple] = {(r.date, r.start_time, r.end_time) for r in existing_q}
# Abwesenheitstypen
types_q = await db.execute(
select(AbsenceType).where(AbsenceType.company_id == user.company_id)
)
abs_types: dict[str, AbsenceType] = {t.name: t for t in types_q.scalars().all()}
time_rows = [r for r in rows if not _is_vacation_row(r)]
vac_rows = [r for r in rows if _is_vacation_row(r)]
# ---- Zeiteinträge ----
seen_slots: set[tuple] = set()
for row in time_rows:
slot = (row.date, row.start, row.end)
if slot in existing_slots or slot in seen_slots:
result.skipped += 1
continue
seen_slots.add(slot)
brk = _break_minutes(row)
entry = TimeEntry(
id=uuid.uuid4(),
user_id=target_user_id,
date=row.date,
start_time=row.start,
end_time=row.end,
break_minutes=brk,
status=EntryStatus.APPROVED,
source=EntrySource.API,
approved_by=approver_id,
note=_note(row),
)
db.add(entry)
result.time_imported += 1
# Bestehende Abwesenheiten für Duplikat-Prüfung
existing_abs_q = await db.execute(
select(Absence.start_date, Absence.end_date, Absence.type_id)
.where(Absence.user_id == target_user_id)
)
existing_absences: set[tuple] = {(r.start_date, r.end_date, r.type_id) for r in existing_abs_q}
# ---- Urlaubsblöcke ----
for start, end, type_name, note in _group_vacation_rows(vac_rows):
t = abs_types.get(type_name)
if not t:
result.errors.append(f"Abwesenheitstyp '{type_name}' nicht gefunden übersprungen.")
result.skipped += 1
continue
if (start, end, t.id) in existing_absences:
result.skipped += 1
continue
# Arbeitstage zählen (MoFr, keine Feiertage)
working_days = sum(
1 for n in range((end - start).days + 1)
if (start + timedelta(days=n)).weekday() < 5
)
absence = Absence(
id=uuid.uuid4(),
user_id=target_user_id,
type_id=t.id,
start_date=start,
end_date=end,
working_days=working_days,
status=AbsenceStatus.APPROVED,
approved_by=approver_id,
note=note or None,
)
db.add(absence)
result.absence_imported += 1
await db.commit()
return result
+87
View File
@@ -0,0 +1,87 @@
import secrets
from datetime import datetime, timezone
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_token
from app.models.kiosk_device import KioskDevice
from app.schemas.kiosk import KioskDeviceCreate, KioskDeviceUpdate
class KioskService:
async def list_devices(self, company_id: UUID, db: AsyncSession) -> list[KioskDevice]:
result = await db.scalars(
select(KioskDevice)
.where(KioskDevice.company_id == company_id)
.order_by(KioskDevice.created_at.desc())
)
return list(result.all())
async def create_device(
self, company_id: UUID, data: KioskDeviceCreate, db: AsyncSession
) -> tuple[KioskDevice, str]:
"""Gerät anlegen. Gibt (device, raw_token) zurück raw_token nur einmalig."""
raw_token = secrets.token_urlsafe(48)
device = KioskDevice(
company_id=company_id,
name=data.name,
location=data.location,
token_hash=hash_token(raw_token),
)
db.add(device)
await db.flush()
return device, raw_token
async def get_device(self, device_id: UUID, company_id: UUID, db: AsyncSession) -> KioskDevice:
device = await db.scalar(
select(KioskDevice).where(
KioskDevice.id == device_id,
KioskDevice.company_id == company_id,
)
)
if device is None:
raise HTTPException(status_code=404, detail="Gerät nicht gefunden.")
return device
async def update_device(
self, device_id: UUID, company_id: UUID, data: KioskDeviceUpdate, db: AsyncSession
) -> KioskDevice:
device = await self.get_device(device_id, company_id, db)
changes = data.model_dump(exclude_none=True)
for field, value in changes.items():
setattr(device, field, value)
return device
async def rotate_token(
self, device_id: UUID, company_id: UUID, db: AsyncSession
) -> tuple[KioskDevice, str]:
"""Token rotieren altes Token wird sofort ungültig."""
device = await self.get_device(device_id, company_id, db)
raw_token = secrets.token_urlsafe(48)
device.token_hash = hash_token(raw_token)
return device, raw_token
async def delete_device(self, device_id: UUID, company_id: UUID, db: AsyncSession) -> None:
device = await self.get_device(device_id, company_id, db)
await db.delete(device)
async def authenticate_device(self, raw_token: str, db: AsyncSession) -> KioskDevice:
"""Gerät per Token authentifizieren (für Kiosk-Endpoints)."""
token_hash = hash_token(raw_token)
device = await db.scalar(
select(KioskDevice).where(
KioskDevice.token_hash == token_hash,
KioskDevice.is_active.is_(True),
)
)
if device is None:
raise HTTPException(status_code=401, detail="Ungültiges oder deaktiviertes Gerät.")
device.last_seen_at = datetime.now(timezone.utc)
return device
kiosk_service = KioskService()
+332
View File
@@ -0,0 +1,332 @@
"""LDAP integration service.
Supports ActiveDirectory and OpenLDAP via ldap3 (pure Python).
Bind passwords are stored Fernet-encrypted using the app SECRET_KEY.
"""
import base64
import hashlib
import logging
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.ldap_config import LdapConfig
from app.models.user import AuthProvider, User, UserRole
logger = logging.getLogger(__name__)
def _fernet():
from cryptography.fernet import Fernet
key = hashlib.sha256(settings.secret_key.encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def encrypt_password(plain: str) -> str:
return _fernet().encrypt(plain.encode()).decode()
def decrypt_password(encrypted: str) -> str:
return _fernet().decrypt(encrypted.encode()).decode()
@dataclass
class ConnectionResult:
success: bool
message: str
@dataclass
class SyncResult:
created: int
updated: int
deactivated: int
errors: list[str]
def _get_attr(entry_attrs: dict, attr_name: str) -> str:
"""Safely extract a string attribute value from ldap3 entry attributes."""
val = entry_attrs.get(attr_name)
if not val:
return ""
if isinstance(val, list):
return str(val[0]) if val else ""
return str(val)
class LdapService:
def _build_server(self, config: LdapConfig):
from ldap3 import Server, Tls
import ssl
tls = None
if config.use_tls:
if config.tls_verify:
tls = Tls(validate=ssl.CERT_REQUIRED)
else:
logger.warning(
"LDAP TLS certificate validation is DISABLED for host %s (company_id=%s). "
"Set tls_verify=True in production to prevent MITM attacks.",
config.host,
config.company_id,
)
tls = Tls(validate=ssl.CERT_NONE)
return Server(
config.host,
port=config.port,
use_ssl=config.use_ssl,
tls=tls,
get_info="ALL",
connect_timeout=5,
)
def _bind_connection(self, config: LdapConfig, dn: str | None = None, password: str | None = None):
"""Return an authenticated ldap3 Connection (simple bind)."""
from ldap3 import Connection, SIMPLE, SYNC
bind_dn = dn or config.bind_dn
bind_pw = password or decrypt_password(config.bind_password_encrypted)
server = self._build_server(config)
conn = Connection(
server,
user=bind_dn,
password=bind_pw,
authentication=SIMPLE,
client_strategy=SYNC,
auto_bind="NO_TLS",
raise_exceptions=False,
)
if not conn.bind():
return None
return conn
# ── Public API ────────────────────────────────────────────────────────────
async def get_config(self, company_id: uuid.UUID, db: AsyncSession) -> LdapConfig | None:
return await db.scalar(
select(LdapConfig).where(LdapConfig.company_id == company_id)
)
async def get_config_or_404(self, company_id: uuid.UUID, db: AsyncSession) -> LdapConfig:
cfg = await self.get_config(company_id, db)
if not cfg:
raise HTTPException(status_code=404, detail="LDAP configuration not found")
return cfg
def test_connection(self, config: LdapConfig) -> ConnectionResult:
try:
conn = self._bind_connection(config)
if conn is None:
return ConnectionResult(success=False, message="Bind fehlgeschlagen DN oder Passwort falsch")
conn.unbind()
return ConnectionResult(success=True, message="Verbindung erfolgreich")
except Exception as exc:
return ConnectionResult(success=False, message=str(exc))
def search_users(self, config: LdapConfig) -> list[dict[str, Any]]:
"""Return raw list of user dicts from LDAP directory."""
from ldap3 import SUBTREE
conn = self._bind_connection(config)
if conn is None:
raise HTTPException(status_code=502, detail="LDAP bind fehlgeschlagen")
attrs = [
config.attr_email,
config.attr_firstname,
config.attr_lastname,
config.attr_username,
]
if config.attr_department:
attrs.append(config.attr_department)
conn.search(
search_base=config.base_dn,
search_filter=config.user_search_filter,
search_scope=SUBTREE,
attributes=attrs,
)
results = []
for entry in conn.entries:
raw = {a: entry[a].value for a in attrs if a in entry}
raw["dn"] = entry.entry_dn
results.append(raw)
conn.unbind()
return results
async def sync_users(
self,
config: LdapConfig,
db: AsyncSession,
default_role: UserRole = UserRole.EMPLOYEE,
) -> SyncResult:
"""Sync LDAP users into the local database."""
from ldap3 import SUBTREE
conn = self._bind_connection(config)
if conn is None:
raise HTTPException(status_code=502, detail="LDAP bind fehlgeschlagen")
attrs = [
config.attr_email,
config.attr_firstname,
config.attr_lastname,
]
if config.attr_department:
attrs.append(config.attr_department)
if config.attr_personnel_number:
attrs.append(config.attr_personnel_number)
conn.search(
search_base=config.base_dn,
search_filter=config.user_search_filter,
search_scope=SUBTREE,
attributes=attrs,
)
entries = list(conn.entries)
conn.unbind()
result = SyncResult(created=0, updated=0, deactivated=0, errors=[])
ldap_emails: set[str] = set()
for entry in entries:
try:
email = _get_attr(entry, config.attr_email).lower().strip()
if not email:
continue
ldap_emails.add(email)
first = _get_attr(entry, config.attr_firstname) or "?"
last = _get_attr(entry, config.attr_lastname) or "?"
dn = entry.entry_dn
ldap_personnel = (
_get_attr(entry, config.attr_personnel_number).strip()
if config.attr_personnel_number else ""
)
# nur Ziffern akzeptieren (Format-Vorgabe)
if ldap_personnel and not ldap_personnel.isdigit():
logger.warning(
"LDAP personnel_number for %s contains non-digits (%r), skipping mapping.",
email, ldap_personnel,
)
ldap_personnel = ""
existing = await db.scalar(select(User).where(User.email == email))
if existing:
existing.first_name = first
existing.last_name = last
existing.ldap_dn = dn
existing.auth_provider = AuthProvider.LDAP
existing.is_active = True
if ldap_personnel and existing.personnel_number != ldap_personnel:
await self._apply_personnel_from_ldap(
existing, ldap_personnel, db, result,
)
result.updated += 1
else:
user = User(
company_id=config.company_id,
email=email,
first_name=first,
last_name=last,
password_hash=None,
auth_provider=AuthProvider.LDAP,
ldap_dn=dn,
role=default_role,
is_active=True,
)
if ldap_personnel:
await self._apply_personnel_from_ldap(
user, ldap_personnel, db, result, company_id=config.company_id,
)
db.add(user)
result.created += 1
except Exception as exc:
result.errors.append(str(exc))
# Deactivate LDAP users no longer in directory
existing_ldap = await db.scalars(
select(User).where(
User.company_id == config.company_id,
User.auth_provider == AuthProvider.LDAP,
User.is_active.is_(True),
)
)
for user in existing_ldap:
if user.email not in ldap_emails:
user.is_active = False
result.deactivated += 1
config.last_sync_at = datetime.now(timezone.utc)
await db.commit()
return result
async def _apply_personnel_from_ldap(
self,
user: User,
ldap_value: str,
db: AsyncSession,
result: SyncResult,
company_id: uuid.UUID | None = None,
) -> None:
"""Apply personnel_number from LDAP, but skip on conflict (no override of reserved numbers)."""
cid = company_id or user.company_id
# Konflikt mit anderem User in derselben Firma?
conflict = await db.scalar(
select(User.id).where(
User.company_id == cid,
User.personnel_number == ldap_value,
User.id != user.id,
)
)
if conflict is not None:
msg = (
f"LDAP-Personalnummer {ldap_value!r} für {user.email} kollidiert mit "
f"vergebener Nummer Wert verworfen."
)
logger.warning(msg)
result.errors.append(msg)
return
user.personnel_number = ldap_value
def authenticate_ldap(self, config: LdapConfig, email: str, password: str) -> bool:
"""Authenticate a user by finding their DN and attempting a bind."""
from ldap3 import SUBTREE
from ldap3.utils.conv import escape_filter_chars
conn = self._bind_connection(config)
if conn is None:
return False
safe_email = escape_filter_chars(email)
conn.search(
search_base=config.base_dn,
search_filter=f"({config.attr_email}={safe_email})",
search_scope=SUBTREE,
attributes=[config.attr_email],
)
if not conn.entries:
conn.unbind()
return False
user_dn = conn.entries[0].entry_dn
conn.unbind()
# Try binding as the found user
user_conn = self._bind_connection(config, dn=user_dn, password=password)
if user_conn is None:
return False
user_conn.unbind()
return True
ldap_service = LdapService()
File diff suppressed because it is too large Load Diff
+524
View File
@@ -0,0 +1,524 @@
from datetime import date, datetime, time, timezone
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit_log import AuditLog
from app.models.time_entry import EntrySource, EntryStatus, TimeEntry
from app.models.user import User, UserRole
from app.models.work_schedule import WorkSchedule
from app.schemas.time_entry import (
BalanceResponse,
ManualEntryCreate,
StampInRequest,
TimeEntryUpdate,
)
def _check_arbzg(start: time, end: time, break_minutes: int) -> list[str]:
"""ArbZG §3 und §4 Prüfung. Gibt Warnungen zurück, blockiert nicht."""
start_mins = start.hour * 60 + start.minute
end_mins = end.hour * 60 + end.minute
if end_mins <= start_mins:
end_mins += 24 * 60 # Nachtschicht
total_mins = end_mins - start_mins
worked_mins = total_mins - break_minutes
worked_hours = worked_mins / 60
warnings: list[str] = []
if worked_hours > 10:
warnings.append(
f"Maximale Arbeitszeit von 10 Stunden überschritten "
f"({worked_hours:.1f}h gearbeitet) ArbZG §3"
)
if total_mins >= 9 * 60 and break_minutes < 45:
warnings.append(
"Bei mehr als 9h Anwesenheit sind mind. 45 min Pause vorgeschrieben ArbZG §4"
)
elif total_mins >= 6 * 60 and break_minutes < 30:
warnings.append(
"Bei mehr als 6h Anwesenheit sind mind. 30 min Pause vorgeschrieben ArbZG §4"
)
return warnings
def _check_rest_period(prev_end: time | None, prev_date: date | None,
new_start: time, new_date: date) -> list[str]:
"""Mindestruhezeit 11h zwischen Schichten ArbZG §5.
Nur relevant bei Schichtwechsel über Tagesgrenzen, nicht bei mehrfachen
Stempelungen am gleichen Tag (z.B. Korrektur oder Pause).
"""
if prev_end is None or prev_date is None:
return []
# Gleicher Tag → kein Schichtwechsel, §5 nicht anwendbar
if prev_date == new_date:
return []
prev_end_dt = datetime.combine(prev_date, prev_end, tzinfo=None)
new_start_dt = datetime.combine(new_date, new_start, tzinfo=None)
rest_hours = (new_start_dt - prev_end_dt).total_seconds() / 3600
# Nur warnen wenn tatsächlich weniger als 11h Ruhe zwischen zwei verschiedenen Tagen
if 0 < rest_hours < 11:
return [
f"Mindestruhezeit von 11h unterschritten "
f"({rest_hours:.1f}h seit letzter Schicht) ArbZG §5"
]
return []
class TimeService:
# ── Stempeluhr ────────────────────────────────────────────────────────────
async def stamp_in(
self,
user: User,
data: StampInRequest,
db: AsyncSession,
) -> tuple[TimeEntry, list[str]]:
today = datetime.now(timezone.utc).date()
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
# Offenen Eintrag für heute prüfen
open_entry = await self._get_open_entry(user.id, db)
if open_entry is not None:
raise HTTPException(status_code=409, detail="Bereits eingestempelt. Bitte zuerst ausstempeln.")
# Letzten abgeschlossenen Eintrag für Ruhezeit-Check holen
last_entry = await db.scalar(
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.end_time.isnot(None))
.order_by(TimeEntry.date.desc(), TimeEntry.end_time.desc())
.limit(1)
)
warnings = _check_rest_period(
last_entry.end_time if last_entry else None,
last_entry.date if last_entry else None,
now_time,
today,
)
entry = TimeEntry(
user_id=user.id,
date=today,
start_time=now_time,
break_minutes=0,
source=data.source,
project_id=data.project_id,
note=data.note,
status=EntryStatus.PENDING,
)
db.add(entry)
await db.flush()
return entry, warnings
async def stamp_out(
self,
user: User,
note: str | None,
db: AsyncSession,
) -> tuple[TimeEntry, list[str]]:
entry = await self._get_open_entry(user.id, db)
if entry is None:
raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.")
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
# Aktive Pause beenden falls vergessen
if entry.break_start is not None:
extra_break = self._calc_break_minutes(entry.break_start, now_time)
entry.break_minutes += extra_break
entry.break_start = None
entry.end_time = now_time
entry.updated_at = datetime.now(timezone.utc)
if note:
entry.note = note
warnings = _check_arbzg(entry.start_time, entry.end_time, entry.break_minutes)
return entry, warnings
async def break_start(self, user: User, db: AsyncSession) -> TimeEntry:
entry = await self._get_open_entry(user.id, db)
if entry is None:
raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.")
if entry.break_start is not None:
raise HTTPException(status_code=409, detail="Pause bereits aktiv.")
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
entry.break_start = now_time
entry.updated_at = datetime.now(timezone.utc)
return entry
async def break_end(self, user: User, db: AsyncSession) -> TimeEntry:
entry = await self._get_open_entry(user.id, db)
if entry is None:
raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.")
if entry.break_start is None:
raise HTTPException(status_code=409, detail="Keine aktive Pause.")
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
extra = self._calc_break_minutes(entry.break_start, now_time)
entry.break_minutes += extra
entry.break_start = None
entry.updated_at = datetime.now(timezone.utc)
return entry
# ── Einträge ──────────────────────────────────────────────────────────────
async def get_today(self, user: User, db: AsyncSession) -> list[TimeEntry]:
today = datetime.now(timezone.utc).date()
result = await db.scalars(
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.date == today)
.order_by(TimeEntry.start_time)
)
return list(result.all())
async def list_entries(
self,
company_id: UUID,
current_user: User,
db: AsyncSession,
user_id: UUID | None = None,
date_from: date | None = None,
date_to: date | None = None,
status: EntryStatus | None = None,
skip: int = 0,
limit: int = 50,
) -> tuple[int, list[TimeEntry]]:
# Basis: nur Einträge der eigenen Company
# Subquery: JOIN user für company_id Filter
q = (
select(TimeEntry)
.join(User, TimeEntry.user_id == User.id)
.where(User.company_id == company_id)
)
# EMPLOYEE sieht nur eigene Einträge
if current_user.role == UserRole.EMPLOYEE:
q = q.where(TimeEntry.user_id == current_user.id)
elif user_id:
q = q.where(TimeEntry.user_id == user_id)
if date_from:
q = q.where(TimeEntry.date >= date_from)
if date_to:
q = q.where(TimeEntry.date <= date_to)
if status:
q = q.where(TimeEntry.status == status)
total = await db.scalar(select(func.count()).select_from(q.subquery()))
entries = await db.scalars(q.order_by(TimeEntry.date.desc(), TimeEntry.start_time.desc()).offset(skip).limit(limit))
return total or 0, list(entries.all())
async def create_manual(
self,
data: ManualEntryCreate,
current_user: User,
db: AsyncSession,
) -> tuple[TimeEntry, list[str]]:
target_user_id = current_user.id
# Employees need explicit permission to create manual entries
_elevated = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
if current_user.role == UserRole.EMPLOYEE and not current_user.can_manual_time_entry:
raise HTTPException(status_code=403, detail="Manuelle Zeiterfassung ist für Ihr Konto nicht freigeschaltet.")
if data.user_id and data.user_id != current_user.id:
if current_user.role not in _elevated:
raise HTTPException(status_code=403, detail="Keine Berechtigung für andere Benutzer.")
target = await db.get(User, data.user_id)
if target is None or target.company_id != current_user.company_id:
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
target_user_id = data.user_id
entry = TimeEntry(
user_id=target_user_id,
date=data.date,
start_time=data.start_time,
end_time=data.end_time,
break_minutes=data.break_minutes,
project_id=data.project_id,
note=data.note,
source=data.source,
status=EntryStatus.PENDING,
)
db.add(entry)
await db.flush()
warnings = _check_arbzg(data.start_time, data.end_time, data.break_minutes)
return entry, warnings
async def update_entry(
self,
entry_id: UUID,
data: TimeEntryUpdate,
current_user: User,
db: AsyncSession,
) -> TimeEntry:
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
entry = await self._get_entry_or_404(entry_id, db)
await self._assert_access(entry, current_user, db)
if entry.status == EntryStatus.APPROVED:
if current_user.role not in _manager_roles:
raise HTTPException(status_code=403, detail="Genehmigte Einträge können nur von Vorgesetzten geändert werden.")
if not data.correction_note:
raise HTTPException(status_code=422, detail="Änderungsgrund (correction_note) ist bei genehmigten Einträgen Pflicht.")
# Vorherigen Zustand für AuditLog sichern
old_snapshot = {
"started_at": entry.started_at.isoformat() if entry.started_at else None,
"ended_at": entry.ended_at.isoformat() if entry.ended_at else None,
"break_minutes": entry.break_minutes,
"note": entry.note,
"correction_note": entry.correction_note,
}
changes = data.model_dump(exclude_none=True)
for field, value in changes.items():
setattr(entry, field, value)
entry.updated_at = datetime.now(timezone.utc)
if entry.status == EntryStatus.APPROVED:
new_snapshot = {
"started_at": entry.started_at.isoformat() if entry.started_at else None,
"ended_at": entry.ended_at.isoformat() if entry.ended_at else None,
"break_minutes": entry.break_minutes,
"note": entry.note,
"correction_note": entry.correction_note,
}
user_obj = await db.get(User, entry.user_id)
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="time_entry_approved_edit",
entity_type="time_entry",
entity_id=entry.id,
old_value=old_snapshot,
new_value={**new_snapshot, "changed_by": str(current_user.id),
"target_user": str(entry.user_id),
"target_user_name": user_obj.full_name if user_obj else None},
))
return entry
async def approve_entry(
self,
entry_id: UUID,
current_user: User,
db: AsyncSession,
) -> TimeEntry:
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Genehmigen.")
entry = await self._get_entry_or_404(entry_id, db)
# Cross-Tenant-Schutz
entry_user = await db.get(User, entry.user_id)
if entry_user is None or entry_user.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
# Self-Approval-Schutz (L-03)
if entry.user_id == current_user.id:
raise HTTPException(
status_code=409,
detail="Eigene Zeiteinträge können nicht selbst genehmigt werden."
)
if entry.status != EntryStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Einträge können genehmigt werden.")
entry.status = EntryStatus.APPROVED
entry.approved_by = current_user.id
entry.updated_at = datetime.now(timezone.utc)
return entry
async def reject_entry(
self,
entry_id: UUID,
current_user: User,
correction_note: str | None,
db: AsyncSession,
) -> TimeEntry:
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Ablehnen.")
entry = await self._get_entry_or_404(entry_id, db)
if entry.status != EntryStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Einträge können abgelehnt werden.")
entry.status = EntryStatus.REJECTED
entry.approved_by = current_user.id
if correction_note:
entry.correction_note = correction_note
entry.updated_at = datetime.now(timezone.utc)
return entry
async def delete_entry(
self,
entry_id: UUID,
current_user: User,
db: AsyncSession,
) -> None:
entry = await self._get_entry_or_404(entry_id, db)
await self._assert_access(entry, current_user, db)
# Genehmigte Einträge dürfen nur von HR/Admin gelöscht werden
if entry.status == EntryStatus.APPROVED:
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
raise HTTPException(
status_code=403,
detail="Genehmigte Einträge können nur von Vorgesetzten gelöscht werden."
)
await db.delete(entry)
async def get_balance(
self,
user_id: UUID,
current_user: User,
db: AsyncSession,
period_start: date | None = None,
period_end: date | None = None,
) -> BalanceResponse:
# Zugriff prüfen
if user_id != current_user.id and current_user.role == UserRole.EMPLOYEE:
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
today = datetime.now(timezone.utc).date()
if period_start is None:
period_start = today.replace(day=1)
if period_end is None:
period_end = today
# Genehmigte Einträge summieren
approved_entries = await db.scalars(
select(TimeEntry).where(
and_(
TimeEntry.user_id == user_id,
TimeEntry.date >= period_start,
TimeEntry.date <= period_end,
TimeEntry.status == EntryStatus.APPROVED,
TimeEntry.end_time.isnot(None),
)
)
)
approved_list = list(approved_entries.all())
total_worked = sum(e.worked_hours or 0.0 for e in approved_list)
# Ausstehende Einträge zählen
pending_count = await db.scalar(
select(func.count(TimeEntry.id)).where(
and_(
TimeEntry.user_id == user_id,
TimeEntry.date >= period_start,
TimeEntry.date <= period_end,
TimeEntry.status == EntryStatus.PENDING,
)
)
) or 0
# Soll-Stunden aus Arbeitsplan ermitteln (neuester gültiger Plan)
schedule = await db.scalar(
select(WorkSchedule)
.join(User, WorkSchedule.company_id == User.company_id)
.where(
User.id == user_id,
WorkSchedule.valid_from <= period_start,
)
.order_by(WorkSchedule.valid_from.desc())
.limit(1)
)
expected = self._calc_expected_hours(period_start, period_end, schedule)
return BalanceResponse(
user_id=user_id,
period_start=period_start,
period_end=period_end,
total_hours_worked=round(total_worked, 2),
expected_hours=round(expected, 2),
overtime_hours=round(total_worked - expected, 2),
approved_entries=len(approved_list),
pending_entries=pending_count,
)
# ── Arbeitspläne ──────────────────────────────────────────────────────────
async def create_work_schedule(
self,
company_id: UUID,
data,
db: AsyncSession,
) -> WorkSchedule:
schedule = WorkSchedule(company_id=company_id, **data.model_dump())
db.add(schedule)
await db.flush()
return schedule
async def list_work_schedules(self, company_id: UUID, db: AsyncSession) -> list[WorkSchedule]:
result = await db.scalars(
select(WorkSchedule)
.where(WorkSchedule.company_id == company_id)
.order_by(WorkSchedule.valid_from.desc())
)
return list(result.all())
# ── Helpers ───────────────────────────────────────────────────────────────
async def _get_open_entry(self, user_id: UUID, db: AsyncSession) -> TimeEntry | None:
return await db.scalar(
select(TimeEntry).where(
TimeEntry.user_id == user_id,
TimeEntry.end_time.is_(None),
).order_by(TimeEntry.date.desc(), TimeEntry.start_time.desc()).limit(1)
)
async def _get_entry_or_404(self, entry_id: UUID, db: AsyncSession) -> TimeEntry:
entry = await db.get(TimeEntry, entry_id)
if entry is None:
raise HTTPException(status_code=404, detail="Zeiterfassungseintrag nicht gefunden.")
return entry
async def _assert_access(self, entry: TimeEntry, user: User, db: AsyncSession) -> None:
if entry.user_id != user.id and user.role not in (
UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN
):
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
entry_user = await db.get(User, entry.user_id)
if entry_user is None or entry_user.company_id != user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
@staticmethod
def _calc_break_minutes(start: time, end: time) -> int:
s = start.hour * 60 + start.minute
e = end.hour * 60 + end.minute
if e < s:
e += 24 * 60
return max(0, e - s)
@staticmethod
def _calc_expected_hours(period_start: date, period_end: date, schedule: WorkSchedule | None) -> float:
"""Soll-Stunden für den Zeitraum berechnen."""
from datetime import timedelta
total = 0.0
current = period_start
while current <= period_end:
wd = current.weekday() # 0=Mon
if schedule:
total += float(schedule.hours_for_weekday(wd))
else:
# Fallback: 8h Mo-Fr
if wd < 5:
total += 8.0
current += timedelta(days=1)
return total
time_service = TimeService()
+308
View File
@@ -0,0 +1,308 @@
"""User CSV Bulk Import validates, creates new users or reactivates deactivated ones."""
from __future__ import annotations
import csv
import io
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import generate_invite_token, hash_password
from app.models.company import Company, PersonnelNumberMode
from app.models.user import User, UserRole
from app.services.email_service import email_service
from app.services.user_service import user_service
REQUIRED_HEADERS = ["email", "first_name", "last_name"]
OPTIONAL_HEADERS = ["role", "personnel_number", "kuerzel"]
TEMPLATE_HEADERS = REQUIRED_HEADERS + OPTIONAL_HEADERS
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
PERSONNEL_RE = re.compile(r"^[0-9]+$")
VALID_ROLES = {r.value for r in UserRole if r != UserRole.SUPER_ADMIN}
# ── Datenstrukturen ──────────────────────────────────────────────────────────
@dataclass
class ImportRowResult:
row: int
email: str
personnel_number: str | None
action: str # created | reactivated | error
message: str | None = None
@dataclass
class ImportResult:
total_rows: int
created: int
reactivated: int
errors: int
items: list[ImportRowResult]
# ── CSV-Parsing ──────────────────────────────────────────────────────────────
def build_template_csv() -> str:
"""CSV template returned via /users/import-template.csv."""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(TEMPLATE_HEADERS)
writer.writerow([
"max@firma.de", "Max", "Mustermann",
"EMPLOYEE", "0042", "MM",
])
return buf.getvalue()
def _normalize(value: str | None) -> str:
return (value or "").strip()
def _parse_csv(content: bytes) -> tuple[list[dict[str, str]], list[str]]:
"""Parse CSV bytes (BOM-safe). Returns (rows, header_errors)."""
text = content.decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(text))
if reader.fieldnames is None:
return [], ["CSV ist leer oder kein gültiger Header gefunden."]
headers = [h.strip() for h in reader.fieldnames]
missing = [h for h in REQUIRED_HEADERS if h not in headers]
if missing:
return [], [f"Pflicht-Spalten fehlen: {', '.join(missing)}"]
rows = list(reader)
return rows, []
# ── Import-Kern (Preview & Apply gemeinsam) ──────────────────────────────────
async def _process_import(
*,
content: bytes,
company_id: UUID,
invited_by: User,
db: AsyncSession,
apply: bool,
) -> ImportResult:
"""Process CSV bulk import. apply=False = validation only (no DB writes, rolled back)."""
rows, header_errors = _parse_csv(content)
items: list[ImportRowResult] = []
if header_errors:
for msg in header_errors:
items.append(ImportRowResult(
row=0, email="", personnel_number=None, action="error", message=msg,
))
return ImportResult(total_rows=0, created=0, reactivated=0, errors=len(items), items=items)
company = await db.get(Company, company_id)
if company is None:
items.append(ImportRowResult(
row=0, email="", personnel_number=None, action="error", message="Firma nicht gefunden.",
))
return ImportResult(total_rows=0, created=0, reactivated=0, errors=1, items=items)
seen_emails_in_csv: set[str] = set()
used_personnel_in_csv: set[str] = set()
created = 0
reactivated = 0
errors = 0
for idx, raw in enumerate(rows, start=2): # CSV row numbers start at 2 (after header)
email = _normalize(raw.get("email")).lower()
first_name = _normalize(raw.get("first_name"))
last_name = _normalize(raw.get("last_name"))
role_str = _normalize(raw.get("role")) or UserRole.EMPLOYEE.value
personnel_number = _normalize(raw.get("personnel_number")) or None
kuerzel = _normalize(raw.get("kuerzel")) or None
# Validation
if not email or not EMAIL_RE.match(email):
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="Ungültige E-Mail-Adresse.",
))
errors += 1
continue
if not first_name or not last_name:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="Vor- und Nachname sind Pflicht.",
))
errors += 1
continue
if role_str not in VALID_ROLES:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message=f"Ungültige Rolle: {role_str}",
))
errors += 1
continue
if personnel_number is not None and not PERSONNEL_RE.match(personnel_number):
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="Personalnummer darf nur Ziffern enthalten.",
))
errors += 1
continue
# Doppelte Mail im Import → Fehler
if email in seen_emails_in_csv:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="E-Mail kommt im Import mehrfach vor.",
))
errors += 1
continue
seen_emails_in_csv.add(email)
# Doppelte Personalnr. im Import → Fehler
if personnel_number and personnel_number in used_personnel_in_csv:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="Personalnummer kommt im Import mehrfach vor.",
))
errors += 1
continue
# Personalnr.-Konflikt mit DB?
if personnel_number:
taken = await db.scalar(
select(User.id).where(
User.company_id == company_id,
User.personnel_number == personnel_number,
)
)
if taken is not None:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="Personalnummer ist bereits vergeben.",
))
errors += 1
continue
# Auto-Vergabe wenn leer (auch im Manuell-Modus laut Anforderung)
if not personnel_number:
personnel_number = await user_service._next_personnel_number(company_id, db)
# E-Mail-Konflikt prüfen (auch deaktivierte User in derselben Firma)
existing_user = await db.scalar(
select(User).where(User.email == email)
)
if existing_user is not None and existing_user.is_active:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="E-Mail bereits aktiv vergeben.",
))
errors += 1
continue
if existing_user is not None and not existing_user.is_active:
# Reaktivieren
if existing_user.company_id != company_id:
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="error", message="E-Mail existiert in anderer Firma.",
))
errors += 1
continue
existing_user.first_name = first_name
existing_user.last_name = last_name
existing_user.role = UserRole(role_str)
if kuerzel:
existing_user.kuerzel = kuerzel
# Personalnr.: behalten, falls schon vorhanden (Reservierung), sonst setzen
if not existing_user.personnel_number:
existing_user.personnel_number = personnel_number
else:
personnel_number = existing_user.personnel_number
existing_user.is_active = True
used_personnel_in_csv.add(personnel_number)
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="reactivated",
))
reactivated += 1
continue
# Neuanlage: Invite-Token generieren, User inaktiv (warten auf Annahme)
raw_token, token_hash = generate_invite_token()
new_user = User(
company_id=company_id,
email=email,
first_name=first_name,
last_name=last_name,
role=UserRole(role_str),
kuerzel=kuerzel,
personnel_number=personnel_number,
password_hash=hash_password(raw_token),
invite_token_hash=token_hash,
invite_expires=datetime.now(timezone.utc) + timedelta(days=7),
is_active=False,
)
db.add(new_user)
await db.flush()
used_personnel_in_csv.add(personnel_number)
if apply:
try:
await email_service.send_invite(new_user, invited_by, raw_token, db)
except Exception as e: # noqa: BLE001
# Mail-Fehler darf Import nicht abbrechen, wird aber gemeldet
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="created",
message=f"Anlage OK, aber Einladungs-Mail fehlgeschlagen: {e}",
))
created += 1
continue
items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number,
action="created",
))
created += 1
return ImportResult(
total_rows=len(rows),
created=created,
reactivated=reactivated,
errors=errors,
items=items,
)
async def preview_csv(
content: bytes, company_id: UUID, invited_by: User, db: AsyncSession,
) -> ImportResult:
"""Validiert CSV ohne DB-Schreibvorgänge (Rollback am Ende)."""
result = await _process_import(
content=content,
company_id=company_id,
invited_by=invited_by,
db=db,
apply=False,
)
await db.rollback()
return result
async def apply_csv(
content: bytes, company_id: UUID, invited_by: User, db: AsyncSession,
) -> ImportResult:
"""Führt Import durch und committet."""
result = await _process_import(
content=content,
company_id=company_id,
invited_by=invited_by,
db=db,
apply=True,
)
await db.commit()
return result
+310
View File
@@ -0,0 +1,310 @@
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import (
generate_invite_token,
hash_password,
hash_token,
verify_password,
)
from app.models import User, UserRole
from app.models.audit_log import AuditLog
from app.models.company import Company, PersonnelNumberMode
from app.schemas.user import InviteAccept, InviteRequest, UserUpdate
from app.services.email_service import email_service
PERSONNEL_NUMBER_MIN_DIGITS = 4
class UserService:
# ── Personalnummer-Helpers ────────────────────────────────────────────────
async def _get_company(self, company_id: UUID, db: AsyncSession) -> Company:
company = await db.get(Company, company_id)
if not company:
raise HTTPException(status_code=404, detail="Company not found")
return company
@staticmethod
def _format_personnel_number(value: int) -> str:
return str(value).zfill(PERSONNEL_NUMBER_MIN_DIGITS)
async def _next_personnel_number(self, company_id: UUID, db: AsyncSession) -> str:
"""Atomic increment + return next personnel number for the company.
Uses UPDATE ... RETURNING to avoid race conditions with parallel inserts.
Skips numbers that are already taken (e.g. manual override) by retrying.
"""
for _ in range(50): # safety bound, in practice 1-2 iterations max
result = await db.execute(
text(
"UPDATE companies "
"SET personnel_number_next = personnel_number_next + 1 "
"WHERE id = :cid "
"RETURNING personnel_number_next - 1 AS used"
),
{"cid": company_id},
)
row = result.first()
if row is None:
raise HTTPException(status_code=404, detail="Company not found")
candidate = self._format_personnel_number(int(row.used))
existing = await db.scalar(
select(User.id).where(
User.company_id == company_id,
User.personnel_number == candidate,
)
)
if existing is None:
return candidate
raise HTTPException(status_code=500, detail="Could not allocate personnel number")
async def next_personnel_suggestion(self, company_id: UUID, db: AsyncSession) -> str:
"""Preview next personnel number without consuming the counter."""
company = await self._get_company(company_id, db)
candidate_int = company.personnel_number_next
while True:
candidate = self._format_personnel_number(candidate_int)
taken = await db.scalar(
select(User.id).where(
User.company_id == company_id,
User.personnel_number == candidate,
)
)
if taken is None:
return candidate
candidate_int += 1
async def _check_personnel_unique(
self,
company_id: UUID,
number: str,
db: AsyncSession,
exclude_user_id: UUID | None = None,
) -> None:
"""Raise 409 if personnel number is already taken (incl. deactivated/reserved)."""
q = select(User.id).where(
User.company_id == company_id,
User.personnel_number == number,
)
if exclude_user_id is not None:
q = q.where(User.id != exclude_user_id)
existing = await db.scalar(q)
if existing is not None:
raise HTTPException(
status_code=409,
detail=f"Personalnummer '{number}' ist bereits vergeben (auch reservierte Nummern bleiben belegt).",
)
async def get_by_personnel_number(
self, number: str, company_id: UUID, db: AsyncSession
) -> User:
user = await db.scalar(
select(User).where(
User.company_id == company_id,
User.personnel_number == number,
)
)
if user is None:
raise HTTPException(status_code=404, detail="Personalnummer nicht gefunden")
return user
# ── Invite ────────────────────────────────────────────────────────────────
async def invite(
self,
data: InviteRequest,
company_id: UUID,
invited_by: User,
db: AsyncSession,
) -> User:
existing = await db.scalar(select(User).where(User.email == data.email))
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
company = await self._get_company(company_id, db)
personnel_number = data.personnel_number
if personnel_number:
await self._check_personnel_unique(company_id, personnel_number, db)
else:
if company.personnel_number_mode == PersonnelNumberMode.AUTO.value:
personnel_number = await self._next_personnel_number(company_id, db)
elif company.personnel_number_required:
raise HTTPException(
status_code=400,
detail="Personalnummer ist in dieser Firma Pflicht.",
)
if data.initial_password:
# Direktanlage mit Passwort sofort aktiv, kein E-Mail-Invite
user = User(
company_id=company_id,
email=data.email,
first_name=data.first_name,
last_name=data.last_name,
role=data.role,
department_id=data.department_id,
personnel_number=personnel_number,
password_hash=hash_password(data.initial_password),
is_active=True,
)
db.add(user)
await db.flush()
db.add(AuditLog(
company_id=company_id,
user_id=invited_by.id,
action="user_created",
entity_type="user",
entity_id=user.id,
new_value={"email": user.email, "role": user.role.value, "direct": True},
))
else:
raw_token, token_hash = generate_invite_token()
user = User(
company_id=company_id,
email=data.email,
first_name=data.first_name,
last_name=data.last_name,
role=data.role,
department_id=data.department_id,
personnel_number=personnel_number,
password_hash=hash_password(raw_token), # Temp overwritten on accept
invite_token_hash=token_hash,
invite_expires=datetime.now(timezone.utc) + timedelta(days=7),
is_active=False,
)
db.add(user)
await db.flush()
db.add(AuditLog(
company_id=company_id,
user_id=invited_by.id,
action="user_invited",
entity_type="user",
entity_id=user.id,
new_value={"email": user.email, "role": user.role.value},
))
await email_service.send_invite(user, invited_by, raw_token, db)
return user
async def accept_invite(self, data: InviteAccept, db: AsyncSession) -> User:
token_hash = hash_token(data.token)
user = await db.scalar(
select(User).where(User.invite_token_hash == token_hash)
)
if not user:
raise HTTPException(status_code=400, detail="Invalid invite token")
if user.invite_expires and user.invite_expires < datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="Invite token expired")
user.password_hash = hash_password(data.password)
user.invite_token_hash = None
user.invite_expires = None
user.is_active = True
return user
# ── Listing ───────────────────────────────────────────────────────────────
async def list_users(
self,
company_id: UUID,
db: AsyncSession,
skip: int = 0,
limit: int = 50,
active_only: bool = True,
search: str | None = None,
) -> tuple[int, list[User]]:
q = select(User).where(User.company_id == company_id)
if active_only:
q = q.where(User.is_active == True)
if search:
pattern = f"%{search.strip()}%"
q = q.where(
or_(
User.email.ilike(pattern),
User.first_name.ilike(pattern),
User.last_name.ilike(pattern),
User.personnel_number.ilike(pattern),
)
)
total = await db.scalar(select(func.count()).select_from(q.subquery()))
users = await db.scalars(q.offset(skip).limit(limit))
return total, list(users.all())
async def get_by_id(self, user_id: UUID, company_id: UUID, db: AsyncSession) -> User:
user = await db.get(User, user_id)
if not user or user.company_id != company_id:
raise HTTPException(status_code=404, detail="User not found")
return user
# ── Update / De/Reactivate ────────────────────────────────────────────────
async def update(
self,
user_id: UUID,
data: UserUpdate,
current_user: User,
db: AsyncSession,
) -> User:
user = await self.get_by_id(user_id, current_user.company_id, db)
changes = data.model_dump(exclude_unset=True)
old_personnel = user.personnel_number
if "personnel_number" in changes:
new_value = changes["personnel_number"]
if new_value:
await self._check_personnel_unique(
current_user.company_id, new_value, db, exclude_user_id=user.id
)
elif user.personnel_number is not None:
# Explizites Löschen erlauben? Plan sagt Reservierung wir verbieten Clear.
raise HTTPException(
status_code=400,
detail="Personalnummer kann nicht gelöscht werden (Reservierung).",
)
for field, value in changes.items():
setattr(user, field, value)
if "personnel_number" in changes and changes["personnel_number"] != old_personnel:
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="user_personnel_number_changed",
entity_type="user",
entity_id=user.id,
old_value={"personnel_number": old_personnel},
new_value={"personnel_number": user.personnel_number},
))
return user
async def deactivate(self, user_id: UUID, current_user: User, db: AsyncSession) -> User:
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
user = await self.get_by_id(user_id, current_user.company_id, db)
user.is_active = False
return user
async def reactivate(self, user_id: UUID, current_user: User, db: AsyncSession) -> User:
user = await self.get_by_id(user_id, current_user.company_id, db)
user.is_active = True
return user
# ── Kiosk ─────────────────────────────────────────────────────────────────
async def set_kiosk_pin(self, user: User, pin: str, db: AsyncSession) -> None:
user.kiosk_pin_hash = hash_password(pin)
async def verify_kiosk_pin(self, user: User, pin: str) -> bool:
if not user.kiosk_pin_hash:
return False
return verify_password(pin, user.kiosk_pin_hash)
user_service = UserService()
View File
+60
View File
@@ -0,0 +1,60 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.core.config import settings
from app.core.database import Base
# Import all models so Alembic sees them
import app.models # noqa: F401
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+129
View File
@@ -0,0 +1,129 @@
"""initial schema - auth tables
Revision ID: 0001_initial
Revises:
Create Date: 2026-03-25
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0001_initial"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── companies ─────────────────────────────────────────────────────────────
op.create_table(
"companies",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("slug", sa.String(100), nullable=False, unique=True),
sa.Column("plan", sa.String(50), server_default="trial"),
sa.Column("logo_url", sa.Text),
sa.Column("country", sa.String(10), server_default="DE"),
sa.Column("state", sa.String(10)),
sa.Column("settings", postgresql.JSONB, server_default="{}"),
)
# ── departments (before users FK target) ─────────────────────────────────
op.create_table(
"departments",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("company_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("companies.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("manager_id", postgresql.UUID(as_uuid=True)), # FK added after users
)
# ── users ─────────────────────────────────────────────────────────────────
op.create_table(
"users",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("company_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("companies.id", ondelete="CASCADE")),
sa.Column("department_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("departments.id", ondelete="SET NULL")),
sa.Column("email", sa.String(255), nullable=False, unique=True),
sa.Column("password_hash", sa.Text, nullable=False),
sa.Column("first_name", sa.String(100), nullable=False),
sa.Column("last_name", sa.String(100), nullable=False),
sa.Column("role", sa.Enum(
"SUPER_ADMIN", "COMPANY_ADMIN", "HR", "MANAGER", "EMPLOYEE",
name="userrole",
), nullable=False),
sa.Column("kiosk_pin_hash", sa.Text),
sa.Column("kiosk_qr_token", sa.Text, unique=True),
sa.Column("is_active", sa.Boolean, server_default="true"),
sa.Column("invite_token_hash", sa.Text),
sa.Column("invite_expires", sa.DateTime(timezone=True)),
sa.Column("last_login", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_users_email", "users", ["email"])
# Now add FK for departments.manager_id → users
op.create_foreign_key(
"fk_departments_manager", "departments", "users", ["manager_id"], ["id"],
ondelete="SET NULL",
)
# ── sessions ──────────────────────────────────────────────────────────────
op.create_table(
"sessions",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("refresh_token_hash", sa.Text, nullable=False, unique=True),
sa.Column("device", sa.String(255)),
sa.Column("ip", sa.String(45)),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
# ── password_resets ───────────────────────────────────────────────────────
op.create_table(
"password_resets",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("token_hash", sa.Text, nullable=False, unique=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_password_resets_user_id", "password_resets", ["user_id"])
# ── audit_logs ────────────────────────────────────────────────────────────
op.create_table(
"audit_logs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("company_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("companies.id", ondelete="SET NULL")),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL")),
sa.Column("action", sa.String(100), nullable=False),
sa.Column("entity_type", sa.String(100)),
sa.Column("entity_id", postgresql.UUID(as_uuid=True)),
sa.Column("old_value", postgresql.JSONB),
sa.Column("new_value", postgresql.JSONB),
sa.Column("ip", sa.String(45)),
sa.Column("created_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), index=True),
)
op.create_index("ix_audit_logs_company_id", "audit_logs", ["company_id"])
op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"])
def downgrade() -> None:
op.drop_table("audit_logs")
op.drop_table("password_resets")
op.drop_table("sessions")
op.drop_constraint("fk_departments_manager", "departments", type_="foreignkey")
op.drop_table("users")
op.execute("DROP TYPE IF EXISTS userrole")
op.drop_table("departments")
op.drop_table("companies")
@@ -0,0 +1,70 @@
"""time entries and work schedules
Revision ID: 0002_time_entries
Revises: 0001_initial
Create Date: 2026-03-27
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0002_time_entries"
down_revision = "0001_initial"
branch_labels = None
depends_on = None
# Enum-Typen explizit definieren (create_type=False → sa.Enum erstellt sie selbst via op.create_table)
entrystatus = sa.Enum("pending", "approved", "rejected", name="entrystatus")
entrysource = sa.Enum("web", "kiosk", "api", "manual", name="entrysource")
def upgrade() -> None:
# ── work_schedules ─────────────────────────────────────────────────────────
op.create_table(
"work_schedules",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("company_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("companies.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("mon_h", sa.Numeric(4, 2), server_default="8.00"),
sa.Column("tue_h", sa.Numeric(4, 2), server_default="8.00"),
sa.Column("wed_h", sa.Numeric(4, 2), server_default="8.00"),
sa.Column("thu_h", sa.Numeric(4, 2), server_default="8.00"),
sa.Column("fri_h", sa.Numeric(4, 2), server_default="8.00"),
sa.Column("sat_h", sa.Numeric(4, 2), server_default="0.00"),
sa.Column("sun_h", sa.Numeric(4, 2), server_default="0.00"),
sa.Column("valid_from", sa.Date, nullable=False),
)
op.create_index("ix_work_schedules_company_id", "work_schedules", ["company_id"])
# ── time_entries ───────────────────────────────────────────────────────────
op.create_table(
"time_entries",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("date", sa.Date, nullable=False),
sa.Column("start_time", sa.Time(timezone=False), nullable=False),
sa.Column("end_time", sa.Time(timezone=False)),
sa.Column("break_minutes", sa.Integer, server_default="0"),
sa.Column("break_start", sa.Time(timezone=False)),
sa.Column("project_id", postgresql.UUID(as_uuid=True)),
sa.Column("note", sa.Text),
sa.Column("status", entrystatus, nullable=False, server_default="pending"),
sa.Column("source", entrysource, nullable=False, server_default="web"),
sa.Column("approved_by", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL")),
sa.Column("correction_note", sa.Text),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_time_entries_user_id", "time_entries", ["user_id"])
op.create_index("ix_time_entries_date", "time_entries", ["date"])
op.create_index("ix_time_entries_user_date", "time_entries", ["user_id", "date"])
def downgrade() -> None:
op.drop_table("time_entries")
op.drop_table("work_schedules")
entrystatus.drop(op.get_bind(), checkfirst=True)
entrysource.drop(op.get_bind(), checkfirst=True)
@@ -0,0 +1,95 @@
"""absence management
Revision ID: 0003_absences
Revises: 0002_time_entries
Create Date: 2026-03-27
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0003_absences"
down_revision = "0002_time_entries"
branch_labels = None
depends_on = None
absencestatus = sa.Enum("pending", "approved", "rejected", "cancelled", name="absencestatus")
def upgrade() -> None:
# ── absence_types ──────────────────────────────────────────────────────────
op.create_table(
"absence_types",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("company_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("companies.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("color", sa.String(7), server_default="#3B82F6"),
sa.Column("requires_approval", sa.Boolean, server_default="true"),
sa.Column("deducts_vacation", sa.Boolean, server_default="false"),
sa.Column("is_paid", sa.Boolean, server_default="true"),
sa.Column("max_days_per_year", sa.Integer),
sa.Column("is_active", sa.Boolean, server_default="true"),
)
op.create_index("ix_absence_types_company_id", "absence_types", ["company_id"])
# ── absences ───────────────────────────────────────────────────────────────
op.create_table(
"absences",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("type_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("absence_types.id", ondelete="RESTRICT"), nullable=False),
sa.Column("start_date", sa.Date, nullable=False),
sa.Column("end_date", sa.Date, nullable=False),
sa.Column("half_day_start", sa.Boolean, server_default="false"),
sa.Column("half_day_end", sa.Boolean, server_default="false"),
sa.Column("working_days", sa.Numeric(5, 1), server_default="0"),
sa.Column("status", absencestatus, nullable=False, server_default="pending"),
sa.Column("approved_by", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL")),
sa.Column("substitute_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL")),
sa.Column("note", sa.Text),
sa.Column("rejection_reason", sa.Text),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_absences_user_id", "absences", ["user_id"])
op.create_index("ix_absences_start_date", "absences", ["start_date"])
# ── vacation_balances ──────────────────────────────────────────────────────
op.create_table(
"vacation_balances",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("year", sa.Integer, nullable=False),
sa.Column("entitled_days", sa.Integer, server_default="30"),
sa.Column("carried_over", sa.Integer, server_default="0"),
sa.Column("used_days", sa.Integer, server_default="0"),
sa.UniqueConstraint("user_id", "year", name="uq_vacation_balance_user_year"),
)
op.create_index("ix_vacation_balances_user_id", "vacation_balances", ["user_id"])
# ── public_holidays ────────────────────────────────────────────────────────
op.create_table(
"public_holidays",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("country", sa.String(10), nullable=False),
sa.Column("state", sa.String(10)),
sa.Column("date", sa.Date, nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("year", sa.Integer, nullable=False),
sa.UniqueConstraint("country", "state", "date", name="uq_public_holiday"),
)
op.create_index("ix_public_holidays_date", "public_holidays", ["date"])
op.create_index("ix_public_holidays_year", "public_holidays", ["year"])
def downgrade() -> None:
op.drop_table("public_holidays")
op.drop_table("vacation_balances")
op.drop_table("absences")
op.drop_table("absence_types")
absencestatus.drop(op.get_bind(), checkfirst=True)
+68
View File
@@ -0,0 +1,68 @@
"""LDAP integration: ldap_configs table + user auth_provider/ldap_dn columns
Revision ID: 0004_ldap
Revises: 0003_absences
Create Date: 2026-03-27
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0004_ldap"
down_revision = "0003_absences"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add auth_provider enum
authprovider = postgresql.ENUM("local", "ldap", name="authprovider", create_type=False)
authprovider.create(op.get_bind(), checkfirst=True)
# Alter users table
op.add_column("users", sa.Column(
"auth_provider",
sa.Enum("local", "ldap", name="authprovider"),
nullable=False,
server_default="local",
))
op.add_column("users", sa.Column("ldap_dn", sa.Text(), nullable=True))
op.alter_column("users", "password_hash", nullable=True)
# Create ldap_configs table
op.create_table(
"ldap_configs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("company_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("companies.id", ondelete="CASCADE"),
nullable=False, unique=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("host", sa.String(255), nullable=False),
sa.Column("port", sa.Integer(), nullable=False, server_default="389"),
sa.Column("use_ssl", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("use_tls", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("bind_dn", sa.Text(), nullable=False),
sa.Column("bind_password_encrypted", sa.Text(), nullable=False),
sa.Column("base_dn", sa.Text(), nullable=False),
sa.Column("user_search_filter", sa.String(512), nullable=False,
server_default="(objectClass=person)"),
sa.Column("attr_email", sa.String(100), nullable=False, server_default="mail"),
sa.Column("attr_firstname", sa.String(100), nullable=False, server_default="givenName"),
sa.Column("attr_lastname", sa.String(100), nullable=False, server_default="sn"),
sa.Column("attr_username", sa.String(100), nullable=False, server_default="sAMAccountName"),
sa.Column("attr_department", sa.String(100), nullable=True),
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_ldap_configs_company_id", "ldap_configs", ["company_id"])
def downgrade() -> None:
op.drop_table("ldap_configs")
op.drop_column("users", "ldap_dn")
op.drop_column("users", "auth_provider")
# LDAP users have no password_hash — set placeholder before restoring NOT NULL
op.execute("UPDATE users SET password_hash = 'LDAP_USER_NO_PASSWORD' WHERE password_hash IS NULL")
op.alter_column("users", "password_hash", nullable=False)
op.execute("DROP TYPE IF EXISTS authprovider")
@@ -0,0 +1,151 @@
"""Work schedule linkage, absence extensions, overtime balance, CalDAV configs
Revision ID: 0005_extensions
Revises: 0004_ldap
Create Date: 2026-03-27
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0005_extensions"
down_revision = "0004_ldap"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── 1. AbsenceCategory enum ───────────────────────────────────────────────
absence_category = postgresql.ENUM(
"vacation", "sick", "overtime_comp", "training", "business_trip", "other",
name="absencecategory", create_type=False,
)
absence_category.create(op.get_bind(), checkfirst=True)
# ── 2. absence_types neue Spalten ───────────────────────────────────────
op.add_column("absence_types", sa.Column(
"category",
sa.Enum("vacation", "sick", "overtime_comp", "training", "business_trip", "other",
name="absencecategory"),
nullable=False, server_default="other",
))
op.add_column("absence_types", sa.Column(
"affects_overtime_balance", sa.Boolean(), nullable=False, server_default="false"
))
op.add_column("absence_types", sa.Column(
"requires_certificate", sa.Boolean(), nullable=False, server_default="false"
))
op.add_column("absence_types", sa.Column(
"certificate_after_days", sa.Integer(), nullable=False, server_default="3"
))
# Bestehende Typen klassifizieren
op.execute("""
UPDATE absence_types SET category = 'vacation'
WHERE lower(name) IN ('urlaub', 'sonderurlaub')
""")
op.execute("""
UPDATE absence_types SET category = 'sick', requires_certificate = true
WHERE lower(name) = 'krankheit'
""")
op.execute("""
UPDATE absence_types SET category = 'business_trip'
WHERE lower(name) = 'dienstreise'
""")
op.execute("""
UPDATE absence_types SET category = 'training'
WHERE lower(name) IN ('weiterbildung', 'bildungsurlaub')
""")
# ── 3. absences CalDAV + Meta + Zertifikat ──────────────────────────────
op.add_column("absences", sa.Column("meta", postgresql.JSONB(), nullable=True))
op.add_column("absences", sa.Column("certificate_required_by", sa.Date(), nullable=True))
op.add_column("absences", sa.Column("certificate_received_at", sa.Date(), nullable=True))
op.add_column("absences", sa.Column("caldav_uid", sa.String(255), nullable=True))
op.add_column("absences", sa.Column("caldav_user_etag", sa.Text(), nullable=True))
op.add_column("absences", sa.Column("caldav_company_etag", sa.Text(), nullable=True))
op.add_column("absences", sa.Column("caldav_last_error", sa.Text(), nullable=True))
op.add_column("absences", sa.Column(
"caldav_synced_at", sa.DateTime(timezone=True), nullable=True
))
# ── 4. users work_schedule_id ───────────────────────────────────────────
op.add_column("users", sa.Column(
"work_schedule_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("work_schedules.id", ondelete="SET NULL"),
nullable=True,
))
# ── 5. overtime_balances ──────────────────────────────────────────────────
op.create_table(
"overtime_balances",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, unique=True),
sa.Column("company_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("companies.id", ondelete="CASCADE"), nullable=False),
sa.Column("total_hours", sa.Numeric(8, 2), nullable=False, server_default="0"),
sa.Column("taken_hours", sa.Numeric(8, 2), nullable=False, server_default="0"),
sa.Column("last_calculated", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_overtime_balances_user_id", "overtime_balances", ["user_id"])
op.create_index("ix_overtime_balances_company_id", "overtime_balances", ["company_id"])
# ── 6. caldav_company_configs ─────────────────────────────────────────────
op.create_table(
"caldav_company_configs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("company_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("companies.id", ondelete="CASCADE"),
nullable=False, unique=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("principal_url", sa.Text(), nullable=False),
sa.Column("calendar_url", sa.Text(), nullable=True),
sa.Column("username", sa.String(255), nullable=False),
sa.Column("password_encrypted", sa.Text(), nullable=False),
sa.Column("calendar_display_name", sa.String(255), nullable=False, server_default=""),
sa.Column("verify_ssl", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_caldav_company_configs_company_id", "caldav_company_configs", ["company_id"])
# ── 7. caldav_user_configs ────────────────────────────────────────────────
op.create_table(
"caldav_user_configs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, unique=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("principal_url", sa.Text(), nullable=False),
sa.Column("calendar_url", sa.Text(), nullable=True),
sa.Column("username", sa.String(255), nullable=False),
sa.Column("password_encrypted", sa.Text(), nullable=False),
sa.Column("calendar_display_name", sa.String(255), nullable=False, server_default=""),
sa.Column("verify_ssl", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_caldav_user_configs_user_id", "caldav_user_configs", ["user_id"])
def downgrade() -> None:
op.drop_table("caldav_user_configs")
op.drop_table("caldav_company_configs")
op.drop_table("overtime_balances")
op.drop_column("users", "work_schedule_id")
for col in ["caldav_synced_at", "caldav_last_error", "caldav_company_etag",
"caldav_user_etag", "caldav_uid", "certificate_received_at",
"certificate_required_by", "meta"]:
op.drop_column("absences", col)
for col in ["certificate_after_days", "requires_certificate",
"affects_overtime_balance", "category"]:
op.drop_column("absence_types", col)
op.execute("DROP TYPE IF EXISTS absencecategory")
+40
View File
@@ -0,0 +1,40 @@
"""smtp_configs table
Revision ID: 0006_smtp
Revises: 0005_extensions
Create Date: 2026-03-27
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "0006_smtp"
down_revision = "0005_extensions"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"smtp_configs",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("company_id", UUID(as_uuid=True),
sa.ForeignKey("companies.id", ondelete="CASCADE"),
nullable=False),
sa.Column("host", sa.String(255), nullable=False),
sa.Column("port", sa.Integer, nullable=False, server_default="587"),
sa.Column("use_tls", sa.Boolean, nullable=False, server_default="false"),
sa.Column("use_starttls", sa.Boolean, nullable=False, server_default="true"),
sa.Column("username", sa.String(255)),
sa.Column("password_encrypted", sa.Text),
sa.Column("from_email", sa.String(255), nullable=False),
sa.Column("from_name", sa.String(255), nullable=False, server_default="TimeMaster"),
sa.Column("is_enabled", sa.Boolean, nullable=False, server_default="true"),
sa.UniqueConstraint("company_id", name="uq_smtp_configs_company"),
)
op.create_index("ix_smtp_configs_company_id", "smtp_configs", ["company_id"])
def downgrade() -> None:
op.drop_index("ix_smtp_configs_company_id")
op.drop_table("smtp_configs")
@@ -0,0 +1,88 @@
"""caldav_configs tables + fix smtp_configs missing FK
Revision ID: 0007_caldav_and_fixes
Revises: 0006_smtp
Create Date: 2026-03-27
Fixes:
- smtp_configs.company_id had no FK constraint (orphaned records possible)
- caldav_company_configs and caldav_user_configs were created via create_all,
not tracked by Alembic now formally managed here
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "0007_caldav_and_fixes"
down_revision = "0006_smtp"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── Fix 1: smtp_configs missing FK ───────────────────────────────────────
# The FK was omitted in 0006_smtp. Add it now.
op.create_foreign_key(
"fk_smtp_configs_company_id",
"smtp_configs", "companies",
["company_id"], ["id"],
ondelete="CASCADE",
)
# ── Fix 2: caldav tables (created via create_all, now Alembic-managed) ───
# Use checkfirst=True so fresh installs that don't have the tables yet
# get them created, while existing installs skip creation silently.
bind = op.get_bind()
if not bind.dialect.has_table(bind, "caldav_company_configs"):
op.create_table(
"caldav_company_configs",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("company_id", UUID(as_uuid=True),
sa.ForeignKey("companies.id", ondelete="CASCADE"),
nullable=False, unique=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("principal_url", sa.Text(), nullable=False, server_default=""),
sa.Column("calendar_url", sa.Text(), nullable=True),
sa.Column("username", sa.String(255), nullable=False, server_default=""),
sa.Column("password_encrypted", sa.Text(), nullable=False, server_default=""),
sa.Column("calendar_display_name", sa.String(255), nullable=False, server_default=""),
sa.Column("verify_ssl", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_caldav_company_configs_company_id", "caldav_company_configs", ["company_id"])
if not bind.dialect.has_table(bind, "caldav_user_configs"):
op.create_table(
"caldav_user_configs",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, unique=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("principal_url", sa.Text(), nullable=False, server_default=""),
sa.Column("calendar_url", sa.Text(), nullable=True),
sa.Column("username", sa.String(255), nullable=False, server_default=""),
sa.Column("password_encrypted", sa.Text(), nullable=False, server_default=""),
sa.Column("calendar_display_name", sa.String(255), nullable=False, server_default=""),
sa.Column("verify_ssl", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_caldav_user_configs_user_id", "caldav_user_configs", ["user_id"])
def downgrade() -> None:
# Remove the smtp FK (restores state to 0006_smtp)
op.drop_constraint("fk_smtp_configs_company_id", "smtp_configs", type_="foreignkey")
# Drop caldav tables only if they were created by this migration
# (i.e., on a fresh install path — existing installs: tables stay)
op.drop_index("ix_caldav_user_configs_user_id", table_name="caldav_user_configs")
op.drop_table("caldav_user_configs")
op.drop_index("ix_caldav_company_configs_company_id", table_name="caldav_company_configs")
op.drop_table("caldav_company_configs")
@@ -0,0 +1,29 @@
"""Add tls_verify column to ldap_configs (H-07 security fix)
Revision ID: 0008_ldap_tls_verify
Revises: 0007_caldav_and_fixes
Create Date: 2026-03-27
Security fix H-07: make LDAP certificate validation configurable.
Default is False for backwards compatibility; set True in production
to enforce CERT_REQUIRED and prevent MITM attacks.
"""
from alembic import op
import sqlalchemy as sa
revision = "0008_ldap_tls_verify"
down_revision = "0007_caldav_and_fixes"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"ldap_configs",
sa.Column("tls_verify", sa.Boolean(), nullable=False, server_default="false"),
)
def downgrade() -> None:
op.drop_column("ldap_configs", "tls_verify")
@@ -0,0 +1,21 @@
"""add correction_note to absences
Revision ID: 0009
Revises: 0008
Create Date: 2026-03-28
"""
from alembic import op
import sqlalchemy as sa
revision = '0009_absence_correction_note'
down_revision = '0008_ldap_tls_verify'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('absences', sa.Column('correction_note', sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column('absences', 'correction_note')
@@ -0,0 +1,23 @@
"""add is_high_rate to public_holidays
Revision ID: 0010
Revises: 0009_absence_correction_note
Create Date: 2026-03-28
"""
from alembic import op
import sqlalchemy as sa
revision = '0010_public_holidays'
down_revision = '0009_absence_correction_note'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('public_holidays', sa.Column(
'is_high_rate', sa.Boolean(), nullable=False, server_default='false'
))
def downgrade() -> None:
op.drop_column('public_holidays', 'is_high_rate')
@@ -0,0 +1,23 @@
"""add name_format to caldav_company_configs
Revision ID: 0011
Revises: 0010_public_holidays
Create Date: 2026-03-28
"""
from alembic import op
import sqlalchemy as sa
revision = '0011_caldav_name_format'
down_revision = '0010_public_holidays'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('caldav_company_configs', sa.Column(
'name_format', sa.String(20), nullable=False, server_default='full'
))
def downgrade() -> None:
op.drop_column('caldav_company_configs', 'name_format')
@@ -0,0 +1,54 @@
"""caldav name_template + users.kuerzel
Revision ID: 0012
Revises: 0011_caldav_name_format
Create Date: 2026-03-28
"""
from alembic import op
import sqlalchemy as sa
revision = '0012_caldav_template_and_kuerzel'
down_revision = '0011_caldav_name_format'
branch_labels = None
depends_on = None
# Mapping alter Festwert → neues Template
_MIGRATION_MAP = {
'full': '$vorname $nachname $typ',
'short': '$vorname_short. $nachname $typ',
'initials': '$kuerzel $typ',
'type_only': '$typ',
}
_DEFAULT = '$vorname $nachname $typ'
def upgrade() -> None:
# 1. Neue Spalte name_template hinzufügen
op.add_column('caldav_company_configs', sa.Column(
'name_template', sa.Text(), nullable=False,
server_default=_DEFAULT,
))
# 2. Bestehende Werte aus name_format migrieren
for old_val, new_val in _MIGRATION_MAP.items():
op.execute(
f"UPDATE caldav_company_configs "
f"SET name_template = '{new_val}' "
f"WHERE name_format = '{old_val}'"
)
# 3. Alte Spalte entfernen
op.drop_column('caldav_company_configs', 'name_format')
# 4. Kürzel-Spalte zu users hinzufügen
op.add_column('users', sa.Column(
'kuerzel', sa.String(20), nullable=True
))
def downgrade() -> None:
op.add_column('caldav_company_configs', sa.Column(
'name_format', sa.String(20), nullable=False, server_default='full'
))
op.drop_column('caldav_company_configs', 'name_template')
op.drop_column('users', 'kuerzel')
@@ -0,0 +1,44 @@
"""projects table + time_entries FK
Revision ID: 0013_projects
Revises: 0012_caldav_template_and_kuerzel
Create Date: 2026-03-28
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "0013_projects"
down_revision = "0012_caldav_template_and_kuerzel"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Projekte-Tabelle
op.create_table(
"projects",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("company_id", UUID(as_uuid=True),
sa.ForeignKey("companies.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("color", sa.String(7), nullable=False, server_default="#3B82F6"),
sa.Column("budget_hours", sa.Numeric(8, 2), nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# FK von time_entries.project_id → projects.id
op.create_foreign_key(
"fk_time_entries_project_id",
"time_entries", "projects",
["project_id"], ["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint("fk_time_entries_project_id", "time_entries", type_="foreignkey")
op.drop_table("projects")
@@ -0,0 +1,21 @@
"""remove projects table
Revision ID: 0014_remove_projects
Revises: 0013_projects
Create Date: 2026-03-28
"""
from alembic import op
revision = "0014_remove_projects"
down_revision = "0013_projects"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_constraint("fk_time_entries_project_id", "time_entries", type_="foreignkey")
op.drop_table("projects")
def downgrade() -> None:
pass
+23
View File
@@ -0,0 +1,23 @@
"""add TOTP fields to users
Revision ID: 0015_totp
Revises: 0014_remove_projects
Create Date: 2026-03-28
"""
from alembic import op
import sqlalchemy as sa
revision = "0015_totp"
down_revision = "0014_remove_projects"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("totp_secret", sa.String(64), nullable=True))
op.add_column("users", sa.Column("totp_enabled", sa.Boolean(), nullable=False, server_default="false"))
def downgrade() -> None:
op.drop_column("users", "totp_enabled")
op.drop_column("users", "totp_secret")
@@ -0,0 +1,24 @@
"""add special_days to vacation_balances
Revision ID: 0016_vacation_special_days
Revises: 0015_totp
Create Date: 2026-03-28
"""
from alembic import op
import sqlalchemy as sa
revision = "0016_vacation_special_days"
down_revision = "0015_totp"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"vacation_balances",
sa.Column("special_days", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("vacation_balances", "special_days")

Some files were not shown because too many files have changed in this diff Show More