feat(overtime): Auszahlungs-Anträge durch Mitarbeiter (opt-in pro Firma)
Mitarbeiter beantragt Überstunden-Auszahlung, HR genehmigt/lehnt ab.
Firmen-Opt-in payout_request_enabled (Default aus - nur HR-Direktbuchung).
Saldo-Abzug erst bei Genehmigung; Reject/Cancel bucht nichts.
- Migration 0042: hours_payouts.status/decided_by/decided_at/rejection_reason
+ companies.payout_request_enabled (nur Spalten, RLS unveraendert)
- Router: POST /hr/payouts/request|{id}/approve|reject|cancel; list status-Filter;
HR-Direktbuchung bleibt (status approved)
- Frontend: PayoutRequestCard (Selbstbedienung in AbsencesPage), HR-Page
Status-Spalte + Genehmigen/Ablehnen, CompanySettings-Toggle
- 4 pytest-Cases
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2176,3 +2176,463 @@ Keine Commits in dieser Session.
|
|||||||
- frontend/src/pages/TenantsPage.tsx | 30 +++++++++++++++++++++---------
|
- frontend/src/pages/TenantsPage.tsx | 30 +++++++++++++++++++++---------
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-06-23 01:44 – 01:44 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- c3cb9ce docs: DEVLOG – agent-10 Reseller/Mandantenübersicht
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 01:45 – 01:45 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 01:45 – 01:47 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 01:47 – 01:48 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 10:45 – 10:47 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 10:54 – 11:01 (7m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 11:04 – 11:35 (30m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 11:46 – 11:48 (2m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 3b2df1c feat: agent-11 PR1 – Vertretung, Storno-Re-Genehmigung, Kommentare
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/models/__init__.py | 2 ++
|
||||||
|
- backend/app/models/absence.py | 11 +++---
|
||||||
|
- backend/app/models/absence_comment.py | 44 +++++++++++++++++++++++
|
||||||
|
- backend/app/routers/absences.py | 43 ++++++++++++++++++++++
|
||||||
|
- backend/app/routers/users.py | 21 +++++++++++
|
||||||
|
- backend/app/schemas/absence.py | 22 ++++++++++++
|
||||||
|
- backend/app/services/absence_service.py | 244 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
|
||||||
|
- backend/app/services/caldav_service.py | 28 +++++++++++++++
|
||||||
|
- backend/app/services/email_service.py | 22 ++++++++++++
|
||||||
|
- backend/migrations/versions/0035_absence_comments.py | 61 +++++++++++++++++++++++++++++++
|
||||||
|
- backend/tests/conftest.py | 2 +-
|
||||||
|
- backend/tests/test_absences.py | 144 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/components/absences/AbsenceCommentsModal.tsx | 83 ++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/components/absences/AbsenceModals.tsx | 17 ++++++++-
|
||||||
|
- frontend/src/hooks/useAbsences.ts | 22 ++++++++----
|
||||||
|
- frontend/src/pages/AbsencesPage.tsx | 40 +++++++++++++++++----
|
||||||
|
- frontend/src/types/absence.ts | 12 ++++++-
|
||||||
|
- frontend/src/utils/calendar.ts | 2 ++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 11:48 – 12:04 (15m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- c9c197d feat: agent-11 PR2 – Urlaubsanspruch (Verfall scharf, Pro-rata, Teilzeit)
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/models/company.py | 8 ++++++++
|
||||||
|
- backend/app/models/user.py | 8 ++++++--
|
||||||
|
- backend/app/routers/absences.py | 16 ++++------------
|
||||||
|
- backend/app/schemas/absence.py | 2 ++
|
||||||
|
- backend/app/schemas/company.py | 6 ++++++
|
||||||
|
- backend/app/schemas/user.py | 7 ++++++-
|
||||||
|
- backend/app/services/absence_service.py | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
|
||||||
|
- backend/migrations/versions/0036_vacation_entitlement.py | 36 ++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/tests/test_absences.py | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/pages/AbsencesPage.tsx | 7 +++++--
|
||||||
|
- frontend/src/pages/CompanySettingsPage.tsx | 41 +++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/pages/UsersPage.tsx | 18 ++++++++++++++++++
|
||||||
|
- frontend/src/types/absence.ts | 1 +
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 12:06 – 12:39 (33m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- c83fb31 feat: agent-11 PR3 – Scheduler + Erinnerungs-Mails + Notification-Prefs
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/core/config.py | 5 +++
|
||||||
|
- backend/app/core/notifications.py | 27 ++++++++++++++++
|
||||||
|
- backend/app/main.py | 9 ++++++
|
||||||
|
- backend/app/models/user.py | 5 ++-
|
||||||
|
- backend/app/routers/absences.py | 18 ++++++-----
|
||||||
|
- backend/app/routers/companies.py | 13 ++++++++
|
||||||
|
- backend/app/routers/users.py | 28 +++++++++++++++++
|
||||||
|
- backend/app/schemas/user.py | 10 ++++++
|
||||||
|
- backend/app/services/absence_service.py | 3 ++
|
||||||
|
- backend/app/services/email_service.py | 59 +++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/app/services/scheduler_service.py | 215 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/migrations/versions/0037_notification_prefs.py | 24 +++++++++++++++
|
||||||
|
- backend/requirements.txt | 1 +
|
||||||
|
- backend/tests/test_reminders.py | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/pages/ProfilePage.tsx | 36 +++++++++++++++++++++-
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 12:41 – 13:19 (38m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 450dbdb feat: agent-12 – Zwei-Stufen-Genehmigung für Abwesenheiten
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/models/absence.py | 6 ++++++
|
||||||
|
- backend/app/models/company.py | 5 +++++
|
||||||
|
- backend/app/schemas/absence.py | 1 +
|
||||||
|
- backend/app/schemas/company.py | 4 ++++
|
||||||
|
- backend/app/services/absence_service.py | 42 +++++++++++++++++++++++++++++++++++++-----
|
||||||
|
- backend/app/services/scheduler_service.py | 5 ++++-
|
||||||
|
- backend/migrations/versions/0038_two_stage_approval.py | 39 +++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/tests/test_absences.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/pages/AbsencesPage.tsx | 6 ++++++
|
||||||
|
- frontend/src/pages/CompanySettingsPage.tsx | 28 +++++++++++++++++++++++++++-
|
||||||
|
- frontend/src/types/absence.ts | 1 +
|
||||||
|
- frontend/src/utils/calendar.ts | 2 ++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 13:20 – 13:24 (4m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 6a34bd1 fix: AuditLogPage – React-key am Listen-Fragment (Konsolen-Warnung)
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- frontend/src/pages/AuditLogPage.tsx | 9 ++++-----
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 13:24 – 13:28 (4m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- e1d423e feat: agent-04 – Team-Dashboard für Manager/HR im Frontend angebunden
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- frontend/src/pages/DashboardPage.tsx | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 13:45 – 14:07 (21m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** backend
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- fe00eb4 fix(security/dsgvo): RLS für hours_payouts nachgezogen (Mandantentrennung)
|
||||||
|
- 31e39c6 fix(security): K-5 – LDAP/CalDAV/SMTP-Verschlüsselung auf zentralen Datenschlüssel
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/migrations/versions/0039_rls_hours_payouts.py | 43 +++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/tests/conftest.py | 2 +-
|
||||||
|
- backend/tests/test_rls.py | 28 ++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 14:23 – 14:31 (8m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 2af2269 feat(dsgvo): Art. 17 Anonymisierung von Mitarbeiterkonten
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/models/user.py | 3 +++
|
||||||
|
- backend/app/routers/users.py | 12 ++++++++++++
|
||||||
|
- backend/app/schemas/user.py | 1 +
|
||||||
|
- backend/app/services/user_service.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/migrations/versions/0040_user_anonymization.py | 23 +++++++++++++++++++++++
|
||||||
|
- backend/tests/test_users.py | 35 +++++++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/pages/UsersPage.tsx | 33 +++++++++++++++++++++++++++++++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 14:31 – 14:32 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/models/user.py | 3 +++
|
||||||
|
- backend/app/routers/users.py | 12 ++++++++++++
|
||||||
|
- backend/app/schemas/user.py | 1 +
|
||||||
|
- backend/app/services/user_service.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/migrations/versions/0040_user_anonymization.py | 23 +++++++++++++++++++++++
|
||||||
|
- backend/tests/test_users.py | 35 +++++++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/pages/UsersPage.tsx | 33 +++++++++++++++++++++++++++++++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 14:36 – 21:41 (7h 04m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/models/user.py | 3 ++
|
||||||
|
- backend/app/routers/users.py | 12 ++++++
|
||||||
|
- backend/app/schemas/user.py | 1 +
|
||||||
|
- backend/app/services/user_service.py | 48 ++++++++++++++++++++++
|
||||||
|
- .../migrations/versions/0040_user_anonymization.py | 23 +++++++++++
|
||||||
|
- backend/tests/test_users.py | 35 ++++++++++++++++
|
||||||
|
- frontend/src/pages/UsersPage.tsx | 33 ++++++++++++++-
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 22:07 – 22:07 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** frontend
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 89d85a9 feat(ui): Navigation neu strukturiert (flache Topbar + Verwaltung-Menü + /settings)
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- frontend/src/App.tsx | 2 ++
|
||||||
|
- frontend/src/components/Layout.tsx | 150 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------------------
|
||||||
|
- frontend/src/components/navConfig.ts | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/pages/SettingsPage.tsx | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 22:25 – 22:28 (2m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- add154b refactor(ui): Verwaltung-Dropdown aufgelöst – Berichte flach, Rest in /settings
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- frontend/src/components/Layout.tsx | 60 ++++--------------------------------------------------------
|
||||||
|
- frontend/src/components/navConfig.ts | 27 +++++++++++++++++----------
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 22:34 – 22:35 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 1978d58 fix(payouts): 500 in GET /hr/payouts – computed Felder als optional
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/schemas/hours_payout.py | 4 ++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-06-23 23:22 – 23:22 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/schemas/hours_payout.py | 4 ++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-08 14:12 – 14:12 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/schemas/hours_payout.py | 4 ++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-08 14:14 – 14:15 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/schemas/hours_payout.py | 4 ++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-08 16:02 – 16:06 (3m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** frontend
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/schemas/hours_payout.py | 4 ++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-08 16:09 – 16:09 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/schemas/hours_payout.py | 4 ++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-08 16:11 – 16:13 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 43ebc83 feat(ical): abonnierbarer read-only Kalender-Feed pro Nutzer
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/main.py | 2 ++
|
||||||
|
- backend/app/models/user.py | 4 ++++
|
||||||
|
- backend/app/routers/ical.py | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/migrations/versions/0041_ical_token.py | 29 +++++++++++++++++++++++++++++
|
||||||
|
- backend/tests/test_ical.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/pages/ProfilePage.tsx | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-08 16:15 – 16:16 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/main.py | 2 ++
|
||||||
|
- backend/app/models/user.py | 4 ++++
|
||||||
|
- backend/app/routers/ical.py | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/migrations/versions/0041_ical_token.py | 29 +++++++++++++++++++++++++++++
|
||||||
|
- backend/tests/test_ical.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/pages/ProfilePage.tsx | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-08 16:16 – 16:20 (4m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/main.py | 2 ++
|
||||||
|
- backend/app/models/user.py | 4 ++++
|
||||||
|
- backend/app/routers/ical.py | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/migrations/versions/0041_ical_token.py | 29 +++++++++++++++++++++++++++++
|
||||||
|
- backend/tests/test_ical.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/pages/ProfilePage.tsx | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-08 16:23 – 16:30 (7m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- f24b25b chore(deploy): entkoppelte Self-Update-Skripte pro Server
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- deploy.sh | 121 +++++-------------------------------
|
||||||
|
- push-frontend.sh | 49 +++++++++++++++
|
||||||
|
- server-update.sh | 68 ++++++++++++++++++++
|
||||||
|
- update.sh | 562 ++++-----------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-08 16:31 – 16:34 (3m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- fad27cb fix(deploy): server-update.sh Health-Check mit Retry (Worker-Startzeit)
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- server-update.sh | 11 +++++++++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-08 16:39 – 16:48 (8m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 2c57c36 fix(deploy): server-update.sh re-exec nach git pull (Self-Edit-Falle)
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- server-update.sh | 14 ++++++++++----
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-08 16:52 – 16:53 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- server-update.sh | 14 ++++++++++----
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-08 17:10 – 17:12 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- server-update.sh | 14 ++++++++++----
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# backend – Dev Log
|
||||||
|
|
||||||
|
## 2026-06-23 13:31 – 13:43 (12m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- frontend/src/pages/DashboardPage.tsx | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
@@ -93,6 +93,8 @@ class Company(Base):
|
|||||||
overtime_expiry_month: Mapped[int] = mapped_column(Integer, nullable=False, default=3) # März
|
overtime_expiry_month: Mapped[int] = mapped_column(Integer, nullable=False, default=3) # März
|
||||||
overtime_expiry_day: Mapped[int] = mapped_column(Integer, nullable=False, default=31) # 31.
|
overtime_expiry_day: Mapped[int] = mapped_column(Integer, nullable=False, default=31) # 31.
|
||||||
overtime_max_carryover_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) # None = alles
|
overtime_max_carryover_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) # None = alles
|
||||||
|
# Überstunden-Auszahlung: Selbstbedienung durch Mitarbeiter (Antrag → HR-Genehmigung)
|
||||||
|
payout_request_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
users: Mapped[list["User"]] = relationship(
|
users: Mapped[list["User"]] = relationship(
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Stunden-Auszahlung: HR/Admin weist Überstunden-Stunden zur Lohn-Auszahlung an."""
|
"""Stunden-Auszahlung: HR/Admin weist Überstunden-Stunden zur Lohn-Auszahlung an."""
|
||||||
|
import enum
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, Text, func
|
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, Text, func
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -15,6 +16,13 @@ if TYPE_CHECKING:
|
|||||||
from app.models.company import Company
|
from app.models.company import Company
|
||||||
|
|
||||||
|
|
||||||
|
class PayoutStatus(str, enum.Enum):
|
||||||
|
REQUESTED = "requested" # Mitarbeiter hat beantragt, wartet auf HR
|
||||||
|
APPROVED = "approved" # genehmigt / HR-Direktbuchung – Saldo abgezogen
|
||||||
|
REJECTED = "rejected" # HR hat abgelehnt
|
||||||
|
CANCELLED = "cancelled" # Antragsteller hat zurückgezogen (nur requested)
|
||||||
|
|
||||||
|
|
||||||
class HoursPayout(Base):
|
class HoursPayout(Base):
|
||||||
"""Ein Auszahlungsvorgang für Überstunden-Stunden."""
|
"""Ein Auszahlungsvorgang für Überstunden-Stunden."""
|
||||||
__tablename__ = "hours_payouts"
|
__tablename__ = "hours_payouts"
|
||||||
@@ -32,14 +40,21 @@ class HoursPayout(Base):
|
|||||||
period_year: Mapped[int | None] = mapped_column(Integer) # Abrechnungsmonat Jahr
|
period_year: Mapped[int | None] = mapped_column(Integer) # Abrechnungsmonat Jahr
|
||||||
period_month: Mapped[int | None] = mapped_column(Integer) # Abrechnungsmonat Monat
|
period_month: Mapped[int | None] = mapped_column(Integer) # Abrechnungsmonat Monat
|
||||||
note: Mapped[str | None] = mapped_column(Text) # Notiz für Buchhaltung
|
note: Mapped[str | None] = mapped_column(Text) # Notiz für Buchhaltung
|
||||||
|
status: Mapped[str] = mapped_column(String(12), nullable=False, default=PayoutStatus.APPROVED.value)
|
||||||
|
rejection_reason: Mapped[str | None] = mapped_column(Text)
|
||||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"),
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
nullable=False
|
nullable=False
|
||||||
)
|
)
|
||||||
|
decided_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), index=True
|
DateTime(timezone=True), server_default=func.now(), index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship("User", foreign_keys=[user_id], lazy="noload")
|
user: Mapped["User"] = relationship("User", foreign_keys=[user_id], lazy="noload")
|
||||||
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by], lazy="noload")
|
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by], lazy="noload")
|
||||||
|
decider: Mapped["User"] = relationship("User", foreign_keys=[decided_by], lazy="noload")
|
||||||
company: Mapped["Company"] = relationship("Company", lazy="noload")
|
company: Mapped["Company"] = relationship("Company", lazy="noload")
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""Stunden-Auszahlung: HR/Admin bucht Überstunden-Stunden zur Lohn-Auszahlung aus."""
|
"""Stunden-Auszahlung: HR/Admin bucht Überstunden aus – oder Mitarbeiter beantragt
|
||||||
|
Auszahlung und HR genehmigt (Firmen-Opt-in `payout_request_enabled`)."""
|
||||||
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -9,10 +11,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.dependencies import get_client_ip, require_role
|
from app.core.dependencies import get_client_ip, require_role
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.hours_payout import HoursPayout
|
from app.models.company import Company
|
||||||
|
from app.models.hours_payout import HoursPayout, PayoutStatus
|
||||||
from app.models.overtime_balance import OvertimeBalance
|
from app.models.overtime_balance import OvertimeBalance
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
from app.schemas.hours_payout import HoursPayoutCreate, HoursPayoutListResponse, HoursPayoutOut
|
from app.schemas.hours_payout import (
|
||||||
|
HoursPayoutCreate,
|
||||||
|
HoursPayoutListResponse,
|
||||||
|
HoursPayoutOut,
|
||||||
|
HoursPayoutReject,
|
||||||
|
HoursPayoutRequest,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(tags=["Stunden-Auszahlung"])
|
router = APIRouter(tags=["Stunden-Auszahlung"])
|
||||||
|
|
||||||
@@ -20,17 +29,30 @@ _hr_roles = (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
|||||||
_all_roles = (UserRole.EMPLOYEE, UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
_all_roles = (UserRole.EMPLOYEE, UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
|
||||||
|
|
||||||
def _build_out(payout: HoursPayout, user: User | None, creator: User | None) -> HoursPayoutOut:
|
async def _build_out(payout: HoursPayout, db: AsyncSession) -> HoursPayoutOut:
|
||||||
out = HoursPayoutOut.model_validate(payout)
|
out = HoursPayoutOut.model_validate(payout)
|
||||||
out.user_name = (
|
user = await db.get(User, payout.user_id)
|
||||||
f"{user.first_name} {user.last_name}" if user else str(payout.user_id)
|
creator = await db.get(User, payout.created_by)
|
||||||
)
|
out.user_name = f"{user.first_name} {user.last_name}" if user else str(payout.user_id)
|
||||||
out.created_by_name = (
|
out.created_by_name = f"{creator.first_name} {creator.last_name}" if creator else str(payout.created_by)
|
||||||
f"{creator.first_name} {creator.last_name}" if creator else str(payout.created_by)
|
if payout.decided_by:
|
||||||
)
|
decider = await db.get(User, payout.decided_by)
|
||||||
|
out.decided_by_name = f"{decider.first_name} {decider.last_name}" if decider else str(payout.decided_by)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_balance(user_id: UUID, company_id: UUID, db: AsyncSession) -> OvertimeBalance:
|
||||||
|
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
|
||||||
|
if ob is None:
|
||||||
|
ob = OvertimeBalance(
|
||||||
|
user_id=user_id, company_id=company_id,
|
||||||
|
total_hours=Decimal("0"), taken_hours=Decimal("0"),
|
||||||
|
)
|
||||||
|
db.add(ob)
|
||||||
|
await db.flush()
|
||||||
|
return ob
|
||||||
|
|
||||||
|
|
||||||
# ── GET /hr/payouts ───────────────────────────────────────────────────────────
|
# ── GET /hr/payouts ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/hr/payouts", response_model=HoursPayoutListResponse)
|
@router.get("/hr/payouts", response_model=HoursPayoutListResponse)
|
||||||
@@ -38,13 +60,11 @@ async def list_payouts(
|
|||||||
user_id: UUID | None = Query(None),
|
user_id: UUID | None = Query(None),
|
||||||
year: int | None = Query(None, ge=2000, le=2100),
|
year: int | None = Query(None, ge=2000, le=2100),
|
||||||
month: int | None = Query(None, ge=1, le=12),
|
month: int | None = Query(None, ge=1, le=12),
|
||||||
|
status: str | None = Query(None),
|
||||||
current_user: User = require_role(*_all_roles),
|
current_user: User = require_role(*_all_roles),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Alle Auszahlungen der eigenen Firma, optional gefiltert nach Mitarbeiter / Monat.
|
"""Auszahlungen der eigenen Firma, optional gefiltert. EMPLOYEE/MANAGER sehen nur eigene."""
|
||||||
EMPLOYEE und MANAGER sehen ausschließlich ihre eigenen Auszahlungen.
|
|
||||||
"""
|
|
||||||
# Employees und Manager sehen nur ihre eigenen Daten – Query-Param wird ignoriert
|
|
||||||
if current_user.role not in _hr_roles:
|
if current_user.role not in _hr_roles:
|
||||||
user_id = current_user.id
|
user_id = current_user.id
|
||||||
|
|
||||||
@@ -55,24 +75,18 @@ async def list_payouts(
|
|||||||
filters.append(HoursPayout.period_year == year)
|
filters.append(HoursPayout.period_year == year)
|
||||||
if month is not None:
|
if month is not None:
|
||||||
filters.append(HoursPayout.period_month == month)
|
filters.append(HoursPayout.period_month == month)
|
||||||
|
if status is not None:
|
||||||
|
filters.append(HoursPayout.status == status)
|
||||||
|
|
||||||
total_count = await db.scalar(
|
total_count = await db.scalar(select(func.count()).select_from(HoursPayout).where(*filters))
|
||||||
select(func.count()).select_from(HoursPayout).where(*filters)
|
|
||||||
)
|
|
||||||
rows = list(await db.scalars(
|
rows = list(await db.scalars(
|
||||||
select(HoursPayout).where(*filters).order_by(HoursPayout.created_at.desc())
|
select(HoursPayout).where(*filters).order_by(HoursPayout.created_at.desc())
|
||||||
))
|
))
|
||||||
|
result = [await _build_out(p, db) for p in rows]
|
||||||
result: list[HoursPayoutOut] = []
|
|
||||||
for payout in rows:
|
|
||||||
user = await db.get(User, payout.user_id)
|
|
||||||
creator = await db.get(User, payout.created_by)
|
|
||||||
result.append(_build_out(payout, user, creator))
|
|
||||||
|
|
||||||
return HoursPayoutListResponse(payouts=result, total_count=total_count or 0)
|
return HoursPayoutListResponse(payouts=result, total_count=total_count or 0)
|
||||||
|
|
||||||
|
|
||||||
# ── POST /hr/payouts ──────────────────────────────────────────────────────────
|
# ── POST /hr/payouts (HR-Direktbuchung) ───────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/hr/payouts", response_model=HoursPayoutOut, status_code=201)
|
@router.post("/hr/payouts", response_model=HoursPayoutOut, status_code=201)
|
||||||
async def create_payout(
|
async def create_payout(
|
||||||
@@ -81,36 +95,15 @@ async def create_payout(
|
|||||||
current_user: User = require_role(*_hr_roles),
|
current_user: User = require_role(*_hr_roles),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Neue Auszahlung anlegen – reduziert sofort den Überstunden-Saldo."""
|
"""HR bucht Auszahlung direkt (sofort genehmigt) – reduziert sofort den Saldo."""
|
||||||
# Ziel-User prüfen
|
|
||||||
target = await db.get(User, data.user_id)
|
target = await db.get(User, data.user_id)
|
||||||
if not target or target.company_id != current_user.company_id:
|
if not target or target.company_id != current_user.company_id:
|
||||||
raise HTTPException(404, "Mitarbeiter nicht gefunden")
|
raise HTTPException(404, "Mitarbeiter nicht gefunden")
|
||||||
|
|
||||||
# OvertimeBalance laden oder anlegen
|
|
||||||
ob = await db.scalar(
|
|
||||||
select(OvertimeBalance).where(OvertimeBalance.user_id == data.user_id)
|
|
||||||
)
|
|
||||||
if ob is None:
|
|
||||||
ob = OvertimeBalance(
|
|
||||||
user_id=data.user_id,
|
|
||||||
company_id=current_user.company_id,
|
|
||||||
total_hours=Decimal("0"),
|
|
||||||
taken_hours=Decimal("0"),
|
|
||||||
)
|
|
||||||
db.add(ob)
|
|
||||||
await db.flush() # id erzeugen
|
|
||||||
|
|
||||||
# Warnung bei Überziehung (kein Hard-Block)
|
|
||||||
hours = Decimal(str(data.hours))
|
hours = Decimal(str(data.hours))
|
||||||
if ob.available_hours < hours:
|
ob = await _get_or_create_balance(data.user_id, current_user.company_id, db)
|
||||||
# Wir blockieren nicht – Auszahlung trotzdem buchen (wie FZA mit overdraft)
|
ob.taken_hours += hours # Überziehung erlaubt (kein Hard-Block, wie FZA)
|
||||||
pass
|
|
||||||
|
|
||||||
# Saldo anpassen
|
|
||||||
ob.taken_hours += hours
|
|
||||||
|
|
||||||
# Auszahlungs-Datensatz anlegen
|
|
||||||
payout = HoursPayout(
|
payout = HoursPayout(
|
||||||
company_id=current_user.company_id,
|
company_id=current_user.company_id,
|
||||||
user_id=data.user_id,
|
user_id=data.user_id,
|
||||||
@@ -118,36 +111,165 @@ async def create_payout(
|
|||||||
period_year=data.period_year,
|
period_year=data.period_year,
|
||||||
period_month=data.period_month,
|
period_month=data.period_month,
|
||||||
note=data.note,
|
note=data.note,
|
||||||
|
status=PayoutStatus.APPROVED.value,
|
||||||
created_by=current_user.id,
|
created_by=current_user.id,
|
||||||
|
decided_by=current_user.id,
|
||||||
|
decided_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
db.add(payout)
|
db.add(payout)
|
||||||
await db.flush() # payout.id erzeugen
|
await db.flush()
|
||||||
|
|
||||||
# AuditLog
|
|
||||||
db.add(AuditLog(
|
db.add(AuditLog(
|
||||||
company_id=current_user.company_id,
|
company_id=current_user.company_id, user_id=current_user.id,
|
||||||
user_id=current_user.id,
|
action="hours_payout_created", entity_type="hours_payout", entity_id=payout.id,
|
||||||
action="hours_payout_created",
|
|
||||||
entity_type="hours_payout",
|
|
||||||
entity_id=payout.id,
|
|
||||||
new_value={
|
new_value={
|
||||||
"user_id": str(data.user_id),
|
"user_id": str(data.user_id), "hours": str(hours),
|
||||||
"hours": str(hours),
|
"period_year": data.period_year, "period_month": data.period_month, "note": data.note,
|
||||||
"period_year": data.period_year,
|
|
||||||
"period_month": data.period_month,
|
|
||||||
"note": data.note,
|
|
||||||
},
|
},
|
||||||
ip=get_client_ip(request),
|
ip=get_client_ip(request),
|
||||||
))
|
))
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(payout)
|
await db.refresh(payout)
|
||||||
|
return await _build_out(payout, db)
|
||||||
creator = await db.get(User, payout.created_by)
|
|
||||||
return _build_out(payout, target, creator)
|
|
||||||
|
|
||||||
|
|
||||||
# ── DELETE /hr/payouts/{payout_id} ───────────────────────────────────────────
|
# ── POST /hr/payouts/request (Mitarbeiter-Selbstbedienung) ────────────────────
|
||||||
|
|
||||||
|
@router.post("/hr/payouts/request", response_model=HoursPayoutOut, status_code=201)
|
||||||
|
async def request_payout(
|
||||||
|
request: Request,
|
||||||
|
data: HoursPayoutRequest,
|
||||||
|
current_user: User = require_role(*_all_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Mitarbeiter beantragt Auszahlung eigener Überstunden. Saldo wird erst bei
|
||||||
|
Genehmigung durch HR abgezogen. Nur wenn Firma `payout_request_enabled` hat."""
|
||||||
|
company = await db.get(Company, current_user.company_id)
|
||||||
|
if not company or not company.payout_request_enabled:
|
||||||
|
raise HTTPException(403, "Auszahlungs-Anträge sind für diese Firma nicht aktiviert.")
|
||||||
|
|
||||||
|
hours = Decimal(str(data.hours))
|
||||||
|
payout = HoursPayout(
|
||||||
|
company_id=current_user.company_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
hours=hours,
|
||||||
|
period_year=data.period_year,
|
||||||
|
period_month=data.period_month,
|
||||||
|
note=data.note,
|
||||||
|
status=PayoutStatus.REQUESTED.value,
|
||||||
|
created_by=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(payout)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id, user_id=current_user.id,
|
||||||
|
action="hours_payout_requested", entity_type="hours_payout", entity_id=payout.id,
|
||||||
|
new_value={"hours": str(hours), "note": data.note},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(payout)
|
||||||
|
return await _build_out(payout, db)
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /hr/payouts/{id}/approve ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/hr/payouts/{payout_id}/approve", response_model=HoursPayoutOut)
|
||||||
|
async def approve_payout(
|
||||||
|
payout_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
current_user: User = require_role(*_hr_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""HR genehmigt einen Antrag – erst jetzt wird der Saldo abgezogen."""
|
||||||
|
payout = await db.get(HoursPayout, payout_id)
|
||||||
|
if payout is None or payout.company_id != current_user.company_id:
|
||||||
|
raise HTTPException(404, "Auszahlung nicht gefunden")
|
||||||
|
if payout.status != PayoutStatus.REQUESTED.value:
|
||||||
|
raise HTTPException(409, "Nur offene Anträge können genehmigt werden.")
|
||||||
|
|
||||||
|
ob = await _get_or_create_balance(payout.user_id, current_user.company_id, db)
|
||||||
|
ob.taken_hours += payout.hours
|
||||||
|
|
||||||
|
payout.status = PayoutStatus.APPROVED.value
|
||||||
|
payout.decided_by = current_user.id
|
||||||
|
payout.decided_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id, user_id=current_user.id,
|
||||||
|
action="hours_payout_approved", entity_type="hours_payout", entity_id=payout.id,
|
||||||
|
new_value={"hours": str(payout.hours), "user_id": str(payout.user_id)},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(payout)
|
||||||
|
return await _build_out(payout, db)
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /hr/payouts/{id}/reject ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/hr/payouts/{payout_id}/reject", response_model=HoursPayoutOut)
|
||||||
|
async def reject_payout(
|
||||||
|
payout_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
data: HoursPayoutReject,
|
||||||
|
current_user: User = require_role(*_hr_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""HR lehnt Antrag ab – kein Saldo-Abzug."""
|
||||||
|
payout = await db.get(HoursPayout, payout_id)
|
||||||
|
if payout is None or payout.company_id != current_user.company_id:
|
||||||
|
raise HTTPException(404, "Auszahlung nicht gefunden")
|
||||||
|
if payout.status != PayoutStatus.REQUESTED.value:
|
||||||
|
raise HTTPException(409, "Nur offene Anträge können abgelehnt werden.")
|
||||||
|
|
||||||
|
payout.status = PayoutStatus.REJECTED.value
|
||||||
|
payout.rejection_reason = data.rejection_reason
|
||||||
|
payout.decided_by = current_user.id
|
||||||
|
payout.decided_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id, user_id=current_user.id,
|
||||||
|
action="hours_payout_rejected", entity_type="hours_payout", entity_id=payout.id,
|
||||||
|
new_value={"rejection_reason": data.rejection_reason, "user_id": str(payout.user_id)},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(payout)
|
||||||
|
return await _build_out(payout, db)
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /hr/payouts/{id}/cancel (Antragsteller zieht zurück) ─────────────────
|
||||||
|
|
||||||
|
@router.post("/hr/payouts/{payout_id}/cancel", response_model=HoursPayoutOut)
|
||||||
|
async def cancel_payout(
|
||||||
|
payout_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
current_user: User = require_role(*_all_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Antragsteller zieht seinen offenen Antrag zurück."""
|
||||||
|
payout = await db.get(HoursPayout, payout_id)
|
||||||
|
if payout is None or payout.company_id != current_user.company_id:
|
||||||
|
raise HTTPException(404, "Auszahlung nicht gefunden")
|
||||||
|
if payout.user_id != current_user.id:
|
||||||
|
raise HTTPException(403, "Nur der eigene Antrag kann zurückgezogen werden.")
|
||||||
|
if payout.status != PayoutStatus.REQUESTED.value:
|
||||||
|
raise HTTPException(409, "Nur offene Anträge können zurückgezogen werden.")
|
||||||
|
|
||||||
|
payout.status = PayoutStatus.CANCELLED.value
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id, user_id=current_user.id,
|
||||||
|
action="hours_payout_cancelled", entity_type="hours_payout", entity_id=payout.id,
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(payout)
|
||||||
|
return await _build_out(payout, db)
|
||||||
|
|
||||||
|
|
||||||
|
# ── DELETE /hr/payouts/{id} ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.delete("/hr/payouts/{payout_id}", status_code=204)
|
@router.delete("/hr/payouts/{payout_id}", status_code=204)
|
||||||
async def delete_payout(
|
async def delete_payout(
|
||||||
@@ -156,34 +278,26 @@ async def delete_payout(
|
|||||||
current_user: User = require_role(*_hr_roles),
|
current_user: User = require_role(*_hr_roles),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Auszahlung stornieren – stellt die Stunden in den Überstunden-Saldo zurück."""
|
"""Auszahlung stornieren – bei genehmigten Buchungen werden die Stunden zurückgebucht."""
|
||||||
payout = await db.get(HoursPayout, payout_id)
|
payout = await db.get(HoursPayout, payout_id)
|
||||||
if payout is None or payout.company_id != current_user.company_id:
|
if payout is None or payout.company_id != current_user.company_id:
|
||||||
raise HTTPException(404, "Auszahlung nicht gefunden")
|
raise HTTPException(404, "Auszahlung nicht gefunden")
|
||||||
|
|
||||||
# OvertimeBalance laden und Stunden zurückbuchen
|
# Nur genehmigte/gebuchte Auszahlungen haben den Saldo belastet → zurückbuchen
|
||||||
ob = await db.scalar(
|
if payout.status == PayoutStatus.APPROVED.value:
|
||||||
select(OvertimeBalance).where(OvertimeBalance.user_id == payout.user_id)
|
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == payout.user_id))
|
||||||
)
|
|
||||||
if ob is not None:
|
if ob is not None:
|
||||||
ob.taken_hours = max(Decimal("0"), ob.taken_hours - payout.hours)
|
ob.taken_hours = max(Decimal("0"), ob.taken_hours - payout.hours)
|
||||||
|
|
||||||
# AuditLog
|
|
||||||
db.add(AuditLog(
|
db.add(AuditLog(
|
||||||
company_id=current_user.company_id,
|
company_id=current_user.company_id, user_id=current_user.id,
|
||||||
user_id=current_user.id,
|
action="hours_payout_deleted", entity_type="hours_payout", entity_id=payout.id,
|
||||||
action="hours_payout_deleted",
|
|
||||||
entity_type="hours_payout",
|
|
||||||
entity_id=payout.id,
|
|
||||||
old_value={
|
old_value={
|
||||||
"user_id": str(payout.user_id),
|
"user_id": str(payout.user_id), "hours": str(payout.hours),
|
||||||
"hours": str(payout.hours),
|
"status": payout.status,
|
||||||
"period_year": payout.period_year,
|
"period_year": payout.period_year, "period_month": payout.period_month, "note": payout.note,
|
||||||
"period_month": payout.period_month,
|
|
||||||
"note": payout.note,
|
|
||||||
},
|
},
|
||||||
ip=get_client_ip(request),
|
ip=get_client_ip(request),
|
||||||
))
|
))
|
||||||
|
|
||||||
await db.delete(payout)
|
await db.delete(payout)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class CompanyOut(BaseModel):
|
|||||||
overtime_expiry_month: int = 3
|
overtime_expiry_month: int = 3
|
||||||
overtime_expiry_day: int = 31
|
overtime_expiry_day: int = 31
|
||||||
overtime_max_carryover_hours: int | None = None
|
overtime_max_carryover_hours: int | None = None
|
||||||
|
payout_request_enabled: bool = False
|
||||||
kiosk_require_approval: bool = True
|
kiosk_require_approval: bool = True
|
||||||
kiosk_track_current_user: bool = True
|
kiosk_track_current_user: bool = True
|
||||||
kiosk_heartbeat_interval_sec: int = 30
|
kiosk_heartbeat_interval_sec: int = 30
|
||||||
@@ -83,6 +84,7 @@ class CompanyUpdate(BaseModel):
|
|||||||
overtime_expiry_month: int | None = Field(None, ge=1, le=12)
|
overtime_expiry_month: int | None = Field(None, ge=1, le=12)
|
||||||
overtime_expiry_day: int | None = Field(None, ge=1, le=31)
|
overtime_expiry_day: int | None = Field(None, ge=1, le=31)
|
||||||
overtime_max_carryover_hours: int | None = Field(None, ge=0, le=9999)
|
overtime_max_carryover_hours: int | None = Field(None, ge=0, le=9999)
|
||||||
|
payout_request_enabled: bool | None = None
|
||||||
kiosk_require_approval: bool | None = None
|
kiosk_require_approval: bool | None = None
|
||||||
kiosk_track_current_user: bool | None = None
|
kiosk_track_current_user: bool | None = None
|
||||||
kiosk_heartbeat_interval_sec: int | None = Field(None, ge=10, le=120)
|
kiosk_heartbeat_interval_sec: int | None = Field(None, ge=10, le=120)
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ class HoursPayoutCreate(BaseModel):
|
|||||||
note: str | None = Field(None, max_length=500)
|
note: str | None = Field(None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class HoursPayoutRequest(BaseModel):
|
||||||
|
"""Mitarbeiter beantragt Auszahlung eigener Überstunden (kein user_id – immer self)."""
|
||||||
|
hours: Decimal = Field(gt=0, le=999.99, decimal_places=2)
|
||||||
|
period_year: int | None = Field(None, ge=2000, le=2100)
|
||||||
|
period_month: int | None = Field(None, ge=1, le=12)
|
||||||
|
note: str | None = Field(None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class HoursPayoutReject(BaseModel):
|
||||||
|
rejection_reason: str | None = Field(None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
class HoursPayoutOut(BaseModel):
|
class HoursPayoutOut(BaseModel):
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -23,8 +35,13 @@ class HoursPayoutOut(BaseModel):
|
|||||||
period_year: int | None
|
period_year: int | None
|
||||||
period_month: int | None
|
period_month: int | None
|
||||||
note: str | None
|
note: str | None
|
||||||
|
status: str = "approved"
|
||||||
|
rejection_reason: str | None = None
|
||||||
created_by: uuid.UUID
|
created_by: uuid.UUID
|
||||||
created_by_name: str = "" # Computed im Router
|
created_by_name: str = "" # Computed im Router
|
||||||
|
decided_by: uuid.UUID | None = None
|
||||||
|
decided_by_name: str = "" # Computed im Router
|
||||||
|
decided_at: datetime | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Auszahlungs-Anträge: Status + Genehmigungs-Metadaten + Firmen-Opt-in
|
||||||
|
|
||||||
|
Revision ID: 0042
|
||||||
|
Revises: 0041
|
||||||
|
Create Date: 2026-07-08
|
||||||
|
|
||||||
|
Erweitert hours_payouts um einen Genehmigungs-Workflow: Mitarbeiter beantragt eine
|
||||||
|
Überstunden-Auszahlung (status='requested'), HR genehmigt/lehnt ab. Bestehende Zeilen
|
||||||
|
und HR-Direktbuchungen sind 'approved'. companies.payout_request_enabled schaltet die
|
||||||
|
Selbstbedienung pro Firma frei (Default aus → nur HR-Direktbuchung wie bisher).
|
||||||
|
|
||||||
|
Keine RLS-Änderung nötig: nur neue Spalten auf bereits gefencten Tabellen
|
||||||
|
(hours_payouts-Policy seit 0039, companies-Policy vorhanden) → conftest unverändert.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0042"
|
||||||
|
down_revision = "0041"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE hours_payouts ADD COLUMN IF NOT EXISTS status VARCHAR(12) NOT NULL DEFAULT 'approved'")
|
||||||
|
op.execute("ALTER TABLE hours_payouts ADD COLUMN IF NOT EXISTS decided_by UUID REFERENCES users(id) ON DELETE SET NULL")
|
||||||
|
op.execute("ALTER TABLE hours_payouts ADD COLUMN IF NOT EXISTS decided_at TIMESTAMPTZ")
|
||||||
|
op.execute("ALTER TABLE hours_payouts ADD COLUMN IF NOT EXISTS rejection_reason TEXT")
|
||||||
|
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS payout_request_enabled BOOLEAN NOT NULL DEFAULT FALSE")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE companies DROP COLUMN IF EXISTS payout_request_enabled")
|
||||||
|
op.execute("ALTER TABLE hours_payouts DROP COLUMN IF EXISTS rejection_reason")
|
||||||
|
op.execute("ALTER TABLE hours_payouts DROP COLUMN IF EXISTS decided_at")
|
||||||
|
op.execute("ALTER TABLE hours_payouts DROP COLUMN IF EXISTS decided_by")
|
||||||
|
op.execute("ALTER TABLE hours_payouts DROP COLUMN IF EXISTS status")
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Tests für Auszahlungs-Anträge mit Genehmigung (router/hours_payouts.py)."""
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||||
|
async def payout_headers(client: AsyncClient):
|
||||||
|
resp = await client.post("/api/v1/auth/register", json={
|
||||||
|
"company_name": "Payout GmbH",
|
||||||
|
"first_name": "Paul",
|
||||||
|
"last_name": "Out",
|
||||||
|
"email": "admin@payoutgmbh.de",
|
||||||
|
"password": "Secret123",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_request_disabled_by_default(client: AsyncClient, payout_headers):
|
||||||
|
r = await client.post("/api/v1/hr/payouts/request",
|
||||||
|
json={"hours": 4}, headers=payout_headers)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_enable_and_request_approve(client: AsyncClient, payout_headers):
|
||||||
|
# Firma schaltet Selbstbedienung frei
|
||||||
|
up = await client.patch("/api/v1/companies/me",
|
||||||
|
json={"payout_request_enabled": True}, headers=payout_headers)
|
||||||
|
assert up.status_code == 200, up.text
|
||||||
|
assert up.json()["payout_request_enabled"] is True
|
||||||
|
|
||||||
|
# Antrag stellen – Status requested, kein Saldo-Abzug
|
||||||
|
r = await client.post("/api/v1/hr/payouts/request",
|
||||||
|
json={"hours": 5, "note": "Bitte auszahlen"}, headers=payout_headers)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
pid = r.json()["id"]
|
||||||
|
assert r.json()["status"] == "requested"
|
||||||
|
|
||||||
|
bal = await client.get("/api/v1/absences/overtime-balance", headers=payout_headers)
|
||||||
|
assert float(bal.json()["taken_hours"]) == 0.0
|
||||||
|
|
||||||
|
# HR genehmigt – jetzt Saldo-Abzug
|
||||||
|
ap = await client.post(f"/api/v1/hr/payouts/{pid}/approve", json={}, headers=payout_headers)
|
||||||
|
assert ap.status_code == 200, ap.text
|
||||||
|
assert ap.json()["status"] == "approved"
|
||||||
|
|
||||||
|
bal2 = await client.get("/api/v1/absences/overtime-balance", headers=payout_headers)
|
||||||
|
assert float(bal2.json()["taken_hours"]) == 5.0
|
||||||
|
|
||||||
|
# Erneutes Genehmigen scheitert
|
||||||
|
ap2 = await client.post(f"/api/v1/hr/payouts/{pid}/approve", json={}, headers=payout_headers)
|
||||||
|
assert ap2.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_reject_no_deduction(client: AsyncClient, payout_headers):
|
||||||
|
r = await client.post("/api/v1/hr/payouts/request",
|
||||||
|
json={"hours": 3}, headers=payout_headers)
|
||||||
|
pid = r.json()["id"]
|
||||||
|
before = float((await client.get("/api/v1/absences/overtime-balance",
|
||||||
|
headers=payout_headers)).json()["taken_hours"])
|
||||||
|
|
||||||
|
rej = await client.post(f"/api/v1/hr/payouts/{pid}/reject",
|
||||||
|
json={"rejection_reason": "kein Budget"}, headers=payout_headers)
|
||||||
|
assert rej.status_code == 200
|
||||||
|
assert rej.json()["status"] == "rejected"
|
||||||
|
assert rej.json()["rejection_reason"] == "kein Budget"
|
||||||
|
|
||||||
|
after = float((await client.get("/api/v1/absences/overtime-balance",
|
||||||
|
headers=payout_headers)).json()["taken_hours"])
|
||||||
|
assert after == before # kein Abzug bei Ablehnung
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_cancel_own_request(client: AsyncClient, payout_headers):
|
||||||
|
r = await client.post("/api/v1/hr/payouts/request",
|
||||||
|
json={"hours": 2}, headers=payout_headers)
|
||||||
|
pid = r.json()["id"]
|
||||||
|
c = await client.post(f"/api/v1/hr/payouts/{pid}/cancel", json={}, headers=payout_headers)
|
||||||
|
assert c.status_code == 200
|
||||||
|
assert c.json()["status"] == "cancelled"
|
||||||
|
# Genehmigen eines zurückgezogenen Antrags scheitert
|
||||||
|
ap = await client.post(f"/api/v1/hr/payouts/{pid}/approve", json={}, headers=payout_headers)
|
||||||
|
assert ap.status_code == 409
|
||||||
@@ -620,3 +620,42 @@ Keine Commits in dieser Session.
|
|||||||
- backend/app/routers/public_stamp.py | 11 +++++++++--
|
- backend/app/routers/public_stamp.py | 11 +++++++++--
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-06-23 21:42 – 21:51 (8m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/models/user.py | 3 +++
|
||||||
|
- backend/app/routers/users.py | 12 ++++++++++++
|
||||||
|
- backend/app/schemas/user.py | 1 +
|
||||||
|
- backend/app/services/user_service.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/migrations/versions/0040_user_anonymization.py | 23 +++++++++++++++++++++++
|
||||||
|
- backend/tests/test_users.py | 35 +++++++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/pages/UsersPage.tsx | 33 +++++++++++++++++++++++++++++++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-08 15:05 – 15:31 (25m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/schemas/hours_payout.py | 4 ++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-08 15:31 – 15:33 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** frontend
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/schemas/hours_payout.py | 4 ++--
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import type { HoursPayoutOut, HoursPayoutListResponse } from '../types/hoursPayout'
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||||
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||||
|
requested: { label: 'Beantragt', cls: 'bg-amber-100 text-amber-700' },
|
||||||
|
approved: { label: 'Genehmigt', cls: 'bg-green-100 text-green-700' },
|
||||||
|
rejected: { label: 'Abgelehnt', cls: 'bg-red-100 text-red-700' },
|
||||||
|
cancelled: { label: 'Zurückgezogen', cls: 'bg-gray-100 text-gray-500' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPeriod(year: number | null, month: number | null): string {
|
||||||
|
if (!year) return ''
|
||||||
|
if (!month) return String(year)
|
||||||
|
return `${MONTH_NAMES[month - 1]} ${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-400 focus:border-transparent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selbstbedienung: Mitarbeiter beantragt Auszahlung eigener Überstunden.
|
||||||
|
* Rendert nur, wenn die Firma `payout_request_enabled` gesetzt hat.
|
||||||
|
*/
|
||||||
|
export function PayoutRequestCard({ availableHours }: { availableHours: number }) {
|
||||||
|
const [enabled, setEnabled] = useState<boolean | null>(null)
|
||||||
|
const [rows, setRows] = useState<HoursPayoutOut[]>([])
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [hours, setHours] = useState<number>(4)
|
||||||
|
const [note, setNote] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<{ payout_request_enabled: boolean }>('/companies/me')
|
||||||
|
.then(c => setEnabled(!!c.payout_request_enabled))
|
||||||
|
.catch(() => setEnabled(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) load()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [enabled])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const data = await api.get<HoursPayoutListResponse>('/hr/payouts')
|
||||||
|
setRows(data.payouts)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!hours || hours <= 0) { setErr('Stunden müssen größer als 0 sein.'); return }
|
||||||
|
setSaving(true); setErr('')
|
||||||
|
try {
|
||||||
|
await api.post<HoursPayoutOut>('/hr/payouts/request', {
|
||||||
|
hours, period_year: null, period_month: null, note: note.trim() || null,
|
||||||
|
})
|
||||||
|
setOpen(false); setNote(''); setHours(4)
|
||||||
|
await load()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setErr(e instanceof Error ? e.message : 'Fehler beim Beantragen')
|
||||||
|
} finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancel(id: string) {
|
||||||
|
if (!confirm('Antrag zurückziehen?')) return
|
||||||
|
try {
|
||||||
|
const updated = await api.post<HoursPayoutOut>(`/hr/payouts/${id}/cancel`, {})
|
||||||
|
setRows(prev => prev.map(p => (p.id === id ? updated : p)))
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(e instanceof Error ? e.message : 'Fehler')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled) return null
|
||||||
|
|
||||||
|
const overBudget = hours > availableHours
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-5'>
|
||||||
|
<div className='flex items-center justify-between mb-3'>
|
||||||
|
<h2 className='text-sm font-semibold text-gray-500 uppercase tracking-wide'>Auszahlungs-Anträge</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => { setOpen(o => !o); setErr('') }}
|
||||||
|
className='px-3 py-1.5 bg-purple-600 text-white text-xs font-semibold rounded-lg hover:bg-purple-700'
|
||||||
|
>
|
||||||
|
{open ? 'Abbrechen' : '💸 Auszahlung beantragen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className='border border-gray-200 rounded-lg p-4 mb-4 space-y-3'>
|
||||||
|
<label className='block'>
|
||||||
|
<span className='text-xs font-medium text-gray-700'>Stunden *</span>
|
||||||
|
<input type='number' min='0.25' max='999' step='0.25' value={hours}
|
||||||
|
onChange={e => setHours(parseFloat(e.target.value))} className={inputClass} />
|
||||||
|
</label>
|
||||||
|
{overBudget && (
|
||||||
|
<p className='text-xs text-orange-600'>⚠️ Überschreitet dein verfügbares Guthaben ({availableHours.toFixed(1)} h) – Antrag ist trotzdem möglich.</p>
|
||||||
|
)}
|
||||||
|
<label className='block'>
|
||||||
|
<span className='text-xs font-medium text-gray-700'>Notiz</span>
|
||||||
|
<textarea value={note} onChange={e => setNote(e.target.value)} maxLength={500} rows={2}
|
||||||
|
placeholder='Optional…' className={inputClass + ' resize-none'} />
|
||||||
|
</label>
|
||||||
|
{err && <p className='text-sm text-red-600'>{err}</p>}
|
||||||
|
<div className='flex justify-end'>
|
||||||
|
<button onClick={submit} disabled={saving}
|
||||||
|
className='px-4 py-2 text-sm font-semibold text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50'>
|
||||||
|
{saving ? 'Sende…' : 'Antrag stellen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className='text-sm text-gray-400'>Noch keine Auszahlungs-Anträge.</p>
|
||||||
|
) : (
|
||||||
|
<div className='divide-y divide-gray-100'>
|
||||||
|
{rows.map(p => {
|
||||||
|
const b = STATUS_BADGE[p.status] ?? { label: p.status, cls: 'bg-gray-100 text-gray-500' }
|
||||||
|
const period = formatPeriod(p.period_year, p.period_month)
|
||||||
|
return (
|
||||||
|
<div key={p.id} className='flex items-center justify-between py-2 text-sm'>
|
||||||
|
<div>
|
||||||
|
<span className='font-semibold text-gray-800'>{Number(p.hours).toFixed(2)} h</span>
|
||||||
|
{period && <span className='text-gray-400 ml-2'>{period}</span>}
|
||||||
|
{p.status === 'rejected' && p.rejection_reason && (
|
||||||
|
<span className='text-gray-400 ml-2 italic'>– {p.rejection_reason}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${b.cls}`}>{b.label}</span>
|
||||||
|
{p.status === 'requested' && (
|
||||||
|
<button onClick={() => cancel(p.id)} className='text-xs text-red-500 hover:underline'>Zurückziehen</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
MONTHS, MONTHS_SHORT, STATUS_LABELS, STATUS_COLORS, MANAGER_ROLES,
|
MONTHS, MONTHS_SHORT, STATUS_LABELS, STATUS_COLORS, MANAGER_ROLES,
|
||||||
} from '../utils/calendar'
|
} from '../utils/calendar'
|
||||||
import { useAbsences } from '../hooks/useAbsences'
|
import { useAbsences } from '../hooks/useAbsences'
|
||||||
|
import { PayoutRequestCard } from '../components/PayoutRequestCard'
|
||||||
import { usePlanerView } from '../hooks/usePlanerView'
|
import { usePlanerView } from '../hooks/usePlanerView'
|
||||||
import {
|
import {
|
||||||
BalanceEditModal,
|
BalanceEditModal,
|
||||||
@@ -395,6 +396,8 @@ export function AbsencesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PayoutRequestCard availableHours={overtimeBalance?.available_hours ?? 0} />
|
||||||
|
|
||||||
<div className='flex gap-2 flex-wrap'>
|
<div className='flex gap-2 flex-wrap'>
|
||||||
{[['', 'Alle'], ['pending', 'Ausstehend'], ['approved', 'Genehmigt'], ['rejected', 'Abgelehnt']].map(([val, label]) => (
|
{[['', 'Alle'], ['pending', 'Ausstehend'], ['approved', 'Genehmigt'], ['rejected', 'Abgelehnt']].map(([val, label]) => (
|
||||||
<button key={val} onClick={() => { setStatusFilter(val); if (val) setSearchParams({ status: val }); else setSearchParams({}) }}
|
<button key={val} onClick={() => { setStatusFilter(val); if (val) setSearchParams({ status: val }); else setSearchParams({}) }}
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export function CompanySettingsPage() {
|
|||||||
// Freizeitausgleich
|
// Freizeitausgleich
|
||||||
const [fzaOverdraftAllowed, setFzaOverdraftAllowed] = useState(true)
|
const [fzaOverdraftAllowed, setFzaOverdraftAllowed] = useState(true)
|
||||||
const [fzaWarningThreshold, setFzaWarningThreshold] = useState(0)
|
const [fzaWarningThreshold, setFzaWarningThreshold] = useState(0)
|
||||||
|
// Auszahlungs-Anträge (Mitarbeiter-Selbstbedienung)
|
||||||
|
const [payoutRequestEnabled, setPayoutRequestEnabled] = useState(false)
|
||||||
// Überstunden-Kappung
|
// Überstunden-Kappung
|
||||||
const [overtimeCapEnabled, setOvertimeCapEnabled] = useState(false)
|
const [overtimeCapEnabled, setOvertimeCapEnabled] = useState(false)
|
||||||
const [overtimeCapHours, setOvertimeCapHours] = useState(150)
|
const [overtimeCapHours, setOvertimeCapHours] = useState(150)
|
||||||
@@ -100,10 +102,11 @@ export function CompanySettingsPage() {
|
|||||||
setPnRequired(c.personnel_number_required ?? false)
|
setPnRequired(c.personnel_number_required ?? false)
|
||||||
setPnMode(c.personnel_number_mode ?? 'manual')
|
setPnMode(c.personnel_number_mode ?? 'manual')
|
||||||
setPnNext(c.personnel_number_next ?? 1)
|
setPnNext(c.personnel_number_next ?? 1)
|
||||||
const cc = c as CompanyOut & { mobile_stamping_enabled?: boolean; overtime_overdraft_allowed?: boolean; overtime_warning_threshold_hours?: number; kiosk_require_approval?: boolean; kiosk_track_current_user?: boolean; kiosk_heartbeat_interval_sec?: number; overtime_cap_hours?: number | null; overtime_expiry_enabled?: boolean; overtime_expiry_month?: number; overtime_expiry_day?: number; overtime_max_carryover_hours?: number | null }
|
const cc = c as CompanyOut & { mobile_stamping_enabled?: boolean; overtime_overdraft_allowed?: boolean; overtime_warning_threshold_hours?: number; kiosk_require_approval?: boolean; kiosk_track_current_user?: boolean; kiosk_heartbeat_interval_sec?: number; overtime_cap_hours?: number | null; overtime_expiry_enabled?: boolean; overtime_expiry_month?: number; overtime_expiry_day?: number; overtime_max_carryover_hours?: number | null; payout_request_enabled?: boolean }
|
||||||
setMobileStamping(cc.mobile_stamping_enabled ?? true)
|
setMobileStamping(cc.mobile_stamping_enabled ?? true)
|
||||||
setFzaOverdraftAllowed(cc.overtime_overdraft_allowed ?? true)
|
setFzaOverdraftAllowed(cc.overtime_overdraft_allowed ?? true)
|
||||||
setFzaWarningThreshold(cc.overtime_warning_threshold_hours ?? 0)
|
setFzaWarningThreshold(cc.overtime_warning_threshold_hours ?? 0)
|
||||||
|
setPayoutRequestEnabled(cc.payout_request_enabled ?? false)
|
||||||
setKioskRequireApproval(cc.kiosk_require_approval ?? true)
|
setKioskRequireApproval(cc.kiosk_require_approval ?? true)
|
||||||
setKioskTrackCurrentUser(cc.kiosk_track_current_user ?? true)
|
setKioskTrackCurrentUser(cc.kiosk_track_current_user ?? true)
|
||||||
setKioskHeartbeatIntervalSec(cc.kiosk_heartbeat_interval_sec ?? 30)
|
setKioskHeartbeatIntervalSec(cc.kiosk_heartbeat_interval_sec ?? 30)
|
||||||
@@ -194,6 +197,7 @@ export function CompanySettingsPage() {
|
|||||||
two_stage_min_days: twoStageMinDays,
|
two_stage_min_days: twoStageMinDays,
|
||||||
overtime_overdraft_allowed: fzaOverdraftAllowed,
|
overtime_overdraft_allowed: fzaOverdraftAllowed,
|
||||||
overtime_warning_threshold_hours: fzaWarningThreshold,
|
overtime_warning_threshold_hours: fzaWarningThreshold,
|
||||||
|
payout_request_enabled: payoutRequestEnabled,
|
||||||
kiosk_require_approval: kioskRequireApproval,
|
kiosk_require_approval: kioskRequireApproval,
|
||||||
kiosk_track_current_user: kioskTrackCurrentUser,
|
kiosk_track_current_user: kioskTrackCurrentUser,
|
||||||
kiosk_heartbeat_interval_sec: kioskHeartbeatIntervalSec,
|
kiosk_heartbeat_interval_sec: kioskHeartbeatIntervalSec,
|
||||||
@@ -797,6 +801,31 @@ export function CompanySettingsPage() {
|
|||||||
className="w-20 border border-gray-300 rounded-lg px-3 py-1.5 text-sm text-right disabled:bg-gray-50 disabled:cursor-not-allowed"
|
className="w-20 border border-gray-300 rounded-lg px-3 py-1.5 text-sm text-right disabled:bg-gray-50 disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Auszahlungs-Anträge durch Mitarbeiter */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-800">Auszahlungs-Anträge durch Mitarbeiter</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
Mitarbeiter können Überstunden-Auszahlungen selbst beantragen; HR genehmigt oder lehnt ab.
|
||||||
|
Wenn deaktiviert, bucht nur HR direkt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isAdmin}
|
||||||
|
onClick={() => setPayoutRequestEnabled(v => !v)}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
|
payoutRequestEnabled ? 'bg-blue-600' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition-transform duration-200 ${
|
||||||
|
payoutRequestEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Überstunden-Konto-Regeln */}
|
{/* Überstunden-Konto-Regeln */}
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ function formatPeriod(year: number | null, month: number | null): string {
|
|||||||
return `${MONTH_NAMES[month - 1]} ${year}`
|
return `${MONTH_NAMES[month - 1]} ${year}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||||
|
requested: { label: 'Beantragt', cls: 'bg-amber-100 text-amber-700' },
|
||||||
|
approved: { label: 'Genehmigt', cls: 'bg-green-100 text-green-700' },
|
||||||
|
rejected: { label: 'Abgelehnt', cls: 'bg-red-100 text-red-700' },
|
||||||
|
cancelled: { label: 'Zurückgezogen', cls: 'bg-gray-100 text-gray-500' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const b = STATUS_BADGE[status] ?? { label: status, cls: 'bg-gray-100 text-gray-500' }
|
||||||
|
return <span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${b.cls}`}>{b.label}</span>
|
||||||
|
}
|
||||||
|
|
||||||
export function HoursPayoutPage() {
|
export function HoursPayoutPage() {
|
||||||
const [me, setMe] = useState<Me | null>(null)
|
const [me, setMe] = useState<Me | null>(null)
|
||||||
const [users, setUsers] = useState<UserItem[]>([])
|
const [users, setUsers] = useState<UserItem[]>([])
|
||||||
@@ -195,6 +207,29 @@ export function HoursPayoutPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleApprove(payout: HoursPayoutOut) {
|
||||||
|
if (!confirm(`Auszahlung über ${Number(payout.hours).toFixed(2)} h für ${payout.user_name} genehmigen? Der Saldo wird abgezogen.`)) return
|
||||||
|
try {
|
||||||
|
const updated = await api.post<HoursPayoutOut>(`/hr/payouts/${payout.id}/approve`, {})
|
||||||
|
setPayouts(prev => prev.map(p => (p.id === payout.id ? updated : p)))
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(e instanceof Error ? e.message : 'Fehler beim Genehmigen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReject(payout: HoursPayoutOut) {
|
||||||
|
const reason = prompt('Grund der Ablehnung (optional):')
|
||||||
|
if (reason === null) return // Abbrechen
|
||||||
|
try {
|
||||||
|
const updated = await api.post<HoursPayoutOut>(`/hr/payouts/${payout.id}/reject`, {
|
||||||
|
rejection_reason: reason.trim() || null,
|
||||||
|
})
|
||||||
|
setPayouts(prev => prev.map(p => (p.id === payout.id ? updated : p)))
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(e instanceof Error ? e.message : 'Fehler beim Ablehnen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (pageLoading) return (
|
if (pageLoading) return (
|
||||||
<div className='min-h-screen bg-gray-50 flex items-center justify-center'><Spinner /></div>
|
<div className='min-h-screen bg-gray-50 flex items-center justify-center'><Spinner /></div>
|
||||||
)
|
)
|
||||||
@@ -286,7 +321,7 @@ export function HoursPayoutPage() {
|
|||||||
<table className='w-full text-sm'>
|
<table className='w-full text-sm'>
|
||||||
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'>
|
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'>
|
||||||
<tr>
|
<tr>
|
||||||
{['Mitarbeiter', 'Stunden', 'Abrechnungsmonat', 'Notiz', 'Angelegt von', 'Datum', 'Aktion'].map(h => (
|
{['Mitarbeiter', 'Stunden', 'Status', 'Abrechnungsmonat', 'Notiz', 'Angelegt von', 'Datum', 'Aktion'].map(h => (
|
||||||
<th key={h} className='px-4 py-3 text-left font-medium'>{h}</th>
|
<th key={h} className='px-4 py-3 text-left font-medium'>{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -300,6 +335,14 @@ export function HoursPayoutPage() {
|
|||||||
{Number(payout.hours).toFixed(2)} h
|
{Number(payout.hours).toFixed(2)} h
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className='px-4 py-3'>
|
||||||
|
<StatusBadge status={payout.status} />
|
||||||
|
{payout.status === 'rejected' && payout.rejection_reason && (
|
||||||
|
<div className='text-xs text-gray-400 mt-0.5 max-w-[10rem] truncate' title={payout.rejection_reason}>
|
||||||
|
{payout.rejection_reason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className='px-4 py-3 text-gray-600'>
|
<td className='px-4 py-3 text-gray-600'>
|
||||||
{formatPeriod(payout.period_year, payout.period_month)}
|
{formatPeriod(payout.period_year, payout.period_month)}
|
||||||
</td>
|
</td>
|
||||||
@@ -313,6 +356,24 @@ export function HoursPayoutPage() {
|
|||||||
})}
|
})}
|
||||||
</td>
|
</td>
|
||||||
<td className='px-4 py-3'>
|
<td className='px-4 py-3'>
|
||||||
|
{payout.status === 'requested' ? (
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprove(payout)}
|
||||||
|
className='text-xs text-green-600 hover:underline font-medium'
|
||||||
|
title='Genehmigen'
|
||||||
|
>
|
||||||
|
✓ Genehmigen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleReject(payout)}
|
||||||
|
className='text-xs text-red-500 hover:underline'
|
||||||
|
title='Ablehnen'
|
||||||
|
>
|
||||||
|
✗ Ablehnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : payout.status === 'approved' ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(payout)}
|
onClick={() => handleDelete(payout)}
|
||||||
className='text-xs text-red-500 hover:underline'
|
className='text-xs text-red-500 hover:underline'
|
||||||
@@ -320,12 +381,15 @@ export function HoursPayoutPage() {
|
|||||||
>
|
>
|
||||||
🗑️ Stornieren
|
🗑️ Stornieren
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className='text-xs text-gray-300'>—</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{payouts.length === 0 && (
|
{payouts.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className='px-4 py-10 text-center text-gray-400'>
|
<td colSpan={8} className='px-4 py-10 text-center text-gray-400'>
|
||||||
Keine Auszahlungen gefunden.
|
Keine Auszahlungen gefunden.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface HoursPayoutCreate {
|
|||||||
note?: string | null
|
note?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PayoutStatus = 'requested' | 'approved' | 'rejected' | 'cancelled'
|
||||||
|
|
||||||
export interface HoursPayoutOut {
|
export interface HoursPayoutOut {
|
||||||
id: string
|
id: string
|
||||||
company_id: string
|
company_id: string
|
||||||
@@ -15,8 +17,13 @@ export interface HoursPayoutOut {
|
|||||||
period_year: number | null
|
period_year: number | null
|
||||||
period_month: number | null
|
period_month: number | null
|
||||||
note: string | null
|
note: string | null
|
||||||
|
status: PayoutStatus
|
||||||
|
rejection_reason: string | null
|
||||||
created_by: string
|
created_by: string
|
||||||
created_by_name: string
|
created_by_name: string
|
||||||
|
decided_by: string | null
|
||||||
|
decided_by_name: string
|
||||||
|
decided_at: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user