diff --git a/DEVLOG.md b/DEVLOG.md index 8db2035..7d7a489 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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 ++++- + +--- diff --git a/backend/app/main.py b/backend/app/main.py index 66ac536..caa3a21 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 ──────────────────────────────────────────────────────────────────── diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 12cc5b0..883e90f 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/special_assignment.py b/backend/app/models/special_assignment.py new file mode 100644 index 0000000..6f480b5 --- /dev/null +++ b/backend/app/models/special_assignment.py @@ -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" diff --git a/backend/app/routers/special_assignments.py b/backend/app/routers/special_assignments.py new file mode 100644 index 0000000..98f66c2 --- /dev/null +++ b/backend/app/routers/special_assignments.py @@ -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) diff --git a/backend/app/schemas/special_assignment.py b/backend/app/schemas/special_assignment.py new file mode 100644 index 0000000..fd785dd --- /dev/null +++ b/backend/app/schemas/special_assignment.py @@ -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] diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index b33efb8..0bebf86 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -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))) diff --git a/backend/migrations/versions/0029_special_assignments.py b/backend/migrations/versions/0029_special_assignments.py new file mode 100644 index 0000000..60ca8d6 --- /dev/null +++ b/backend/migrations/versions/0029_special_assignments.py @@ -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") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 6334309..f093e05 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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", diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx index 47ffb20..0c07628 100644 --- a/frontend/src/pages/ReportsPage.tsx +++ b/frontend/src/pages/ReportsPage.tsx @@ -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 = { @@ -132,6 +133,10 @@ export function ReportsPage() { const [expandedUsers, setExpandedUsers] = useState>(new Set()) const [expandedWeeks, setExpandedWeeks] = useState>(new Set()) const [sickStats, setSickStats] = useState(null) + const [payrollReport, setPayrollReport] = useState(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(`/absences/sick-stats${params}`) setSickStats(stats) setTimeReport(null); setAbsenceReport(null); setOvertimeReport(null) + } else if (type === 'special') { + const report = await api.get( + `/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(`/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 ( @@ -206,7 +218,7 @@ export function ReportsPage() { {/* Report type */}
- {([['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]) => (
- {/* Quick-select */} -
-

Schnellauswahl

-
- {[['Dieser Monat', monthRange()],['Letzter Monat', monthRange(-1)],['Quartal', quarterRange()]] .map(([l, [f, t]]) => ( - - ))} -
-
+ {type !== 'special' && ( + <> + {/* Quick-select */} +
+

Schnellauswahl

+
+ {[['Dieser Monat', monthRange()],['Letzter Monat', monthRange(-1)],['Quartal', quarterRange()]] .map(([l, [f, t]]) => ( + + ))} +
+
+ {/* Date range */} +
+

Von

+ setDateFrom(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' /> +
+
+

Bis

+ 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' /> +
+ + )} - {/* Date range */} -
-

Von

- setDateFrom(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' /> -
-
-

Bis

- 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' /> -
+ {type === 'special' && ( + <> +
+

Jahr

+ 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' /> +
+
+

Monat

+ +
+ + )} - {/* Employee filter (manager only) */} - {isManager && colleagues.length > 0 && ( + {/* Employee filter (manager only) – nicht für special */} + {isManager && colleagues.length > 0 && type !== 'special' && (

Mitarbeiter

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' /> +
+
+ + 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' /> +
+
+ + 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' /> +
+
+ + +
+
+ + 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' /> +
+
+ {assignmentError &&

{assignmentError}

} + + + )} + +