feat: agent-11 PR2 – Urlaubsanspruch (Verfall scharf, Pro-rata, Teilzeit)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 12:03:42 +02:00
co-authored by Claude Opus 4.8
parent 6fa66b8c13
commit e8bed43570
13 changed files with 277 additions and 20 deletions
+8
View File
@@ -70,6 +70,14 @@ 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)
# 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 # Freizeitausgleich-Konfiguration
overtime_overdraft_allowed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) 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) overtime_warning_threshold_hours: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+6 -2
View File
@@ -1,9 +1,9 @@
import uuid import uuid
import enum import enum
from datetime import datetime from datetime import datetime, date
from typing import TYPE_CHECKING 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.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship 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") 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 auth
kiosk_pin_hash: Mapped[str | None] = mapped_column(Text) kiosk_pin_hash: Mapped[str | None] = mapped_column(Text)
kiosk_qr_token: Mapped[str | None] = mapped_column(Text, unique=True) kiosk_qr_token: Mapped[str | None] = mapped_column(Text, unique=True)
+4 -12
View File
@@ -39,18 +39,8 @@ router = APIRouter(tags=["Abwesenheiten"])
def _carryover_expiry(company: Company, year: int) -> tuple[date | None, bool]: def _carryover_expiry(company: Company, year: int) -> tuple[date | None, bool]:
"""Verfallsdatum für Resturlaub berechnen. """Verfallsdatum für Resturlaub. Delegiert an den Service (eine Quelle der Wahrheit)."""
Gibt (expires_at, is_expired) zurück. None wenn kein Verfall konfiguriert.""" return absence_service._carryover_expired(company, year)
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
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN) _admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_manager_roles = (UserRole.MANAGER, UserRole.HR, 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) expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
return VacationBalanceOut.model_validate(balance).model_copy(update={ return VacationBalanceOut.model_validate(balance).model_copy(update={
"pending_days": pending, "pending_days": pending,
"available_days": absence_service.effective_available(balance, expired),
"carried_over_expires_at": expires_at, "carried_over_expires_at": expires_at,
"carried_over_expired": expired, "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) expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
return VacationBalanceOut.model_validate(balance).model_copy(update={ return VacationBalanceOut.model_validate(balance).model_copy(update={
"pending_days": pending, "pending_days": pending,
"available_days": absence_service.effective_available(balance, expired),
"carried_over_expires_at": expires_at, "carried_over_expires_at": expires_at,
"carried_over_expired": expired, "carried_over_expired": expired,
}) })
+2
View File
@@ -188,6 +188,8 @@ class VacationBalanceOut(BaseModel):
used_days: int used_days: int
total_days: int total_days: int
remaining_days: int remaining_days: int
# Effektiv verfügbar: berücksichtigt verfallenen Resturlaub (zur Laufzeit befüllt).
available_days: int = 0
pending_days: float = 0 pending_days: float = 0
# Resturlaub-Verfall (wird zur Laufzeit befüllt, nicht in DB) # Resturlaub-Verfall (wird zur Laufzeit befüllt, nicht in DB)
carried_over_expires_at: date | None = None carried_over_expires_at: date | None = None
+6
View File
@@ -47,6 +47,9 @@ class CompanyOut(BaseModel):
kiosk_track_current_user: bool = True kiosk_track_current_user: bool = True
kiosk_heartbeat_interval_sec: int = 30 kiosk_heartbeat_interval_sec: int = 30
public_stamp_enabled: bool = False public_stamp_enabled: bool = False
vacation_default_days: int = 30
vacation_prorate_first_year: bool = True
vacation_from_schedule: bool = False
class PublicStampTokenStatus(BaseModel): class PublicStampTokenStatus(BaseModel):
@@ -82,6 +85,9 @@ class CompanyUpdate(BaseModel):
kiosk_track_current_user: bool | None = None kiosk_track_current_user: bool | None = None
kiosk_heartbeat_interval_sec: int | None = Field(None, ge=10, le=120) kiosk_heartbeat_interval_sec: int | None = Field(None, ge=10, le=120)
public_stamp_enabled: bool | None = None 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): class DepartmentOut(BaseModel):
+6 -1
View File
@@ -1,5 +1,5 @@
import uuid import uuid
from datetime import datetime from datetime import datetime, date
from pydantic import BaseModel, EmailStr, Field, model_validator from pydantic import BaseModel, EmailStr, Field, model_validator
@@ -27,6 +27,9 @@ class UserOut(BaseModel):
kuerzel: str | None = None kuerzel: str | None = None
personnel_number: str | None = None personnel_number: str | None = None
can_manual_time_entry: bool = False 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): class UserUpdate(BaseModel):
@@ -39,6 +42,8 @@ class UserUpdate(BaseModel):
personnel_number: str | None = Field(None, max_length=50, pattern=PERSONNEL_NUMBER_PATTERN) personnel_number: str | None = Field(None, max_length=50, pattern=PERSONNEL_NUMBER_PATTERN)
can_manual_time_entry: bool | None = None can_manual_time_entry: bool | None = None
is_active: bool | None = None is_active: bool | None = None
entry_date: date | None = None
exit_date: date | None = None
class InviteRequest(BaseModel): class InviteRequest(BaseModel):
+77 -3
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import math
from datetime import date, timedelta from datetime import date, timedelta
from uuid import UUID from uuid import UUID
@@ -168,10 +169,13 @@ class AbsenceService:
warnings: list[str] = [] warnings: list[str] = []
if absence_type.deducts_vacation: if absence_type.deducts_vacation:
balance = await self._get_or_create_balance(current_user.id, data.start_date.year, db) 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( warnings.append(
f"Urlaubskonto reicht möglicherweise nicht aus: " 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 # Überschneidung mit eigenen Abwesenheiten prüfen
@@ -631,7 +635,13 @@ class AbsenceService:
) )
) )
carried = max(0, prev.remaining_days) if prev else 0 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( balance = VacationBalance(
user_id=user_id, user_id=user_id,
year=year, year=year,
@@ -642,6 +652,70 @@ class AbsenceService:
await db.flush() await db.flush()
return balance 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( async def _deduct_vacation(
self, user_id: UUID, year: int, days: int, db: AsyncSession self, user_id: UUID, year: int, days: int, db: AsyncSession
) -> None: ) -> None:
@@ -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")
+67
View File
@@ -625,3 +625,70 @@ async def test_absence_comments(
assert lst.status_code == 200 assert lst.status_code == 200
bodies = [c["body"] for c in lst.json()] bodies = [c["body"] for c in lst.json()]
assert "Bitte zuegig pruefen" in bodies 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)
+5 -2
View File
@@ -374,8 +374,11 @@ export function AbsencesPage() {
<div><p className='text-xs text-gray-500'>Genommen</p><p className='text-xl font-bold text-gray-800'>{balance.used_days}</p></div> <div><p className='text-xs text-gray-500'>Genommen</p><p className='text-xl font-bold text-gray-800'>{balance.used_days}</p></div>
{balance.pending_days > 0 && <div><p className='text-xs text-gray-500'>Beantragt</p><p className='text-xl font-bold text-yellow-600'>{balance.pending_days}</p></div>} {balance.pending_days > 0 && <div><p className='text-xs text-gray-500'>Beantragt</p><p className='text-xl font-bold text-yellow-600'>{balance.pending_days}</p></div>}
<div> <div>
<p className='text-xs text-gray-500'>Verbleibend</p> <p className='text-xs text-gray-500'>Verfügbar</p>
<p className={`text-xl font-bold ${balance.remaining_days > 5 ? 'text-green-600' : balance.remaining_days > 0 ? 'text-yellow-600' : 'text-red-600'}`}>{balance.remaining_days}</p> <p className={`text-xl font-bold ${balance.available_days > 5 ? 'text-green-600' : balance.available_days > 0 ? 'text-yellow-600' : 'text-red-600'}`}>{balance.available_days}</p>
{balance.carried_over_expired && balance.carried_over > 0 && (
<p className='text-[11px] text-red-500'>Resturlaub verfallen</p>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -49,6 +49,10 @@ export function CompanySettingsPage() {
// Resturlaub-Verfall // Resturlaub-Verfall
const [carryoverMonth, setCarryoverMonth] = useState('') const [carryoverMonth, setCarryoverMonth] = useState('')
const [carryoverDay, setCarryoverDay] = useState('') const [carryoverDay, setCarryoverDay] = useState('')
// Urlaubsanspruch (PR2)
const [vacationDefaultDays, setVacationDefaultDays] = useState(30)
const [vacationProrate, setVacationProrate] = useState(true)
const [vacationFromSchedule, setVacationFromSchedule] = useState(false)
// Personalnummern // Personalnummern
const [pnRequired, setPnRequired] = useState(false) const [pnRequired, setPnRequired] = useState(false)
const [pnMode, setPnMode] = useState<'manual' | 'auto'>('manual') const [pnMode, setPnMode] = useState<'manual' | 'auto'>('manual')
@@ -106,6 +110,10 @@ export function CompanySettingsPage() {
setOvertimeExpiryMonth(cc.overtime_expiry_month ?? 3) setOvertimeExpiryMonth(cc.overtime_expiry_month ?? 3)
setOvertimeExpiryDay(cc.overtime_expiry_day ?? 31) setOvertimeExpiryDay(cc.overtime_expiry_day ?? 31)
setOvertimeMaxCarryoverHours(cc.overtime_max_carryover_hours ?? null) 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(() => {}) }).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)
@@ -174,6 +182,9 @@ 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,
vacation_default_days: vacationDefaultDays,
vacation_prorate_first_year: vacationProrate,
vacation_from_schedule: vacationFromSchedule,
overtime_overdraft_allowed: fzaOverdraftAllowed, overtime_overdraft_allowed: fzaOverdraftAllowed,
overtime_warning_threshold_hours: fzaWarningThreshold, overtime_warning_threshold_hours: fzaWarningThreshold,
kiosk_require_approval: kioskRequireApproval, kiosk_require_approval: kioskRequireApproval,
@@ -347,6 +358,36 @@ export function CompanySettingsPage() {
</p> </p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Jahresurlaub (Vollzeit)
</label>
<div className="flex items-center gap-2">
<input
type="number" min={0} max={365} value={vacationDefaultDays}
onChange={e => 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"
/>
<span className="text-sm text-gray-500">Tage/Jahr für neue Urlaubskonten</span>
</div>
</div>
<label className={`flex items-start gap-3 ${isAdmin ? 'cursor-pointer' : 'opacity-60'}`}>
<input type="checkbox" checked={vacationProrate} disabled={!isAdmin}
onChange={e => setVacationProrate(e.target.checked)} className="mt-0.5" />
<span className="text-sm text-gray-700">
Anteilig im Ein-/Austrittsjahr
<span className="block text-xs text-gray-400">Zwölftel-Regel anhand Ein-/Austrittsdatum des Mitarbeiters.</span>
</span>
</label>
<label className={`flex items-start gap-3 ${isAdmin ? 'cursor-pointer' : 'opacity-60'}`}>
<input type="checkbox" checked={vacationFromSchedule} disabled={!isAdmin}
onChange={e => setVacationFromSchedule(e.target.checked)} className="mt-0.5" />
<span className="text-sm text-gray-700">
Teilzeit-Anspruch aus Arbeitsplan ableiten
<span className="block text-xs text-gray-400">Anspruch = Jahresurlaub × Arbeitstage pro Woche ÷ 5.</span>
</span>
</label>
<div className="border-t border-gray-100 pt-4">
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Resturlaub verfällt am Resturlaub verfällt am
</label> </label>
+18
View File
@@ -18,6 +18,8 @@ interface UserOut {
kuerzel: string | null kuerzel: string | null
personnel_number: string | null personnel_number: string | null
can_manual_time_entry: boolean can_manual_time_entry: boolean
entry_date: string | null
exit_date: string | null
last_login: string | null last_login: string | null
created_at: string created_at: string
} }
@@ -126,6 +128,8 @@ export function UsersPage() {
const [editKuerzel, setEditKuerzel] = useState<string>('') const [editKuerzel, setEditKuerzel] = useState<string>('')
const [editPersonnelNr, setEditPersonnelNr] = useState<string>('') const [editPersonnelNr, setEditPersonnelNr] = useState<string>('')
const [editCanManual, setEditCanManual] = useState(false) const [editCanManual, setEditCanManual] = useState(false)
const [editEntryDate, setEditEntryDate] = useState<string>('')
const [editExitDate, setEditExitDate] = useState<string>('')
const [editLoading, setEditLoading] = useState(false) const [editLoading, setEditLoading] = useState(false)
// Personalnummer-Verfügbarkeitsprüfung // Personalnummer-Verfügbarkeitsprüfung
@@ -226,6 +230,8 @@ export function UsersPage() {
work_schedule_id: editScheduleId || null, work_schedule_id: editScheduleId || null,
kuerzel: editKuerzel.trim() || null, kuerzel: editKuerzel.trim() || null,
can_manual_time_entry: editCanManual, can_manual_time_entry: editCanManual,
entry_date: editEntryDate || null,
exit_date: editExitDate || null,
} }
// Personalnr. nur senden wenn geändert // Personalnr. nur senden wenn geändert
const newPn = editPersonnelNr.trim() const newPn = editPersonnelNr.trim()
@@ -423,6 +429,7 @@ export function UsersPage() {
setEditUser(u); setEditRole(u.role); setEditScheduleId(u.work_schedule_id) setEditUser(u); setEditRole(u.role); setEditScheduleId(u.work_schedule_id)
setEditKuerzel(u.kuerzel ?? ''); setEditPersonnelNr(u.personnel_number ?? '') setEditKuerzel(u.kuerzel ?? ''); setEditPersonnelNr(u.personnel_number ?? '')
setEditCanManual(u.can_manual_time_entry); setPersonnelCheck('idle') setEditCanManual(u.can_manual_time_entry); setPersonnelCheck('idle')
setEditEntryDate(u.entry_date ?? ''); setEditExitDate(u.exit_date ?? '')
}} }}
className='text-xs text-blue-600 hover:underline' className='text-xs text-blue-600 hover:underline'
> >
@@ -691,6 +698,17 @@ export function UsersPage() {
<p className='mt-1 text-xs text-gray-400'>Noch keine Arbeitszeitmodelle angelegt.</p> <p className='mt-1 text-xs text-gray-400'>Noch keine Arbeitszeitmodelle angelegt.</p>
)} )}
</div> </div>
<div className='grid grid-cols-2 gap-3'>
<div>
<label className='block text-xs font-medium text-gray-700 mb-1'>Eintrittsdatum</label>
<input type='date' value={editEntryDate} onChange={e => setEditEntryDate(e.target.value)} className={inputClass} />
</div>
<div>
<label className='block text-xs font-medium text-gray-700 mb-1'>Austrittsdatum</label>
<input type='date' value={editExitDate} onChange={e => setEditExitDate(e.target.value)} className={inputClass} />
</div>
<p className='col-span-2 -mt-1 text-xs text-gray-400'>Basis für anteilige Urlaubsberechnung (Zwölftel-Regel).</p>
</div>
<div> <div>
<label className='block text-xs font-medium text-gray-700 mb-1'> <label className='block text-xs font-medium text-gray-700 mb-1'>
Kürzel Kürzel
+1
View File
@@ -97,6 +97,7 @@ export interface VacationBalanceOut {
used_days: number used_days: number
total_days: number total_days: number
remaining_days: number remaining_days: number
available_days: number
pending_days: number pending_days: number
carried_over_expires_at: string | null carried_over_expires_at: string | null
carried_over_expired: boolean carried_over_expired: boolean