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 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 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 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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 { 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() {
|
||||
<Route path='/settings/kiosk' element={<KioskDevicesPage />} />
|
||||
<Route path='/settings/audit-log' element={<AuditLogPage />} />
|
||||
<Route path='/hr/special-assignments' element={<SpecialAssignmentsPage />} />
|
||||
<Route path='/hr/payouts' element={<HoursPayoutPage />} />
|
||||
<Route path='/profile' element={<ProfilePage />} />
|
||||
</Route>
|
||||
<Route path='*' element={<Navigate to='/login' replace />} />
|
||||
|
||||
@@ -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'] },
|
||||
]
|
||||
|
||||
|
||||
@@ -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