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:
@@ -1558,3 +1558,21 @@ Keine Commits in dieser Session.
|
|||||||
- backend/app/services/report_service.py | 19 ++++++++++++++++---
|
- 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 ++++++++++++++++++++-
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
|
|
||||||
interface TimeEntryOut {
|
interface TimeEntryOut {
|
||||||
@@ -17,6 +17,8 @@ interface TimeEntryListResponse {
|
|||||||
items: TimeEntryOut[]
|
items: TimeEntryOut[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type View = 'today' | 'month'
|
||||||
|
|
||||||
function fmtTime(iso: string | null): string {
|
function fmtTime(iso: string | null): string {
|
||||||
if (!iso) return '–'
|
if (!iso) return '–'
|
||||||
if (/^\d{2}:\d{2}(:\d{2})?$/.test(iso)) return iso.slice(0, 5)
|
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() {
|
export function MobileTodayScreen() {
|
||||||
const [entries, setEntries] = useState<TimeEntryOut[]>([])
|
const [view, setView] = useState<View>('today')
|
||||||
const [loading, setLoading] = useState(true)
|
const [entries, setEntries] = useState<TimeEntryOut[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
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 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 () => {
|
const load = useCallback(async () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -67,10 +75,40 @@ export function MobileTodayScreen() {
|
|||||||
}
|
}
|
||||||
}, [today])
|
}, [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(() => { load() }, [load])
|
||||||
|
useEffect(() => { if (view === 'month') loadMonth() }, [view, loadMonth])
|
||||||
|
|
||||||
const totalWorked = entries.reduce((sum, e) => sum + (e.worked_hours ?? 0), 0)
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col items-center justify-center flex-1 py-20 gap-4'>
|
<div className='flex flex-col items-center justify-center flex-1 py-20 gap-4'>
|
||||||
@@ -82,20 +120,25 @@ export function MobileTodayScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col gap-4 px-4 pt-4'>
|
<div className='flex flex-col gap-4 px-4 pt-4'>
|
||||||
{/* Header-Karte mit Datum + Gesamtzeit */}
|
|
||||||
<div className='bg-white rounded-2xl border border-gray-200 shadow-sm px-5 py-4 flex items-center justify-between'>
|
{/* Heute / Monat Toggle */}
|
||||||
<div>
|
<div className='bg-gray-100 rounded-xl p-1 flex'>
|
||||||
<p className='text-xs text-gray-400 font-medium uppercase tracking-widest'>Heute</p>
|
<button
|
||||||
<p className='text-base font-semibold text-gray-800 mt-0.5'>
|
onClick={() => setView('today')}
|
||||||
{new Date(today + 'T00:00:00').toLocaleDateString('de-DE', {
|
className={`flex-1 py-2 rounded-lg text-sm font-semibold transition-colors ${
|
||||||
weekday: 'long', day: '2-digit', month: 'long',
|
view === 'today' ? 'bg-white text-gray-800 shadow-sm' : 'text-gray-500'
|
||||||
})}
|
}`}
|
||||||
</p>
|
>
|
||||||
</div>
|
Heute
|
||||||
<div className='text-right'>
|
</button>
|
||||||
<p className='text-xs text-gray-400'>Gesamt</p>
|
<button
|
||||||
<p className='text-2xl font-bold text-gray-800'>{fmtH(totalWorked)}</p>
|
onClick={() => setView('month')}
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -104,42 +147,124 @@ export function MobileTodayScreen() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Eintrags-Liste */}
|
{/* ── HEUTE-ANSICHT ─────────────────────────────────────────────── */}
|
||||||
{entries.length === 0 ? (
|
{view === 'today' && (
|
||||||
<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>
|
<div className='bg-white rounded-2xl border border-gray-200 shadow-sm px-5 py-4 flex items-center justify-between'>
|
||||||
<p className='text-sm font-medium text-gray-500'>Noch keine Einträge heute</p>
|
<div>
|
||||||
<p className='text-xs text-gray-400 mt-1'>Stempel dich ein, um loszulegen</p>
|
<p className='text-xs text-gray-400 font-medium uppercase tracking-widest'>Heute</p>
|
||||||
</div>
|
<p className='text-base font-semibold text-gray-800 mt-0.5'>
|
||||||
) : (
|
{new Date(today + 'T00:00:00').toLocaleDateString('de-DE', {
|
||||||
<div className='flex flex-col gap-3'>
|
weekday: 'long', day: '2-digit', month: 'long',
|
||||||
{entries.map((entry, idx) => (
|
})}
|
||||||
<div key={entry.id} className='bg-white rounded-2xl border border-gray-200 shadow-sm px-5 py-4'>
|
</p>
|
||||||
<div className='flex items-start justify-between gap-3'>
|
</div>
|
||||||
<div>
|
<div className='text-right'>
|
||||||
<p className='text-xs text-gray-400 mb-1'>Eintrag {idx + 1}</p>
|
<p className='text-xs text-gray-400'>Gesamt</p>
|
||||||
<div className='flex items-center gap-2'>
|
<p className='text-2xl font-bold text-gray-800'>{fmtH(totalWorked)}</p>
|
||||||
<span className='text-lg font-mono font-semibold text-gray-800'>{fmtTime(entry.start_time)}</span>
|
</div>
|
||||||
<span className='text-gray-400'>→</span>
|
</div>
|
||||||
<span className='text-lg font-mono font-semibold text-gray-800'>{fmtTime(entry.end_time)}</span>
|
|
||||||
|
{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>
|
||||||
|
<p className='text-sm font-medium text-gray-500'>Noch keine Einträge heute</p>
|
||||||
|
<p className='text-xs text-gray-400 mt-1'>Stempel dich ein, um loszulegen</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='flex flex-col gap-3'>
|
||||||
|
{entries.map((entry, idx) => (
|
||||||
|
<div key={entry.id} className='bg-white rounded-2xl border border-gray-200 shadow-sm px-5 py-4'>
|
||||||
|
<div className='flex items-start justify-between gap-3'>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs text-gray-400 mb-1'>Eintrag {idx + 1}</p>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<span className='text-lg font-mono font-semibold text-gray-800'>{fmtTime(entry.start_time)}</span>
|
||||||
|
<span className='text-gray-400'>→</span>
|
||||||
|
<span className='text-lg font-mono font-semibold text-gray-800'>{fmtTime(entry.end_time)}</span>
|
||||||
|
</div>
|
||||||
|
{entry.break_minutes > 0 && (
|
||||||
|
<p className='text-xs text-gray-400 mt-1'>Pause: {entry.break_minutes} min</p>
|
||||||
|
)}
|
||||||
|
{entry.note && (
|
||||||
|
<p className='text-xs text-gray-500 mt-1 italic'>{entry.note}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col items-end gap-2 flex-shrink-0'>
|
||||||
|
<p className='text-xl font-bold text-gray-800'>{fmtH(entry.worked_hours)}</p>
|
||||||
|
<span className={`text-xs px-2.5 py-1 rounded-full font-medium ${STATUS_COLORS[entry.status] ?? 'bg-gray-100 text-gray-500'}`}>
|
||||||
|
{STATUS_LABELS[entry.status] ?? entry.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{entry.break_minutes > 0 && (
|
|
||||||
<p className='text-xs text-gray-400 mt-1'>Pause: {entry.break_minutes} min</p>
|
|
||||||
)}
|
|
||||||
{entry.note && (
|
|
||||||
<p className='text-xs text-gray-500 mt-1 italic'>{entry.note}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col items-end gap-2 flex-shrink-0'>
|
|
||||||
<p className='text-xl font-bold text-gray-800'>{fmtH(entry.worked_hours)}</p>
|
|
||||||
<span className={`text-xs px-2.5 py-1 rounded-full font-medium ${STATUS_COLORS[entry.status] ?? 'bg-gray-100 text-gray-500'}`}>
|
|
||||||
{STATUS_LABELS[entry.status] ?? entry.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
{monthDaysWorked > 0 && (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user