From eb8dace0fd37cfa008e23a5cacbe04c56aa11dc5 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 8 Jul 2026 22:43:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(overtime):=20Auszahlungs-Antr=C3=A4ge=20du?= =?UTF-8?q?rch=20Mitarbeiter=20(opt-in=20pro=20Firma)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DEVLOG.md | 460 ++++++++++++++++++ backend/DEVLOG.md | 13 + backend/app/models/company.py | 2 + backend/app/models/hours_payout.py | 17 +- backend/app/routers/hours_payouts.py | 284 +++++++---- backend/app/schemas/company.py | 2 + backend/app/schemas/hours_payout.py | 17 + .../versions/0042_payout_requests.py | 36 ++ backend/tests/test_hours_payouts.py | 87 ++++ frontend/DEVLOG.md | 39 ++ frontend/src/components/PayoutRequestCard.tsx | 150 ++++++ frontend/src/pages/AbsencesPage.tsx | 3 + frontend/src/pages/CompanySettingsPage.tsx | 31 +- frontend/src/pages/HoursPayoutPage.tsx | 82 +++- frontend/src/types/hoursPayout.ts | 7 + 15 files changed, 1134 insertions(+), 96 deletions(-) create mode 100644 backend/DEVLOG.md create mode 100644 backend/migrations/versions/0042_payout_requests.py create mode 100644 backend/tests/test_hours_payouts.py create mode 100644 frontend/src/components/PayoutRequestCard.tsx diff --git a/DEVLOG.md b/DEVLOG.md index b6bea08..edf3ee7 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2176,3 +2176,463 @@ Keine Commits in dieser Session. - 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 ++++++++++---- + +--- diff --git a/backend/DEVLOG.md b/backend/DEVLOG.md new file mode 100644 index 0000000..38398fe --- /dev/null +++ b/backend/DEVLOG.md @@ -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 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- diff --git a/backend/app/models/company.py b/backend/app/models/company.py index e0e6106..d78d6f9 100644 --- a/backend/app/models/company.py +++ b/backend/app/models/company.py @@ -93,6 +93,8 @@ class Company(Base): 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_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 users: Mapped[list["User"]] = relationship( diff --git a/backend/app/models/hours_payout.py b/backend/app/models/hours_payout.py index 9ad2838..61f5fd3 100644 --- a/backend/app/models/hours_payout.py +++ b/backend/app/models/hours_payout.py @@ -1,10 +1,11 @@ """Stunden-Auszahlung: HR/Admin weist Überstunden-Stunden zur Lohn-Auszahlung an.""" +import enum import uuid from datetime import datetime from decimal import Decimal 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.orm import Mapped, mapped_column, relationship @@ -15,6 +16,13 @@ if TYPE_CHECKING: 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): """Ein Auszahlungsvorgang für Überstunden-Stunden.""" __tablename__ = "hours_payouts" @@ -32,14 +40,21 @@ class HoursPayout(Base): period_year: Mapped[int | None] = mapped_column(Integer) # Abrechnungsmonat Jahr period_month: Mapped[int | None] = mapped_column(Integer) # Abrechnungsmonat Monat 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( UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), 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( DateTime(timezone=True), server_default=func.now(), index=True ) user: Mapped["User"] = relationship("User", foreign_keys=[user_id], 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") diff --git a/backend/app/routers/hours_payouts.py b/backend/app/routers/hours_payouts.py index fa2a461..6ffc823 100644 --- a/backend/app/routers/hours_payouts.py +++ b/backend/app/routers/hours_payouts.py @@ -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 uuid import UUID @@ -9,10 +11,17 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.dependencies import get_client_ip, require_role 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.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"]) @@ -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) -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.user_name = ( - f"{user.first_name} {user.last_name}" if user else str(payout.user_id) - ) - out.created_by_name = ( - f"{creator.first_name} {creator.last_name}" if creator else str(payout.created_by) - ) + user = await db.get(User, 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 = 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 +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 ─────────────────────────────────────────────────────────── @router.get("/hr/payouts", response_model=HoursPayoutListResponse) @@ -38,13 +60,11 @@ async def list_payouts( user_id: UUID | None = Query(None), year: int | None = Query(None, ge=2000, le=2100), month: int | None = Query(None, ge=1, le=12), + status: str | None = Query(None), current_user: User = require_role(*_all_roles), db: AsyncSession = Depends(get_db), ): - """Alle Auszahlungen der eigenen Firma, optional gefiltert nach Mitarbeiter / Monat. - EMPLOYEE und MANAGER sehen ausschließlich ihre eigenen Auszahlungen. - """ - # Employees und Manager sehen nur ihre eigenen Daten – Query-Param wird ignoriert + """Auszahlungen der eigenen Firma, optional gefiltert. EMPLOYEE/MANAGER sehen nur eigene.""" if current_user.role not in _hr_roles: user_id = current_user.id @@ -55,24 +75,18 @@ async def list_payouts( filters.append(HoursPayout.period_year == year) if month is not None: filters.append(HoursPayout.period_month == month) + if status is not None: + filters.append(HoursPayout.status == status) - total_count = await db.scalar( - select(func.count()).select_from(HoursPayout).where(*filters) - ) + total_count = await db.scalar(select(func.count()).select_from(HoursPayout).where(*filters)) rows = list(await db.scalars( select(HoursPayout).where(*filters).order_by(HoursPayout.created_at.desc()) )) - - 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)) - + result = [await _build_out(p, db) for p in rows] 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) async def create_payout( @@ -81,36 +95,15 @@ async def create_payout( current_user: User = require_role(*_hr_roles), db: AsyncSession = Depends(get_db), ): - """Neue Auszahlung anlegen – reduziert sofort den Überstunden-Saldo.""" - # Ziel-User prüfen + """HR bucht Auszahlung direkt (sofort genehmigt) – reduziert sofort den Saldo.""" target = await db.get(User, data.user_id) if not target or target.company_id != current_user.company_id: 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)) - if ob.available_hours < hours: - # Wir blockieren nicht – Auszahlung trotzdem buchen (wie FZA mit overdraft) - pass + ob = await _get_or_create_balance(data.user_id, current_user.company_id, db) + ob.taken_hours += hours # Überziehung erlaubt (kein Hard-Block, wie FZA) - # Saldo anpassen - ob.taken_hours += hours - - # Auszahlungs-Datensatz anlegen payout = HoursPayout( company_id=current_user.company_id, user_id=data.user_id, @@ -118,36 +111,165 @@ async def create_payout( period_year=data.period_year, period_month=data.period_month, note=data.note, + status=PayoutStatus.APPROVED.value, created_by=current_user.id, + decided_by=current_user.id, + decided_at=datetime.utcnow(), ) db.add(payout) - await db.flush() # payout.id erzeugen + await db.flush() - # AuditLog db.add(AuditLog( - company_id=current_user.company_id, - user_id=current_user.id, - action="hours_payout_created", - entity_type="hours_payout", - entity_id=payout.id, + company_id=current_user.company_id, user_id=current_user.id, + action="hours_payout_created", entity_type="hours_payout", entity_id=payout.id, new_value={ - "user_id": str(data.user_id), - "hours": str(hours), - "period_year": data.period_year, - "period_month": data.period_month, - "note": data.note, + "user_id": str(data.user_id), "hours": str(hours), + "period_year": data.period_year, "period_month": data.period_month, "note": data.note, }, ip=get_client_ip(request), )) - await db.commit() await db.refresh(payout) - - creator = await db.get(User, payout.created_by) - return _build_out(payout, target, creator) + return await _build_out(payout, db) -# ── 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) async def delete_payout( @@ -156,34 +278,26 @@ async def delete_payout( current_user: User = require_role(*_hr_roles), 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) if payout is None or payout.company_id != current_user.company_id: raise HTTPException(404, "Auszahlung nicht gefunden") - # OvertimeBalance laden und Stunden zurückbuchen - ob = await db.scalar( - select(OvertimeBalance).where(OvertimeBalance.user_id == payout.user_id) - ) - if ob is not None: - ob.taken_hours = max(Decimal("0"), ob.taken_hours - payout.hours) + # Nur genehmigte/gebuchte Auszahlungen haben den Saldo belastet → zurückbuchen + if payout.status == PayoutStatus.APPROVED.value: + ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == payout.user_id)) + if ob is not None: + ob.taken_hours = max(Decimal("0"), ob.taken_hours - payout.hours) - # AuditLog db.add(AuditLog( - company_id=current_user.company_id, - user_id=current_user.id, - action="hours_payout_deleted", - entity_type="hours_payout", - entity_id=payout.id, + company_id=current_user.company_id, user_id=current_user.id, + action="hours_payout_deleted", entity_type="hours_payout", entity_id=payout.id, old_value={ - "user_id": str(payout.user_id), - "hours": str(payout.hours), - "period_year": payout.period_year, - "period_month": payout.period_month, - "note": payout.note, + "user_id": str(payout.user_id), "hours": str(payout.hours), + "status": payout.status, + "period_year": payout.period_year, "period_month": payout.period_month, "note": payout.note, }, ip=get_client_ip(request), )) - await db.delete(payout) await db.commit() diff --git a/backend/app/schemas/company.py b/backend/app/schemas/company.py index fbeb937..eda28ed 100644 --- a/backend/app/schemas/company.py +++ b/backend/app/schemas/company.py @@ -43,6 +43,7 @@ class CompanyOut(BaseModel): overtime_expiry_month: int = 3 overtime_expiry_day: int = 31 overtime_max_carryover_hours: int | None = None + payout_request_enabled: bool = False kiosk_require_approval: bool = True kiosk_track_current_user: bool = True 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_day: int | None = Field(None, ge=1, le=31) 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_track_current_user: bool | None = None kiosk_heartbeat_interval_sec: int | None = Field(None, ge=10, le=120) diff --git a/backend/app/schemas/hours_payout.py b/backend/app/schemas/hours_payout.py index 862670c..e3b2527 100644 --- a/backend/app/schemas/hours_payout.py +++ b/backend/app/schemas/hours_payout.py @@ -12,6 +12,18 @@ class HoursPayoutCreate(BaseModel): 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): model_config = {"from_attributes": True} @@ -23,8 +35,13 @@ class HoursPayoutOut(BaseModel): period_year: int | None period_month: int | None note: str | None + status: str = "approved" + rejection_reason: str | None = None created_by: uuid.UUID 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 diff --git a/backend/migrations/versions/0042_payout_requests.py b/backend/migrations/versions/0042_payout_requests.py new file mode 100644 index 0000000..751bd03 --- /dev/null +++ b/backend/migrations/versions/0042_payout_requests.py @@ -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") diff --git a/backend/tests/test_hours_payouts.py b/backend/tests/test_hours_payouts.py new file mode 100644 index 0000000..94afe7e --- /dev/null +++ b/backend/tests/test_hours_payouts.py @@ -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 diff --git a/frontend/DEVLOG.md b/frontend/DEVLOG.md index 814aefe..aa4d5f3 100644 --- a/frontend/DEVLOG.md +++ b/frontend/DEVLOG.md @@ -620,3 +620,42 @@ Keine Commits in dieser Session. - 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 ++-- + +--- diff --git a/frontend/src/components/PayoutRequestCard.tsx b/frontend/src/components/PayoutRequestCard.tsx new file mode 100644 index 0000000..e0d5b5f --- /dev/null +++ b/frontend/src/components/PayoutRequestCard.tsx @@ -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 = { + 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(null) + const [rows, setRows] = useState([]) + const [open, setOpen] = useState(false) + const [hours, setHours] = useState(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('/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('/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(`/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 ( +
+
+

Auszahlungs-Anträge

+ +
+ + {open && ( +
+ + {overBudget && ( +

⚠️ Überschreitet dein verfügbares Guthaben ({availableHours.toFixed(1)} h) – Antrag ist trotzdem möglich.

+ )} +