feat: Freizeitausgleich-Lücken geschlossen (Gap 1-3) + konfigurierbare Schwellwerte
Gap-1: Überziehschutz für Überstundenkonto
- Company.overtime_overdraft_allowed (default: true) – blockiert FZA wenn deaktiviert
- Company.overtime_warning_threshold_hours (default: 0) – Warnung wenn Konto unter Schwelle fällt
- warnings[] jetzt in approve_absence Response (AbsenceApproveOut)
- Migration 0028_overtime_fza_config.py
Gap-2: total_hours wird bei Zeiteintrag-Genehmigung neu berechnet
- time_service.approve_entry() ruft _recalculate_overtime_balance() auf
- last_calculated Timestamp wird gesetzt
Gap-3: Stornierung genehmigter FZA-Anträge bucht taken_hours zurück
- _refund_overtime() Helfer hinzugefügt
- cancel_absence() erlaubt jetzt HR/Admin auch genehmigte Abwesenheiten zu stornieren
- DELETE /absences/{id} gibt jetzt AbsenceOut zurück (statt 204)
- Mitarbeiter können genehmigte FZA-Anträge nicht selbst stornieren (409)
Frontend:
- CompanySettingsPage: neuer Abschnitt 'Freizeitausgleich' mit Toggle + Schwellwert-Eingabe
Tests: backend/tests/test_fza.py mit 6 Tests (alle 3 Gaps)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1162,3 +1162,26 @@ Keine Commits in dieser Session.
|
|||||||
- frontend/src/pages/mobile/MobilePage.tsx | 17 +-
|
- 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 --
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class Company(Base):
|
|||||||
# Mobile-Konfiguration
|
# Mobile-Konfiguration
|
||||||
mobile_stamping_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
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
|
# Relationships
|
||||||
users: Mapped[list["User"]] = relationship("User", back_populates="company", lazy="noload")
|
users: Mapped[list["User"]] = relationship("User", back_populates="company", lazy="noload")
|
||||||
departments: Mapped[list["Department"]] = relationship("Department", back_populates="company", lazy="noload")
|
departments: Mapped[list["Department"]] = relationship("Department", back_populates="company", lazy="noload")
|
||||||
|
|||||||
@@ -278,26 +278,36 @@ async def get_absence(
|
|||||||
return AbsenceOut.model_validate(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(
|
async def cancel_absence(
|
||||||
absence_id: UUID,
|
absence_id: UUID,
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Eigenen ausstehenden Antrag stornieren."""
|
"""Antrag stornieren.
|
||||||
await absence_service.cancel_absence(absence_id, current_user, db)
|
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()
|
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(
|
async def approve_absence(
|
||||||
absence_id: UUID,
|
absence_id: UUID,
|
||||||
current_user: User = require_role(*_manager_roles),
|
current_user: User = require_role(*_manager_roles),
|
||||||
db: AsyncSession = Depends(get_db),
|
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()
|
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)
|
@router.post("/absences/{absence_id}/reject", response_model=AbsenceOut)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class CompanyOut(BaseModel):
|
|||||||
personnel_number_mode: PersonnelNumberModeT = "manual"
|
personnel_number_mode: PersonnelNumberModeT = "manual"
|
||||||
personnel_number_next: int = 1
|
personnel_number_next: int = 1
|
||||||
mobile_stamping_enabled: bool = True
|
mobile_stamping_enabled: bool = True
|
||||||
|
overtime_overdraft_allowed: bool = True
|
||||||
|
overtime_warning_threshold_hours: int = 0
|
||||||
|
|
||||||
|
|
||||||
class CompanyUpdate(BaseModel):
|
class CompanyUpdate(BaseModel):
|
||||||
@@ -32,6 +34,8 @@ class CompanyUpdate(BaseModel):
|
|||||||
personnel_number_mode: PersonnelNumberModeT | None = None
|
personnel_number_mode: PersonnelNumberModeT | None = None
|
||||||
personnel_number_next: int | None = Field(None, ge=1)
|
personnel_number_next: int | None = Field(None, ge=1)
|
||||||
mobile_stamping_enabled: bool | None = None
|
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):
|
class DepartmentOut(BaseModel):
|
||||||
|
|||||||
@@ -298,10 +298,28 @@ class AbsenceService:
|
|||||||
absence = await db.get(Absence, absence_id)
|
absence = await db.get(Absence, absence_id)
|
||||||
if absence is None:
|
if absence is None:
|
||||||
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
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.")
|
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
|
absence.status = AbsenceStatus.CANCELLED
|
||||||
|
|
||||||
# Audit-Log (DSGVO)
|
# 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)
|
await self._deduct_vacation(absence.user_id, absence.start_date.year, int(absence.working_days), db)
|
||||||
|
|
||||||
# Überstundenkonto abziehen wenn Freizeitausgleich
|
# Überstundenkonto abziehen wenn Freizeitausgleich
|
||||||
|
fza_warnings: list[str] = []
|
||||||
if absence_type and absence_type.affects_overtime_balance:
|
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)
|
# Audit-Log (DSGVO)
|
||||||
db.add(AuditLog(
|
db.add(AuditLog(
|
||||||
@@ -383,7 +402,7 @@ class AbsenceService:
|
|||||||
from app.services.caldav_service import caldav_service
|
from app.services.caldav_service import caldav_service
|
||||||
asyncio.create_task(caldav_service.sync_approved(absence, db))
|
asyncio.create_task(caldav_service.sync_approved(absence, db))
|
||||||
|
|
||||||
return absence
|
return absence, fza_warnings
|
||||||
|
|
||||||
async def reject_absence(
|
async def reject_absence(
|
||||||
self, absence_id: UUID, data: AbsenceReject, current_user: User, db: AsyncSession
|
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 = await self._get_or_create_balance(user_id, year, db)
|
||||||
balance.used_days += days
|
balance.used_days += days
|
||||||
|
|
||||||
async def _deduct_overtime(
|
async def _calc_daily_hours(self, user_id: UUID, db: AsyncSession) -> Decimal:
|
||||||
self, user_id: UUID, working_days: float, db: AsyncSession
|
"""Tägliche Soll-Stunden aus Arbeitsplan ermitteln (Fallback: 8h)."""
|
||||||
) -> None:
|
|
||||||
"""Zieht working_days × tägliche Stunden vom Überstundenkonto ab."""
|
|
||||||
# Stunden/Tag aus Arbeitsplan ermitteln (Fallback: 8h)
|
|
||||||
user = await db.get(User, user_id)
|
user = await db.get(User, user_id)
|
||||||
daily_hours = Decimal("8.00")
|
daily_hours = Decimal("8.00")
|
||||||
if user and user.work_schedule_id:
|
if user and user.work_schedule_id:
|
||||||
@@ -579,20 +595,64 @@ class AbsenceService:
|
|||||||
)
|
)
|
||||||
if working_days_in_week > 0:
|
if working_days_in_week > 0:
|
||||||
daily_hours = schedule.weekly_hours / Decimal(working_days_in_week)
|
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
|
hours_to_deduct = Decimal(str(working_days)) * daily_hours
|
||||||
|
|
||||||
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
|
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
|
||||||
if ob is None:
|
if ob is None:
|
||||||
# Erstelle Eintrag mit 0 Überstunden — taken_hours kann negativ werden
|
|
||||||
company_id = user.company_id if user else None
|
company_id = user.company_id if user else None
|
||||||
if not company_id:
|
if not company_id:
|
||||||
return
|
return []
|
||||||
ob = OvertimeBalance(user_id=user_id, company_id=company_id)
|
ob = OvertimeBalance(user_id=user_id, company_id=company_id)
|
||||||
db.add(ob)
|
db.add(ob)
|
||||||
await db.flush()
|
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
|
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(
|
async def _get_holiday_dates(
|
||||||
self, company_id: UUID, year: int, db: AsyncSession
|
self, company_id: UUID, year: int, db: AsyncSession
|
||||||
|
|||||||
@@ -336,6 +336,17 @@ class TimeService:
|
|||||||
entry.status = EntryStatus.APPROVED
|
entry.status = EntryStatus.APPROVED
|
||||||
entry.approved_by = current_user.id
|
entry.approved_by = current_user.id
|
||||||
entry.updated_at = datetime.now(timezone.utc)
|
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
|
return entry
|
||||||
|
|
||||||
async def reject_entry(
|
async def reject_entry(
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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"
|
||||||
@@ -55,6 +55,9 @@ export function CompanySettingsPage() {
|
|||||||
const [pnNext, setPnNext] = useState(1)
|
const [pnNext, setPnNext] = useState(1)
|
||||||
// Mobile
|
// Mobile
|
||||||
const [mobileStamping, setMobileStamping] = useState(true)
|
const [mobileStamping, setMobileStamping] = useState(true)
|
||||||
|
// Freizeitausgleich
|
||||||
|
const [fzaOverdraftAllowed, setFzaOverdraftAllowed] = useState(true)
|
||||||
|
const [fzaWarningThreshold, setFzaWarningThreshold] = useState(0)
|
||||||
// Busylight
|
// Busylight
|
||||||
const [blStatus, setBlStatus] = useState<{ configured: boolean; created_at: string | null } | null>(null)
|
const [blStatus, setBlStatus] = useState<{ configured: boolean; created_at: string | null } | null>(null)
|
||||||
const [blPlaintext, setBlPlaintext] = useState<string | null>(null)
|
const [blPlaintext, setBlPlaintext] = useState<string | null>(null)
|
||||||
@@ -78,7 +81,10 @@ 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)
|
||||||
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(() => {})
|
}).catch(() => {})
|
||||||
api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token')
|
api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token')
|
||||||
.then(setBlStatus)
|
.then(setBlStatus)
|
||||||
@@ -147,6 +153,8 @@ export function CompanySettingsPage() {
|
|||||||
personnel_number_mode: pnMode,
|
personnel_number_mode: pnMode,
|
||||||
personnel_number_next: pnNext,
|
personnel_number_next: pnNext,
|
||||||
mobile_stamping_enabled: mobileStamping,
|
mobile_stamping_enabled: mobileStamping,
|
||||||
|
overtime_overdraft_allowed: fzaOverdraftAllowed,
|
||||||
|
overtime_warning_threshold_hours: fzaWarningThreshold,
|
||||||
})
|
})
|
||||||
setCompany(updated)
|
setCompany(updated)
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
@@ -554,6 +562,57 @@ export function CompanySettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Freizeitausgleich-Einstellungen */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">⏱️</span>
|
||||||
|
<h2 className="font-semibold text-gray-700">Freizeitausgleich</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Überziehen erlauben */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-800">Überziehen des Überstundenkontos erlauben</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
Wenn deaktiviert, wird FZA-Genehmigung abgelehnt falls nicht genug Überstunden vorhanden sind.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isAdmin}
|
||||||
|
onClick={() => setFzaOverdraftAllowed(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 ${
|
||||||
|
fzaOverdraftAllowed ? '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 ${
|
||||||
|
fzaOverdraftAllowed ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warnschwelle */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-800">Warnschwelle Überstundenkonto (Stunden)</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
Warnung beim Genehmigen wenn das Konto unter diesen Wert fällt. 0 = keine Warnung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
value={fzaWarningThreshold}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Firmen-Info (readonly) */}
|
{/* Firmen-Info (readonly) */}
|
||||||
{company && (
|
{company && (
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
|||||||
Reference in New Issue
Block a user