diff --git a/DEVLOG.md b/DEVLOG.md index fcb49be..076e09c 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1491,3 +1491,70 @@ Keine Commits in dieser Session. - frontend/src/types/hoursPayout.ts | 26 ++ --- +## 2026-05-25 22:21 – 22:22 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- 549783a feat: Stunden-Auszahlungen in /mobile Profil-Screen + +### Geänderte Dateien +- DEVLOG.md | 21 ++++++ +- frontend/src/pages/mobile/MobileProfileScreen.tsx | 87 ++++++++++++++++++++++- + +--- +## 2026-05-25 22:25 – 22:29 (3m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- 23b4588 fix: Überstunden tages-weise berechnen statt Gesamtzeitraum + +### Geänderte Dateien +- backend/app/services/report_service.py | 19 ++++++++++++++++--- + +--- +## 2026-05-25 22:32 – 22:33 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/services/report_service.py | 19 ++++++++++++++++--- + +--- +## 2026-05-25 22:37 – 22:39 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/services/report_service.py | 19 ++++++++++++++++--- + +--- +## 2026-05-25 22:39 – 22:40 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/services/report_service.py | 19 ++++++++++++++++--- + +--- +## 2026-05-25 22:44 – 22:47 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/services/report_service.py | 19 ++++++++++++++++--- + +--- diff --git a/backend/app/models/company.py b/backend/app/models/company.py index b55b336..d3aa84f 100644 --- a/backend/app/models/company.py +++ b/backend/app/models/company.py @@ -55,6 +55,13 @@ class Company(Base): # 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) + # Überstunden-Kappung + overtime_cap_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) + # Überstunden-Verfall + overtime_expiry_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + overtime_expiry_month: Mapped[int] = mapped_column(Integer, nullable=False, default=3) # März + overtime_expiry_day: Mapped[int] = mapped_column(Integer, nullable=False, default=31) # 31. + overtime_max_carryover_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) # None = alles # Relationships users: Mapped[list["User"]] = relationship("User", back_populates="company", lazy="noload") diff --git a/backend/app/models/overtime_balance.py b/backend/app/models/overtime_balance.py index 4418a45..7551e3d 100644 --- a/backend/app/models/overtime_balance.py +++ b/backend/app/models/overtime_balance.py @@ -34,6 +34,7 @@ class OvertimeBalance(Base): total_hours: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=Decimal("0")) taken_hours: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=Decimal("0")) last_calculated: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_expiry_applied_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() diff --git a/backend/app/routers/absences.py b/backend/app/routers/absences.py index 8ac1170..411135e 100644 --- a/backend/app/routers/absences.py +++ b/backend/app/routers/absences.py @@ -210,6 +210,15 @@ async def get_overtime_balance( ) if bal is None: return OvertimeBalanceOut(total_hours=0, taken_hours=0, available_hours=0) + + # Verfall anwenden wenn nötig + from app.services.report_service import apply_overtime_expiry_if_needed + from app.models.company import Company as CompanyModel + company = await db.get(CompanyModel, current_user.company_id) + changed = await apply_overtime_expiry_if_needed(bal, company, db) + if changed: + await db.commit() + return OvertimeBalanceOut( total_hours=float(bal.total_hours), taken_hours=float(bal.taken_hours), @@ -217,6 +226,29 @@ async def get_overtime_balance( ) +@router.post("/absences/overtime-balance/apply-expiry") +async def apply_overtime_expiry_all( + current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN), + db: AsyncSession = Depends(get_db), +): + """Überstunden-Verfall manuell für alle Mitarbeiter der Firma anwenden.""" + from sqlalchemy import select as sa_select + from app.models.company import Company as CompanyModel + from app.services.report_service import apply_overtime_expiry_if_needed + + company = await db.get(CompanyModel, current_user.company_id) + balances = list(await db.scalars( + sa_select(OvertimeBalance).where(OvertimeBalance.company_id == current_user.company_id) + )) + applied_count = 0 + for bal in balances: + changed = await apply_overtime_expiry_if_needed(bal, company, db) + if changed: + applied_count += 1 + await db.commit() + return {"applied_to": applied_count, "total": len(balances)} + + @router.get("/absences/", response_model=AbsenceListResponse) async def list_absences( current_user: CurrentUser, diff --git a/backend/app/schemas/company.py b/backend/app/schemas/company.py index 2a53624..d2868b1 100644 --- a/backend/app/schemas/company.py +++ b/backend/app/schemas/company.py @@ -24,6 +24,11 @@ class CompanyOut(BaseModel): mobile_stamping_enabled: bool = True overtime_overdraft_allowed: bool = True overtime_warning_threshold_hours: int = 0 + overtime_cap_hours: int | None = None + overtime_expiry_enabled: bool = False + overtime_expiry_month: int = 3 + overtime_expiry_day: int = 31 + overtime_max_carryover_hours: int | None = None kiosk_require_approval: bool = True kiosk_track_current_user: bool = True kiosk_heartbeat_interval_sec: int = 30 @@ -39,6 +44,11 @@ class CompanyUpdate(BaseModel): mobile_stamping_enabled: bool | None = None overtime_overdraft_allowed: bool | None = None overtime_warning_threshold_hours: int | None = Field(None, ge=0) + overtime_cap_hours: int | None = Field(None, ge=1, le=9999) + overtime_expiry_enabled: bool | None = None + overtime_expiry_month: int | None = Field(None, ge=1, le=12) + overtime_expiry_day: int | None = Field(None, ge=1, le=31) + overtime_max_carryover_hours: int | None = Field(None, ge=0, le=9999) kiosk_require_approval: bool | None = None kiosk_track_current_user: bool | None = None kiosk_heartbeat_interval_sec: int | None = Field(None, ge=10, le=120) diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index 599ec14..b041e4c 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -242,9 +242,63 @@ async def _recalculate_overtime_balance( bal.total_hours = Decimal(str(round(overtime, 2))) bal.last_calculated = datetime.utcnow() + + # Kappung anwenden + company = await db.get(Company, user.company_id) + if company and company.overtime_cap_hours is not None: + cap = Decimal(str(company.overtime_cap_hours)) + if bal.total_hours > cap: + bal.total_hours = cap + return bal +async def apply_overtime_expiry_if_needed( + bal: OvertimeBalance, + company, # Company model + db: AsyncSession, +) -> bool: + """ + Prüft ob der Überstunden-Verfall angewendet werden muss und tut es ggf. + Gibt True zurück wenn Verfall angewendet wurde. + """ + if not company or not company.overtime_expiry_enabled: + return False + + today = date.today() + try: + expiry_this_year = date(today.year, company.overtime_expiry_month, company.overtime_expiry_day) + except ValueError: + # Ungültiges Datum (z.B. 31. Februar) – überspringen + return False + + expiry_last_year_year = today.year - 1 + try: + expiry_last_year = date(expiry_last_year_year, company.overtime_expiry_month, company.overtime_expiry_day) + except ValueError: + expiry_last_year = None + + last_applicable_expiry = expiry_this_year if today >= expiry_this_year else expiry_last_year + if last_applicable_expiry is None: + return False + + # Schon angewendet? + if bal.last_expiry_applied_at: + applied_date = bal.last_expiry_applied_at.date() if hasattr(bal.last_expiry_applied_at, 'date') else bal.last_expiry_applied_at + if applied_date >= last_applicable_expiry: + return False + + # Verfall anwenden: available_hours auf max_carryover kappen + available = bal.total_hours - bal.taken_hours + if company.overtime_max_carryover_hours is not None: + max_carry = Decimal(str(company.overtime_max_carryover_hours)) + if available > max_carry: + bal.total_hours = bal.taken_hours + max_carry + + bal.last_expiry_applied_at = datetime.utcnow() + return True + + def _check_arbzg_day(entry: TimeEntry) -> list[str]: """ArbZG-Prüfung für einen einzelnen Zeiteintrag.""" if entry.end_time is None: diff --git a/backend/migrations/versions/0031_overtime_cap_expiry.py b/backend/migrations/versions/0031_overtime_cap_expiry.py new file mode 100644 index 0000000..7f1559e --- /dev/null +++ b/backend/migrations/versions/0031_overtime_cap_expiry.py @@ -0,0 +1,33 @@ +"""overtime cap and expiry config + +Revision ID: 0031 +Revises: 0030 +Create Date: 2026-05-25 +""" +from alembic import op +import sqlalchemy as sa + +revision = '0031' +down_revision = '0030' +branch_labels = None +depends_on = None + + +def upgrade(): + # Companies: Kappung + Verfall + op.add_column('companies', sa.Column('overtime_cap_hours', sa.Integer(), nullable=True)) + op.add_column('companies', sa.Column('overtime_expiry_enabled', sa.Boolean(), nullable=False, server_default='false')) + op.add_column('companies', sa.Column('overtime_expiry_month', sa.Integer(), nullable=False, server_default='3')) + op.add_column('companies', sa.Column('overtime_expiry_day', sa.Integer(), nullable=False, server_default='31')) + op.add_column('companies', sa.Column('overtime_max_carryover_hours', sa.Integer(), nullable=True)) + # OvertimeBalance: Verfall-Zeitstempel + op.add_column('overtime_balances', sa.Column('last_expiry_applied_at', sa.DateTime(timezone=True), nullable=True)) + + +def downgrade(): + op.drop_column('companies', 'overtime_cap_hours') + op.drop_column('companies', 'overtime_expiry_enabled') + op.drop_column('companies', 'overtime_expiry_month') + op.drop_column('companies', 'overtime_expiry_day') + op.drop_column('companies', 'overtime_max_carryover_hours') + op.drop_column('overtime_balances', 'last_expiry_applied_at') diff --git a/frontend/src/pages/CompanySettingsPage.tsx b/frontend/src/pages/CompanySettingsPage.tsx index e9d05d6..001e9ed 100644 --- a/frontend/src/pages/CompanySettingsPage.tsx +++ b/frontend/src/pages/CompanySettingsPage.tsx @@ -62,6 +62,14 @@ export function CompanySettingsPage() { // Freizeitausgleich const [fzaOverdraftAllowed, setFzaOverdraftAllowed] = useState(true) const [fzaWarningThreshold, setFzaWarningThreshold] = useState(0) + // Überstunden-Kappung + const [overtimeCapEnabled, setOvertimeCapEnabled] = useState(false) + const [overtimeCapHours, setOvertimeCapHours] = useState(150) + // Überstunden-Verfall + const [overtimeExpiryEnabled, setOvertimeExpiryEnabled] = useState(false) + const [overtimeExpiryMonth, setOvertimeExpiryMonth] = useState(3) + const [overtimeExpiryDay, setOvertimeExpiryDay] = useState(31) + const [overtimeMaxCarryoverHours, setOvertimeMaxCarryoverHours] = useState(null) // Busylight const [blStatus, setBlStatus] = useState<{ configured: boolean; created_at: string | null } | null>(null) const [blPlaintext, setBlPlaintext] = useState(null) @@ -85,13 +93,19 @@ export function CompanySettingsPage() { setPnRequired(c.personnel_number_required ?? false) setPnMode(c.personnel_number_mode ?? 'manual') 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 } + 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 } setMobileStamping(cc.mobile_stamping_enabled ?? true) setFzaOverdraftAllowed(cc.overtime_overdraft_allowed ?? true) setFzaWarningThreshold(cc.overtime_warning_threshold_hours ?? 0) setKioskRequireApproval(cc.kiosk_require_approval ?? true) setKioskTrackCurrentUser(cc.kiosk_track_current_user ?? true) setKioskHeartbeatIntervalSec(cc.kiosk_heartbeat_interval_sec ?? 30) + setOvertimeCapEnabled(cc.overtime_cap_hours != null) + setOvertimeCapHours(cc.overtime_cap_hours ?? 150) + setOvertimeExpiryEnabled(cc.overtime_expiry_enabled ?? false) + setOvertimeExpiryMonth(cc.overtime_expiry_month ?? 3) + setOvertimeExpiryDay(cc.overtime_expiry_day ?? 31) + setOvertimeMaxCarryoverHours(cc.overtime_max_carryover_hours ?? null) }).catch(() => {}) api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token') .then(setBlStatus) @@ -165,6 +179,11 @@ export function CompanySettingsPage() { kiosk_require_approval: kioskRequireApproval, kiosk_track_current_user: kioskTrackCurrentUser, kiosk_heartbeat_interval_sec: kioskHeartbeatIntervalSec, + overtime_cap_hours: overtimeCapEnabled ? overtimeCapHours : null, + overtime_expiry_enabled: overtimeExpiryEnabled, + overtime_expiry_month: overtimeExpiryMonth, + overtime_expiry_day: overtimeExpiryDay, + overtime_max_carryover_hours: overtimeExpiryEnabled ? overtimeMaxCarryoverHours : null, }) setCompany(updated) setSaved(true) @@ -176,6 +195,18 @@ export function CompanySettingsPage() { } } + async function applyOvertimeExpiry() { + if (!confirm('Verfall jetzt auf alle Mitarbeiter anwenden? Nicht rückgängig zu machen.')) return + try { + await api.post('/absences/overtime-balance/apply-expiry', {}) + setError(null) + setSaved(true) + setTimeout(() => setSaved(false), 3000) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Fehler beim Anwenden des Verfalls') + } + } + const isAdmin = me?.role === 'COMPANY_ADMIN' || me?.role === 'SUPER_ADMIN' const hourOptions = Array.from({ length: 24 }, (_, i) => i.toString()) @@ -699,6 +730,134 @@ export function CompanySettingsPage() { + {/* Überstunden-Konto-Regeln */} +
+
+ 📊 +

