feat: Monatsansicht im /mobile Heute-Screen

- Toggle 'Heute / <Monatsname>' oben im Screen
- Monats-KPIs: Gesamtstunden, Arbeitstage, Ø pro Tag
- Tagesliste absteigend mit Datum, Uhrzeit, Status, Stunden
- Lazy-Load: Monatsdaten werden erst beim Wechsel geladen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 22:56:04 +02:00
parent 23ba7f1762
commit d0fdaef447
2 changed files with 193 additions and 50 deletions
+18
View File
@@ -1558,3 +1558,21 @@ Keine Commits in dieser Session.
- backend/app/services/report_service.py | 19 ++++++++++++++++---
---
## 2026-05-25 22:48 22:51 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- 23ba7f1 feat: Überstunden-Kappung + Jahresverfall pro Firma konfigurierbar
### Geänderte Dateien
- DEVLOG.md | 67 +++++++++
- backend/app/models/company.py | 7 +
- backend/app/models/overtime_balance.py | 1 +
- backend/app/routers/absences.py | 32 ++++
- backend/app/schemas/company.py | 10 ++
- backend/app/services/report_service.py | 54 +++++++
- .../versions/0031_overtime_cap_expiry.py | 33 +++++
- frontend/src/pages/CompanySettingsPage.tsx | 161 ++++++++++++++++++++-
---
+134 -9
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react'
import { useEffect, useState, useCallback, useMemo } from 'react'
import { api } from '../../api/client'
interface TimeEntryOut {
@@ -17,6 +17,8 @@ interface TimeEntryListResponse {
items: TimeEntryOut[]
}
type View = 'today' | 'month'
function fmtTime(iso: string | null): string {
if (!iso) return ''
if (/^\d{2}:\d{2}(:\d{2})?$/.test(iso)) return iso.slice(0, 5)
@@ -49,11 +51,17 @@ const STATUS_COLORS: Record<string, string> = {
}
export function MobileTodayScreen() {
const [view, setView] = useState<View>('today')
const [entries, setEntries] = useState<TimeEntryOut[]>([])
const [monthEntries, setMonthEntries] = useState<TimeEntryOut[]>([])
const [loading, setLoading] = useState(true)
const [loadingMonth, setLoadingMonth] = useState(false)
const [error, setError] = useState<string | null>(null)
const today = new Date().toISOString().slice(0, 10)
const now = new Date()
const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`
const monthEnd = today
const load = useCallback(async () => {
setError(null)
@@ -67,10 +75,40 @@ export function MobileTodayScreen() {
}
}, [today])
const loadMonth = useCallback(async () => {
if (monthEntries.length > 0) return // schon geladen
setLoadingMonth(true)
try {
const res = await api.get<TimeEntryListResponse>(
`/time/entries?date_from=${monthStart}&date_to=${monthEnd}&limit=500`
)
setMonthEntries(res.items)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoadingMonth(false)
}
}, [monthStart, monthEnd, monthEntries.length])
useEffect(() => { load() }, [load])
useEffect(() => { if (view === 'month') loadMonth() }, [view, loadMonth])
const totalWorked = entries.reduce((sum, e) => sum + (e.worked_hours ?? 0), 0)
// Monat: Stunden pro Tag gruppieren
const monthByDate = useMemo(() => {
const map = new Map<string, TimeEntryOut[]>()
for (const e of monthEntries) {
if (!map.has(e.date)) map.set(e.date, [])
map.get(e.date)!.push(e)
}
return new Map([...map.entries()].sort((a, b) => b[0].localeCompare(a[0])))
}, [monthEntries])
const monthTotalHours = monthEntries.reduce((sum, e) => sum + (e.worked_hours ?? 0), 0)
const monthDaysWorked = monthByDate.size
const monthName = now.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })
if (loading) {
return (
<div className='flex flex-col items-center justify-center flex-1 py-20 gap-4'>
@@ -82,7 +120,36 @@ export function MobileTodayScreen() {
return (
<div className='flex flex-col gap-4 px-4 pt-4'>
{/* Header-Karte mit Datum + Gesamtzeit */}
{/* Heute / Monat Toggle */}
<div className='bg-gray-100 rounded-xl p-1 flex'>
<button
onClick={() => setView('today')}
className={`flex-1 py-2 rounded-lg text-sm font-semibold transition-colors ${
view === 'today' ? 'bg-white text-gray-800 shadow-sm' : 'text-gray-500'
}`}
>
Heute
</button>
<button
onClick={() => setView('month')}
className={`flex-1 py-2 rounded-lg text-sm font-semibold transition-colors ${
view === 'month' ? 'bg-white text-gray-800 shadow-sm' : 'text-gray-500'
}`}
>
{now.toLocaleDateString('de-DE', { month: 'long' })}
</button>
</div>
{error && (
<div className='bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700'>
{error}
</div>
)}
{/* ── HEUTE-ANSICHT ─────────────────────────────────────────────── */}
{view === 'today' && (
<>
<div className='bg-white rounded-2xl border border-gray-200 shadow-sm px-5 py-4 flex items-center justify-between'>
<div>
<p className='text-xs text-gray-400 font-medium uppercase tracking-widest'>Heute</p>
@@ -98,13 +165,6 @@ export function MobileTodayScreen() {
</div>
</div>
{error && (
<div className='bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700'>
{error}
</div>
)}
{/* Eintrags-Liste */}
{entries.length === 0 ? (
<div className='bg-white rounded-2xl border border-gray-200 shadow-sm px-5 py-10 text-center'>
<p className='text-4xl mb-3'>📋</p>
@@ -141,6 +201,71 @@ export function MobileTodayScreen() {
))}
</div>
)}
</>
)}
{/* ── MONATS-ANSICHT ────────────────────────────────────────────── */}
{view === 'month' && (
<>
{/* Monats-KPIs */}
<div className='bg-white rounded-2xl border border-gray-200 shadow-sm px-5 py-4'>
<p className='text-xs text-gray-400 font-medium uppercase tracking-widest mb-3'>{monthName}</p>
<div className='grid grid-cols-2 gap-4'>
<div>
<p className='text-2xl font-bold text-gray-800'>{fmtH(monthTotalHours)}</p>
<p className='text-xs text-gray-400 mt-0.5'>Gesamt gearbeitet</p>
</div>
<div>
<p className='text-2xl font-bold text-gray-800'>{monthDaysWorked}</p>
<p className='text-xs text-gray-400 mt-0.5'>Arbeitstage</p>
</div>
</div>
{monthDaysWorked > 0 && (
<p className='text-xs text-gray-400 mt-3 border-t border-gray-100 pt-3'>
Ø {fmtH(monthTotalHours / monthDaysWorked)} pro Tag
</p>
)}
</div>
{loadingMonth ? (
<div className='flex justify-center py-10'>
<div className='animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent' />
</div>
) : monthByDate.size === 0 ? (
<div className='bg-white rounded-2xl border border-gray-200 shadow-sm px-5 py-10 text-center'>
<p className='text-4xl mb-3'>📅</p>
<p className='text-sm font-medium text-gray-500'>Keine Einträge diesen Monat</p>
</div>
) : (
<div className='flex flex-col gap-2'>
{[...monthByDate.entries()].map(([date, dayEntries]) => {
const dayTotal = dayEntries.reduce((s, e) => s + (e.worked_hours ?? 0), 0)
const d = new Date(date + 'T00:00:00')
return (
<div key={date} className='bg-white rounded-2xl border border-gray-200 shadow-sm px-5 py-3.5 flex items-center justify-between'>
<div>
<p className='text-sm font-semibold text-gray-800'>
{d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })}
</p>
<p className='text-xs text-gray-400 mt-0.5'>
{dayEntries.length === 1
? `${fmtTime(dayEntries[0].start_time)} ${fmtTime(dayEntries[0].end_time)}`
: `${dayEntries.length} Einträge`}
</p>
</div>
<div className='text-right'>
<p className='text-base font-bold text-gray-800'>{fmtH(dayTotal)}</p>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[dayEntries[0].status] ?? 'bg-gray-100 text-gray-500'}`}>
{STATUS_LABELS[dayEntries[0].status] ?? dayEntries[0].status}
</span>
</div>
</div>
)
})}
</div>
)}
</>
)}
</div>
)
}