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:
@@ -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 [entries, setEntries] = useState<TimeEntryOut[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
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,20 +120,25 @@ export function MobileTodayScreen() {
|
||||
|
||||
return (
|
||||
<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'>
|
||||
<div>
|
||||
<p className='text-xs text-gray-400 font-medium uppercase tracking-widest'>Heute</p>
|
||||
<p className='text-base font-semibold text-gray-800 mt-0.5'>
|
||||
{new Date(today + 'T00:00:00').toLocaleDateString('de-DE', {
|
||||
weekday: 'long', day: '2-digit', month: 'long',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className='text-right'>
|
||||
<p className='text-xs text-gray-400'>Gesamt</p>
|
||||
<p className='text-2xl font-bold text-gray-800'>{fmtH(totalWorked)}</p>
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
@@ -104,42 +147,124 @@ export function MobileTodayScreen() {
|
||||
</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>
|
||||
<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>
|
||||
{/* ── 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>
|
||||
<p className='text-base font-semibold text-gray-800 mt-0.5'>
|
||||
{new Date(today + 'T00:00:00').toLocaleDateString('de-DE', {
|
||||
weekday: 'long', day: '2-digit', month: 'long',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className='text-right'>
|
||||
<p className='text-xs text-gray-400'>Gesamt</p>
|
||||
<p className='text-2xl font-bold text-gray-800'>{fmtH(totalWorked)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 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>
|
||||
{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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user