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 --
|
- 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 kiosk
|
||||||
from app.routers import busylight
|
from app.routers import busylight
|
||||||
from app.routers import audit
|
from app.routers import audit
|
||||||
|
from app.routers import special_assignments
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@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(kiosk.router, prefix=API_PREFIX)
|
||||||
app.include_router(busylight.router, prefix=API_PREFIX)
|
app.include_router(busylight.router, prefix=API_PREFIX)
|
||||||
app.include_router(audit.router, prefix=API_PREFIX)
|
app.include_router(audit.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(special_assignments.router, prefix=API_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
# ── Health ────────────────────────────────────────────────────────────────────
|
# ── Health ────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.models.public_holiday import PublicHoliday
|
|||||||
from app.models.smtp_config import SmtpConfig
|
from app.models.smtp_config import SmtpConfig
|
||||||
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
|
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
|
||||||
from app.models.kiosk_device import KioskDevice, KioskAuthMethod
|
from app.models.kiosk_device import KioskDevice, KioskAuthMethod
|
||||||
|
from app.models.special_assignment import SpecialAssignment, AssignmentMode
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Company",
|
"Company",
|
||||||
@@ -35,4 +36,6 @@ __all__ = [
|
|||||||
"PublicHoliday",
|
"PublicHoliday",
|
||||||
"KioskDevice",
|
"KioskDevice",
|
||||||
"KioskAuthMethod",
|
"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(
|
async def _recalculate_overtime_balance(
|
||||||
user: User, schedule: WorkSchedule | None, db: AsyncSession
|
user: User, schedule: WorkSchedule | None, db: AsyncSession
|
||||||
) -> OvertimeBalance:
|
) -> 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(
|
entries = list(await db.scalars(
|
||||||
select(TimeEntry).where(
|
select(TimeEntry).where(
|
||||||
TimeEntry.user_id == user.id,
|
TimeEntry.user_id == user.id,
|
||||||
@@ -197,10 +204,27 @@ async def _recalculate_overtime_balance(
|
|||||||
bal.last_calculated = datetime.utcnow()
|
bal.last_calculated = datetime.utcnow()
|
||||||
return bal
|
return bal
|
||||||
|
|
||||||
date_from = min(e.date for e in entries)
|
# Sondervertretungs-Zuweisungen laden (nur FZA-relevante)
|
||||||
date_to = max(e.date for e in entries)
|
date_from_all = min(e.date for e in entries)
|
||||||
expected = _expected_hours(schedule, date_from, date_to)
|
date_to_all = max(e.date for e in entries)
|
||||||
worked = sum(e.worked_hours or 0.0 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)
|
overtime = max(0.0, worked - expected)
|
||||||
|
|
||||||
bal.total_hours = Decimal(str(round(overtime, 2)))
|
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 = [
|
_COMPANY_COL_TABLES = [
|
||||||
"absence_types", "audit_logs", "caldav_company_configs", "departments",
|
"absence_types", "audit_logs", "caldav_company_configs", "departments",
|
||||||
"kiosk_devices", "ldap_configs", "overtime_balances", "smtp_configs",
|
"kiosk_devices", "ldap_configs", "overtime_balances", "smtp_configs",
|
||||||
"users", "work_schedules",
|
"special_assignments", "users", "work_schedules",
|
||||||
]
|
]
|
||||||
_USER_JOIN_TABLES = [
|
_USER_JOIN_TABLES = [
|
||||||
"absences", "caldav_user_configs", "password_resets",
|
"absences", "caldav_user_configs", "password_resets",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import { Spinner } from '../components/Spinner'
|
import { Spinner } from '../components/Spinner'
|
||||||
import { Layout } from '../components/Layout'
|
import { Layout } from '../components/Layout'
|
||||||
|
import type { PayrollAssignmentReport } from '../types/specialAssignment'
|
||||||
|
|
||||||
interface UserOut {
|
interface UserOut {
|
||||||
id: string; first_name: string; last_name: string; email: string; role: string
|
id: string; first_name: string; last_name: string; email: string; role: string
|
||||||
@@ -68,7 +69,7 @@ interface SickStatsRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
type ReportType = 'time' | 'absences' | 'overtime' | 'sick'
|
type ReportType = 'time' | 'absences' | 'overtime' | 'sick' | 'special'
|
||||||
const MANAGER_ROLES = ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER']
|
const MANAGER_ROLES = ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER']
|
||||||
|
|
||||||
const STATUS_DE: Record<string, string> = {
|
const STATUS_DE: Record<string, string> = {
|
||||||
@@ -132,6 +133,10 @@ export function ReportsPage() {
|
|||||||
const [expandedUsers, setExpandedUsers] = useState<Set<string>>(new Set())
|
const [expandedUsers, setExpandedUsers] = useState<Set<string>>(new Set())
|
||||||
const [expandedWeeks, setExpandedWeeks] = useState<Set<string>>(new Set())
|
const [expandedWeeks, setExpandedWeeks] = useState<Set<string>>(new Set())
|
||||||
const [sickStats, setSickStats] = useState<SickStatsRow[] | null>(null)
|
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 [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -162,6 +167,12 @@ export function ReportsPage() {
|
|||||||
const stats = await api.get<SickStatsRow[]>(`/absences/sick-stats${params}`)
|
const stats = await api.get<SickStatsRow[]>(`/absences/sick-stats${params}`)
|
||||||
setSickStats(stats)
|
setSickStats(stats)
|
||||||
setTimeReport(null); setAbsenceReport(null); setOvertimeReport(null)
|
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 {
|
} else {
|
||||||
const [simple, detail] = await Promise.all([
|
const [simple, detail] = await Promise.all([
|
||||||
api.get<OvertimeReport>(`/reports/overtime${p}`),
|
api.get<OvertimeReport>(`/reports/overtime${p}`),
|
||||||
@@ -171,13 +182,14 @@ export function ReportsPage() {
|
|||||||
setOvertimeDetail(detail)
|
setOvertimeDetail(detail)
|
||||||
setExpandedUsers(new Set())
|
setExpandedUsers(new Set())
|
||||||
setExpandedWeeks(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') }
|
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||||
finally { setLoading(false) }
|
finally { setLoading(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const download = async (format: 'csv' | 'xlsx' | 'pdf') => {
|
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 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 ep = type === 'time' ? `/reports/time/export${p}` : type === 'absences' ? `/reports/absences/export${p}` : `/reports/overtime/export${p}`
|
||||||
const token = localStorage.getItem('access_token')
|
const token = localStorage.getItem('access_token')
|
||||||
@@ -190,7 +202,7 @@ export function ReportsPage() {
|
|||||||
|
|
||||||
const setQuick = (from: string, to: string) => { setDateFrom(from); setDateTo(to) }
|
const setQuick = (from: string, to: string) => { setDateFrom(from); setDateTo(to) }
|
||||||
|
|
||||||
const hasResult = !!(timeReport || absenceReport || overtimeReport || sickStats)
|
const hasResult = !!(timeReport || absenceReport || overtimeReport || sickStats || payrollReport)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout userRole={user?.role ?? ''} userName={user ? `${user.first_name} ${user.last_name}` : ''}>
|
<Layout userRole={user?.role ?? ''} userName={user ? `${user.first_name} ${user.last_name}` : ''}>
|
||||||
@@ -206,7 +218,7 @@ export function ReportsPage() {
|
|||||||
|
|
||||||
{/* Report type */}
|
{/* Report type */}
|
||||||
<div className='flex rounded-lg border border-gray-300 overflow-hidden w-fit'>
|
<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)}
|
<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'}`}>
|
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}
|
{l}
|
||||||
@@ -215,6 +227,8 @@ export function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex flex-wrap gap-3 items-end'>
|
<div className='flex flex-wrap gap-3 items-end'>
|
||||||
|
{type !== 'special' && (
|
||||||
|
<>
|
||||||
{/* Quick-select */}
|
{/* Quick-select */}
|
||||||
<div>
|
<div>
|
||||||
<p className='text-xs font-medium text-gray-500 mb-1'>Schnellauswahl</p>
|
<p className='text-xs font-medium text-gray-500 mb-1'>Schnellauswahl</p>
|
||||||
@@ -227,7 +241,6 @@ export function ReportsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date range */}
|
{/* Date range */}
|
||||||
<div>
|
<div>
|
||||||
<p className='text-xs font-medium text-gray-500 mb-1'>Von</p>
|
<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)}
|
<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' />
|
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>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Employee filter (manager only) */}
|
{type === 'special' && (
|
||||||
{isManager && colleagues.length > 0 && (
|
<>
|
||||||
|
<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>
|
<div>
|
||||||
<p className='text-xs font-medium text-gray-500 mb-1'>Mitarbeiter</p>
|
<p className='text-xs font-medium text-gray-500 mb-1'>Mitarbeiter</p>
|
||||||
<select value={filterUser} onChange={e => setFilterUser(e.target.value)}
|
<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 className='px-6 py-4 border-b border-gray-100 flex items-center justify-between flex-wrap gap-3'>
|
||||||
<div>
|
<div>
|
||||||
<h2 className='font-semibold text-gray-800'>
|
<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>
|
</h2>
|
||||||
<p className='text-xs text-gray-400 mt-0.5'>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex gap-2 no-print'>
|
<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>
|
<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('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('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>
|
<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>
|
</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 ── */}
|
{/* ── Sick stats table ── */}
|
||||||
{sickStats && (
|
{sickStats && (
|
||||||
<div className='overflow-x-auto'>
|
<div className='overflow-x-auto'>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { SpecialAssignmentOut, SpecialAssignmentCreate, AssignmentMode } from '../types/specialAssignment'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import { Spinner } from '../components/Spinner'
|
import { Spinner } from '../components/Spinner'
|
||||||
import { Layout } from '../components/Layout'
|
import { Layout } from '../components/Layout'
|
||||||
@@ -134,6 +135,15 @@ export function UsersPage() {
|
|||||||
// Company-Settings (für Auto-Modus / Pflicht-Anzeige)
|
// Company-Settings (für Auto-Modus / Pflicht-Anzeige)
|
||||||
const [company, setCompany] = useState<CompanyOut | null>(null)
|
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
|
// CSV-Import modal
|
||||||
const [showImport, setShowImport] = useState(false)
|
const [showImport, setShowImport] = useState(false)
|
||||||
const [importFile, setImportFile] = useState<File | null>(null)
|
const [importFile, setImportFile] = useState<File | null>(null)
|
||||||
@@ -419,7 +429,17 @@ export function UsersPage() {
|
|||||||
<td className='px-4 py-3'>
|
<td className='px-4 py-3'>
|
||||||
<div className='flex gap-2 justify-end'>
|
<div className='flex gap-2 justify-end'>
|
||||||
<button
|
<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'
|
className='text-xs text-blue-600 hover:underline'
|
||||||
>
|
>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
@@ -763,6 +783,96 @@ export function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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'>
|
<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={() => 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'>
|
<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