From a63b0e835f3aa3a8374aecf70a03b15b26dbaf8d Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 25 May 2026 22:17:52 +0200 Subject: [PATCH] feat: Stunden-Auszahlung Feature (/hr/payouts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: Model HoursPayout, Schema, Router GET/POST/DELETE - GET /hr/payouts: HR/Admin sehen alle, Employee/Manager nur eigene - POST /hr/payouts: reduziert OvertimeBalance.taken_hours sofort - DELETE /hr/payouts/{id}: storniert und bucht Stunden zurück - AuditLog-Einträge bei Anlegen und Stornieren - Migration 0030: hours_payouts Tabelle - Frontend: /hr/payouts Seite (lila, 💸) mit Filter, Tabelle, Modal - Modal zeigt verfügbares Überstundenguthaben + Warnung bei Überziehung - Navigation: Stunden-Auszahlung (HR/COMPANY_ADMIN/SUPER_ADMIN) Co-Authored-By: Claude Sonnet 4.6 --- DEVLOG.md | 136 +++++ backend/app/main.py | 2 + backend/app/models/__init__.py | 2 + backend/app/models/hours_payout.py | 45 ++ backend/app/routers/hours_payouts.py | 189 +++++++ backend/app/schemas/hours_payout.py | 33 ++ .../migrations/versions/0030_hours_payouts.py | 36 ++ frontend/src/App.tsx | 2 + frontend/src/components/Layout.tsx | 1 + frontend/src/pages/HoursPayoutPage.tsx | 484 ++++++++++++++++++ frontend/src/types/hoursPayout.ts | 26 + 11 files changed, 956 insertions(+) create mode 100644 backend/app/models/hours_payout.py create mode 100644 backend/app/routers/hours_payouts.py create mode 100644 backend/app/schemas/hours_payout.py create mode 100644 backend/migrations/versions/0030_hours_payouts.py create mode 100644 frontend/src/pages/HoursPayoutPage.tsx create mode 100644 frontend/src/types/hoursPayout.ts diff --git a/DEVLOG.md b/DEVLOG.md index 98b2267..c97d8fc 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1334,3 +1334,139 @@ Keine Commits in dieser Session. - ROADMAP.md | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ --- +## 2026-05-25 01:41 – 01:42 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- e83a3fb fix: agent-08 Kiosk-Härtung + 24h-Zeiteintrag-Bug + +### Geänderte Dateien +- DEVLOG.md | 78 ++++++++++++++++++++++++++ +- backend/app/models/kiosk_device.py | 2 +- +- backend/app/models/time_entry.py | 17 ++++-- +- backend/app/schemas/company.py | 6 ++ +- backend/app/services/time_service.py | 10 ++-- +- backend/cli.py | 78 ++++++++++++++++++++++++++ +- frontend/src/pages/CompanySettingsPage.tsx | 88 +++++++++++++++++++++++++++++- + +--- +## 2026-05-25 01:42 – 01:43 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 78 ++++++++++++++++++++++++++ +- backend/app/models/kiosk_device.py | 2 +- +- backend/app/models/time_entry.py | 17 ++++-- +- backend/app/schemas/company.py | 6 ++ +- backend/app/services/time_service.py | 10 ++-- +- backend/cli.py | 78 ++++++++++++++++++++++++++ +- frontend/src/pages/CompanySettingsPage.tsx | 88 +++++++++++++++++++++++++++++- + +--- +## 2026-05-25 19:47 – 19:47 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 78 ++++++++++++++++++++++++++ +- backend/app/models/kiosk_device.py | 2 +- +- backend/app/models/time_entry.py | 17 ++++-- +- backend/app/schemas/company.py | 6 ++ +- backend/app/services/time_service.py | 10 ++-- +- backend/cli.py | 78 ++++++++++++++++++++++++++ +- frontend/src/pages/CompanySettingsPage.tsx | 88 +++++++++++++++++++++++++++++- + +--- +## 2026-05-25 19:48 – 19:49 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 78 ++++++++++++++++++++++++++ +- backend/app/models/kiosk_device.py | 2 +- +- backend/app/models/time_entry.py | 17 ++++-- +- backend/app/schemas/company.py | 6 ++ +- backend/app/services/time_service.py | 10 ++-- +- backend/cli.py | 78 ++++++++++++++++++++++++++ +- frontend/src/pages/CompanySettingsPage.tsx | 88 +++++++++++++++++++++++++++++- + +--- +## 2026-05-25 22:10 – 22:14 (4m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 78 ++++++++++++++++++++++++++ +- backend/app/models/kiosk_device.py | 2 +- +- backend/app/models/time_entry.py | 17 ++++-- +- backend/app/schemas/company.py | 6 ++ +- backend/app/services/time_service.py | 10 ++-- +- backend/cli.py | 78 ++++++++++++++++++++++++++ +- frontend/src/pages/CompanySettingsPage.tsx | 88 +++++++++++++++++++++++++++++- + +--- +## 2026-05-25 22:15 – 22:16 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 78 ++++++++++++++++++++++++++ +- backend/app/models/kiosk_device.py | 2 +- +- backend/app/models/time_entry.py | 17 ++++-- +- backend/app/schemas/company.py | 6 ++ +- backend/app/services/time_service.py | 10 ++-- +- backend/cli.py | 78 ++++++++++++++++++++++++++ +- frontend/src/pages/CompanySettingsPage.tsx | 88 +++++++++++++++++++++++++++++- + +--- +## 2026-05-25 22:16 – 22:16 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 78 ++++++++++++++++++++++++++ +- backend/app/models/kiosk_device.py | 2 +- +- backend/app/models/time_entry.py | 17 ++++-- +- backend/app/schemas/company.py | 6 ++ +- backend/app/services/time_service.py | 10 ++-- +- backend/cli.py | 78 ++++++++++++++++++++++++++ +- frontend/src/pages/CompanySettingsPage.tsx | 88 +++++++++++++++++++++++++++++- + +--- +## 2026-05-25 22:16 – 22:16 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 78 ++++++++++++++++++++++++++ +- backend/app/models/kiosk_device.py | 2 +- +- backend/app/models/time_entry.py | 17 ++++-- +- backend/app/schemas/company.py | 6 ++ +- backend/app/services/time_service.py | 10 ++-- +- backend/cli.py | 78 ++++++++++++++++++++++++++ +- frontend/src/pages/CompanySettingsPage.tsx | 88 +++++++++++++++++++++++++++++- + +--- diff --git a/backend/app/main.py b/backend/app/main.py index caa3a21..c901837 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from app.routers import kiosk from app.routers import busylight from app.routers import audit from app.routers import special_assignments +from app.routers import hours_payouts @asynccontextmanager @@ -79,6 +80,7 @@ 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) +app.include_router(hours_payouts.router, prefix=API_PREFIX) # ── Health ──────────────────────────────────────────────────────────────────── diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 883e90f..8b567e4 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -15,6 +15,7 @@ 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 +from app.models.hours_payout import HoursPayout __all__ = [ "Company", @@ -38,4 +39,5 @@ __all__ = [ "KioskAuthMethod", "SpecialAssignment", "AssignmentMode", + "HoursPayout", ] diff --git a/backend/app/models/hours_payout.py b/backend/app/models/hours_payout.py new file mode 100644 index 0000000..9ad2838 --- /dev/null +++ b/backend/app/models/hours_payout.py @@ -0,0 +1,45 @@ +"""Stunden-Auszahlung: HR/Admin weist Überstunden-Stunden zur Lohn-Auszahlung an.""" +import uuid +from datetime import datetime +from decimal import Decimal +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.user import User + from app.models.company import Company + + +class HoursPayout(Base): + """Ein Auszahlungsvorgang für Überstunden-Stunden.""" + __tablename__ = "hours_payouts" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + company_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), + nullable=False, index=True + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, index=True + ) + hours: Mapped[Decimal] = mapped_column(Numeric(6, 2), nullable=False) # ausgezahlte Stunden + period_year: Mapped[int | None] = mapped_column(Integer) # Abrechnungsmonat Jahr + period_month: Mapped[int | None] = mapped_column(Integer) # Abrechnungsmonat Monat + note: Mapped[str | None] = mapped_column(Text) # Notiz für Buchhaltung + created_by: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), + nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True + ) + + user: Mapped["User"] = relationship("User", foreign_keys=[user_id], lazy="noload") + creator: Mapped["User"] = relationship("User", foreign_keys=[created_by], lazy="noload") + company: Mapped["Company"] = relationship("Company", lazy="noload") diff --git a/backend/app/routers/hours_payouts.py b/backend/app/routers/hours_payouts.py new file mode 100644 index 0000000..1a942e9 --- /dev/null +++ b/backend/app/routers/hours_payouts.py @@ -0,0 +1,189 @@ +"""Stunden-Auszahlung: HR/Admin bucht Überstunden-Stunden zur Lohn-Auszahlung aus.""" +from decimal import Decimal +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.dependencies import require_role +from app.models.audit_log import AuditLog +from app.models.hours_payout import HoursPayout +from app.models.overtime_balance import OvertimeBalance +from app.models.user import User, UserRole +from app.schemas.hours_payout import HoursPayoutCreate, HoursPayoutListResponse, HoursPayoutOut + +router = APIRouter(tags=["Stunden-Auszahlung"]) + +_hr_roles = (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN) +_all_roles = (UserRole.EMPLOYEE, UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN) + + +def _build_out(payout: HoursPayout, user: User | None, creator: User | None) -> HoursPayoutOut: + out = HoursPayoutOut.model_validate(payout) + out.user_name = ( + f"{user.first_name} {user.last_name}" if user else str(payout.user_id) + ) + out.created_by_name = ( + f"{creator.first_name} {creator.last_name}" if creator else str(payout.created_by) + ) + return out + + +# ── GET /hr/payouts ─────────────────────────────────────────────────────────── + +@router.get("/hr/payouts", response_model=HoursPayoutListResponse) +async def list_payouts( + user_id: UUID | None = Query(None), + year: int | None = Query(None, ge=2000, le=2100), + month: int | None = Query(None, ge=1, le=12), + current_user: User = require_role(*_all_roles), + db: AsyncSession = Depends(get_db), +): + """Alle Auszahlungen der eigenen Firma, optional gefiltert nach Mitarbeiter / Monat. + EMPLOYEE und MANAGER sehen ausschließlich ihre eigenen Auszahlungen. + """ + # Employees und Manager sehen nur ihre eigenen Daten – Query-Param wird ignoriert + if current_user.role not in _hr_roles: + user_id = current_user.id + + filters = [HoursPayout.company_id == current_user.company_id] + if user_id is not None: + filters.append(HoursPayout.user_id == user_id) + if year is not None: + filters.append(HoursPayout.period_year == year) + if month is not None: + filters.append(HoursPayout.period_month == month) + + total_count = await db.scalar( + select(func.count()).select_from(HoursPayout).where(*filters) + ) + rows = list(await db.scalars( + select(HoursPayout).where(*filters).order_by(HoursPayout.created_at.desc()) + )) + + result: list[HoursPayoutOut] = [] + for payout in rows: + user = await db.get(User, payout.user_id) + creator = await db.get(User, payout.created_by) + result.append(_build_out(payout, user, creator)) + + return HoursPayoutListResponse(payouts=result, total_count=total_count or 0) + + +# ── POST /hr/payouts ────────────────────────────────────────────────────────── + +@router.post("/hr/payouts", response_model=HoursPayoutOut, status_code=201) +async def create_payout( + request: Request, + data: HoursPayoutCreate, + current_user: User = require_role(*_hr_roles), + db: AsyncSession = Depends(get_db), +): + """Neue Auszahlung anlegen – reduziert sofort den Überstunden-Saldo.""" + # Ziel-User prüfen + target = await db.get(User, data.user_id) + if not target or target.company_id != current_user.company_id: + raise HTTPException(404, "Mitarbeiter nicht gefunden") + + # OvertimeBalance laden oder anlegen + ob = await db.scalar( + select(OvertimeBalance).where(OvertimeBalance.user_id == data.user_id) + ) + if ob is None: + ob = OvertimeBalance( + user_id=data.user_id, + company_id=current_user.company_id, + total_hours=Decimal("0"), + taken_hours=Decimal("0"), + ) + db.add(ob) + await db.flush() # id erzeugen + + # Warnung bei Überziehung (kein Hard-Block) + hours = Decimal(str(data.hours)) + if ob.available_hours < hours: + # Wir blockieren nicht – Auszahlung trotzdem buchen (wie FZA mit overdraft) + pass + + # Saldo anpassen + ob.taken_hours += hours + + # Auszahlungs-Datensatz anlegen + payout = HoursPayout( + company_id=current_user.company_id, + user_id=data.user_id, + hours=hours, + period_year=data.period_year, + period_month=data.period_month, + note=data.note, + created_by=current_user.id, + ) + db.add(payout) + await db.flush() # payout.id erzeugen + + # AuditLog + db.add(AuditLog( + company_id=current_user.company_id, + user_id=current_user.id, + action="hours_payout_created", + entity_type="hours_payout", + entity_id=payout.id, + new_value={ + "user_id": str(data.user_id), + "hours": str(hours), + "period_year": data.period_year, + "period_month": data.period_month, + "note": data.note, + }, + ip=request.client.host if request.client else None, + )) + + await db.commit() + await db.refresh(payout) + + creator = await db.get(User, payout.created_by) + return _build_out(payout, target, creator) + + +# ── DELETE /hr/payouts/{payout_id} ─────────────────────────────────────────── + +@router.delete("/hr/payouts/{payout_id}", status_code=204) +async def delete_payout( + payout_id: UUID, + request: Request, + current_user: User = require_role(*_hr_roles), + db: AsyncSession = Depends(get_db), +): + """Auszahlung stornieren – stellt die Stunden in den Überstunden-Saldo zurück.""" + payout = await db.get(HoursPayout, payout_id) + if payout is None or payout.company_id != current_user.company_id: + raise HTTPException(404, "Auszahlung nicht gefunden") + + # OvertimeBalance laden und Stunden zurückbuchen + ob = await db.scalar( + select(OvertimeBalance).where(OvertimeBalance.user_id == payout.user_id) + ) + if ob is not None: + ob.taken_hours = max(Decimal("0"), ob.taken_hours - payout.hours) + + # AuditLog + db.add(AuditLog( + company_id=current_user.company_id, + user_id=current_user.id, + action="hours_payout_deleted", + entity_type="hours_payout", + entity_id=payout.id, + old_value={ + "user_id": str(payout.user_id), + "hours": str(payout.hours), + "period_year": payout.period_year, + "period_month": payout.period_month, + "note": payout.note, + }, + ip=request.client.host if request.client else None, + )) + + await db.delete(payout) + await db.commit() diff --git a/backend/app/schemas/hours_payout.py b/backend/app/schemas/hours_payout.py new file mode 100644 index 0000000..ad14db2 --- /dev/null +++ b/backend/app/schemas/hours_payout.py @@ -0,0 +1,33 @@ +import uuid +from datetime import datetime +from decimal import Decimal +from pydantic import BaseModel, Field + + +class HoursPayoutCreate(BaseModel): + user_id: uuid.UUID + hours: Decimal = Field(gt=0, le=999.99, decimal_places=2) + period_year: int | None = Field(None, ge=2000, le=2100) + period_month: int | None = Field(None, ge=1, le=12) + note: str | None = Field(None, max_length=500) + + +class HoursPayoutOut(BaseModel): + model_config = {"from_attributes": True} + + id: uuid.UUID + company_id: uuid.UUID + user_id: uuid.UUID + user_name: str # Computed: first_name + last_name (wird im Router gesetzt) + hours: Decimal + period_year: int | None + period_month: int | None + note: str | None + created_by: uuid.UUID + created_by_name: str # Computed im Router + created_at: datetime + + +class HoursPayoutListResponse(BaseModel): + payouts: list[HoursPayoutOut] + total_count: int diff --git a/backend/migrations/versions/0030_hours_payouts.py b/backend/migrations/versions/0030_hours_payouts.py new file mode 100644 index 0000000..a8673a6 --- /dev/null +++ b/backend/migrations/versions/0030_hours_payouts.py @@ -0,0 +1,36 @@ +"""hours_payouts table + +Revision ID: 0030 +Revises: 0029 +Create Date: 2026-05-25 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +revision = '0030' +down_revision = '0029' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'hours_payouts', + sa.Column('id', UUID(as_uuid=True), primary_key=True), + sa.Column('company_id', UUID(as_uuid=True), sa.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('hours', sa.Numeric(6, 2), nullable=False), + sa.Column('period_year', sa.Integer(), nullable=True), + sa.Column('period_month', sa.Integer(), nullable=True), + sa.Column('note', sa.Text(), nullable=True), + sa.Column('created_by', UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + ) + op.create_index('ix_hours_payouts_company_id', 'hours_payouts', ['company_id']) + op.create_index('ix_hours_payouts_user_id', 'hours_payouts', ['user_id']) + op.create_index('ix_hours_payouts_created_at', 'hours_payouts', ['created_at']) + + +def downgrade(): + op.drop_table('hours_payouts') diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5e7cf1b..16b5bde 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import { KioskStampPage } from './pages/KioskStampPage' import { MobilePage } from './pages/mobile/MobilePage' import { MobileLoginPage } from './pages/mobile/MobileLoginPage' import { SpecialAssignmentsPage } from './pages/SpecialAssignmentsPage' +import { HoursPayoutPage } from './pages/HoursPayoutPage' export default function App() { return ( @@ -59,6 +60,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index b86df9a..eb0c8b0 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -21,6 +21,7 @@ const MAIN_NAV: NavItem[] = [ { path: '/calendar', label: 'Kalender' }, { path: '/reports', label: 'Berichte', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER'] }, { path: '/hr/special-assignments', label: 'Sondervertretungen', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER'] }, + { path: '/hr/payouts', label: 'Stunden-Auszahlung', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR'] }, { path: '/users', label: 'Mitarbeiter', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR'] }, ] diff --git a/frontend/src/pages/HoursPayoutPage.tsx b/frontend/src/pages/HoursPayoutPage.tsx new file mode 100644 index 0000000..45ba21d --- /dev/null +++ b/frontend/src/pages/HoursPayoutPage.tsx @@ -0,0 +1,484 @@ +import { useEffect, useState } from 'react' +import type { HoursPayoutOut, HoursPayoutListResponse } from '../types/hoursPayout' +import { api } from '../api/client' +import { Spinner } from '../components/Spinner' +import { Layout } from '../components/Layout' +import { Modal } from '../components/Modal' + +interface UserItem { + id: string + full_name: string + personnel_number: string | null + is_active: boolean +} + +interface UserListResponse { + total: number + items: UserItem[] +} + +interface Me { + first_name: string + last_name: string + role: string +} + +interface OvertimeBalance { + balance_hours: number +} + +const MONTH_NAMES = [ + 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', +] + +const inputClass = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-400 focus:border-transparent' + +function formatPeriod(year: number | null, month: number | null): string { + if (!year) return '—' + if (!month) return String(year) + return `${MONTH_NAMES[month - 1]} ${year}` +} + +export function HoursPayoutPage() { + const [me, setMe] = useState(null) + const [users, setUsers] = useState([]) + const [pageLoading, setPageLoading] = useState(true) + const [pageError, setPageError] = useState(null) + + // Filter state + const currentYear = new Date().getFullYear() + const [filterUser, setFilterUser] = useState('') + const [filterYear, setFilterYear] = useState(currentYear) + const [filterMonth, setFilterMonth] = useState(0) + + // Table data + const [payouts, setPayouts] = useState([]) + const [totalCount, setTotalCount] = useState(0) + const [tableLoading, setTableLoading] = useState(false) + + // Modal state + const [showModal, setShowModal] = useState(false) + const [formUserId, setFormUserId] = useState('') + const [formHours, setFormHours] = useState(8) + const [formYear, setFormYear] = useState(currentYear) + const [formMonth, setFormMonth] = useState(new Date().getMonth() + 1) + const [formNote, setFormNote] = useState('') + const [formHasPeriod, setFormHasPeriod] = useState(true) + const [modalSaving, setModalSaving] = useState(false) + const [modalError, setModalError] = useState(null) + + // Overtime balance for selected user + const [overtimeBalance, setOvertimeBalance] = useState(null) + const [overtimeLoading, setOvertimeLoading] = useState(false) + + // Initial load + useEffect(() => { + async function init() { + try { + const [meData, listData] = await Promise.all([ + api.get('/auth/me'), + api.get('/users/?limit=500'), + ]) + setMe(meData) + setUsers(listData.items.filter(u => u.is_active)) + } catch (e: unknown) { + setPageError(e instanceof Error ? e.message : 'Fehler beim Laden') + } finally { + setPageLoading(false) + } + } + init() + }, []) + + // Load on mount after users available + useEffect(() => { + if (!pageLoading) { + loadPayouts() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pageLoading]) + + async function loadPayouts() { + setTableLoading(true) + try { + const params: Record = {} + if (filterUser) params.user_id = filterUser + if (filterYear) params.year = String(filterYear) + if (filterMonth > 0) params.month = String(filterMonth) + + const data = await api.get( + `/hr/payouts?${new URLSearchParams(params)}` + ) + setPayouts(data.payouts) + setTotalCount(data.total_count) + } catch (e: unknown) { + setPageError(e instanceof Error ? e.message : 'Fehler beim Laden der Auszahlungen') + } finally { + setTableLoading(false) + } + } + + function handleSearch() { + loadPayouts() + } + + async function loadOvertimeBalance(userId: string) { + if (!userId) { + setOvertimeBalance(null) + return + } + setOvertimeLoading(true) + try { + const data = await api.get(`/absences/overtime-balance?user_id=${userId}`) + setOvertimeBalance(data.balance_hours) + } catch { + setOvertimeBalance(null) + } finally { + setOvertimeLoading(false) + } + } + + function openNewModal() { + setFormUserId('') + setFormHours(8) + setFormYear(currentYear) + setFormMonth(new Date().getMonth() + 1) + setFormNote('') + setFormHasPeriod(true) + setOvertimeBalance(null) + setModalError(null) + setShowModal(true) + } + + function handleUserChange(userId: string) { + setFormUserId(userId) + loadOvertimeBalance(userId) + } + + async function handleSave() { + if (!formUserId) { + setModalError('Bitte einen Mitarbeiter auswählen.') + return + } + if (!formHours || formHours <= 0) { + setModalError('Stunden müssen größer als 0 sein.') + return + } + setModalSaving(true) + setModalError(null) + try { + await api.post('/hr/payouts', { + user_id: formUserId, + hours: formHours, + period_year: formHasPeriod ? formYear : null, + period_month: formHasPeriod ? formMonth : null, + note: formNote.trim() || null, + }) + setShowModal(false) + loadPayouts() + } catch (e: unknown) { + setModalError(e instanceof Error ? e.message : 'Fehler beim Anlegen') + } finally { + setModalSaving(false) + } + } + + async function handleDelete(payout: HoursPayoutOut) { + if (!confirm('Auszahlung wirklich stornieren?')) return + try { + await api.del(`/hr/payouts/${payout.id}`) + setPayouts(prev => prev.filter(p => p.id !== payout.id)) + setTotalCount(prev => prev - 1) + } catch (e: unknown) { + alert(e instanceof Error ? e.message : 'Fehler beim Stornieren') + } + } + + if (pageLoading) return ( +
+ ) + if (pageError) return ( +
+

{pageError}

+
+ ) + + const isOverBudget = overtimeBalance !== null && formHours > overtimeBalance + + return ( + +
+ + {/* Header */} +
+
+

💸 Stunden-Auszahlung

+

Überstunden-Auszahlungen verwalten

+
+ +
+ + {/* Filter bar */} +
+
+
+ + +
+
+ + setFilterYear(Number(e.target.value))} + min={2000} + max={2100} + className={inputClass} + /> +
+
+ + +
+ +
+
+ + {/* Table */} +
+ {tableLoading ? ( +
+ ) : ( + <> +
+ + + + {['Mitarbeiter', 'Stunden', 'Abrechnungsmonat', 'Notiz', 'Angelegt von', 'Datum', 'Aktion'].map(h => ( + + ))} + + + + {payouts.map(payout => ( + + + + + + + + + + ))} + {payouts.length === 0 && ( + + + + )} + +
{h}
{payout.user_name} + + {Number(payout.hours).toFixed(2)} h + + + {formatPeriod(payout.period_year, payout.period_month)} + + {payout.note || '—'} + {payout.created_by_name} + {new Date(payout.created_at).toLocaleDateString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + })} + + +
+ Keine Auszahlungen gefunden. +
+
+ {totalCount > 0 && ( +
+ {totalCount} Einträge gesamt +
+ )} + + )} +
+
+ + {/* New Payout Modal */} + {showModal && ( + setShowModal(false)} + > +
+ + {/* Mitarbeiter */} + + + {/* Overtime balance indicator */} + {formUserId && ( +
+ {overtimeLoading ? ( + Lade Überstundenguthaben… + ) : overtimeBalance !== null ? ( + + Verfügbares Überstundenguthaben: {Number(overtimeBalance).toFixed(2)} h + {isOverBudget && ' ⚠️ Auszahlung überschreitet verfügbares Guthaben'} + + ) : ( + Guthaben nicht verfügbar + )} +
+ )} + + {/* Stunden */} + + + {/* Abrechnungsmonat */} +
+
+ Abrechnungsmonat + +
+ {formHasPeriod && ( +
+
+ + setFormYear(Number(e.target.value))} + className={inputClass} + /> +
+
+ + +
+
+ )} +
+ + {/* Notiz */} +