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:
2026-05-25 22:17:52 +02:00
parent e83a3fbbdd
commit a63b0e835f
11 changed files with 956 additions and 0 deletions
+136
View File
@@ -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 +++++++++++++++++++++++++++++-
---
+2
View File
@@ -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 ────────────────────────────────────────────────────────────────────
+2
View File
@@ -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",
] ]
+45
View File
@@ -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")
+189
View File
@@ -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()
+33
View File
@@ -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')
+2
View File
@@ -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 />} />
+1
View File
@@ -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'] },
] ]
+484
View File
@@ -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>
)
}
+26
View File
@@ -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
}