diff --git a/DEVLOG.md b/DEVLOG.md index 620b46b..8db2035 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1162,3 +1162,26 @@ Keine Commits in dieser Session. - frontend/src/pages/mobile/MobilePage.tsx | 17 +- --- +## 2026-05-24 23:50 – 23:54 (3m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- 0ba16bb fix: ternäre Button-Kette in MobileStampScreen korrigiert +- c8804ef feat: Admin-Toggle für mobile Zeiterfassung + +### Geänderte Dateien +- frontend/src/pages/mobile/MobileStampScreen.tsx | 2 -- + +--- +## 2026-05-24 23:55 – 23:55 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- frontend/src/pages/mobile/MobileStampScreen.tsx | 2 -- + +--- diff --git a/backend/app/models/company.py b/backend/app/models/company.py index 7107744..b55b336 100644 --- a/backend/app/models/company.py +++ b/backend/app/models/company.py @@ -52,6 +52,10 @@ class Company(Base): # Mobile-Konfiguration mobile_stamping_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + # Freizeitausgleich-Konfiguration + overtime_overdraft_allowed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + overtime_warning_threshold_hours: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # Relationships users: Mapped[list["User"]] = relationship("User", back_populates="company", lazy="noload") departments: Mapped[list["Department"]] = relationship("Department", back_populates="company", lazy="noload") diff --git a/backend/app/routers/absences.py b/backend/app/routers/absences.py index 23105a3..8ac1170 100644 --- a/backend/app/routers/absences.py +++ b/backend/app/routers/absences.py @@ -278,26 +278,36 @@ async def get_absence( return AbsenceOut.model_validate(absence) -@router.delete("/absences/{absence_id}", status_code=204) +@router.delete("/absences/{absence_id}", response_model=AbsenceOut) async def cancel_absence( absence_id: UUID, current_user: CurrentUser, db: AsyncSession = Depends(get_db), ): - """Eigenen ausstehenden Antrag stornieren.""" - await absence_service.cancel_absence(absence_id, current_user, db) + """Antrag stornieren. + Eigene PENDING-Anträge: alle Rollen. + APPROVED-Anträge: nur HR/COMPANY_ADMIN/SUPER_ADMIN (mit Rückbuchung von FZA-Stunden). + """ + absence = await absence_service.cancel_absence(absence_id, current_user, db) await db.commit() + return AbsenceOut.model_validate(absence) -@router.post("/absences/{absence_id}/approve", response_model=AbsenceOut) +class AbsenceApproveOut(AbsenceOut): + warnings: list[str] = [] + + +@router.post("/absences/{absence_id}/approve", response_model=AbsenceApproveOut) async def approve_absence( absence_id: UUID, current_user: User = require_role(*_manager_roles), db: AsyncSession = Depends(get_db), ): - absence = await absence_service.approve_absence(absence_id, current_user, db) + absence, warnings = await absence_service.approve_absence(absence_id, current_user, db) await db.commit() - return AbsenceOut.model_validate(absence) + out = AbsenceApproveOut.model_validate(absence) + out.warnings = warnings + return out @router.post("/absences/{absence_id}/reject", response_model=AbsenceOut) diff --git a/backend/app/schemas/company.py b/backend/app/schemas/company.py index b820083..f40229b 100644 --- a/backend/app/schemas/company.py +++ b/backend/app/schemas/company.py @@ -22,6 +22,8 @@ class CompanyOut(BaseModel): personnel_number_mode: PersonnelNumberModeT = "manual" personnel_number_next: int = 1 mobile_stamping_enabled: bool = True + overtime_overdraft_allowed: bool = True + overtime_warning_threshold_hours: int = 0 class CompanyUpdate(BaseModel): @@ -32,6 +34,8 @@ class CompanyUpdate(BaseModel): personnel_number_mode: PersonnelNumberModeT | None = None personnel_number_next: int | None = Field(None, ge=1) mobile_stamping_enabled: bool | None = None + overtime_overdraft_allowed: bool | None = None + overtime_warning_threshold_hours: int | None = Field(None, ge=0) class DepartmentOut(BaseModel): diff --git a/backend/app/services/absence_service.py b/backend/app/services/absence_service.py index 0ccce80..b2c6a11 100644 --- a/backend/app/services/absence_service.py +++ b/backend/app/services/absence_service.py @@ -298,10 +298,28 @@ class AbsenceService: absence = await db.get(Absence, absence_id) if absence is None: raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.") - if absence.user_id != current_user.id: + + is_admin = current_user.role in (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN) + + if absence.user_id != current_user.id and not is_admin: raise HTTPException(status_code=403, detail="Nur eigene Anträge können storniert werden.") - if absence.status != AbsenceStatus.PENDING: - raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können gelöscht werden.") + + if absence.status == AbsenceStatus.APPROVED: + if not is_admin: + raise HTTPException( + status_code=409, + detail="Genehmigte Anträge können nur von HR/Admin storniert werden." + ) + # Überstunden zurückbuchen wenn Freizeitausgleich + absence_type = await db.get(AbsenceType, absence.type_id) + if absence_type and absence_type.affects_overtime_balance: + await self._refund_overtime(absence.user_id, absence.working_days, db) + elif absence.status != AbsenceStatus.PENDING: + raise HTTPException( + status_code=409, + detail="Nur ausstehende oder genehmigte Anträge können storniert werden." + ) + absence.status = AbsenceStatus.CANCELLED # Audit-Log (DSGVO) @@ -357,8 +375,9 @@ class AbsenceService: await self._deduct_vacation(absence.user_id, absence.start_date.year, int(absence.working_days), db) # Überstundenkonto abziehen wenn Freizeitausgleich + fza_warnings: list[str] = [] if absence_type and absence_type.affects_overtime_balance: - await self._deduct_overtime(absence.user_id, absence.working_days, db) + fza_warnings = await self._deduct_overtime(absence.user_id, absence.working_days, db) # Audit-Log (DSGVO) db.add(AuditLog( @@ -383,7 +402,7 @@ class AbsenceService: from app.services.caldav_service import caldav_service asyncio.create_task(caldav_service.sync_approved(absence, db)) - return absence + return absence, fza_warnings async def reject_absence( self, absence_id: UUID, data: AbsenceReject, current_user: User, db: AsyncSession @@ -562,11 +581,8 @@ class AbsenceService: balance = await self._get_or_create_balance(user_id, year, db) balance.used_days += days - async def _deduct_overtime( - self, user_id: UUID, working_days: float, db: AsyncSession - ) -> None: - """Zieht working_days × tägliche Stunden vom Überstundenkonto ab.""" - # Stunden/Tag aus Arbeitsplan ermitteln (Fallback: 8h) + async def _calc_daily_hours(self, user_id: UUID, db: AsyncSession) -> Decimal: + """Tägliche Soll-Stunden aus Arbeitsplan ermitteln (Fallback: 8h).""" user = await db.get(User, user_id) daily_hours = Decimal("8.00") if user and user.work_schedule_id: @@ -579,20 +595,64 @@ class AbsenceService: ) if working_days_in_week > 0: daily_hours = schedule.weekly_hours / Decimal(working_days_in_week) + return daily_hours + async def _deduct_overtime( + self, user_id: UUID, working_days: float, db: AsyncSession + ) -> list[str]: + """Zieht working_days × tägliche Stunden vom Überstundenkonto ab. + Gibt Warnungen zurück. Wirft HTTPException wenn Überziehen verboten ist.""" + user = await db.get(User, user_id) + daily_hours = await self._calc_daily_hours(user_id, db) hours_to_deduct = Decimal(str(working_days)) * daily_hours ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id)) if ob is None: - # Erstelle Eintrag mit 0 Überstunden — taken_hours kann negativ werden company_id = user.company_id if user else None if not company_id: - return + return [] ob = OvertimeBalance(user_id=user_id, company_id=company_id) db.add(ob) await db.flush() + # Firmen-Konfiguration für Überziehen laden + company = await db.get(Company, ob.company_id) + overdraft_allowed = company.overtime_overdraft_allowed if company else True + warning_threshold = Decimal(str(company.overtime_warning_threshold_hours if company else 0)) + + available = ob.available_hours + warnings: list[str] = [] + + if available < hours_to_deduct and not overdraft_allowed: + raise HTTPException( + status_code=422, + detail=( + f"Nicht genug Überstunden für Freizeitausgleich. " + f"Verfügbar: {float(available):.1f}h, benötigt: {float(hours_to_deduct):.1f}h." + ), + ) + + after_deduction = available - hours_to_deduct + if warning_threshold > 0 and after_deduction < warning_threshold: + sign = "-" if after_deduction < 0 else "" + warnings.append( + f"Überstundenkonto sinkt unter die Warnschwelle " + f"({float(warning_threshold):.0f}h). Verbleibend: {sign}{abs(float(after_deduction)):.1f}h." + ) + ob.taken_hours += hours_to_deduct + return warnings + + async def _refund_overtime( + self, user_id: UUID, working_days: float, db: AsyncSession + ) -> None: + """Rückbuchung von Freizeitausgleichs-Stunden (z.B. bei Stornierung).""" + daily_hours = await self._calc_daily_hours(user_id, db) + hours_to_refund = Decimal(str(working_days)) * daily_hours + + ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id)) + if ob is not None: + ob.taken_hours = max(Decimal("0"), ob.taken_hours - hours_to_refund) async def _get_holiday_dates( self, company_id: UUID, year: int, db: AsyncSession diff --git a/backend/app/services/time_service.py b/backend/app/services/time_service.py index 4df918b..321a538 100644 --- a/backend/app/services/time_service.py +++ b/backend/app/services/time_service.py @@ -336,6 +336,17 @@ class TimeService: entry.status = EntryStatus.APPROVED entry.approved_by = current_user.id entry.updated_at = datetime.now(timezone.utc) + + # Überstundenkonto neuberechnen (Gap-2-Fix) + from app.services.report_service import _recalculate_overtime_balance + schedule = await db.scalar( + select(WorkSchedule) + .where(WorkSchedule.company_id == entry_user.company_id) + .order_by(WorkSchedule.valid_from.desc()) + .limit(1) + ) + await _recalculate_overtime_balance(entry_user, schedule, db) + return entry async def reject_entry( diff --git a/backend/migrations/versions/0028_overtime_fza_config.py b/backend/migrations/versions/0028_overtime_fza_config.py new file mode 100644 index 0000000..fbfe839 --- /dev/null +++ b/backend/migrations/versions/0028_overtime_fza_config.py @@ -0,0 +1,47 @@ +"""Freizeitausgleich-Konfiguration: Überstunden-Überziehen und Warnschwelle + +Revision ID: 0028 +Revises: 0027 +Create Date: 2026-05-25 + +Neue Felder in companies: + overtime_overdraft_allowed BOOLEAN DEFAULT TRUE + - Steuert ob das Überstundenkonto ins Minus gezogen werden darf. + - Default TRUE: bestehende Firmen behalten bisheriges Verhalten (kein Block). + overtime_warning_threshold_hours INTEGER DEFAULT 0 + - Warnung wenn Konto nach Abzug unter diesen Wert fällt. + - Default 0: keine Warnung (bestehende Firmen unverändert). +""" +from alembic import op +import sqlalchemy as sa + +revision = "0028" +down_revision = "0027" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "companies", + sa.Column( + "overtime_overdraft_allowed", + sa.Boolean(), + nullable=False, + server_default=sa.text("true"), + ), + ) + op.add_column( + "companies", + sa.Column( + "overtime_warning_threshold_hours", + sa.Integer(), + nullable=False, + server_default=sa.text("0"), + ), + ) + + +def downgrade() -> None: + op.drop_column("companies", "overtime_warning_threshold_hours") + op.drop_column("companies", "overtime_overdraft_allowed") diff --git a/backend/tests/test_fza.py b/backend/tests/test_fza.py new file mode 100644 index 0000000..2c010c9 --- /dev/null +++ b/backend/tests/test_fza.py @@ -0,0 +1,382 @@ +"""Tests für Freizeitausgleich (FZA) Lücken-Fixes. + +Gap-1: Überstunden-Überziehschutz (configurable: allow/block + warning threshold) +Gap-2: Überstundenkonto wird bei Zeiteintrag-Genehmigung neu berechnet +Gap-3: Stornierung eines genehmigten FZA-Antrags bucht taken_hours zurück +""" +import pytest +import pytest_asyncio +from datetime import date, time +from decimal import Decimal +from httpx import AsyncClient +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.absence import Absence, AbsenceStatus +from app.models.absence_type import AbsenceType, AbsenceCategory +from app.models.company import Company +from app.models.overtime_balance import OvertimeBalance +from app.models.time_entry import TimeEntry, EntryStatus +from app.models.user import User, UserRole + + +# ───────────────────────────────────────────────────────────────────────────── +# Fixtures +# ───────────────────────────────────────────────────────────────────────────── + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def fza_company(client: AsyncClient): + """Eigene Company für FZA-Tests.""" + resp = await client.post("/api/v1/auth/register", json={ + "company_name": "FZA Test GmbH", + "first_name": "FZA", + "last_name": "Admin", + "email": "admin@fza-test.de", + "password": "Secret123", + }) + assert resp.status_code == 201, resp.text + tokens = resp.json() + me = await client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + return {"tokens": tokens, "user": me.json()} + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def fza_admin_headers(fza_company): + return {"Authorization": f"Bearer {fza_company['tokens']['access_token']}"} + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def fza_hr_headers(client: AsyncClient, fza_admin_headers): + """HR-User der FZA Test GmbH.""" + resp = await client.post("/api/v1/users/invite", json={ + "first_name": "HR", + "last_name": "Manager", + "email": "hr@fza-test.de", + "role": "HR", + "initial_password": "Secret123", + }, headers=fza_admin_headers) + assert resp.status_code == 201, resp.text + login = await client.post("/api/v1/auth/login", json={ + "email": "hr@fza-test.de", + "password": "Secret123", + }) + assert login.status_code == 200, login.text + return {"Authorization": f"Bearer {login.json()['access_token']}"} + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def fza_employee_headers(client: AsyncClient, fza_admin_headers): + """Mitarbeiter der FZA Test GmbH.""" + resp = await client.post("/api/v1/users/invite", json={ + "first_name": "Franz", + "last_name": "Feierabend", + "email": "franz@fza-test.de", + "role": "EMPLOYEE", + "initial_password": "Secret123", + }, headers=fza_admin_headers) + assert resp.status_code == 201, resp.text + login = await client.post("/api/v1/auth/login", json={ + "email": "franz@fza-test.de", + "password": "Secret123", + }) + assert login.status_code == 200, login.text + return {"Authorization": f"Bearer {login.json()['access_token']}"} + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def fza_type_id(client: AsyncClient, fza_admin_headers): + """FZA-Abwesenheitstyp der Company erstellen.""" + resp = await client.post("/api/v1/absence-types/", json={ + "name": "Freizeitausgleich", + "category": "overtime_comp", + "color": "#f97316", + "requires_approval": True, + "deducts_vacation": False, + "affects_overtime_balance": True, + }, headers=fza_admin_headers) + assert resp.status_code == 201, resp.text + return resp.json()["id"] + + +async def _seed_overtime_balance( + db_session: AsyncSession, + admin_user: dict, + total_hours: float, +) -> None: + """Setzt total_hours in OvertimeBalance direkt (ohne API).""" + await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) + user_id = admin_user["id"] + company_id = admin_user["company_id"] + ob = await db_session.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(str(total_hours)), + ) + db_session.add(ob) + else: + ob.total_hours = Decimal(str(total_hours)) + ob.taken_hours = Decimal("0") + await db_session.flush() + + +# ───────────────────────────────────────────────────────────────────────────── +# Gap-1a: Überziehen erlaubt (default) – geht durch +# ───────────────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio(loop_scope="session") +async def test_fza_overdraft_allowed_by_default( + client: AsyncClient, + db_session: AsyncSession, + fza_company: dict, + fza_admin_headers: dict, + fza_hr_headers: dict, + fza_employee_headers: dict, + fza_type_id: str, +): + """Standardmäßig (overdraft_allowed=True) darf FZA auch bei leerem Konto genehmigt werden.""" + emp_me = (await client.get("/api/v1/auth/me", headers=fza_employee_headers)).json() + + # Überstundenkonto auf 4h setzen (1 Tag = 8h benötigt) + await _seed_overtime_balance(db_session, emp_me, total_hours=4.0) + await db_session.commit() + + # HR-User für Genehmigung holen + hr_me = (await client.get("/api/v1/auth/me", headers=fza_hr_headers)).json() + emp_id = emp_me["id"] + company_id = emp_me["company_id"] + + # Antrag als Mitarbeiter stellen + resp = await client.post("/api/v1/absences/", json={ + "type_id": fza_type_id, + "start_date": "2026-07-01", + "end_date": "2026-07-01", + }, headers=fza_employee_headers) + assert resp.status_code == 201, resp.text + absence_id = resp.json()["id"] + + # HR genehmigt – sollte trotz Überziehen funktionieren + resp2 = await client.post(f"/api/v1/absences/{absence_id}/approve", headers=fza_hr_headers) + assert resp2.status_code == 200, resp2.text + body = resp2.json() + assert body["status"] == "approved" + + +# ───────────────────────────────────────────────────────────────────────────── +# Gap-1b: Überziehen blockiert +# ───────────────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio(loop_scope="session") +async def test_fza_overdraft_blocked( + client: AsyncClient, + db_session: AsyncSession, + fza_company: dict, + fza_admin_headers: dict, + fza_hr_headers: dict, + fza_employee_headers: dict, + fza_type_id: str, +): + """Mit overtime_overdraft_allowed=False wird Genehmigung abgelehnt wenn Konto leer.""" + emp_me = (await client.get("/api/v1/auth/me", headers=fza_employee_headers)).json() + await _seed_overtime_balance(db_session, emp_me, total_hours=2.0) # nur 2h + await db_session.commit() + + # Firma: kein Überziehen erlauben + resp = await client.patch("/api/v1/companies/me", json={ + "overtime_overdraft_allowed": False, + }, headers=fza_admin_headers) + assert resp.status_code == 200, resp.text + + # Antrag für 1 Tag (= 8h) – mehr als verfügbar + resp2 = await client.post("/api/v1/absences/", json={ + "type_id": fza_type_id, + "start_date": "2026-07-02", + "end_date": "2026-07-02", + }, headers=fza_employee_headers) + assert resp2.status_code == 201, resp2.text + absence_id = resp2.json()["id"] + + # Genehmigung muss fehlschlagen + resp3 = await client.post(f"/api/v1/absences/{absence_id}/approve", headers=fza_hr_headers) + assert resp3.status_code == 422, resp3.text + assert "Nicht genug Überstunden" in resp3.json()["detail"] + + # Reset + await client.patch("/api/v1/companies/me", json={ + "overtime_overdraft_allowed": True, + }, headers=fza_admin_headers) + + +# ───────────────────────────────────────────────────────────────────────────── +# Gap-1c: Warnschwelle +# ───────────────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio(loop_scope="session") +async def test_fza_warning_threshold( + client: AsyncClient, + db_session: AsyncSession, + fza_company: dict, + fza_admin_headers: dict, + fza_hr_headers: dict, + fza_employee_headers: dict, + fza_type_id: str, +): + """Warnschwelle: Genehmigung geht durch, aber warnings werden zurückgegeben.""" + emp_me = (await client.get("/api/v1/auth/me", headers=fza_employee_headers)).json() + await _seed_overtime_balance(db_session, emp_me, total_hours=16.0) + await db_session.commit() + + # Warnschwelle auf 10h setzen + await client.patch("/api/v1/companies/me", json={ + "overtime_warning_threshold_hours": 10, + }, headers=fza_admin_headers) + + # Antrag für 1 Tag (8h) → verbleibend 8h < 10h Schwelle → Warnung + resp = await client.post("/api/v1/absences/", json={ + "type_id": fza_type_id, + "start_date": "2026-07-03", + "end_date": "2026-07-03", + }, headers=fza_employee_headers) + assert resp.status_code == 201, resp.text + absence_id = resp.json()["id"] + + resp2 = await client.post(f"/api/v1/absences/{absence_id}/approve", headers=fza_hr_headers) + assert resp2.status_code == 200, resp2.text + body = resp2.json() + assert body["status"] == "approved" + assert len(body.get("warnings", [])) > 0, "Warnschwellen-Warnung erwartet" + assert "Warnschwelle" in body["warnings"][0] + + # Reset + await client.patch("/api/v1/companies/me", json={ + "overtime_warning_threshold_hours": 0, + }, headers=fza_admin_headers) + + +# ───────────────────────────────────────────────────────────────────────────── +# Gap-3: Stornierung genehmigter FZA → Rückbuchung +# ───────────────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio(loop_scope="session") +async def test_fza_cancel_approved_refunds( + client: AsyncClient, + db_session: AsyncSession, + fza_company: dict, + fza_admin_headers: dict, + fza_hr_headers: dict, + fza_employee_headers: dict, + fza_type_id: str, +): + """HR storniert genehmigten FZA → taken_hours werden zurückgebucht.""" + emp_me = (await client.get("/api/v1/auth/me", headers=fza_employee_headers)).json() + await _seed_overtime_balance(db_session, emp_me, total_hours=40.0) + await db_session.commit() + + # Antrag stellen + resp = await client.post("/api/v1/absences/", json={ + "type_id": fza_type_id, + "start_date": "2026-07-07", + "end_date": "2026-07-07", + }, headers=fza_employee_headers) + assert resp.status_code == 201, resp.text + absence_id = resp.json()["id"] + + # Genehmigen + r2 = await client.post(f"/api/v1/absences/{absence_id}/approve", headers=fza_hr_headers) + assert r2.status_code == 200, r2.text + + # OvertimeBalance: taken_hours sollte jetzt 8h sein + await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) + ob = await db_session.scalar( + select(OvertimeBalance).where(OvertimeBalance.user_id == emp_me["id"]) + ) + await db_session.refresh(ob) + taken_after_approve = float(ob.taken_hours) + assert taken_after_approve == pytest.approx(8.0, abs=0.1), f"Erwartet 8h, got {taken_after_approve}" + + # HR storniert + r3 = await client.delete(f"/api/v1/absences/{absence_id}", headers=fza_hr_headers) + assert r3.status_code == 200, r3.text + assert r3.json()["status"] == "cancelled" + + # Rückbuchung prüfen + await db_session.refresh(ob) + taken_after_cancel = float(ob.taken_hours) + assert taken_after_cancel == pytest.approx(0.0, abs=0.1), f"Rückbuchung fehlgeschlagen, got {taken_after_cancel}" + + +@pytest.mark.asyncio(loop_scope="session") +async def test_fza_employee_cannot_cancel_approved( + client: AsyncClient, + db_session: AsyncSession, + fza_admin_headers: dict, + fza_hr_headers: dict, + fza_employee_headers: dict, + fza_type_id: str, +): + """Mitarbeiter kann genehmigten FZA-Antrag nicht selbst stornieren.""" + emp_me = (await client.get("/api/v1/auth/me", headers=fza_employee_headers)).json() + await _seed_overtime_balance(db_session, emp_me, total_hours=40.0) + await db_session.commit() + + resp = await client.post("/api/v1/absences/", json={ + "type_id": fza_type_id, + "start_date": "2026-07-08", + "end_date": "2026-07-08", + }, headers=fza_employee_headers) + assert resp.status_code == 201, resp.text + absence_id = resp.json()["id"] + + # Genehmigen (durch HR) + r2 = await client.post(f"/api/v1/absences/{absence_id}/approve", headers=fza_hr_headers) + assert r2.status_code == 200, r2.text + + # Mitarbeiter versucht zu stornieren → 409 + r3 = await client.delete(f"/api/v1/absences/{absence_id}", headers=fza_employee_headers) + assert r3.status_code == 409, r3.text + assert "HR/Admin" in r3.json()["detail"] + + +# ───────────────────────────────────────────────────────────────────────────── +# Gap-2: Zeiteintrag-Genehmigung aktualisiert OvertimeBalance.total_hours +# ───────────────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio(loop_scope="session") +async def test_time_entry_approval_updates_overtime_balance( + client: AsyncClient, + db_session: AsyncSession, + fza_admin_headers: dict, + fza_hr_headers: dict, + fza_employee_headers: dict, +): + """Nach Genehmigung eines Zeiteintrags wird OvertimeBalance.total_hours neu berechnet.""" + emp_me = (await client.get("/api/v1/auth/me", headers=fza_employee_headers)).json() + hr_me = (await client.get("/api/v1/auth/me", headers=fza_hr_headers)).json() + + # Eintrag manuell anlegen (10h, 2h Überstunden bei 8h Soll) + resp = await client.post("/api/v1/time/entries", json={ + "date": "2026-06-01", + "start_time": "08:00", + "end_time": "18:00", + "break_minutes": 0, + }, headers=fza_employee_headers) + # Kann 201 oder 200 sein je nach Implementation + assert resp.status_code in (200, 201), resp.text + entry_id = resp.json()["id"] + + # Genehmigen (HR != EMPLOYEE, Self-Approval ist geblockt) + r2 = await client.post(f"/api/v1/time/entries/{entry_id}/approve", headers=fza_hr_headers) + assert r2.status_code == 200, r2.text + + # OvertimeBalance.last_calculated sollte jetzt gesetzt sein + await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) + ob = await db_session.scalar( + select(OvertimeBalance).where(OvertimeBalance.user_id == emp_me["id"]) + ) + if ob: + await db_session.refresh(ob) + assert ob.last_calculated is not None, "last_calculated sollte nach Genehmigung gesetzt sein" diff --git a/frontend/src/pages/CompanySettingsPage.tsx b/frontend/src/pages/CompanySettingsPage.tsx index 39cba66..3797685 100644 --- a/frontend/src/pages/CompanySettingsPage.tsx +++ b/frontend/src/pages/CompanySettingsPage.tsx @@ -55,6 +55,9 @@ export function CompanySettingsPage() { const [pnNext, setPnNext] = useState(1) // Mobile const [mobileStamping, setMobileStamping] = useState(true) + // Freizeitausgleich + const [fzaOverdraftAllowed, setFzaOverdraftAllowed] = useState(true) + const [fzaWarningThreshold, setFzaWarningThreshold] = useState(0) // Busylight const [blStatus, setBlStatus] = useState<{ configured: boolean; created_at: string | null } | null>(null) const [blPlaintext, setBlPlaintext] = useState(null) @@ -78,7 +81,10 @@ export function CompanySettingsPage() { setPnRequired(c.personnel_number_required ?? false) setPnMode(c.personnel_number_mode ?? 'manual') setPnNext(c.personnel_number_next ?? 1) - setMobileStamping((c as CompanyOut & { mobile_stamping_enabled?: boolean }).mobile_stamping_enabled ?? true) + const cc = c as CompanyOut & { mobile_stamping_enabled?: boolean; overtime_overdraft_allowed?: boolean; overtime_warning_threshold_hours?: number } + setMobileStamping(cc.mobile_stamping_enabled ?? true) + setFzaOverdraftAllowed(cc.overtime_overdraft_allowed ?? true) + setFzaWarningThreshold(cc.overtime_warning_threshold_hours ?? 0) }).catch(() => {}) api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token') .then(setBlStatus) @@ -147,6 +153,8 @@ export function CompanySettingsPage() { personnel_number_mode: pnMode, personnel_number_next: pnNext, mobile_stamping_enabled: mobileStamping, + overtime_overdraft_allowed: fzaOverdraftAllowed, + overtime_warning_threshold_hours: fzaWarningThreshold, }) setCompany(updated) setSaved(true) @@ -554,6 +562,57 @@ export function CompanySettingsPage() { + {/* Freizeitausgleich-Einstellungen */} +
+
+ ⏱️ +

Freizeitausgleich

+
+ + {/* Überziehen erlauben */} +
+
+

Überziehen des Überstundenkontos erlauben

+

+ Wenn deaktiviert, wird FZA-Genehmigung abgelehnt falls nicht genug Überstunden vorhanden sind. +

+
+ +
+ + {/* Warnschwelle */} +
+
+

Warnschwelle Überstundenkonto (Stunden)

+

+ Warnung beim Genehmigen wenn das Konto unter diesen Wert fällt. 0 = keine Warnung. +

+
+ setFzaWarningThreshold(Math.max(0, parseInt(e.target.value) || 0))} + 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" + /> +
+
+ {/* Firmen-Info (readonly) */} {company && (