feat: Stunden-Auszahlungen in /mobile Profil-Screen

- Überstunden-Saldo (Gesamt/Entnommen/Verfügbar) als 3-spaltige Karte
- Letzte 5 Auszahlungen mit Stunden (lila), Abrechnungsmonat, Notiz
- Parallel-Load mit Overtime-Balance beim Profil-Laden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 22:22:08 +02:00
parent a63b0e835f
commit 549783a05e
2 changed files with 105 additions and 3 deletions
+21
View File
@@ -1470,3 +1470,24 @@ Keine Commits in dieser Session.
- frontend/src/pages/CompanySettingsPage.tsx | 88 +++++++++++++++++++++++++++++- - frontend/src/pages/CompanySettingsPage.tsx | 88 +++++++++++++++++++++++++++++-
--- ---
## 2026-05-25 22:17 22:18 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- a63b0e8 feat: Stunden-Auszahlung Feature (/hr/payouts)
### Geänderte Dateien
- DEVLOG.md | 136 ++++++
- backend/app/main.py | 2 +
- backend/app/models/__init__.py | 2 +
- backend/app/models/hours_payout.py | 45 ++
- backend/app/routers/hours_payouts.py | 189 +++++++++
- backend/app/schemas/hours_payout.py | 33 ++
- backend/migrations/versions/0030_hours_payouts.py | 36 ++
- frontend/src/App.tsx | 2 +
- frontend/src/components/Layout.tsx | 1 +
- frontend/src/pages/HoursPayoutPage.tsx | 484 ++++++++++++++++++++++
- frontend/src/types/hoursPayout.ts | 26 ++
---
@@ -11,6 +11,30 @@ interface UserOut {
personnel_number: string | null personnel_number: string | null
} }
interface OvertimeBalance {
total_hours: number
taken_hours: number
available_hours: number
}
interface HoursPayoutOut {
id: string
hours: number
period_year: number | null
period_month: number | null
note: string | null
created_by_name: string
created_at: string
}
interface HoursPayoutListResponse {
payouts: HoursPayoutOut[]
total_count: number
}
const MONTH_NAMES = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun',
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
const ROLE_LABELS: Record<string, string> = { const ROLE_LABELS: Record<string, string> = {
SUPER_ADMIN: 'Super Admin', SUPER_ADMIN: 'Super Admin',
COMPANY_ADMIN: 'Administrator', COMPANY_ADMIN: 'Administrator',
@@ -24,12 +48,21 @@ export function MobileProfileScreen() {
const [user, setUser] = useState<UserOut | null>(null) const [user, setUser] = useState<UserOut | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [overtime, setOvertime] = useState<OvertimeBalance | null>(null)
const [payouts, setPayouts] = useState<HoursPayoutOut[]>([])
const load = useCallback(async () => { const load = useCallback(async () => {
setError(null) setError(null)
try { try {
const me = await api.get<UserOut>('/auth/me') const me = await api.get<UserOut>('/auth/me')
setUser(me) setUser(me)
// Überstunden-Saldo + eigene Auszahlungen parallel laden
const [bal, payoutData] = await Promise.all([
api.get<OvertimeBalance>(`/absences/overtime-balance?user_id=${me.id}`).catch(() => null),
api.get<HoursPayoutListResponse>(`/hr/payouts?user_id=${me.id}`).catch(() => null),
])
if (bal) setOvertime(bal)
if (payoutData) setPayouts(payoutData.payouts.slice(0, 5)) // letzte 5
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden') setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally { } finally {
@@ -96,6 +129,54 @@ export function MobileProfileScreen() {
)} )}
</div> </div>
{/* Überstunden-Saldo */}
{overtime !== null && (
<div className='bg-white rounded-2xl border border-gray-200 shadow-sm px-5 py-4'>
<p className='text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3'>Überstunden-Konto</p>
<div className='grid grid-cols-3 gap-2 text-center'>
<div>
<p className='text-lg font-bold text-gray-800'>{overtime.total_hours.toFixed(1)}</p>
<p className='text-xs text-gray-400'>Gesamt</p>
</div>
<div>
<p className='text-lg font-bold text-orange-600'>{overtime.taken_hours.toFixed(1)}</p>
<p className='text-xs text-gray-400'>Entnommen</p>
</div>
<div>
<p className={`text-lg font-bold ${overtime.available_hours >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{overtime.available_hours.toFixed(1)}
</p>
<p className='text-xs text-gray-400'>Verfügbar</p>
</div>
</div>
</div>
)}
{/* Stunden-Auszahlungen */}
{payouts.length > 0 && (
<div className='bg-white rounded-2xl border border-gray-200 shadow-sm px-5 py-4'>
<p className='text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3'>
💸 Stunden-Auszahlungen
</p>
<div className='divide-y divide-gray-100'>
{payouts.map(p => (
<div key={p.id} className='flex items-center justify-between py-3 first:pt-0 last:pb-0'>
<div>
<p className='text-sm font-semibold text-purple-700'>{p.hours.toFixed(2)} h</p>
<p className='text-xs text-gray-400'>
{p.period_year && p.period_month
? `${MONTH_NAMES[p.period_month - 1]} ${p.period_year}`
: new Date(p.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
</p>
{p.note && <p className='text-xs text-gray-500 mt-0.5 truncate max-w-[180px]'>{p.note}</p>}
</div>
<p className='text-xs text-gray-400 text-right'>{p.created_by_name}</p>
</div>
))}
</div>
</div>
)}
{/* Desktop-Version Link */} {/* Desktop-Version Link */}
<a <a
href='/dashboard' href='/dashboard'