feat(overtime): Auszahlungs-Anträge durch Mitarbeiter (opt-in pro Firma)
Security Audit / Node.js Dependency Audit (push) Has been cancelled
Security Audit / Python Dependency Audit (push) Has been cancelled

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:
2026-07-08 22:43:26 +02:00
co-authored by Claude Opus 4.8
parent 2c57c36abd
commit 65596e80a4
15 changed files with 1134 additions and 96 deletions
+460
View File
@@ -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 ++++++++++----
---
+13
View File
@@ -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 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
+2
View File
@@ -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(
+16 -1
View File
@@ -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")
+199 -85
View File
@@ -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()
+2
View File
@@ -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)
+17
View File
@@ -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")
+87
View File
@@ -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
+39
View File
@@ -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>
)
}
+3
View File
@@ -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({}) }}
+30 -1
View File
@@ -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 */}
+73 -9
View File
@@ -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,19 +356,40 @@ export function HoursPayoutPage() {
})} })}
</td> </td>
<td className='px-4 py-3'> <td className='px-4 py-3'>
<button {payout.status === 'requested' ? (
onClick={() => handleDelete(payout)} <div className='flex gap-2'>
className='text-xs text-red-500 hover:underline' <button
title='Stornieren' onClick={() => handleApprove(payout)}
> className='text-xs text-green-600 hover:underline font-medium'
🗑 Stornieren title='Genehmigen'
</button> >
Genehmigen
</button>
<button
onClick={() => handleReject(payout)}
className='text-xs text-red-500 hover:underline'
title='Ablehnen'
>
Ablehnen
</button>
</div>
) : payout.status === 'approved' ? (
<button
onClick={() => handleDelete(payout)}
className='text-xs text-red-500 hover:underline'
title='Stornieren'
>
🗑 Stornieren
</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>
+7
View File
@@ -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
} }