Überstunden-Konto

+
+ + {/* Kappungsgrenze */} +
+
+
+

Kappungsgrenze aktivieren

+

+ Das Überstunden-Konto kann nicht über diesen Wert anwachsen. +

+
+ +
+ {overtimeCapEnabled && ( +
+

Maximale Überstunden (Stunden)

+ setOvertimeCapHours(Math.max(1, parseInt(e.target.value) || 1))} + disabled={!isAdmin} + className="w-24 border border-gray-300 rounded-lg px-3 py-1.5 text-sm text-right disabled:bg-gray-50" + /> +
+ )} +
+ + {/* Verfall */} +
+
+
+

Jahresverfall aktivieren

+

+ Nicht genommene Überstunden verfallen einmal jährlich zum konfigurierten Stichtag. +

+
+ +
+ {overtimeExpiryEnabled && ( +
+ {/* Stichtag */} +
+

Verfallsstichtag

+
+ setOvertimeExpiryDay(Math.min(31, Math.max(1, parseInt(e.target.value) || 1)))} + disabled={!isAdmin} + className="w-16 border border-gray-300 rounded-lg px-2 py-1.5 text-sm text-right disabled:bg-gray-50" + /> + . + +
+
+ {/* Max. Übertrag */} +
+
+

Maximaler Übertrag (Stunden)

+

0 = alles verfällt · leer = alles übertragen

+
+ setOvertimeMaxCarryoverHours(e.target.value === '' ? null : Math.max(0, parseInt(e.target.value) || 0))} + disabled={!isAdmin} + className="w-28 border border-gray-300 rounded-lg px-3 py-1.5 text-sm text-right disabled:bg-gray-50 placeholder-gray-400" + /> +
+ {/* Manueller Trigger */} + {isAdmin && ( +
+

Verfall jetzt auf alle Mitarbeiter anwenden

+ +
+ )} +
+ )} +
+
+ {/* Firmen-Info (readonly) */} {company && (