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:
2026-05-25 00:55:47 +02:00
parent 1170e59e49
commit d60349df67
12 changed files with 837 additions and 39 deletions
+57
View File
@@ -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 ++++-
---
+2
View File
@@ -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 ────────────────────────────────────────────────────────────────────
+3
View File
@@ -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",
]
+38
View File
@@ -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"
+246
View File
@@ -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)
+76
View File
@@ -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]
+29 -5
View File
@@ -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")
+1 -1
View File
@@ -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",
+140 -10
View File
@@ -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'>
+111 -1
View File
@@ -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'>
+51
View File
@@ -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[];
}