feat: Stunden-Auszahlung Feature (/hr/payouts)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1334,3 +1334,139 @@ Keine Commits in dieser Session.
|
|||||||
- ROADMAP.md | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
- 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 +++++++++++++++++++++++++++++-
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.routers import kiosk
|
|||||||
from app.routers import busylight
|
from app.routers import busylight
|
||||||
from app.routers import audit
|
from app.routers import audit
|
||||||
from app.routers import special_assignments
|
from app.routers import special_assignments
|
||||||
|
from app.routers import hours_payouts
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -79,6 +80,7 @@ app.include_router(kiosk.router, prefix=API_PREFIX)
|
|||||||
app.include_router(busylight.router, prefix=API_PREFIX)
|
app.include_router(busylight.router, prefix=API_PREFIX)
|
||||||
app.include_router(audit.router, prefix=API_PREFIX)
|
app.include_router(audit.router, prefix=API_PREFIX)
|
||||||
app.include_router(special_assignments.router, prefix=API_PREFIX)
|
app.include_router(special_assignments.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(hours_payouts.router, prefix=API_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
# ── Health ────────────────────────────────────────────────────────────────────
|
# ── Health ────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.models.smtp_config import SmtpConfig
|
|||||||
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
|
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
|
||||||
from app.models.kiosk_device import KioskDevice, KioskAuthMethod
|
from app.models.kiosk_device import KioskDevice, KioskAuthMethod
|
||||||
from app.models.special_assignment import SpecialAssignment, AssignmentMode
|
from app.models.special_assignment import SpecialAssignment, AssignmentMode
|
||||||
|
from app.models.hours_payout import HoursPayout
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Company",
|
"Company",
|
||||||
@@ -38,4 +39,5 @@ __all__ = [
|
|||||||
"KioskAuthMethod",
|
"KioskAuthMethod",
|
||||||
"SpecialAssignment",
|
"SpecialAssignment",
|
||||||
"AssignmentMode",
|
"AssignmentMode",
|
||||||
|
"HoursPayout",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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')
|
||||||
@@ -27,6 +27,7 @@ import { KioskStampPage } from './pages/KioskStampPage'
|
|||||||
import { MobilePage } from './pages/mobile/MobilePage'
|
import { MobilePage } from './pages/mobile/MobilePage'
|
||||||
import { MobileLoginPage } from './pages/mobile/MobileLoginPage'
|
import { MobileLoginPage } from './pages/mobile/MobileLoginPage'
|
||||||
import { SpecialAssignmentsPage } from './pages/SpecialAssignmentsPage'
|
import { SpecialAssignmentsPage } from './pages/SpecialAssignmentsPage'
|
||||||
|
import { HoursPayoutPage } from './pages/HoursPayoutPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -59,6 +60,7 @@ export default function App() {
|
|||||||
<Route path='/settings/kiosk' element={<KioskDevicesPage />} />
|
<Route path='/settings/kiosk' element={<KioskDevicesPage />} />
|
||||||
<Route path='/settings/audit-log' element={<AuditLogPage />} />
|
<Route path='/settings/audit-log' element={<AuditLogPage />} />
|
||||||
<Route path='/hr/special-assignments' element={<SpecialAssignmentsPage />} />
|
<Route path='/hr/special-assignments' element={<SpecialAssignmentsPage />} />
|
||||||
|
<Route path='/hr/payouts' element={<HoursPayoutPage />} />
|
||||||
<Route path='/profile' element={<ProfilePage />} />
|
<Route path='/profile' element={<ProfilePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path='*' element={<Navigate to='/login' replace />} />
|
<Route path='*' element={<Navigate to='/login' replace />} />
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const MAIN_NAV: NavItem[] = [
|
|||||||
{ path: '/calendar', label: 'Kalender' },
|
{ path: '/calendar', label: 'Kalender' },
|
||||||
{ path: '/reports', label: 'Berichte', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER'] },
|
{ 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/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'] },
|
{ path: '/users', label: 'Mitarbeiter', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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<Me | null>(null)
|
||||||
|
const [users, setUsers] = useState<UserItem[]>([])
|
||||||
|
const [pageLoading, setPageLoading] = useState(true)
|
||||||
|
const [pageError, setPageError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const [filterUser, setFilterUser] = useState('')
|
||||||
|
const [filterYear, setFilterYear] = useState<number>(currentYear)
|
||||||
|
const [filterMonth, setFilterMonth] = useState<number>(0)
|
||||||
|
|
||||||
|
// Table data
|
||||||
|
const [payouts, setPayouts] = useState<HoursPayoutOut[]>([])
|
||||||
|
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<number>(8)
|
||||||
|
const [formYear, setFormYear] = useState<number>(currentYear)
|
||||||
|
const [formMonth, setFormMonth] = useState<number>(new Date().getMonth() + 1)
|
||||||
|
const [formNote, setFormNote] = useState('')
|
||||||
|
const [formHasPeriod, setFormHasPeriod] = useState(true)
|
||||||
|
const [modalSaving, setModalSaving] = useState(false)
|
||||||
|
const [modalError, setModalError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Overtime balance for selected user
|
||||||
|
const [overtimeBalance, setOvertimeBalance] = useState<number | null>(null)
|
||||||
|
const [overtimeLoading, setOvertimeLoading] = useState(false)
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const [meData, listData] = await Promise.all([
|
||||||
|
api.get<Me>('/auth/me'),
|
||||||
|
api.get<UserListResponse>('/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<string, string> = {}
|
||||||
|
if (filterUser) params.user_id = filterUser
|
||||||
|
if (filterYear) params.year = String(filterYear)
|
||||||
|
if (filterMonth > 0) params.month = String(filterMonth)
|
||||||
|
|
||||||
|
const data = await api.get<HoursPayoutListResponse>(
|
||||||
|
`/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<OvertimeBalance>(`/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<HoursPayoutOut>('/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 (
|
||||||
|
<div className='min-h-screen bg-gray-50 flex items-center justify-center'><Spinner /></div>
|
||||||
|
)
|
||||||
|
if (pageError) return (
|
||||||
|
<div className='min-h-screen bg-gray-50 flex items-center justify-center'>
|
||||||
|
<p className='text-red-600'>{pageError}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const isOverBudget = overtimeBalance !== null && formHours > overtimeBalance
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout userRole={me?.role ?? ''} userName={`${me?.first_name} ${me?.last_name}`}>
|
||||||
|
<div className='space-y-6'>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div>
|
||||||
|
<h1 className='text-xl font-bold text-gray-800'>💸 Stunden-Auszahlung</h1>
|
||||||
|
<p className='text-sm text-gray-500 mt-0.5'>Überstunden-Auszahlungen verwalten</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openNewModal}
|
||||||
|
className='px-4 py-2 bg-purple-600 text-white text-sm font-semibold rounded-lg hover:bg-purple-700 transition-colors'
|
||||||
|
>
|
||||||
|
+ Auszahlung anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter bar */}
|
||||||
|
<div className='bg-white rounded-xl border border-gray-200 shadow-sm p-4'>
|
||||||
|
<div className='flex flex-wrap gap-3 items-end'>
|
||||||
|
<div className='flex-1 min-w-48'>
|
||||||
|
<label className='block text-xs font-medium text-gray-700 mb-1'>Mitarbeiter</label>
|
||||||
|
<select
|
||||||
|
value={filterUser}
|
||||||
|
onChange={e => setFilterUser(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value=''>Alle Mitarbeiter</option>
|
||||||
|
{users.map(u => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.full_name}{u.personnel_number ? ` (${u.personnel_number})` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className='w-32'>
|
||||||
|
<label className='block text-xs font-medium text-gray-700 mb-1'>Jahr</label>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
value={filterYear}
|
||||||
|
onChange={e => setFilterYear(Number(e.target.value))}
|
||||||
|
min={2000}
|
||||||
|
max={2100}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='w-44'>
|
||||||
|
<label className='block text-xs font-medium text-gray-700 mb-1'>Monat</label>
|
||||||
|
<select
|
||||||
|
value={filterMonth}
|
||||||
|
onChange={e => setFilterMonth(Number(e.target.value))}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value={0}>Alle Monate</option>
|
||||||
|
{MONTH_NAMES.map((name, i) => (
|
||||||
|
<option key={i + 1} value={i + 1}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={tableLoading}
|
||||||
|
className='px-4 py-2 bg-purple-600 text-white text-sm font-semibold rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors'
|
||||||
|
>
|
||||||
|
{tableLoading ? 'Lade…' : 'Suchen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className='bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden'>
|
||||||
|
{tableLoading ? (
|
||||||
|
<div className='flex items-center justify-center py-16'><Spinner /></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className='overflow-x-auto'>
|
||||||
|
<table className='w-full text-sm'>
|
||||||
|
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'>
|
||||||
|
<tr>
|
||||||
|
{['Mitarbeiter', 'Stunden', 'Abrechnungsmonat', 'Notiz', 'Angelegt von', 'Datum', 'Aktion'].map(h => (
|
||||||
|
<th key={h} className='px-4 py-3 text-left font-medium'>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className='divide-y divide-gray-100'>
|
||||||
|
{payouts.map(payout => (
|
||||||
|
<tr key={payout.id} className='hover:bg-gray-50 transition-colors'>
|
||||||
|
<td className='px-4 py-3 font-medium text-gray-800'>{payout.user_name}</td>
|
||||||
|
<td className='px-4 py-3'>
|
||||||
|
<span className='font-bold text-purple-700'>
|
||||||
|
{Number(payout.hours).toFixed(2)} h
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className='px-4 py-3 text-gray-600'>
|
||||||
|
{formatPeriod(payout.period_year, payout.period_month)}
|
||||||
|
</td>
|
||||||
|
<td className='px-4 py-3 text-gray-500 max-w-xs truncate'>
|
||||||
|
{payout.note || '—'}
|
||||||
|
</td>
|
||||||
|
<td className='px-4 py-3 text-gray-500'>{payout.created_by_name}</td>
|
||||||
|
<td className='px-4 py-3 text-gray-500'>
|
||||||
|
{new Date(payout.created_at).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className='px-4 py-3'>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(payout)}
|
||||||
|
className='text-xs text-red-500 hover:underline'
|
||||||
|
title='Stornieren'
|
||||||
|
>
|
||||||
|
🗑️ Stornieren
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{payouts.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className='px-4 py-10 text-center text-gray-400'>
|
||||||
|
Keine Auszahlungen gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<div className='px-4 py-3 border-t border-gray-100 text-xs text-gray-400'>
|
||||||
|
{totalCount} Einträge gesamt
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Payout Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<Modal
|
||||||
|
title='Auszahlung anlegen'
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
|
||||||
|
{/* Mitarbeiter */}
|
||||||
|
<label className='block'>
|
||||||
|
<span className='text-xs font-medium text-gray-700'>Mitarbeiter *</span>
|
||||||
|
<select
|
||||||
|
value={formUserId}
|
||||||
|
onChange={e => handleUserChange(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value=''>— bitte wählen —</option>
|
||||||
|
{users.map(u => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.full_name}{u.personnel_number ? ` (${u.personnel_number})` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Overtime balance indicator */}
|
||||||
|
{formUserId && (
|
||||||
|
<div className='text-sm'>
|
||||||
|
{overtimeLoading ? (
|
||||||
|
<span className='text-gray-400'>Lade Überstundenguthaben…</span>
|
||||||
|
) : overtimeBalance !== null ? (
|
||||||
|
<span className={isOverBudget ? 'text-orange-600 font-medium' : 'text-green-700'}>
|
||||||
|
Verfügbares Überstundenguthaben: {Number(overtimeBalance).toFixed(2)} h
|
||||||
|
{isOverBudget && ' ⚠️ Auszahlung überschreitet verfügbares Guthaben'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className='text-gray-400'>Guthaben nicht verfügbar</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stunden */}
|
||||||
|
<label className='block'>
|
||||||
|
<span className='text-xs font-medium text-gray-700'>Stunden *</span>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
min='0.25'
|
||||||
|
max='999'
|
||||||
|
step='0.25'
|
||||||
|
value={formHours}
|
||||||
|
onChange={e => setFormHours(parseFloat(e.target.value))}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Abrechnungsmonat */}
|
||||||
|
<div>
|
||||||
|
<div className='flex items-center gap-2 mb-2'>
|
||||||
|
<span className='text-xs font-medium text-gray-700'>Abrechnungsmonat</span>
|
||||||
|
<label className='flex items-center gap-1 text-xs text-gray-500 cursor-pointer'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
checked={formHasPeriod}
|
||||||
|
onChange={e => setFormHasPeriod(e.target.checked)}
|
||||||
|
className='rounded'
|
||||||
|
/>
|
||||||
|
angeben
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{formHasPeriod && (
|
||||||
|
<div className='flex gap-3'>
|
||||||
|
<div className='flex-1'>
|
||||||
|
<label className='block text-xs text-gray-500 mb-1'>Jahr</label>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
min={2000}
|
||||||
|
max={2100}
|
||||||
|
value={formYear}
|
||||||
|
onChange={e => setFormYear(Number(e.target.value))}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex-1'>
|
||||||
|
<label className='block text-xs text-gray-500 mb-1'>Monat</label>
|
||||||
|
<select
|
||||||
|
value={formMonth}
|
||||||
|
onChange={e => setFormMonth(Number(e.target.value))}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{MONTH_NAMES.map((name, i) => (
|
||||||
|
<option key={i + 1} value={i + 1}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notiz */}
|
||||||
|
<label className='block'>
|
||||||
|
<span className='text-xs font-medium text-gray-700'>Notiz</span>
|
||||||
|
<textarea
|
||||||
|
value={formNote}
|
||||||
|
onChange={e => setFormNote(e.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
rows={3}
|
||||||
|
placeholder='Optionale Notiz zur Auszahlung…'
|
||||||
|
className={inputClass + ' resize-none'}
|
||||||
|
/>
|
||||||
|
<span className='text-xs text-gray-400'>{formNote.length}/500</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{modalError && (
|
||||||
|
<div className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>
|
||||||
|
{modalError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='flex justify-end gap-2 pt-2'>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className='px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50'
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={modalSaving}
|
||||||
|
className='px-4 py-2 text-sm font-semibold text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50'
|
||||||
|
>
|
||||||
|
{modalSaving ? 'Speichere…' : 'Auszahlung anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export interface HoursPayoutCreate {
|
||||||
|
user_id: string
|
||||||
|
hours: number
|
||||||
|
period_year?: number | null
|
||||||
|
period_month?: number | null
|
||||||
|
note?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HoursPayoutOut {
|
||||||
|
id: string
|
||||||
|
company_id: string
|
||||||
|
user_id: string
|
||||||
|
user_name: string
|
||||||
|
hours: number
|
||||||
|
period_year: number | null
|
||||||
|
period_month: number | null
|
||||||
|
note: string | null
|
||||||
|
created_by: string
|
||||||
|
created_by_name: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HoursPayoutListResponse {
|
||||||
|
payouts: HoursPayoutOut[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user