feat(overtime): Auszahlungs-Anträge durch Mitarbeiter (opt-in pro Firma)
Mitarbeiter beantragt Überstunden-Auszahlung, HR genehmigt/lehnt ab.
Firmen-Opt-in payout_request_enabled (Default aus - nur HR-Direktbuchung).
Saldo-Abzug erst bei Genehmigung; Reject/Cancel bucht nichts.
- Migration 0042: hours_payouts.status/decided_by/decided_at/rejection_reason
+ companies.payout_request_enabled (nur Spalten, RLS unveraendert)
- Router: POST /hr/payouts/request|{id}/approve|reject|cancel; list status-Filter;
HR-Direktbuchung bleibt (status approved)
- Frontend: PayoutRequestCard (Selbstbedienung in AbsencesPage), HR-Page
Status-Spalte + Genehmigen/Ablehnen, CompanySettings-Toggle
- 4 pytest-Cases
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import type { HoursPayoutOut, HoursPayoutListResponse } from '../types/hoursPayout'
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
]
|
||||
|
||||
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
requested: { label: 'Beantragt', cls: 'bg-amber-100 text-amber-700' },
|
||||
approved: { label: 'Genehmigt', cls: 'bg-green-100 text-green-700' },
|
||||
rejected: { label: 'Abgelehnt', cls: 'bg-red-100 text-red-700' },
|
||||
cancelled: { label: 'Zurückgezogen', cls: 'bg-gray-100 text-gray-500' },
|
||||
}
|
||||
|
||||
function formatPeriod(year: number | null, month: number | null): string {
|
||||
if (!year) return ''
|
||||
if (!month) return String(year)
|
||||
return `${MONTH_NAMES[month - 1]} ${year}`
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
/**
|
||||
* Selbstbedienung: Mitarbeiter beantragt Auszahlung eigener Überstunden.
|
||||
* Rendert nur, wenn die Firma `payout_request_enabled` gesetzt hat.
|
||||
*/
|
||||
export function PayoutRequestCard({ availableHours }: { availableHours: number }) {
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null)
|
||||
const [rows, setRows] = useState<HoursPayoutOut[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [hours, setHours] = useState<number>(4)
|
||||
const [note, setNote] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.get<{ payout_request_enabled: boolean }>('/companies/me')
|
||||
.then(c => setEnabled(!!c.payout_request_enabled))
|
||||
.catch(() => setEnabled(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled])
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await api.get<HoursPayoutListResponse>('/hr/payouts')
|
||||
setRows(data.payouts)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!hours || hours <= 0) { setErr('Stunden müssen größer als 0 sein.'); return }
|
||||
setSaving(true); setErr('')
|
||||
try {
|
||||
await api.post<HoursPayoutOut>('/hr/payouts/request', {
|
||||
hours, period_year: null, period_month: null, note: note.trim() || null,
|
||||
})
|
||||
setOpen(false); setNote(''); setHours(4)
|
||||
await load()
|
||||
} catch (e: unknown) {
|
||||
setErr(e instanceof Error ? e.message : 'Fehler beim Beantragen')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
async function cancel(id: string) {
|
||||
if (!confirm('Antrag zurückziehen?')) return
|
||||
try {
|
||||
const updated = await api.post<HoursPayoutOut>(`/hr/payouts/${id}/cancel`, {})
|
||||
setRows(prev => prev.map(p => (p.id === id ? updated : p)))
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Fehler')
|
||||
}
|
||||
}
|
||||
|
||||
if (!enabled) return null
|
||||
|
||||
const overBudget = hours > availableHours
|
||||
|
||||
return (
|
||||
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-5'>
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<h2 className='text-sm font-semibold text-gray-500 uppercase tracking-wide'>Auszahlungs-Anträge</h2>
|
||||
<button
|
||||
onClick={() => { setOpen(o => !o); setErr('') }}
|
||||
className='px-3 py-1.5 bg-purple-600 text-white text-xs font-semibold rounded-lg hover:bg-purple-700'
|
||||
>
|
||||
{open ? 'Abbrechen' : '💸 Auszahlung beantragen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className='border border-gray-200 rounded-lg p-4 mb-4 space-y-3'>
|
||||
<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={hours}
|
||||
onChange={e => setHours(parseFloat(e.target.value))} className={inputClass} />
|
||||
</label>
|
||||
{overBudget && (
|
||||
<p className='text-xs text-orange-600'>⚠️ Überschreitet dein verfügbares Guthaben ({availableHours.toFixed(1)} h) – Antrag ist trotzdem möglich.</p>
|
||||
)}
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Notiz</span>
|
||||
<textarea value={note} onChange={e => setNote(e.target.value)} maxLength={500} rows={2}
|
||||
placeholder='Optional…' className={inputClass + ' resize-none'} />
|
||||
</label>
|
||||
{err && <p className='text-sm text-red-600'>{err}</p>}
|
||||
<div className='flex justify-end'>
|
||||
<button onClick={submit} disabled={saving}
|
||||
className='px-4 py-2 text-sm font-semibold text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50'>
|
||||
{saving ? 'Sende…' : 'Antrag stellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className='text-sm text-gray-400'>Noch keine Auszahlungs-Anträge.</p>
|
||||
) : (
|
||||
<div className='divide-y divide-gray-100'>
|
||||
{rows.map(p => {
|
||||
const b = STATUS_BADGE[p.status] ?? { label: p.status, cls: 'bg-gray-100 text-gray-500' }
|
||||
const period = formatPeriod(p.period_year, p.period_month)
|
||||
return (
|
||||
<div key={p.id} className='flex items-center justify-between py-2 text-sm'>
|
||||
<div>
|
||||
<span className='font-semibold text-gray-800'>{Number(p.hours).toFixed(2)} h</span>
|
||||
{period && <span className='text-gray-400 ml-2'>{period}</span>}
|
||||
{p.status === 'rejected' && p.rejection_reason && (
|
||||
<span className='text-gray-400 ml-2 italic'>– {p.rejection_reason}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${b.cls}`}>{b.label}</span>
|
||||
{p.status === 'requested' && (
|
||||
<button onClick={() => cancel(p.id)} className='text-xs text-red-500 hover:underline'>Zurückziehen</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MONTHS, MONTHS_SHORT, STATUS_LABELS, STATUS_COLORS, MANAGER_ROLES,
|
||||
} from '../utils/calendar'
|
||||
import { useAbsences } from '../hooks/useAbsences'
|
||||
import { PayoutRequestCard } from '../components/PayoutRequestCard'
|
||||
import { usePlanerView } from '../hooks/usePlanerView'
|
||||
import {
|
||||
BalanceEditModal,
|
||||
@@ -395,6 +396,8 @@ export function AbsencesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PayoutRequestCard availableHours={overtimeBalance?.available_hours ?? 0} />
|
||||
|
||||
<div className='flex gap-2 flex-wrap'>
|
||||
{[['', 'Alle'], ['pending', 'Ausstehend'], ['approved', 'Genehmigt'], ['rejected', 'Abgelehnt']].map(([val, label]) => (
|
||||
<button key={val} onClick={() => { setStatusFilter(val); if (val) setSearchParams({ status: val }); else setSearchParams({}) }}
|
||||
|
||||
@@ -69,6 +69,8 @@ export function CompanySettingsPage() {
|
||||
// Freizeitausgleich
|
||||
const [fzaOverdraftAllowed, setFzaOverdraftAllowed] = useState(true)
|
||||
const [fzaWarningThreshold, setFzaWarningThreshold] = useState(0)
|
||||
// Auszahlungs-Anträge (Mitarbeiter-Selbstbedienung)
|
||||
const [payoutRequestEnabled, setPayoutRequestEnabled] = useState(false)
|
||||
// Überstunden-Kappung
|
||||
const [overtimeCapEnabled, setOvertimeCapEnabled] = useState(false)
|
||||
const [overtimeCapHours, setOvertimeCapHours] = useState(150)
|
||||
@@ -100,10 +102,11 @@ export function CompanySettingsPage() {
|
||||
setPnRequired(c.personnel_number_required ?? false)
|
||||
setPnMode(c.personnel_number_mode ?? 'manual')
|
||||
setPnNext(c.personnel_number_next ?? 1)
|
||||
const cc = c as CompanyOut & { mobile_stamping_enabled?: boolean; overtime_overdraft_allowed?: boolean; overtime_warning_threshold_hours?: number; kiosk_require_approval?: boolean; kiosk_track_current_user?: boolean; kiosk_heartbeat_interval_sec?: number; overtime_cap_hours?: number | null; overtime_expiry_enabled?: boolean; overtime_expiry_month?: number; overtime_expiry_day?: number; overtime_max_carryover_hours?: number | null }
|
||||
const cc = c as CompanyOut & { mobile_stamping_enabled?: boolean; overtime_overdraft_allowed?: boolean; overtime_warning_threshold_hours?: number; kiosk_require_approval?: boolean; kiosk_track_current_user?: boolean; kiosk_heartbeat_interval_sec?: number; overtime_cap_hours?: number | null; overtime_expiry_enabled?: boolean; overtime_expiry_month?: number; overtime_expiry_day?: number; overtime_max_carryover_hours?: number | null; payout_request_enabled?: boolean }
|
||||
setMobileStamping(cc.mobile_stamping_enabled ?? true)
|
||||
setFzaOverdraftAllowed(cc.overtime_overdraft_allowed ?? true)
|
||||
setFzaWarningThreshold(cc.overtime_warning_threshold_hours ?? 0)
|
||||
setPayoutRequestEnabled(cc.payout_request_enabled ?? false)
|
||||
setKioskRequireApproval(cc.kiosk_require_approval ?? true)
|
||||
setKioskTrackCurrentUser(cc.kiosk_track_current_user ?? true)
|
||||
setKioskHeartbeatIntervalSec(cc.kiosk_heartbeat_interval_sec ?? 30)
|
||||
@@ -194,6 +197,7 @@ export function CompanySettingsPage() {
|
||||
two_stage_min_days: twoStageMinDays,
|
||||
overtime_overdraft_allowed: fzaOverdraftAllowed,
|
||||
overtime_warning_threshold_hours: fzaWarningThreshold,
|
||||
payout_request_enabled: payoutRequestEnabled,
|
||||
kiosk_require_approval: kioskRequireApproval,
|
||||
kiosk_track_current_user: kioskTrackCurrentUser,
|
||||
kiosk_heartbeat_interval_sec: kioskHeartbeatIntervalSec,
|
||||
@@ -797,6 +801,31 @@ export function CompanySettingsPage() {
|
||||
className="w-20 border border-gray-300 rounded-lg px-3 py-1.5 text-sm text-right disabled:bg-gray-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auszahlungs-Anträge durch Mitarbeiter */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">Auszahlungs-Anträge durch Mitarbeiter</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Mitarbeiter können Überstunden-Auszahlungen selbst beantragen; HR genehmigt oder lehnt ab.
|
||||
Wenn deaktiviert, bucht nur HR direkt.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isAdmin}
|
||||
onClick={() => setPayoutRequestEnabled(v => !v)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
payoutRequestEnabled ? 'bg-blue-600' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition-transform duration-200 ${
|
||||
payoutRequestEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Überstunden-Konto-Regeln */}
|
||||
|
||||
@@ -40,6 +40,18 @@ function formatPeriod(year: number | null, month: number | null): string {
|
||||
return `${MONTH_NAMES[month - 1]} ${year}`
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
requested: { label: 'Beantragt', cls: 'bg-amber-100 text-amber-700' },
|
||||
approved: { label: 'Genehmigt', cls: 'bg-green-100 text-green-700' },
|
||||
rejected: { label: 'Abgelehnt', cls: 'bg-red-100 text-red-700' },
|
||||
cancelled: { label: 'Zurückgezogen', cls: 'bg-gray-100 text-gray-500' },
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const b = STATUS_BADGE[status] ?? { label: status, cls: 'bg-gray-100 text-gray-500' }
|
||||
return <span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${b.cls}`}>{b.label}</span>
|
||||
}
|
||||
|
||||
export function HoursPayoutPage() {
|
||||
const [me, setMe] = useState<Me | null>(null)
|
||||
const [users, setUsers] = useState<UserItem[]>([])
|
||||
@@ -195,6 +207,29 @@ export function HoursPayoutPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove(payout: HoursPayoutOut) {
|
||||
if (!confirm(`Auszahlung über ${Number(payout.hours).toFixed(2)} h für ${payout.user_name} genehmigen? Der Saldo wird abgezogen.`)) return
|
||||
try {
|
||||
const updated = await api.post<HoursPayoutOut>(`/hr/payouts/${payout.id}/approve`, {})
|
||||
setPayouts(prev => prev.map(p => (p.id === payout.id ? updated : p)))
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Fehler beim Genehmigen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(payout: HoursPayoutOut) {
|
||||
const reason = prompt('Grund der Ablehnung (optional):')
|
||||
if (reason === null) return // Abbrechen
|
||||
try {
|
||||
const updated = await api.post<HoursPayoutOut>(`/hr/payouts/${payout.id}/reject`, {
|
||||
rejection_reason: reason.trim() || null,
|
||||
})
|
||||
setPayouts(prev => prev.map(p => (p.id === payout.id ? updated : p)))
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Fehler beim Ablehnen')
|
||||
}
|
||||
}
|
||||
|
||||
if (pageLoading) return (
|
||||
<div className='min-h-screen bg-gray-50 flex items-center justify-center'><Spinner /></div>
|
||||
)
|
||||
@@ -286,7 +321,7 @@ export function HoursPayoutPage() {
|
||||
<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 => (
|
||||
{['Mitarbeiter', 'Stunden', 'Status', 'Abrechnungsmonat', 'Notiz', 'Angelegt von', 'Datum', 'Aktion'].map(h => (
|
||||
<th key={h} className='px-4 py-3 text-left font-medium'>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -300,6 +335,14 @@ export function HoursPayoutPage() {
|
||||
{Number(payout.hours).toFixed(2)} h
|
||||
</span>
|
||||
</td>
|
||||
<td className='px-4 py-3'>
|
||||
<StatusBadge status={payout.status} />
|
||||
{payout.status === 'rejected' && payout.rejection_reason && (
|
||||
<div className='text-xs text-gray-400 mt-0.5 max-w-[10rem] truncate' title={payout.rejection_reason}>
|
||||
{payout.rejection_reason}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className='px-4 py-3 text-gray-600'>
|
||||
{formatPeriod(payout.period_year, payout.period_month)}
|
||||
</td>
|
||||
@@ -313,19 +356,40 @@ export function HoursPayoutPage() {
|
||||
})}
|
||||
</td>
|
||||
<td className='px-4 py-3'>
|
||||
<button
|
||||
onClick={() => handleDelete(payout)}
|
||||
className='text-xs text-red-500 hover:underline'
|
||||
title='Stornieren'
|
||||
>
|
||||
🗑️ Stornieren
|
||||
</button>
|
||||
{payout.status === 'requested' ? (
|
||||
<div className='flex gap-2'>
|
||||
<button
|
||||
onClick={() => handleApprove(payout)}
|
||||
className='text-xs text-green-600 hover:underline font-medium'
|
||||
title='Genehmigen'
|
||||
>
|
||||
✓ Genehmigen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReject(payout)}
|
||||
className='text-xs text-red-500 hover:underline'
|
||||
title='Ablehnen'
|
||||
>
|
||||
✗ Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
) : payout.status === 'approved' ? (
|
||||
<button
|
||||
onClick={() => handleDelete(payout)}
|
||||
className='text-xs text-red-500 hover:underline'
|
||||
title='Stornieren'
|
||||
>
|
||||
🗑️ Stornieren
|
||||
</button>
|
||||
) : (
|
||||
<span className='text-xs text-gray-300'>—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{payouts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className='px-4 py-10 text-center text-gray-400'>
|
||||
<td colSpan={8} className='px-4 py-10 text-center text-gray-400'>
|
||||
Keine Auszahlungen gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface HoursPayoutCreate {
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
export type PayoutStatus = 'requested' | 'approved' | 'rejected' | 'cancelled'
|
||||
|
||||
export interface HoursPayoutOut {
|
||||
id: string
|
||||
company_id: string
|
||||
@@ -15,8 +17,13 @@ export interface HoursPayoutOut {
|
||||
period_year: number | null
|
||||
period_month: number | null
|
||||
note: string | null
|
||||
status: PayoutStatus
|
||||
rejection_reason: string | null
|
||||
created_by: string
|
||||
created_by_name: string
|
||||
decided_by: string | null
|
||||
decided_by_name: string
|
||||
decided_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user