feat: Sondervertretungs-Faktoren (special_assignments)
- Neues Model SpecialAssignment mit AssignmentMode (fza|payroll|both)
- CRUD-Endpunkte unter /users/{id}/special-assignments
- Payroll-Report: GET /reports/special-assignments/payroll?year=&month=
- Migration 0029: special_assignments Tabelle + btree_gist Overlap-Constraint
- _recalculate_overtime_balance berücksichtigt FZA-Faktoren
- Frontend: Sondervertretungs-Zeiträume im UsersPage Edit-Modal
- Frontend: ReportsPage neuer Tab 'Sondervertretungen' mit Payroll-Tabelle + CSV-Export
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1185,3 +1185,60 @@ Keine Commits in dieser Session.
|
||||
- frontend/src/pages/mobile/MobileStampScreen.tsx | 2 --
|
||||
|
||||
---
|
||||
## 2026-05-24 23:58 – 00:19 (20m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
- f70736f test: test_cancel_absence an neue 200-Response angepasst (vorher 204)
|
||||
- fd382e3 test: FZA tests korrigiert (can_manual_time_entry, TimeEntryWithWarnings, CalDAV race fix)
|
||||
- 3450029 feat: Freizeitausgleich-Lücken geschlossen (Gap 1-3) + konfigurierbare Schwellwerte
|
||||
|
||||
### Geänderte Dateien
|
||||
- backend/tests/test_absences.py | 3 ++-
|
||||
|
||||
---
|
||||
## 2026-05-25 00:21 – 00:22 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
- 1170e59 fix: AuditLog bei FZA-Stornierung mit korrektem old_status und fza_hours_refunded-Flag
|
||||
|
||||
### Geänderte Dateien
|
||||
- backend/app/services/absence_service.py | 5 ++++-
|
||||
|
||||
---
|
||||
## 2026-05-25 00:26 – 00:29 (3m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- backend/app/services/absence_service.py | 5 ++++-
|
||||
|
||||
---
|
||||
## 2026-05-25 00:33 – 00:39 (6m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- backend/app/services/absence_service.py | 5 ++++-
|
||||
|
||||
---
|
||||
## 2026-05-25 00:42 – 00:43 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- backend/app/services/absence_service.py | 5 ++++-
|
||||
|
||||
---
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.routers import import_kimai
|
||||
from app.routers import kiosk
|
||||
from app.routers import busylight
|
||||
from app.routers import audit
|
||||
from app.routers import special_assignments
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -77,6 +78,7 @@ app.include_router(import_kimai.router, prefix=API_PREFIX)
|
||||
app.include_router(kiosk.router, prefix=API_PREFIX)
|
||||
app.include_router(busylight.router, prefix=API_PREFIX)
|
||||
app.include_router(audit.router, prefix=API_PREFIX)
|
||||
app.include_router(special_assignments.router, prefix=API_PREFIX)
|
||||
|
||||
|
||||
# ── Health ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.models.public_holiday import PublicHoliday
|
||||
from app.models.smtp_config import SmtpConfig
|
||||
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
|
||||
from app.models.kiosk_device import KioskDevice, KioskAuthMethod
|
||||
from app.models.special_assignment import SpecialAssignment, AssignmentMode
|
||||
|
||||
__all__ = [
|
||||
"Company",
|
||||
@@ -35,4 +36,6 @@ __all__ = [
|
||||
"PublicHoliday",
|
||||
"KioskDevice",
|
||||
"KioskAuthMethod",
|
||||
"SpecialAssignment",
|
||||
"AssignmentMode",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Sondervertretungs-Faktoren: Per-User Zeitraum-Zuweisung mit Multiplikator."""
|
||||
import enum
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import CheckConstraint, Date, Enum, ForeignKey, Numeric, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AssignmentMode(str, enum.Enum):
|
||||
fza = "fza" # Nur FZA-Stunden-Anrechnung
|
||||
payroll = "payroll" # Nur Gehaltsabrechnung (Bericht)
|
||||
both = "both" # Beides
|
||||
|
||||
|
||||
class SpecialAssignment(Base):
|
||||
"""Sondervertretungs-Zeitraum mit Faktor für einen Mitarbeiter."""
|
||||
__tablename__ = "special_assignments"
|
||||
__table_args__ = (
|
||||
CheckConstraint("factor > 0 AND factor <= 10", name="ck_special_assignment_factor"),
|
||||
CheckConstraint("date_from <= date_to", name="ck_special_assignment_dates"),
|
||||
)
|
||||
|
||||
id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||
user_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
company_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
date_from: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
date_to: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
|
||||
factor: Mapped[Decimal] = mapped_column(Numeric(5, 3), nullable=False)
|
||||
mode: Mapped[AssignmentMode] = mapped_column(Enum(AssignmentMode, name="assignment_mode"), nullable=False, default=AssignmentMode.both)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
label: Mapped[str | None] = mapped_column(String(100), nullable=True) # z.B. "Schichtleiter Vertretung"
|
||||
@@ -0,0 +1,246 @@
|
||||
"""CRUD für Sondervertretungs-Zeiträume (Special Assignments)."""
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import require_role
|
||||
from app.models.special_assignment import AssignmentMode, SpecialAssignment
|
||||
from app.models.time_entry import EntryStatus, TimeEntry
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas.special_assignment import (
|
||||
PayrollAssignmentEntry,
|
||||
PayrollAssignmentReport,
|
||||
PayrollAssignmentRow,
|
||||
SpecialAssignmentCreate,
|
||||
SpecialAssignmentOut,
|
||||
SpecialAssignmentUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["Sondervertretungen"])
|
||||
|
||||
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||
|
||||
|
||||
async def _get_assignment_or_404(
|
||||
assignment_id: UUID, company_id: UUID, db: AsyncSession
|
||||
) -> SpecialAssignment:
|
||||
sa = await db.get(SpecialAssignment, assignment_id)
|
||||
if sa is None or sa.company_id != company_id:
|
||||
raise HTTPException(404, "Zuweisung nicht gefunden")
|
||||
return sa
|
||||
|
||||
|
||||
async def _check_overlap(
|
||||
user_id: UUID,
|
||||
date_from: date,
|
||||
date_to: date,
|
||||
db: AsyncSession,
|
||||
exclude_id: UUID | None = None,
|
||||
) -> None:
|
||||
"""Prüft ob sich der Zeitraum mit einer vorhandenen Zuweisung überschneidet."""
|
||||
q = select(SpecialAssignment).where(
|
||||
SpecialAssignment.user_id == user_id,
|
||||
SpecialAssignment.date_from <= date_to,
|
||||
SpecialAssignment.date_to >= date_from,
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.where(SpecialAssignment.id != exclude_id)
|
||||
existing = await db.scalar(q)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"Zeitraum überschneidet sich mit vorhandener Zuweisung "
|
||||
f"({existing.date_from}–{existing.date_to})",
|
||||
)
|
||||
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/users/{user_id}/special-assignments", response_model=list[SpecialAssignmentOut])
|
||||
async def list_assignments(
|
||||
user_id: UUID,
|
||||
current_user: User = Depends(require_role(*_manager_roles)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
target = await db.get(User, user_id)
|
||||
if not target or target.company_id != current_user.company_id:
|
||||
raise HTTPException(404, "Mitarbeiter nicht gefunden")
|
||||
rows = list(await db.scalars(
|
||||
select(SpecialAssignment)
|
||||
.where(SpecialAssignment.user_id == user_id)
|
||||
.order_by(SpecialAssignment.date_from)
|
||||
))
|
||||
return [SpecialAssignmentOut.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/special-assignments", response_model=SpecialAssignmentOut, status_code=201)
|
||||
async def create_assignment(
|
||||
user_id: UUID,
|
||||
data: SpecialAssignmentCreate,
|
||||
current_user: User = Depends(require_role(*_manager_roles)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
target = await db.get(User, user_id)
|
||||
if not target or target.company_id != current_user.company_id:
|
||||
raise HTTPException(404, "Mitarbeiter nicht gefunden")
|
||||
await _check_overlap(user_id, data.date_from, data.date_to, db)
|
||||
sa = SpecialAssignment(
|
||||
user_id=user_id,
|
||||
company_id=current_user.company_id,
|
||||
**data.model_dump(),
|
||||
)
|
||||
db.add(sa)
|
||||
await db.commit()
|
||||
await db.refresh(sa)
|
||||
return SpecialAssignmentOut.model_validate(sa)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}/special-assignments/{assignment_id}", response_model=SpecialAssignmentOut)
|
||||
async def update_assignment(
|
||||
user_id: UUID,
|
||||
assignment_id: UUID,
|
||||
data: SpecialAssignmentUpdate,
|
||||
current_user: User = Depends(require_role(*_manager_roles)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sa = await _get_assignment_or_404(assignment_id, current_user.company_id, db)
|
||||
if sa.user_id != user_id:
|
||||
raise HTTPException(404, "Zuweisung nicht gefunden")
|
||||
updates = data.model_dump(exclude_unset=True)
|
||||
new_from = updates.get("date_from", sa.date_from)
|
||||
new_to = updates.get("date_to", sa.date_to)
|
||||
if new_from > new_to:
|
||||
raise HTTPException(422, "date_from darf nicht nach date_to liegen")
|
||||
await _check_overlap(user_id, new_from, new_to, db, exclude_id=assignment_id)
|
||||
for field, value in updates.items():
|
||||
setattr(sa, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(sa)
|
||||
return SpecialAssignmentOut.model_validate(sa)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}/special-assignments/{assignment_id}", status_code=204)
|
||||
async def delete_assignment(
|
||||
user_id: UUID,
|
||||
assignment_id: UUID,
|
||||
current_user: User = Depends(require_role(*_manager_roles)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sa = await _get_assignment_or_404(assignment_id, current_user.company_id, db)
|
||||
if sa.user_id != user_id:
|
||||
raise HTTPException(404, "Zuweisung nicht gefunden")
|
||||
await db.delete(sa)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Payroll-Report ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/reports/special-assignments/payroll", response_model=PayrollAssignmentReport)
|
||||
async def payroll_report(
|
||||
year: int = Query(..., ge=2000, le=2100),
|
||||
month: int = Query(..., ge=1, le=12),
|
||||
current_user: User = Depends(require_role(*_manager_roles)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Payroll-Report: Für jeden Mitarbeiter die Sondervertretungs-Stunden im Monat."""
|
||||
from calendar import monthrange
|
||||
first_day = date(year, month, 1)
|
||||
last_day = date(year, month, monthrange(year, month)[1])
|
||||
|
||||
# Alle Zuweisungen der Firma, die den Monat überschneiden
|
||||
assignments = list(await db.scalars(
|
||||
select(SpecialAssignment).where(
|
||||
SpecialAssignment.company_id == current_user.company_id,
|
||||
SpecialAssignment.mode.in_([AssignmentMode.payroll, AssignmentMode.both]),
|
||||
SpecialAssignment.date_from <= last_day,
|
||||
SpecialAssignment.date_to >= first_day,
|
||||
).order_by(SpecialAssignment.user_id, SpecialAssignment.date_from)
|
||||
))
|
||||
|
||||
if not assignments:
|
||||
return PayrollAssignmentReport(year=year, month=month, rows=[])
|
||||
|
||||
# User-IDs ermitteln
|
||||
user_ids = list({a.user_id for a in assignments})
|
||||
users_map: dict[UUID, User] = {}
|
||||
for uid in user_ids:
|
||||
u = await db.get(User, uid)
|
||||
if u:
|
||||
users_map[uid] = u
|
||||
|
||||
# Genehmigte Zeit-Einträge für betroffene User im Monat laden
|
||||
entries = list(await db.scalars(
|
||||
select(TimeEntry).where(
|
||||
TimeEntry.user_id.in_(user_ids),
|
||||
TimeEntry.date >= first_day,
|
||||
TimeEntry.date <= last_day,
|
||||
TimeEntry.status == EntryStatus.APPROVED,
|
||||
TimeEntry.end_time.isnot(None),
|
||||
)
|
||||
))
|
||||
|
||||
# Gruppierung: user_id → list[TimeEntry]
|
||||
entries_by_user: dict[UUID, list[TimeEntry]] = {}
|
||||
for e in entries:
|
||||
entries_by_user.setdefault(e.user_id, []).append(e)
|
||||
|
||||
rows: list[PayrollAssignmentRow] = []
|
||||
|
||||
# Groupby user_id
|
||||
from itertools import groupby
|
||||
sorted_assignments = sorted(assignments, key=lambda a: a.user_id)
|
||||
for uid, user_assignments_iter in groupby(sorted_assignments, key=lambda a: a.user_id):
|
||||
user_assignments = list(user_assignments_iter)
|
||||
user = users_map.get(uid)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
user_entries = entries_by_user.get(uid, [])
|
||||
# Index: date → worked_hours
|
||||
hours_by_date: dict[date, float] = {}
|
||||
for e in user_entries:
|
||||
hours_by_date[e.date] = hours_by_date.get(e.date, 0.0) + (e.worked_hours or 0.0)
|
||||
|
||||
assignment_entries: list[PayrollAssignmentEntry] = []
|
||||
for sa in user_assignments:
|
||||
# Effektiver Zeitraum innerhalb des Monats
|
||||
eff_from = max(sa.date_from, first_day)
|
||||
eff_to = min(sa.date_to, last_day)
|
||||
|
||||
# Stunden im Zeitraum summieren
|
||||
normal_hours = sum(
|
||||
hours for d, hours in hours_by_date.items()
|
||||
if eff_from <= d <= eff_to
|
||||
)
|
||||
factor_hours = round(normal_hours * float(sa.factor), 2)
|
||||
extra_hours = round(factor_hours - normal_hours, 2)
|
||||
|
||||
assignment_entries.append(PayrollAssignmentEntry(
|
||||
assignment_id=sa.id,
|
||||
label=sa.label,
|
||||
date_from=eff_from,
|
||||
date_to=eff_to,
|
||||
factor=sa.factor,
|
||||
normal_hours=round(normal_hours, 2),
|
||||
factor_hours=factor_hours,
|
||||
extra_hours=extra_hours,
|
||||
))
|
||||
|
||||
total_normal = sum(e.normal_hours for e in assignment_entries)
|
||||
total_factor = sum(e.factor_hours for e in assignment_entries)
|
||||
|
||||
rows.append(PayrollAssignmentRow(
|
||||
user_id=uid,
|
||||
user_name=f"{user.first_name} {user.last_name}",
|
||||
personnel_number=getattr(user, "personnel_number", None),
|
||||
assignments=assignment_entries,
|
||||
total_normal_hours=round(total_normal, 2),
|
||||
total_factor_hours=round(total_factor, 2),
|
||||
total_extra_hours=round(total_factor - total_normal, 2),
|
||||
))
|
||||
|
||||
return PayrollAssignmentReport(year=year, month=month, rows=rows)
|
||||
@@ -0,0 +1,76 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.models.special_assignment import AssignmentMode
|
||||
|
||||
|
||||
class SpecialAssignmentCreate(BaseModel):
|
||||
date_from: date
|
||||
date_to: date
|
||||
factor: Decimal = Field(gt=0, le=10, decimal_places=3)
|
||||
mode: AssignmentMode = AssignmentMode.both
|
||||
description: str | None = None
|
||||
label: str | None = Field(None, max_length=100)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def dates_valid(self) -> "SpecialAssignmentCreate":
|
||||
if self.date_from > self.date_to:
|
||||
raise ValueError("date_from darf nicht nach date_to liegen")
|
||||
return self
|
||||
|
||||
|
||||
class SpecialAssignmentUpdate(BaseModel):
|
||||
date_from: date | None = None
|
||||
date_to: date | None = None
|
||||
factor: Decimal | None = Field(None, gt=0, le=10)
|
||||
mode: AssignmentMode | None = None
|
||||
description: str | None = None
|
||||
label: str | None = Field(None, max_length=100)
|
||||
|
||||
|
||||
class SpecialAssignmentOut(BaseModel):
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
company_id: UUID
|
||||
date_from: date
|
||||
date_to: date
|
||||
factor: Decimal
|
||||
mode: AssignmentMode
|
||||
description: str | None = None
|
||||
label: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Payroll-Report-Schemas ────────────────────────────────────────────────────
|
||||
|
||||
class PayrollAssignmentEntry(BaseModel):
|
||||
"""Einzelner Zeitraum mit Faktor für den Payroll-Report."""
|
||||
assignment_id: UUID
|
||||
label: str | None
|
||||
date_from: date
|
||||
date_to: date
|
||||
factor: Decimal
|
||||
normal_hours: float # Stunden ohne Faktor
|
||||
factor_hours: float # Stunden * Faktor (effektiv für Abrechnung)
|
||||
extra_hours: float # factor_hours - normal_hours (Mehrwert)
|
||||
|
||||
|
||||
class PayrollAssignmentRow(BaseModel):
|
||||
"""Zusammenfassung pro Mitarbeiter."""
|
||||
user_id: UUID
|
||||
user_name: str
|
||||
personnel_number: str | None
|
||||
assignments: list[PayrollAssignmentEntry]
|
||||
total_normal_hours: float
|
||||
total_factor_hours: float
|
||||
total_extra_hours: float
|
||||
|
||||
|
||||
class PayrollAssignmentReport(BaseModel):
|
||||
year: int
|
||||
month: int
|
||||
rows: list[PayrollAssignmentRow]
|
||||
@@ -181,7 +181,14 @@ async def _get_or_create_overtime_balance(user: User, db: AsyncSession) -> Overt
|
||||
async def _recalculate_overtime_balance(
|
||||
user: User, schedule: WorkSchedule | None, db: AsyncSession
|
||||
) -> OvertimeBalance:
|
||||
"""Berechnet das Überstundenguthaben neu aus allen genehmigten Zeiteinträgen."""
|
||||
"""Berechnet das Überstundenguthaben neu aus allen genehmigten Zeiteinträgen.
|
||||
|
||||
Sondervertretungs-Faktoren (mode=fza|both) werden berücksichtigt:
|
||||
Für jeden Zeiteintrag wird geprüft ob der Tag in einem aktiven Sondervertretungs-
|
||||
Zeitraum liegt; falls ja wird worked_hours mit dem Faktor multipliziert.
|
||||
"""
|
||||
from app.models.special_assignment import AssignmentMode, SpecialAssignment
|
||||
|
||||
entries = list(await db.scalars(
|
||||
select(TimeEntry).where(
|
||||
TimeEntry.user_id == user.id,
|
||||
@@ -197,10 +204,27 @@ async def _recalculate_overtime_balance(
|
||||
bal.last_calculated = datetime.utcnow()
|
||||
return bal
|
||||
|
||||
date_from = min(e.date for e in entries)
|
||||
date_to = max(e.date for e in entries)
|
||||
expected = _expected_hours(schedule, date_from, date_to)
|
||||
worked = sum(e.worked_hours or 0.0 for e in entries)
|
||||
# Sondervertretungs-Zuweisungen laden (nur FZA-relevante)
|
||||
date_from_all = min(e.date for e in entries)
|
||||
date_to_all = max(e.date for e in entries)
|
||||
special_assignments = list(await db.scalars(
|
||||
select(SpecialAssignment).where(
|
||||
SpecialAssignment.user_id == user.id,
|
||||
SpecialAssignment.mode.in_([AssignmentMode.fza, AssignmentMode.both]),
|
||||
SpecialAssignment.date_from <= date_to_all,
|
||||
SpecialAssignment.date_to >= date_from_all,
|
||||
)
|
||||
))
|
||||
|
||||
def _fza_factor(entry_date: date) -> float:
|
||||
"""Gibt den Faktor für einen Tag zurück (1.0 wenn keine Zuweisung aktiv)."""
|
||||
for sa in special_assignments:
|
||||
if sa.date_from <= entry_date <= sa.date_to:
|
||||
return float(sa.factor)
|
||||
return 1.0
|
||||
|
||||
expected = _expected_hours(schedule, date_from_all, date_to_all)
|
||||
worked = sum((e.worked_hours or 0.0) * _fza_factor(e.date) for e in entries)
|
||||
overtime = max(0.0, worked - expected)
|
||||
|
||||
bal.total_hours = Decimal(str(round(overtime, 2)))
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Sondervertretungs-Faktoren: special_assignments Tabelle
|
||||
|
||||
Revision ID: 0029
|
||||
Revises: 0028
|
||||
Create Date: 2026-05-25
|
||||
|
||||
Neue Tabelle special_assignments:
|
||||
- user_id + company_id (ForeignKeys mit CASCADE)
|
||||
- date_from / date_to
|
||||
- factor NUMERIC(5,3) – Multiplikator (z.B. 1.5)
|
||||
- mode ENUM(fza|payroll|both)
|
||||
- label / description (optional)
|
||||
- Overlap-Check per Constraint (date_from <= date_to) + App-seitige Prüfung
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0029"
|
||||
down_revision = "0028"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS btree_gist")
|
||||
|
||||
# Enum erzeugen
|
||||
op.execute("CREATE TYPE assignment_mode AS ENUM ('fza', 'payroll', 'both')")
|
||||
|
||||
op.create_table(
|
||||
"special_assignments",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("company_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("companies.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("date_from", sa.Date, nullable=False),
|
||||
sa.Column("date_to", sa.Date, nullable=False),
|
||||
sa.Column("factor", sa.Numeric(5, 3), nullable=False),
|
||||
sa.Column("mode", sa.Enum("fza", "payroll", "both", name="assignment_mode", create_type=False), nullable=False, server_default="both"),
|
||||
sa.Column("label", sa.String(100), nullable=True),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.CheckConstraint("factor > 0 AND factor <= 10", name="ck_special_assignment_factor"),
|
||||
sa.CheckConstraint("date_from <= date_to", name="ck_special_assignment_dates"),
|
||||
)
|
||||
|
||||
# Exclusion Constraint: kein überlappender Zeitraum pro User
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE special_assignments
|
||||
ADD CONSTRAINT special_assignments_no_overlap
|
||||
EXCLUDE USING gist (
|
||||
user_id WITH =,
|
||||
daterange(date_from, date_to, '[]') WITH &&
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("special_assignments")
|
||||
op.execute("DROP TYPE IF EXISTS assignment_mode")
|
||||
@@ -28,7 +28,7 @@ def _rls_using_join(): return (
|
||||
_COMPANY_COL_TABLES = [
|
||||
"absence_types", "audit_logs", "caldav_company_configs", "departments",
|
||||
"kiosk_devices", "ldap_configs", "overtime_balances", "smtp_configs",
|
||||
"users", "work_schedules",
|
||||
"special_assignments", "users", "work_schedules",
|
||||
]
|
||||
_USER_JOIN_TABLES = [
|
||||
"absences", "caldav_user_configs", "password_resets",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { Spinner } from '../components/Spinner'
|
||||
import { Layout } from '../components/Layout'
|
||||
import type { PayrollAssignmentReport } from '../types/specialAssignment'
|
||||
|
||||
interface UserOut {
|
||||
id: string; first_name: string; last_name: string; email: string; role: string
|
||||
@@ -68,7 +69,7 @@ interface SickStatsRow {
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
type ReportType = 'time' | 'absences' | 'overtime' | 'sick'
|
||||
type ReportType = 'time' | 'absences' | 'overtime' | 'sick' | 'special'
|
||||
const MANAGER_ROLES = ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER']
|
||||
|
||||
const STATUS_DE: Record<string, string> = {
|
||||
@@ -132,6 +133,10 @@ export function ReportsPage() {
|
||||
const [expandedUsers, setExpandedUsers] = useState<Set<string>>(new Set())
|
||||
const [expandedWeeks, setExpandedWeeks] = useState<Set<string>>(new Set())
|
||||
const [sickStats, setSickStats] = useState<SickStatsRow[] | null>(null)
|
||||
const [payrollReport, setPayrollReport] = useState<PayrollAssignmentReport | null>(null)
|
||||
// für Sondervertretungs-Tab: Jahr/Monat-Auswahl
|
||||
const [specialYear, setSpecialYear] = useState(new Date().getFullYear())
|
||||
const [specialMonth, setSpecialMonth] = useState(new Date().getMonth() + 1)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -162,6 +167,12 @@ export function ReportsPage() {
|
||||
const stats = await api.get<SickStatsRow[]>(`/absences/sick-stats${params}`)
|
||||
setSickStats(stats)
|
||||
setTimeReport(null); setAbsenceReport(null); setOvertimeReport(null)
|
||||
} else if (type === 'special') {
|
||||
const report = await api.get<PayrollAssignmentReport>(
|
||||
`/reports/special-assignments/payroll?year=${specialYear}&month=${specialMonth}`
|
||||
)
|
||||
setPayrollReport(report)
|
||||
setTimeReport(null); setAbsenceReport(null); setOvertimeReport(null); setSickStats(null)
|
||||
} else {
|
||||
const [simple, detail] = await Promise.all([
|
||||
api.get<OvertimeReport>(`/reports/overtime${p}`),
|
||||
@@ -171,13 +182,14 @@ export function ReportsPage() {
|
||||
setOvertimeDetail(detail)
|
||||
setExpandedUsers(new Set())
|
||||
setExpandedWeeks(new Set())
|
||||
setTimeReport(null); setAbsenceReport(null); setSickStats(null)
|
||||
setTimeReport(null); setAbsenceReport(null); setSickStats(null); setPayrollReport(null)
|
||||
}
|
||||
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const download = async (format: 'csv' | 'xlsx' | 'pdf') => {
|
||||
if (type === 'special' || type === 'sick') return // eigener Export in der Tabelle
|
||||
const p = `?date_from=${dateFrom}&date_to=${dateTo}${filterUser ? `&user_id=${filterUser}` : ''}&format=${format}`
|
||||
const ep = type === 'time' ? `/reports/time/export${p}` : type === 'absences' ? `/reports/absences/export${p}` : `/reports/overtime/export${p}`
|
||||
const token = localStorage.getItem('access_token')
|
||||
@@ -190,7 +202,7 @@ export function ReportsPage() {
|
||||
|
||||
const setQuick = (from: string, to: string) => { setDateFrom(from); setDateTo(to) }
|
||||
|
||||
const hasResult = !!(timeReport || absenceReport || overtimeReport || sickStats)
|
||||
const hasResult = !!(timeReport || absenceReport || overtimeReport || sickStats || payrollReport)
|
||||
|
||||
return (
|
||||
<Layout userRole={user?.role ?? ''} userName={user ? `${user.first_name} ${user.last_name}` : ''}>
|
||||
@@ -206,7 +218,7 @@ export function ReportsPage() {
|
||||
|
||||
{/* Report type */}
|
||||
<div className='flex rounded-lg border border-gray-300 overflow-hidden w-fit'>
|
||||
{([['time','Zeiterfassung'],['absences','Abwesenheiten'],['overtime','Überstunden'],['sick','Krankmeldungen']] as [ReportType,string][]).map(([v,l]) => (
|
||||
{([['time','Zeiterfassung'],['absences','Abwesenheiten'],['overtime','Überstunden'],['sick','Krankmeldungen'],['special','Sondervertretungen']] as [ReportType,string][]).map(([v,l]) => (
|
||||
<button key={v} onClick={() => setType(v)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${type===v ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-50'}`}>
|
||||
{l}
|
||||
@@ -215,6 +227,8 @@ export function ReportsPage() {
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-3 items-end'>
|
||||
{type !== 'special' && (
|
||||
<>
|
||||
{/* Quick-select */}
|
||||
<div>
|
||||
<p className='text-xs font-medium text-gray-500 mb-1'>Schnellauswahl</p>
|
||||
@@ -227,7 +241,6 @@ export function ReportsPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date range */}
|
||||
<div>
|
||||
<p className='text-xs font-medium text-gray-500 mb-1'>Von</p>
|
||||
@@ -239,9 +252,31 @@ export function ReportsPage() {
|
||||
<input type='date' value={dateTo} min={dateFrom} onChange={e => setDateTo(e.target.value)}
|
||||
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Employee filter (manager only) */}
|
||||
{isManager && colleagues.length > 0 && (
|
||||
{type === 'special' && (
|
||||
<>
|
||||
<div>
|
||||
<p className='text-xs font-medium text-gray-500 mb-1'>Jahr</p>
|
||||
<input type='number' value={specialYear} min={2020} max={2100}
|
||||
onChange={e => setSpecialYear(parseInt(e.target.value))}
|
||||
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-28' />
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs font-medium text-gray-500 mb-1'>Monat</p>
|
||||
<select value={specialMonth} onChange={e => setSpecialMonth(parseInt(e.target.value))}
|
||||
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'>
|
||||
{['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'].map((m, i) => (
|
||||
<option key={i+1} value={i+1}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Employee filter (manager only) – nicht für special */}
|
||||
{isManager && colleagues.length > 0 && type !== 'special' && (
|
||||
<div>
|
||||
<p className='text-xs font-medium text-gray-500 mb-1'>Mitarbeiter</p>
|
||||
<select value={filterUser} onChange={e => setFilterUser(e.target.value)}
|
||||
@@ -269,15 +304,15 @@ export function ReportsPage() {
|
||||
<div className='px-6 py-4 border-b border-gray-100 flex items-center justify-between flex-wrap gap-3'>
|
||||
<div>
|
||||
<h2 className='font-semibold text-gray-800'>
|
||||
{type === 'time' ? 'Zeiterfassung' : type === 'absences' ? 'Abwesenheiten' : type === 'overtime' ? 'Überstunden' : 'Krankmeldungen'}
|
||||
{type === 'time' ? 'Zeiterfassung' : type === 'absences' ? 'Abwesenheiten' : type === 'overtime' ? 'Überstunden' : type === 'special' ? 'Sondervertretungen' : 'Krankmeldungen'}
|
||||
</h2>
|
||||
<p className='text-xs text-gray-400 mt-0.5'>
|
||||
{type === 'sick' ? 'Rolling 12 Monate ab heute' : `${fmtDate(dateFrom)} – ${fmtDate(dateTo)}`}
|
||||
{type === 'sick' ? 'Rolling 12 Monate ab heute' : type === 'special' ? `${specialYear}, Monat ${specialMonth}` : `${fmtDate(dateFrom)} – ${fmtDate(dateTo)}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex gap-2 no-print'>
|
||||
<button onClick={() => window.print()} className='text-xs px-3 py-1.5 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'>Drucken</button>
|
||||
{type !== 'sick' && (<>
|
||||
{type !== 'sick' && type !== 'special' && (<>
|
||||
<button onClick={() => download('csv')} className='text-xs px-3 py-1.5 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'>CSV</button>
|
||||
<button onClick={() => download('xlsx')} className='text-xs px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700'>Excel</button>
|
||||
<button onClick={() => download('pdf')} className='text-xs px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700'>PDF</button>
|
||||
@@ -759,6 +794,101 @@ export function ReportsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Sondervertretungen Payroll-Report ── */}
|
||||
{payrollReport && (
|
||||
<div className='overflow-x-auto'>
|
||||
{payrollReport.rows.length === 0 ? (
|
||||
<p className='text-center text-gray-400 py-12 text-sm'>
|
||||
Keine Sondervertretungs-Zuweisungen in {payrollReport.month}/{payrollReport.year}.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex justify-end px-4 py-2 no-print'>
|
||||
<button
|
||||
onClick={() => {
|
||||
const rows = payrollReport.rows.flatMap(r =>
|
||||
r.assignments.map(a => [
|
||||
r.user_name, r.personnel_number ?? '',
|
||||
a.label ?? '', a.date_from, a.date_to,
|
||||
a.factor, a.normal_hours, a.factor_hours, a.extra_hours
|
||||
])
|
||||
)
|
||||
const header = 'Mitarbeiter,Pers-Nr,Bezeichnung,Von,Bis,Faktor,Normal-Std,Faktor-Std,Extra-Std'
|
||||
const csv = [header, ...rows.map(r => r.join(','))].join('\n')
|
||||
const blob = new Blob([csv], { type: 'text/csv' })
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob)
|
||||
a.download = `sondervertretung_${payrollReport.year}_${String(payrollReport.month).padStart(2,'0')}.csv`; a.click()
|
||||
}}
|
||||
className='px-3 py-1.5 text-xs border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'
|
||||
>
|
||||
CSV Export
|
||||
</button>
|
||||
</div>
|
||||
<table className='w-full text-sm'>
|
||||
<thead className='bg-amber-50 text-xs text-amber-700 uppercase tracking-wide'>
|
||||
<tr>
|
||||
<th className='px-4 py-3 text-left'>Mitarbeiter</th>
|
||||
<th className='px-4 py-3 text-left'>Pers.-Nr.</th>
|
||||
<th className='px-4 py-3 text-left'>Bezeichnung</th>
|
||||
<th className='px-4 py-3 text-left'>Zeitraum</th>
|
||||
<th className='px-4 py-3 text-right'>Faktor</th>
|
||||
<th className='px-4 py-3 text-right'>Normal-Std.</th>
|
||||
<th className='px-4 py-3 text-right'>Faktor-Std.</th>
|
||||
<th className='px-4 py-3 text-right font-bold'>Extra-Std.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='divide-y divide-gray-100'>
|
||||
{payrollReport.rows.map(row => (
|
||||
row.assignments.map((a, i) => (
|
||||
<tr key={a.assignment_id} className='hover:bg-amber-50/30'>
|
||||
{i === 0 && (
|
||||
<>
|
||||
<td className='px-4 py-3 font-medium text-gray-800' rowSpan={row.assignments.length}>
|
||||
{row.user_name}
|
||||
{row.assignments.length > 1 && (
|
||||
<div className='text-xs text-gray-400 mt-0.5'>
|
||||
Gesamt: +{row.total_extra_hours.toFixed(1)}h
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className='px-4 py-3 text-gray-500 font-mono text-xs' rowSpan={row.assignments.length}>
|
||||
{row.personnel_number ?? '–'}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td className='px-4 py-3 text-gray-700'>{a.label ?? '–'}</td>
|
||||
<td className='px-4 py-3 text-gray-600 text-xs'>{a.date_from} – {a.date_to}</td>
|
||||
<td className='px-4 py-3 text-right font-semibold text-amber-700'>×{Number(a.factor).toFixed(2)}</td>
|
||||
<td className='px-4 py-3 text-right text-gray-600'>{a.normal_hours.toFixed(1)}</td>
|
||||
<td className='px-4 py-3 text-right text-gray-700'>{a.factor_hours.toFixed(1)}</td>
|
||||
<td className='px-4 py-3 text-right font-semibold text-green-700'>+{a.extra_hours.toFixed(1)}</td>
|
||||
</tr>
|
||||
))
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className='bg-gray-50 text-sm font-semibold'>
|
||||
<tr>
|
||||
<td colSpan={5} className='px-4 py-3 text-gray-700'>Gesamt</td>
|
||||
<td className='px-4 py-3 text-right'>
|
||||
{payrollReport.rows.reduce((s, r) => s + r.total_normal_hours, 0).toFixed(1)}
|
||||
</td>
|
||||
<td className='px-4 py-3 text-right'>
|
||||
{payrollReport.rows.reduce((s, r) => s + r.total_factor_hours, 0).toFixed(1)}
|
||||
</td>
|
||||
<td className='px-4 py-3 text-right text-green-700'>
|
||||
+{payrollReport.rows.reduce((s, r) => s + r.total_extra_hours, 0).toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
<p className='text-xs text-gray-400 px-4 py-3 border-t border-gray-100'>
|
||||
Sondervertretungs-Faktor: Stunden × Faktor (z.B. ×1,5 für Schichtleiter-Vertretung). Extra-Stunden = Differenz zur Normarbeitszeit.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Sick stats table ── */}
|
||||
{sickStats && (
|
||||
<div className='overflow-x-auto'>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { SpecialAssignmentOut, SpecialAssignmentCreate, AssignmentMode } from '../types/specialAssignment'
|
||||
import { api } from '../api/client'
|
||||
import { Spinner } from '../components/Spinner'
|
||||
import { Layout } from '../components/Layout'
|
||||
@@ -134,6 +135,15 @@ export function UsersPage() {
|
||||
// Company-Settings (für Auto-Modus / Pflicht-Anzeige)
|
||||
const [company, setCompany] = useState<CompanyOut | null>(null)
|
||||
|
||||
// Sondervertretungs-Zuweisungen im Edit-Modal
|
||||
const [assignments, setAssignments] = useState<SpecialAssignmentOut[]>([])
|
||||
const [assignmentsLoading, setAssignmentsLoading] = useState(false)
|
||||
const [newAssignment, setNewAssignment] = useState<SpecialAssignmentCreate>({
|
||||
date_from: '', date_to: '', factor: 1.5, mode: 'both',
|
||||
})
|
||||
const [assignmentSaving, setAssignmentSaving] = useState(false)
|
||||
const [assignmentError, setAssignmentError] = useState('')
|
||||
|
||||
// CSV-Import modal
|
||||
const [showImport, setShowImport] = useState(false)
|
||||
const [importFile, setImportFile] = useState<File | null>(null)
|
||||
@@ -419,7 +429,17 @@ export function UsersPage() {
|
||||
<td className='px-4 py-3'>
|
||||
<div className='flex gap-2 justify-end'>
|
||||
<button
|
||||
onClick={() => { 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') }}
|
||||
onClick={() => {
|
||||
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')
|
||||
setAssignmentError('')
|
||||
setAssignmentsLoading(true)
|
||||
api.get<SpecialAssignmentOut[]>(`/users/${u.id}/special-assignments`).then(r => {
|
||||
setAssignments(r)
|
||||
setAssignmentsLoading(false)
|
||||
}).catch(() => setAssignmentsLoading(false))
|
||||
}}
|
||||
className='text-xs text-blue-600 hover:underline'
|
||||
>
|
||||
Bearbeiten
|
||||
@@ -763,6 +783,96 @@ export function UsersPage() {
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{/* ── Sondervertretungs-Zeiträume ── */}
|
||||
<div className='border-t border-gray-100 pt-3'>
|
||||
<h4 className='text-xs font-semibold text-gray-700 mb-2'>🏅 Sondervertretungs-Zeiträume</h4>
|
||||
{assignmentsLoading ? (
|
||||
<p className='text-xs text-gray-400'>Lade…</p>
|
||||
) : (
|
||||
<>
|
||||
{assignments.length === 0 && (
|
||||
<p className='text-xs text-gray-400 mb-2'>Keine Zuweisungen vorhanden.</p>
|
||||
)}
|
||||
{assignments.map(a => (
|
||||
<div key={a.id} className='flex items-center justify-between bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-1.5 text-xs'>
|
||||
<div>
|
||||
<span className='font-medium text-amber-800'>{a.label || 'Sondervertretung'}</span>
|
||||
<span className='ml-2 text-amber-700'>{a.date_from} – {a.date_to}</span>
|
||||
<span className='ml-2 font-semibold text-amber-900'>×{Number(a.factor).toFixed(2)}</span>
|
||||
<span className='ml-2 text-gray-500'>({a.mode})</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await api.del(`/users/${editUser!.id}/special-assignments/${a.id}`)
|
||||
setAssignments(prev => prev.filter(x => x.id !== a.id))
|
||||
}}
|
||||
className='text-red-500 hover:text-red-700 ml-2 font-bold'
|
||||
title='Löschen'
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
{/* Neue Zuweisung anlegen */}
|
||||
<div className='grid grid-cols-2 gap-2 mt-2'>
|
||||
<div>
|
||||
<label className='text-xs text-gray-600'>Von</label>
|
||||
<input type='date' value={newAssignment.date_from}
|
||||
onChange={e => setNewAssignment(p => ({ ...p, date_from: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-2 py-1.5 text-xs mt-0.5' />
|
||||
</div>
|
||||
<div>
|
||||
<label className='text-xs text-gray-600'>Bis</label>
|
||||
<input type='date' value={newAssignment.date_to}
|
||||
onChange={e => setNewAssignment(p => ({ ...p, date_to: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-2 py-1.5 text-xs mt-0.5' />
|
||||
</div>
|
||||
<div>
|
||||
<label className='text-xs text-gray-600'>Faktor (z.B. 1.5)</label>
|
||||
<input type='number' step='0.1' min='0.1' max='10' value={newAssignment.factor}
|
||||
onChange={e => setNewAssignment(p => ({ ...p, factor: parseFloat(e.target.value) }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-2 py-1.5 text-xs mt-0.5' />
|
||||
</div>
|
||||
<div>
|
||||
<label className='text-xs text-gray-600'>Ziel</label>
|
||||
<select value={newAssignment.mode}
|
||||
onChange={e => setNewAssignment(p => ({ ...p, mode: e.target.value as AssignmentMode }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-2 py-1.5 text-xs mt-0.5'>
|
||||
<option value='both'>FZA + Abrechnung</option>
|
||||
<option value='fza'>Nur FZA</option>
|
||||
<option value='payroll'>Nur Abrechnung</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className='col-span-2'>
|
||||
<label className='text-xs text-gray-600'>Bezeichnung (optional)</label>
|
||||
<input type='text' value={newAssignment.label ?? ''}
|
||||
onChange={e => setNewAssignment(p => ({ ...p, label: e.target.value }))}
|
||||
placeholder='z.B. Schichtleiter-Vertretung'
|
||||
className='w-full border border-gray-300 rounded-lg px-2 py-1.5 text-xs mt-0.5' />
|
||||
</div>
|
||||
</div>
|
||||
{assignmentError && <p className='text-xs text-red-600 mt-1'>{assignmentError}</p>}
|
||||
<button
|
||||
onClick={async () => {
|
||||
setAssignmentError('')
|
||||
setAssignmentSaving(true)
|
||||
try {
|
||||
const r = await api.post<SpecialAssignmentOut>(`/users/${editUser!.id}/special-assignments`, newAssignment)
|
||||
setAssignments(prev => [...prev, r])
|
||||
setNewAssignment({ date_from: '', date_to: '', factor: 1.5, mode: 'both' })
|
||||
} catch (e: any) {
|
||||
setAssignmentError(e?.detail || e?.message || 'Fehler beim Speichern')
|
||||
} finally {
|
||||
setAssignmentSaving(false)
|
||||
}
|
||||
}}
|
||||
disabled={assignmentSaving || !newAssignment.date_from || !newAssignment.date_to}
|
||||
className='mt-2 px-3 py-1.5 text-xs font-medium text-amber-700 border border-amber-300 rounded-lg hover:bg-amber-50 disabled:opacity-50'
|
||||
>
|
||||
{assignmentSaving ? 'Speichere…' : '+ Zeitraum hinzufügen'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-2 pt-2'>
|
||||
<button onClick={() => setEditUser(null)} className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>Abbrechen</button>
|
||||
<button onClick={handleEditRole} disabled={editLoading} className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
export type AssignmentMode = 'fza' | 'payroll' | 'both';
|
||||
|
||||
export interface SpecialAssignmentOut {
|
||||
id: string;
|
||||
user_id: string;
|
||||
company_id: string;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
factor: number;
|
||||
mode: AssignmentMode;
|
||||
description: string | null;
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export interface SpecialAssignmentCreate {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
factor: number;
|
||||
mode: AssignmentMode;
|
||||
description?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// ── Payroll Report ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PayrollAssignmentEntry {
|
||||
assignment_id: string;
|
||||
label: string | null;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
factor: number;
|
||||
normal_hours: number;
|
||||
factor_hours: number;
|
||||
extra_hours: number;
|
||||
}
|
||||
|
||||
export interface PayrollAssignmentRow {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
personnel_number: string | null;
|
||||
assignments: PayrollAssignmentEntry[];
|
||||
total_normal_hours: number;
|
||||
total_factor_hours: number;
|
||||
total_extra_hours: number;
|
||||
}
|
||||
|
||||
export interface PayrollAssignmentReport {
|
||||
year: number;
|
||||
month: number;
|
||||
rows: PayrollAssignmentRow[];
|
||||
}
|
||||
Reference in New Issue
Block a user