From c9c197ddaedebab7a2ec402efdd4b176b38ec38a Mon Sep 17 00:00:00 2001
From: patrick
Date: Tue, 23 Jun 2026 12:03:42 +0200
Subject: [PATCH] =?UTF-8?q?feat:=20agent-11=20PR2=20=E2=80=93=20Urlaubsans?=
=?UTF-8?q?pruch=20(Verfall=20scharf,=20Pro-rata,=20Teilzeit)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Korrektheit der Urlaubskonten (Feature-Parität mit Urlaubsverwaltung):
- Verfall scharfgeschaltet: neuer effektiv verfügbarer Saldo (available_days)
schließt verfallenen, noch nicht verbrauchten Resturlaub aus; Konto-Warnung
beim Antrag nutzt jetzt available statt remaining. (Verfallsdatum bleibt in
company.settings, UI bereits vorhanden.)
- Anteilige Berechnung (Zwölftel) im Ein-/Austrittsjahr anhand neuer Felder
users.entry_date / exit_date; opt-in pro Firma (vacation_prorate_first_year).
- Teilzeit: Anspruch optional aus Arbeitstagen/Woche des WorkSchedule abgeleitet
(vacation_from_schedule), Basis vacation_default_days.
- _get_or_create_balance berechnet den Grundanspruch jetzt frisch
(_compute_entitlement); _carryover_expired/effective_available als Service-API,
Router delegiert.
Frontend: CompanySettingsPage (Jahresurlaub + Pro-rata- und Teilzeit-Toggles),
UsersPage (Ein-/Austrittsdatum im Edit-Modal), AbsencesPage ("Verfügbar" + Hinweis
bei verfallenem Resturlaub).
Migration 0036. 183/183 Tests grün. Deployed auf 137 + 164.
Co-Authored-By: Claude Opus 4.8
---
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 ++++++++++++++++++-
.../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 +
13 files changed, 277 insertions(+), 20 deletions(-)
create mode 100644 backend/migrations/versions/0036_vacation_entitlement.py
diff --git a/backend/app/models/company.py b/backend/app/models/company.py
index 83774b4..f8b4a19 100644
--- a/backend/app/models/company.py
+++ b/backend/app/models/company.py
@@ -70,6 +70,14 @@ class Company(Base):
# Mobile-Konfiguration
mobile_stamping_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ # Urlaubsanspruch-Konfiguration
+ # Basis-Jahresurlaub (Vollzeit) für neu angelegte Urlaubskonten.
+ vacation_default_days: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
+ # Anteilige Berechnung im Ein-/Austrittsjahr (Zwölftel-Regel).
+ vacation_prorate_first_year: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ # Teilzeit: Anspruch aus Arbeitstagen/Woche des WorkSchedule ableiten (opt-in).
+ vacation_from_schedule: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+
# 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)
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index 5ef9f2b..d7e4c3c 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -1,9 +1,9 @@
import uuid
import enum
-from datetime import datetime
+from datetime import datetime, date
from typing import TYPE_CHECKING
-from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text, func
+from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -50,6 +50,10 @@ class User(Base):
UUID(as_uuid=True), ForeignKey("work_schedules.id", ondelete="SET NULL")
)
+ # Ein-/Austrittsdatum – Basis für anteilige Urlaubsberechnung (Zwölftel-Regel)
+ entry_date: Mapped[date | None] = mapped_column(Date)
+ exit_date: Mapped[date | None] = mapped_column(Date)
+
# Kiosk auth
kiosk_pin_hash: Mapped[str | None] = mapped_column(Text)
kiosk_qr_token: Mapped[str | None] = mapped_column(Text, unique=True)
diff --git a/backend/app/routers/absences.py b/backend/app/routers/absences.py
index 88da6f3..54f5ad1 100644
--- a/backend/app/routers/absences.py
+++ b/backend/app/routers/absences.py
@@ -39,18 +39,8 @@ router = APIRouter(tags=["Abwesenheiten"])
def _carryover_expiry(company: Company, year: int) -> tuple[date | None, bool]:
- """Verfallsdatum für Resturlaub berechnen.
- Gibt (expires_at, is_expired) zurück. None wenn kein Verfall konfiguriert."""
- s = company.settings or {}
- month = s.get("carryover_expires_month")
- day = s.get("carryover_expires_day")
- if not month or not day:
- return None, False
- try:
- expires_at = date(year, int(month), int(day))
- return expires_at, date.today() > expires_at
- except ValueError:
- return None, False
+ """Verfallsdatum für Resturlaub. Delegiert an den Service (eine Quelle der Wahrheit)."""
+ return absence_service._carryover_expired(company, year)
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
@@ -142,6 +132,7 @@ async def get_own_balance(
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
return VacationBalanceOut.model_validate(balance).model_copy(update={
"pending_days": pending,
+ "available_days": absence_service.effective_available(balance, expired),
"carried_over_expires_at": expires_at,
"carried_over_expired": expired,
})
@@ -165,6 +156,7 @@ async def get_balance_for_user(
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
return VacationBalanceOut.model_validate(balance).model_copy(update={
"pending_days": pending,
+ "available_days": absence_service.effective_available(balance, expired),
"carried_over_expires_at": expires_at,
"carried_over_expired": expired,
})
diff --git a/backend/app/schemas/absence.py b/backend/app/schemas/absence.py
index 9a3903b..5e360c7 100644
--- a/backend/app/schemas/absence.py
+++ b/backend/app/schemas/absence.py
@@ -188,6 +188,8 @@ class VacationBalanceOut(BaseModel):
used_days: int
total_days: int
remaining_days: int
+ # Effektiv verfügbar: berücksichtigt verfallenen Resturlaub (zur Laufzeit befüllt).
+ available_days: int = 0
pending_days: float = 0
# Resturlaub-Verfall (wird zur Laufzeit befüllt, nicht in DB)
carried_over_expires_at: date | None = None
diff --git a/backend/app/schemas/company.py b/backend/app/schemas/company.py
index 8ec8999..e236164 100644
--- a/backend/app/schemas/company.py
+++ b/backend/app/schemas/company.py
@@ -47,6 +47,9 @@ class CompanyOut(BaseModel):
kiosk_track_current_user: bool = True
kiosk_heartbeat_interval_sec: int = 30
public_stamp_enabled: bool = False
+ vacation_default_days: int = 30
+ vacation_prorate_first_year: bool = True
+ vacation_from_schedule: bool = False
class PublicStampTokenStatus(BaseModel):
@@ -82,6 +85,9 @@ class CompanyUpdate(BaseModel):
kiosk_track_current_user: bool | None = None
kiosk_heartbeat_interval_sec: int | None = Field(None, ge=10, le=120)
public_stamp_enabled: bool | None = None
+ vacation_default_days: int | None = Field(None, ge=0, le=365)
+ vacation_prorate_first_year: bool | None = None
+ vacation_from_schedule: bool | None = None
class DepartmentOut(BaseModel):
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
index bbb8315..3fc044f 100644
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -1,5 +1,5 @@
import uuid
-from datetime import datetime
+from datetime import datetime, date
from pydantic import BaseModel, EmailStr, Field, model_validator
@@ -27,6 +27,9 @@ class UserOut(BaseModel):
kuerzel: str | None = None
personnel_number: str | None = None
can_manual_time_entry: bool = False
+ work_schedule_id: uuid.UUID | None = None
+ entry_date: date | None = None
+ exit_date: date | None = None
class UserUpdate(BaseModel):
@@ -39,6 +42,8 @@ class UserUpdate(BaseModel):
personnel_number: str | None = Field(None, max_length=50, pattern=PERSONNEL_NUMBER_PATTERN)
can_manual_time_entry: bool | None = None
is_active: bool | None = None
+ entry_date: date | None = None
+ exit_date: date | None = None
class InviteRequest(BaseModel):
diff --git a/backend/app/services/absence_service.py b/backend/app/services/absence_service.py
index a59121f..05e872d 100644
--- a/backend/app/services/absence_service.py
+++ b/backend/app/services/absence_service.py
@@ -1,4 +1,5 @@
import asyncio
+import math
from datetime import date, timedelta
from uuid import UUID
@@ -168,10 +169,13 @@ class AbsenceService:
warnings: list[str] = []
if absence_type.deducts_vacation:
balance = await self._get_or_create_balance(current_user.id, data.start_date.year, db)
- if balance.remaining_days < working_days:
+ company = await db.get(Company, current_user.company_id)
+ _, expired = self._carryover_expired(company, data.start_date.year)
+ available = self.effective_available(balance, expired)
+ if available < working_days:
warnings.append(
f"Urlaubskonto reicht möglicherweise nicht aus: "
- f"{balance.remaining_days} Tage verfügbar, {working_days} Tage beantragt."
+ f"{available} Tage verfügbar, {working_days} Tage beantragt."
)
# Überschneidung mit eigenen Abwesenheiten prüfen
@@ -631,7 +635,13 @@ class AbsenceService:
)
)
carried = max(0, prev.remaining_days) if prev else 0
- entitled = prev.entitled_days if prev else 30
+ # Grundanspruch frisch berechnen (Firmen-Default · Teilzeit · Pro-rata).
+ # Fallback auf Vorjahres-Anspruch bzw. 30, falls keine Konfig greift.
+ user = await db.get(User, user_id)
+ company = await db.get(Company, user.company_id) if user and user.company_id else None
+ entitled = await self._compute_entitlement(user, company, year, db)
+ if entitled is None:
+ entitled = prev.entitled_days if prev else 30
balance = VacationBalance(
user_id=user_id,
year=year,
@@ -642,6 +652,70 @@ class AbsenceService:
await db.flush()
return balance
+ @staticmethod
+ def _working_days_per_week(schedule: WorkSchedule) -> int:
+ return sum(
+ 1 for h in [schedule.mon_h, schedule.tue_h, schedule.wed_h,
+ schedule.thu_h, schedule.fri_h, schedule.sat_h, schedule.sun_h]
+ if h and h > 0
+ )
+
+ async def _compute_entitlement(
+ self, user: "User | None", company: "Company | None", year: int, db: AsyncSession
+ ) -> int | None:
+ """Grundurlaub für ein neues Konto: Firmen-Default, optional Teilzeit-Skalierung
+ (Arbeitstage/Woche aus WorkSchedule) und anteilig im Ein-/Austrittsjahr (Zwölftel).
+ Gibt None zurück, wenn keine Berechnung möglich ist (→ Aufrufer nutzt Fallback)."""
+ if company is None:
+ return None
+ days = float(company.vacation_default_days)
+
+ # Teilzeit: Anspruch proportional zu den Arbeitstagen/Woche (5 = Vollzeit)
+ if company.vacation_from_schedule and user and user.work_schedule_id:
+ schedule = await db.get(WorkSchedule, user.work_schedule_id)
+ if schedule:
+ wd = self._working_days_per_week(schedule)
+ days = days * wd / 5.0
+
+ # Anteilig im Ein-/Austrittsjahr (Zwölftel ab/bis Monat)
+ if company.vacation_prorate_first_year and user:
+ months = 12
+ if user.entry_date:
+ if user.entry_date.year > year:
+ months = 0
+ elif user.entry_date.year == year:
+ months = min(months, 12 - user.entry_date.month + 1)
+ if user.exit_date:
+ if user.exit_date.year < year:
+ months = 0
+ elif user.exit_date.year == year:
+ months = min(months, user.exit_date.month)
+ days = days * max(0, months) / 12.0
+
+ # Kaufmännisch runden
+ return int(math.floor(days + 0.5))
+
+ @staticmethod
+ def _carryover_expired(company: "Company | None", year: int) -> tuple[date | None, bool]:
+ """Verfallsdatum des Resturlaubs aus company.settings. (expires_at, is_expired)."""
+ s = (company.settings or {}) if company else {}
+ month, day = s.get("carryover_expires_month"), s.get("carryover_expires_day")
+ if not month or not day:
+ return None, False
+ try:
+ expires_at = date(year, int(month), int(day))
+ return expires_at, date.today() > expires_at
+ except (ValueError, TypeError):
+ return None, False
+
+ @staticmethod
+ def effective_available(balance: VacationBalance, expired: bool) -> int:
+ """Effektiv verfügbare Urlaubstage. Bei verfallenem Resturlaub zählt der noch
+ nicht verbrauchte Teil des Übertrags nicht mehr mit."""
+ if expired:
+ return balance.entitled_days + balance.special_days - max(0, balance.used_days - balance.carried_over)
+ return balance.remaining_days
+
async def _deduct_vacation(
self, user_id: UUID, year: int, days: int, db: AsyncSession
) -> None:
diff --git a/backend/migrations/versions/0036_vacation_entitlement.py b/backend/migrations/versions/0036_vacation_entitlement.py
new file mode 100644
index 0000000..50da1a4
--- /dev/null
+++ b/backend/migrations/versions/0036_vacation_entitlement.py
@@ -0,0 +1,36 @@
+"""Vacation entitlement: pro-rata + part-time config (agent-11 PR2)
+
+Revision ID: 0036
+Revises: 0035
+Create Date: 2026-06-23
+
+- users.entry_date / exit_date (Basis für anteilige Urlaubsberechnung, Zwölftel)
+- companies.vacation_default_days (Vollzeit-Grundurlaub für neue Konten)
+- companies.vacation_prorate_first_year (anteilig im Ein-/Austrittsjahr)
+- companies.vacation_from_schedule (Teilzeit-Anspruch aus WorkSchedule ableiten)
+
+Resturlaub-Verfall bleibt in companies.settings (carryover_expires_month/day),
+wird in PR2 nur scharf geschaltet (effektiv verfügbarer Saldo) – keine Spalte nötig.
+"""
+from alembic import op
+
+revision = "0036"
+down_revision = "0035"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS entry_date DATE")
+ op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS exit_date DATE")
+ op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS vacation_default_days INTEGER NOT NULL DEFAULT 30")
+ op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS vacation_prorate_first_year BOOLEAN NOT NULL DEFAULT TRUE")
+ op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS vacation_from_schedule BOOLEAN NOT NULL DEFAULT FALSE")
+
+
+def downgrade() -> None:
+ op.execute("ALTER TABLE companies DROP COLUMN IF EXISTS vacation_from_schedule")
+ op.execute("ALTER TABLE companies DROP COLUMN IF EXISTS vacation_prorate_first_year")
+ op.execute("ALTER TABLE companies DROP COLUMN IF EXISTS vacation_default_days")
+ op.execute("ALTER TABLE users DROP COLUMN IF EXISTS exit_date")
+ op.execute("ALTER TABLE users DROP COLUMN IF EXISTS entry_date")
diff --git a/backend/tests/test_absences.py b/backend/tests/test_absences.py
index f8609fe..9882bb9 100644
--- a/backend/tests/test_absences.py
+++ b/backend/tests/test_absences.py
@@ -625,3 +625,70 @@ async def test_absence_comments(
assert lst.status_code == 200
bodies = [c["body"] for c in lst.json()]
assert "Bitte zuegig pruefen" in bodies
+
+
+# ── agent-11 PR2: Urlaubsanspruch (Verfall · Pro-rata · Teilzeit) ──────────────
+
+def test_effective_available_not_expired():
+ import uuid as _u
+ from app.services.absence_service import AbsenceService
+ from app.models.vacation_balance import VacationBalance
+ b = VacationBalance(user_id=_u.uuid4(), year=2026, entitled_days=30, special_days=0, carried_over=5, used_days=3)
+ assert AbsenceService.effective_available(b, False) == 32 # 30 + 5 - 3
+ assert AbsenceService.effective_available(b, True) == 30 # Resturlaub verfällt (3 < 5)
+
+
+def test_effective_available_expired_overdraw():
+ import uuid as _u
+ from app.services.absence_service import AbsenceService
+ from app.models.vacation_balance import VacationBalance
+ b = VacationBalance(user_id=_u.uuid4(), year=2026, entitled_days=30, special_days=0, carried_over=5, used_days=8)
+ assert AbsenceService.effective_available(b, True) == 27 # 30 - max(0, 8 - 5)
+
+
+@pytest.mark.asyncio
+async def test_company_vacation_settings_roundtrip(client: AsyncClient, abs_headers):
+ r = await client.patch("/api/v1/companies/me", json={
+ "vacation_default_days": 28, "vacation_prorate_first_year": True, "vacation_from_schedule": False,
+ }, headers=abs_headers)
+ assert r.status_code == 200, r.text
+ assert r.json()["vacation_default_days"] == 28
+ await client.patch("/api/v1/companies/me", json={"vacation_default_days": 30}, headers=abs_headers)
+
+
+@pytest.mark.asyncio
+async def test_prorate_entry_year(client: AsyncClient, abs_headers):
+ inv = await client.post("/api/v1/users/invite", json={
+ "first_name": "Pro", "last_name": "Rata", "email": "prorata@absenceag.de",
+ "role": "EMPLOYEE", "initial_password": "Secret123",
+ }, headers=abs_headers)
+ uid = inv.json()["id"]
+ yr = date.today().year
+ await client.patch(f"/api/v1/users/{uid}", json={"entry_date": f"{yr}-07-01"}, headers=abs_headers)
+ bal = await client.get(f"/api/v1/absences/balance/{uid}?year={yr}", headers=abs_headers)
+ assert bal.status_code == 200, bal.text
+ assert bal.json()["entitled_days"] == 15 # 30 * 6/12 (Eintritt Juli)
+
+
+@pytest.mark.asyncio
+async def test_part_time_entitlement_from_schedule(client: AsyncClient, abs_headers):
+ sched = await client.post("/api/v1/time/schedules", json={
+ "name": "Teilzeit 3 Tage", "mon_h": 8, "tue_h": 8, "wed_h": 8,
+ "thu_h": 0, "fri_h": 0, "valid_from": "2026-01-01",
+ }, headers=abs_headers)
+ assert sched.status_code == 201, sched.text
+ sid = sched.json()["id"]
+ await client.patch("/api/v1/companies/me", json={
+ "vacation_from_schedule": True, "vacation_default_days": 30,
+ }, headers=abs_headers)
+ inv = await client.post("/api/v1/users/invite", json={
+ "first_name": "Teil", "last_name": "Zeit", "email": "teilzeit@absenceag.de",
+ "role": "EMPLOYEE", "initial_password": "Secret123",
+ }, headers=abs_headers)
+ uid = inv.json()["id"]
+ await client.patch(f"/api/v1/users/{uid}", json={"work_schedule_id": sid}, headers=abs_headers)
+ yr = date.today().year
+ bal = await client.get(f"/api/v1/absences/balance/{uid}?year={yr}", headers=abs_headers)
+ assert bal.status_code == 200, bal.text
+ assert bal.json()["entitled_days"] == 18 # 30 * 3/5
+ await client.patch("/api/v1/companies/me", json={"vacation_from_schedule": False}, headers=abs_headers)
diff --git a/frontend/src/pages/AbsencesPage.tsx b/frontend/src/pages/AbsencesPage.tsx
index 0cff54a..b08dc8a 100644
--- a/frontend/src/pages/AbsencesPage.tsx
+++ b/frontend/src/pages/AbsencesPage.tsx
@@ -374,8 +374,11 @@ export function AbsencesPage() {
Genommen
{balance.used_days}
{balance.pending_days > 0 && Beantragt
{balance.pending_days}
}
-
Verbleibend
-
5 ? 'text-green-600' : balance.remaining_days > 0 ? 'text-yellow-600' : 'text-red-600'}`}>{balance.remaining_days}
+
Verfügbar
+
5 ? 'text-green-600' : balance.available_days > 0 ? 'text-yellow-600' : 'text-red-600'}`}>{balance.available_days}
+ {balance.carried_over_expired && balance.carried_over > 0 && (
+
Resturlaub verfallen
+ )}
diff --git a/frontend/src/pages/CompanySettingsPage.tsx b/frontend/src/pages/CompanySettingsPage.tsx
index 1850112..fbcec76 100644
--- a/frontend/src/pages/CompanySettingsPage.tsx
+++ b/frontend/src/pages/CompanySettingsPage.tsx
@@ -49,6 +49,10 @@ export function CompanySettingsPage() {
// Resturlaub-Verfall
const [carryoverMonth, setCarryoverMonth] = useState('')
const [carryoverDay, setCarryoverDay] = useState('')
+ // Urlaubsanspruch (PR2)
+ const [vacationDefaultDays, setVacationDefaultDays] = useState(30)
+ const [vacationProrate, setVacationProrate] = useState(true)
+ const [vacationFromSchedule, setVacationFromSchedule] = useState(false)
// Personalnummern
const [pnRequired, setPnRequired] = useState(false)
const [pnMode, setPnMode] = useState<'manual' | 'auto'>('manual')
@@ -106,6 +110,10 @@ export function CompanySettingsPage() {
setOvertimeExpiryMonth(cc.overtime_expiry_month ?? 3)
setOvertimeExpiryDay(cc.overtime_expiry_day ?? 31)
setOvertimeMaxCarryoverHours(cc.overtime_max_carryover_hours ?? null)
+ const vc = c as CompanyOut & { vacation_default_days?: number; vacation_prorate_first_year?: boolean; vacation_from_schedule?: boolean }
+ setVacationDefaultDays(vc.vacation_default_days ?? 30)
+ setVacationProrate(vc.vacation_prorate_first_year ?? true)
+ setVacationFromSchedule(vc.vacation_from_schedule ?? false)
}).catch(() => {})
api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token')
.then(setBlStatus)
@@ -174,6 +182,9 @@ export function CompanySettingsPage() {
personnel_number_mode: pnMode,
personnel_number_next: pnNext,
mobile_stamping_enabled: mobileStamping,
+ vacation_default_days: vacationDefaultDays,
+ vacation_prorate_first_year: vacationProrate,
+ vacation_from_schedule: vacationFromSchedule,
overtime_overdraft_allowed: fzaOverdraftAllowed,
overtime_warning_threshold_hours: fzaWarningThreshold,
kiosk_require_approval: kioskRequireApproval,
@@ -347,6 +358,36 @@ export function CompanySettingsPage() {
+
+
+ setVacationDefaultDays(parseInt(e.target.value) || 0)}
+ disabled={!isAdmin}
+ className="w-24 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50"
+ />
+ Tage/Jahr für neue Urlaubskonten
+
+
+
+
+
diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx
index 6c3746c..a52d4c4 100644
--- a/frontend/src/pages/UsersPage.tsx
+++ b/frontend/src/pages/UsersPage.tsx
@@ -18,6 +18,8 @@ interface UserOut {
kuerzel: string | null
personnel_number: string | null
can_manual_time_entry: boolean
+ entry_date: string | null
+ exit_date: string | null
last_login: string | null
created_at: string
}
@@ -126,6 +128,8 @@ export function UsersPage() {
const [editKuerzel, setEditKuerzel] = useState
('')
const [editPersonnelNr, setEditPersonnelNr] = useState('')
const [editCanManual, setEditCanManual] = useState(false)
+ const [editEntryDate, setEditEntryDate] = useState('')
+ const [editExitDate, setEditExitDate] = useState('')
const [editLoading, setEditLoading] = useState(false)
// Personalnummer-Verfügbarkeitsprüfung
@@ -226,6 +230,8 @@ export function UsersPage() {
work_schedule_id: editScheduleId || null,
kuerzel: editKuerzel.trim() || null,
can_manual_time_entry: editCanManual,
+ entry_date: editEntryDate || null,
+ exit_date: editExitDate || null,
}
// Personalnr. nur senden wenn geändert
const newPn = editPersonnelNr.trim()
@@ -423,6 +429,7 @@ export function UsersPage() {
setEditUser(u); setEditRole(u.role); setEditScheduleId(u.work_schedule_id)
setEditKuerzel(u.kuerzel ?? ''); setEditPersonnelNr(u.personnel_number ?? '')
setEditCanManual(u.can_manual_time_entry); setPersonnelCheck('idle')
+ setEditEntryDate(u.entry_date ?? ''); setEditExitDate(u.exit_date ?? '')
}}
className='text-xs text-blue-600 hover:underline'
>
@@ -691,6 +698,17 @@ export function UsersPage() {
Noch keine Arbeitszeitmodelle angelegt.
)}
+
+
+
+ setEditEntryDate(e.target.value)} className={inputClass} />
+
+
+
+ setEditExitDate(e.target.value)} className={inputClass} />
+
+
Basis für anteilige Urlaubsberechnung (Zwölftel-Regel).
+