feat: mobile Login-Seite /mobile/login

- MobileLoginPage.tsx: touch-optimiertes Login-Formular
  - E-Mail + Passwort mit großen Touch-Targets (min-h-[52px])
  - TOTP-Flow: nach erstem Login automatisch 6-stelliges Code-Feld
  - Numerische Tastatur (inputMode=numeric) für TOTP-Eingabe
  - Fehlerbehandlung + Ladezustand
  - Link zur Desktop-Version
- MobilePage: Redirect zu /mobile/login statt /login
- App.tsx: Route /mobile/login registriert (kein Layout-Wrapper)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 21:17:32 +02:00
parent 8a04525dfc
commit edb1568801
8 changed files with 1000 additions and 0 deletions
+77
View File
@@ -1017,3 +1017,80 @@ Keine Commits in dieser Session.
- docs/deployment.md | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-05-24 13:15 19:45 (6h 29m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- 62c4e74 security: 9 Findings aus Security-Audit behoben (CRITICAL + HIGH + MEDIUM)
### Geänderte Dateien
- DEVLOG.md | 63 ++++++++++++++++++++
- backend/app/core/crypto.py | 42 +++++++++++++
- backend/app/core/kiosk_security.py | 37 ++++++++----
- backend/app/main.py | 18 ++++--
- backend/app/models/ldap_config.py | 2 +-
- backend/app/models/user.py | 2 +-
- backend/app/routers/auth.py | 51 +++++++++++++---
- backend/app/routers/import_kimai.py | 17 +++++-
- backend/app/routers/users.py | 20 ++++++-
- backend/app/services/auth_service.py | 13 ++++-
- backend/app/services/caldav_service.py | 17 ++++++
- backend/migrations/versions/0026_security_fixes.py | 68 ++++++++++++++++++++++
---
## 2026-05-24 19:53 20:58 (1h 05m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 63 ++++++++++++++++++++
- backend/app/core/crypto.py | 42 +++++++++++++
- backend/app/core/kiosk_security.py | 37 ++++++++----
- backend/app/main.py | 18 ++++--
- backend/app/models/ldap_config.py | 2 +-
- backend/app/models/user.py | 2 +-
- backend/app/routers/auth.py | 51 +++++++++++++---
- backend/app/routers/import_kimai.py | 17 +++++-
- backend/app/routers/users.py | 20 ++++++-
- backend/app/services/auth_service.py | 13 ++++-
- backend/app/services/caldav_service.py | 17 ++++++
- backend/migrations/versions/0026_security_fixes.py | 68 ++++++++++++++++++++++
---
## 2026-05-24 21:00 21:05 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 63 ++++++++++++++++++++
- backend/app/core/crypto.py | 42 +++++++++++++
- backend/app/core/kiosk_security.py | 37 ++++++++----
- backend/app/main.py | 18 ++++--
- backend/app/models/ldap_config.py | 2 +-
- backend/app/models/user.py | 2 +-
- backend/app/routers/auth.py | 51 +++++++++++++---
- backend/app/routers/import_kimai.py | 17 +++++-
- backend/app/routers/users.py | 20 ++++++-
- backend/app/services/auth_service.py | 13 ++++-
- backend/app/services/caldav_service.py | 17 ++++++
- backend/migrations/versions/0026_security_fixes.py | 68 ++++++++++++++++++++++
---
## 2026-05-24 21:09 21:12 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- 8a04525 fix: auto-refresh access token on 401 in API client
### Geänderte Dateien
- frontend/src/api/client.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++++
---
+4
View File
@@ -24,6 +24,8 @@ import { KioskDevicesPage } from './pages/KioskDevicesPage'
import { AuditLogPage } from './pages/AuditLogPage'
import { KioskSetupPage } from './pages/KioskSetupPage'
import { KioskStampPage } from './pages/KioskStampPage'
import { MobilePage } from './pages/mobile/MobilePage'
import { MobileLoginPage } from './pages/mobile/MobileLoginPage'
export default function App() {
return (
@@ -36,6 +38,8 @@ export default function App() {
<Route path='/auth/reset-password' element={<ResetPasswordPage />} />
<Route path='/kiosk/setup' element={<KioskSetupPage />} />
<Route path='/kiosk' element={<KioskStampPage />} />
<Route path='/mobile' element={<MobilePage />} />
<Route path='/mobile/login' element={<MobileLoginPage />} />
<Route element={<ProtectedRoute />}>
<Route path='/dashboard' element={<DashboardPage />} />
<Route path='/time' element={<TimeTrackingPage />} />
@@ -0,0 +1,69 @@
type Screen = 'stamp' | 'today' | 'profile'
interface Props {
active: Screen
onChange: (s: Screen) => void
}
export function MobileBottomNav({ active, onChange }: Props) {
const items: { id: Screen; label: string; icon: React.ReactNode }[] = [
{
id: 'stamp',
label: 'Stempeln',
icon: (
<svg className='w-6 h-6' fill='none' viewBox='0 0 24 24' stroke='currentColor' strokeWidth={1.75}>
<circle cx='12' cy='12' r='9' />
<polyline points='12 7 12 12 15 15' />
</svg>
),
},
{
id: 'today',
label: 'Heute',
icon: (
<svg className='w-6 h-6' fill='none' viewBox='0 0 24 24' stroke='currentColor' strokeWidth={1.75}>
<rect x='3' y='4' width='18' height='18' rx='2' ry='2' />
<line x1='16' y1='2' x2='16' y2='6' />
<line x1='8' y1='2' x2='8' y2='6' />
<line x1='3' y1='10' x2='21' y2='10' />
</svg>
),
},
{
id: 'profile',
label: 'Profil',
icon: (
<svg className='w-6 h-6' fill='none' viewBox='0 0 24 24' stroke='currentColor' strokeWidth={1.75}>
<path strokeLinecap='round' strokeLinejoin='round' d='M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2' />
<circle cx='12' cy='7' r='4' />
</svg>
),
},
]
return (
<nav
className='fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-20'
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
>
<div className='flex'>
{items.map(item => (
<button
key={item.id}
onClick={() => onChange(item.id)}
className={`flex-1 flex flex-col items-center justify-center gap-1 min-h-[56px] py-2 transition-colors ${
active === item.id
? 'text-blue-600'
: 'text-gray-400'
}`}
>
{item.icon}
<span className={`text-[11px] font-medium leading-none ${active === item.id ? 'text-blue-600' : 'text-gray-400'}`}>
{item.label}
</span>
</button>
))}
</div>
</nav>
)
}
@@ -0,0 +1,195 @@
import { useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../context/AuthContext'
import { api } from '../../api/client'
interface LoginResponse {
access_token: string
refresh_token: string
totp_required?: boolean
partial_token?: string
}
export function MobileLoginPage() {
const { login } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [partialToken, setPartialToken] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const totpInputRef = useRef<HTMLInputElement>(null)
// Schritt 1: E-Mail + Passwort
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
const res = await api.post<LoginResponse>('/auth/login', { email, password })
if (res.totp_required && res.partial_token) {
// 2FA erforderlich → TOTP-Formular zeigen
setPartialToken(res.partial_token)
setTimeout(() => totpInputRef.current?.focus(), 100)
} else {
// Direkt eingeloggt
localStorage.setItem('access_token', res.access_token)
localStorage.setItem('refresh_token', res.refresh_token)
await login(email, password)
navigate('/mobile', { replace: true })
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Anmeldung fehlgeschlagen')
} finally {
setLoading(false)
}
}
// Schritt 2: TOTP-Code
const handleTotp = async (e: React.FormEvent) => {
e.preventDefault()
if (!partialToken) return
setLoading(true)
setError(null)
try {
const res = await api.post<LoginResponse>('/auth/totp/login', {
partial_token: partialToken,
code: totpCode,
})
localStorage.setItem('access_token', res.access_token)
localStorage.setItem('refresh_token', res.refresh_token)
// AuthContext-State synchronisieren (löst Re-Render aus)
window.dispatchEvent(new Event('storage'))
navigate('/mobile', { replace: true })
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Ungültiger Code')
setTotpCode('')
totpInputRef.current?.focus()
} finally {
setLoading(false)
}
}
return (
<div
className='min-h-screen bg-gray-50 flex flex-col items-center justify-center px-6'
style={{ paddingTop: 'env(safe-area-inset-top)', paddingBottom: 'env(safe-area-inset-bottom)' }}
>
{/* Logo */}
<div className='flex flex-col items-center mb-10 gap-3'>
<div className='w-16 h-16 bg-blue-600 rounded-2xl flex items-center justify-center shadow-lg'>
<span className='text-white font-bold text-2xl'>TM</span>
</div>
<h1 className='text-2xl font-bold text-gray-900'>TimeMaster</h1>
<p className='text-sm text-gray-500'>
{partialToken ? 'Zwei-Faktor-Authentifizierung' : 'Bitte melde dich an'}
</p>
</div>
{/* Fehler-Banner */}
{error && (
<div className='w-full max-w-sm mb-4 bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700'>
{error}
</div>
)}
{/* Formular */}
<div className='w-full max-w-sm bg-white rounded-2xl border border-gray-200 shadow-sm p-6'>
{!partialToken ? (
/* ── Login-Formular ── */
<form onSubmit={handleLogin} className='flex flex-col gap-4'>
<div className='flex flex-col gap-1.5'>
<label className='text-sm font-medium text-gray-700'>E-Mail</label>
<input
type='email'
value={email}
onChange={e => setEmail(e.target.value)}
placeholder='deine@email.de'
required
autoComplete='email'
className='min-h-[52px] px-4 rounded-xl border border-gray-300 text-gray-900 text-base focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
/>
</div>
<div className='flex flex-col gap-1.5'>
<label className='text-sm font-medium text-gray-700'>Passwort</label>
<input
type='password'
value={password}
onChange={e => setPassword(e.target.value)}
placeholder='••••••••'
required
autoComplete='current-password'
className='min-h-[52px] px-4 rounded-xl border border-gray-300 text-gray-900 text-base focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
/>
</div>
<button
type='submit'
disabled={loading || !email || !password}
className='min-h-[52px] mt-2 rounded-xl bg-blue-600 active:bg-blue-800 text-white font-semibold text-base transition-colors disabled:opacity-50 flex items-center justify-center gap-2'
>
{loading
? <span className='animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent' />
: 'Anmelden'
}
</button>
</form>
) : (
/* ── TOTP-Formular ── */
<form onSubmit={handleTotp} className='flex flex-col gap-4'>
<div className='flex flex-col items-center gap-2 mb-2'>
<div className='w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center'>
<span className='text-2xl'>🔐</span>
</div>
<p className='text-sm text-gray-500 text-center'>
Gib den 6-stelligen Code aus deiner Authenticator-App ein.
</p>
</div>
<input
ref={totpInputRef}
type='text'
inputMode='numeric'
pattern='[0-9]{6}'
maxLength={6}
value={totpCode}
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
placeholder='000000'
required
autoComplete='one-time-code'
className='min-h-[64px] px-4 rounded-xl border border-gray-300 text-gray-900 text-3xl font-mono tracking-widest text-center focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
/>
<button
type='submit'
disabled={loading || totpCode.length !== 6}
className='min-h-[52px] rounded-xl bg-blue-600 active:bg-blue-800 text-white font-semibold text-base transition-colors disabled:opacity-50 flex items-center justify-center gap-2'
>
{loading
? <span className='animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent' />
: 'Bestätigen'
}
</button>
<button
type='button'
onClick={() => { setPartialToken(null); setTotpCode(''); setError(null) }}
className='text-sm text-gray-400 text-center py-1 active:text-gray-600'
>
Zurück zur Anmeldung
</button>
</form>
)}
</div>
{/* Link zur Desktop-Version */}
<a
href='/login'
className='mt-6 text-sm text-gray-400 active:text-gray-600'
>
Desktop-Version öffnen
</a>
</div>
)
}
+75
View File
@@ -0,0 +1,75 @@
import { useState, useEffect } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../../context/AuthContext'
import { MobileBottomNav } from './MobileBottomNav'
import { MobileStampScreen } from './MobileStampScreen'
import { MobileTodayScreen } from './MobileTodayScreen'
import { MobileProfileScreen } from './MobileProfileScreen'
type Screen = 'stamp' | 'today' | 'profile'
const SCREEN_TITLES: Record<Screen, string> = {
stamp: 'Zeiterfassung',
today: 'Heute',
profile: 'Profil',
}
export function MobilePage() {
const { token } = useAuth()
const [screen, setScreen] = useState<Screen>('stamp')
const [time, setTime] = useState('')
// Uhrzeit im Header live aktualisieren
useEffect(() => {
const update = () => {
setTime(new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }))
}
update()
const id = setInterval(update, 1000)
return () => clearInterval(id)
}, [])
if (!token) return <Navigate to='/mobile/login' replace />
return (
<div
className='min-h-screen bg-gray-50 flex flex-col'
style={{ paddingTop: 'env(safe-area-inset-top)' }}
>
{/* Header */}
<header className='bg-white border-b border-gray-200 sticky top-0 z-10'>
<div className='flex items-center justify-between px-4 h-14'>
<div className='flex items-center gap-2'>
<div className='w-7 h-7 bg-blue-600 rounded-lg flex items-center justify-center'>
<span className='text-white font-bold text-xs'>TM</span>
</div>
<span className='font-bold text-gray-800 text-sm'>TimeMaster</span>
</div>
<div className='text-right'>
<p className='text-base font-mono font-semibold text-gray-800 leading-none'>{time}</p>
<p className='text-xs text-gray-400 leading-none mt-0.5'>
{new Date().toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })}
</p>
</div>
</div>
{/* Screen-Titel */}
<div className='px-4 pb-3'>
<h1 className='text-xl font-bold text-gray-900'>{SCREEN_TITLES[screen]}</h1>
</div>
</header>
{/* Content */}
<main
className='flex-1 overflow-y-auto'
style={{ paddingBottom: 'calc(72px + env(safe-area-inset-bottom))' }}
>
{screen === 'stamp' && <MobileStampScreen />}
{screen === 'today' && <MobileTodayScreen />}
{screen === 'profile' && <MobileProfileScreen />}
</main>
{/* Bottom Navigation */}
<MobileBottomNav active={screen} onChange={setScreen} />
</div>
)
}
@@ -0,0 +1,124 @@
import { useEffect, useState, useCallback } from 'react'
import { api } from '../../api/client'
import { useAuth } from '../../context/AuthContext'
interface UserOut {
id: string
first_name: string
last_name: string
email: string
role: string
personnel_number: string | null
}
const ROLE_LABELS: Record<string, string> = {
SUPER_ADMIN: 'Super Admin',
COMPANY_ADMIN: 'Administrator',
HR: 'HR',
MANAGER: 'Manager',
EMPLOYEE: 'Mitarbeiter',
}
export function MobileProfileScreen() {
const { logout } = useAuth()
const [user, setUser] = useState<UserOut | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
setError(null)
try {
const me = await api.get<UserOut>('/auth/me')
setUser(me)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
if (loading) {
return (
<div className='flex flex-col items-center justify-center flex-1 py-20 gap-4'>
<div className='animate-spin rounded-full h-10 w-10 border-4 border-blue-500 border-t-transparent' />
<p className='text-sm text-gray-400'>Wird geladen</p>
</div>
)
}
const initials = user
? `${user.first_name[0] ?? ''}${user.last_name[0] ?? ''}`.toUpperCase()
: '?'
return (
<div className='flex flex-col gap-4 px-4 pt-4'>
{error && (
<div className='bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700'>
{error}
</div>
)}
{user && (
<>
{/* Avatar + Name */}
<div className='bg-white rounded-2xl border border-gray-200 shadow-sm px-5 py-6 flex flex-col items-center gap-3'>
<div className='w-20 h-20 rounded-full bg-blue-100 flex items-center justify-center'>
<span className='text-blue-700 font-bold text-3xl'>{initials}</span>
</div>
<div className='text-center'>
<p className='text-xl font-bold text-gray-800'>
{user.first_name} {user.last_name}
</p>
<p className='text-sm text-gray-500 mt-0.5'>{user.email}</p>
<span className='inline-block mt-2 px-3 py-1 rounded-full bg-blue-100 text-blue-700 text-xs font-semibold'>
{ROLE_LABELS[user.role] ?? user.role}
</span>
</div>
</div>
{/* Detail-Felder */}
<div className='bg-white rounded-2xl border border-gray-200 shadow-sm divide-y divide-gray-100'>
<div className='flex items-center justify-between px-5 py-4'>
<span className='text-sm text-gray-500'>E-Mail</span>
<span className='text-sm font-medium text-gray-800'>{user.email}</span>
</div>
<div className='flex items-center justify-between px-5 py-4'>
<span className='text-sm text-gray-500'>Rolle</span>
<span className='text-sm font-medium text-gray-800'>{ROLE_LABELS[user.role] ?? user.role}</span>
</div>
{user.personnel_number && (
<div className='flex items-center justify-between px-5 py-4'>
<span className='text-sm text-gray-500'>Personalnummer</span>
<span className='text-sm font-mono font-medium text-gray-800'>{user.personnel_number}</span>
</div>
)}
</div>
{/* Desktop-Version Link */}
<a
href='/dashboard'
className='bg-white rounded-2xl border border-gray-200 shadow-sm px-5 py-4 flex items-center justify-between min-h-[56px] active:bg-gray-50 transition-colors'
>
<span className='text-sm font-medium text-gray-700'>Desktop-Version öffnen</span>
<svg className='w-5 h-5 text-gray-400' fill='none' viewBox='0 0 24 24' stroke='currentColor' strokeWidth={1.75}>
<path strokeLinecap='round' strokeLinejoin='round' d='M9 5l7 7-7 7' />
</svg>
</a>
{/* Abmelden */}
<button
onClick={logout}
className='min-h-[56px] w-full rounded-2xl border border-red-200 text-red-600 font-semibold text-base active:bg-red-50 transition-colors flex items-center justify-center gap-2'
>
<svg className='w-5 h-5' fill='none' viewBox='0 0 24 24' stroke='currentColor' strokeWidth={1.75}>
<path strokeLinecap='round' strokeLinejoin='round' d='M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1' />
</svg>
Abmelden
</button>
</>
)}
</div>
)
}
@@ -0,0 +1,310 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { api } from '../../api/client'
interface TodayStatus {
today_open: boolean
today_start: string | null
today_hours_so_far: number | null
break_start?: string | null
break_minutes?: number
}
interface TimeEntryWithWarnings {
entry: { id: string }
warnings: string[]
}
interface BalanceResponse {
overtime_hours: number
total_hours_worked: number
expected_hours: number
period_start: string
period_end: string
}
function getMondayOfCurrentWeek(): string {
const now = new Date()
const day = now.getDay()
const diff = day === 0 ? -6 : 1 - day
const monday = new Date(now)
monday.setDate(now.getDate() + diff)
return monday.toISOString().slice(0, 10)
}
function fmtHMS(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
function fmtMS(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
function fmtH(h: number | null): string {
if (h === null || h === undefined) return ''
const sign = h < 0 ? '-' : h > 0 ? '+' : ''
const abs = Math.abs(h)
const hrs = Math.floor(abs)
const min = Math.round((abs - hrs) * 60)
return `${sign}${hrs}h ${min}m`
}
function fmtTime(iso: string | null): string {
if (!iso) return ''
if (/^\d{2}:\d{2}(:\d{2})?$/.test(iso)) return iso.slice(0, 5)
const d = new Date(iso)
if (isNaN(d.getTime())) return iso.slice(0, 5)
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
export function MobileStampScreen() {
const [dashboard, setDashboard] = useState<TodayStatus | null>(null)
const [balance, setBalance] = useState<BalanceResponse | null>(null)
const [loading, setLoading] = useState(true)
const [stamping, setStamping] = useState(false)
const [error, setError] = useState<string | null>(null)
const [warnings, setWarnings] = useState<string[]>([])
const [liveSeconds, setLiveSeconds] = useState(0)
const [breakSeconds, setBreakSeconds] = useState(0)
const tickerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const breakTickerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const load = useCallback(async () => {
setError(null)
try {
const monday = getMondayOfCurrentWeek()
const [dash, bal] = await Promise.all([
api.get<TodayStatus>('/dashboard/me'),
api.get<BalanceResponse>(`/time/balance/me?period_start=${monday}`),
])
setDashboard(dash)
setBalance(bal)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
// Live-Ticker: läuft nur wenn eingestempelt und keine Pause
useEffect(() => {
if (tickerRef.current) clearInterval(tickerRef.current)
if (dashboard?.today_open && dashboard.today_start && !dashboard.break_start) {
const startStr = dashboard.today_start
const today = new Date()
const [h, m, s] = startStr.split(':').map(Number)
const startMs = new Date(today.getFullYear(), today.getMonth(), today.getDate(), h, m, s || 0).getTime()
const pausedMs = (dashboard.break_minutes ?? 0) * 60 * 1000
const update = () => setLiveSeconds(Math.max(0, Math.floor((Date.now() - startMs - pausedMs) / 1000)))
update()
tickerRef.current = setInterval(update, 1000)
} else {
setLiveSeconds(0)
}
return () => { if (tickerRef.current) clearInterval(tickerRef.current) }
}, [dashboard?.today_open, dashboard?.today_start, dashboard?.break_start, dashboard?.break_minutes])
// Pausen-Ticker
useEffect(() => {
if (breakTickerRef.current) clearInterval(breakTickerRef.current)
if (dashboard?.today_open && dashboard.break_start) {
const breakStartMs = new Date(dashboard.break_start).getTime()
const update = () => setBreakSeconds(Math.max(0, Math.floor((Date.now() - breakStartMs) / 1000)))
update()
breakTickerRef.current = setInterval(update, 1000)
} else {
setBreakSeconds(0)
}
return () => { if (breakTickerRef.current) clearInterval(breakTickerRef.current) }
}, [dashboard?.today_open, dashboard?.break_start])
const stampIn = async () => {
setStamping(true); setError(null); setWarnings([])
try {
const res = await api.post<TimeEntryWithWarnings>('/time/stamp-in', {})
if (res.warnings.length) setWarnings(res.warnings)
await load()
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setStamping(false) }
}
const stampOut = async () => {
setStamping(true); setError(null); setWarnings([])
try {
const res = await api.post<TimeEntryWithWarnings>('/time/stamp-out', {})
if (res.warnings.length) setWarnings(res.warnings)
await load()
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setStamping(false) }
}
const breakStart = async () => {
setStamping(true); setError(null)
try { await api.post('/time/break-start', {}); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setStamping(false) }
}
const breakEnd = async () => {
setStamping(true); setError(null)
try { await api.post('/time/break-end', {}); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setStamping(false) }
}
const isOpen = dashboard?.today_open ?? false
const isOnBreak = isOpen && !!dashboard?.break_start
if (loading) {
return (
<div className='flex flex-col items-center justify-center flex-1 py-20 gap-4'>
<div className='animate-spin rounded-full h-10 w-10 border-4 border-blue-500 border-t-transparent' />
<p className='text-sm text-gray-400'>Wird geladen</p>
</div>
)
}
return (
<div className='flex flex-col gap-4 px-4 pt-4'>
{/* Fehler */}
{error && (
<div className='bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700'>
{error}
</div>
)}
{/* Warnungen */}
{warnings.length > 0 && (
<div className='bg-yellow-50 border border-yellow-200 rounded-xl px-4 py-3'>
<p className='text-sm font-semibold text-yellow-800 mb-1'>Hinweise</p>
<ul className='text-sm text-yellow-700 list-disc list-inside space-y-0.5'>
{warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
{/* Status-Karte */}
<div className='bg-white rounded-2xl border border-gray-200 shadow-sm px-5 py-5 flex flex-col items-center gap-3'>
{/* Status-Label */}
<div className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-semibold ${
isOnBreak
? 'bg-yellow-100 text-yellow-700'
: isOpen
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}>
<span className={`w-2 h-2 rounded-full ${isOnBreak ? 'bg-yellow-400' : isOpen ? 'bg-green-500' : 'bg-gray-400'}`} />
{isOnBreak
? `In Pause seit ${fmtTime(dashboard?.break_start ?? null)}`
: isOpen
? `Eingestempelt seit ${fmtTime(dashboard?.today_start ?? null)}`
: 'Nicht eingestempelt'
}
</div>
{/* Live-Uhr */}
{isOpen && (
<div className='text-center py-2'>
{isOnBreak ? (
<>
<p className='text-xs font-medium text-yellow-500 uppercase tracking-widest mb-1'>Pause</p>
<p className='text-5xl font-mono font-bold text-yellow-500 tabular-nums'>{fmtMS(breakSeconds)}</p>
<p className='text-xs text-gray-400 mt-2'>
Pause gesamt: {(dashboard?.break_minutes ?? 0) + Math.floor(breakSeconds / 60)} min
</p>
</>
) : (
<>
<p className='text-xs font-medium text-green-600 uppercase tracking-widest mb-1'>Arbeitszeit</p>
<p className='text-5xl font-mono font-bold text-gray-800 tabular-nums'>{fmtHMS(liveSeconds)}</p>
{(dashboard?.break_minutes ?? 0) > 0 && (
<p className='text-xs text-gray-400 mt-2'>Pause: {dashboard!.break_minutes} min</p>
)}
</>
)}
</div>
)}
{/* Haupt-Button */}
{!isOpen ? (
<button
onClick={stampIn}
disabled={stamping}
className='w-4/5 min-h-[80px] rounded-3xl bg-green-500 active:bg-green-700 text-white text-2xl font-bold shadow-md transition-all disabled:opacity-50 flex items-center justify-center'
>
{stamping ? (
<span className='animate-spin rounded-full h-7 w-7 border-4 border-white border-t-transparent' />
) : 'EINSTEMPELN'}
</button>
) : isOnBreak ? (
<button
onClick={breakEnd}
disabled={stamping}
className='w-4/5 min-h-[80px] rounded-3xl bg-yellow-400 active:bg-yellow-600 text-white text-2xl font-bold shadow-md transition-all disabled:opacity-50 flex items-center justify-center'
>
{stamping ? (
<span className='animate-spin rounded-full h-7 w-7 border-4 border-white border-t-transparent' />
) : 'PAUSE BEENDEN'}
</button>
) : (
<button
onClick={stampOut}
disabled={stamping}
className='w-4/5 min-h-[80px] rounded-3xl bg-red-500 active:bg-red-700 text-white text-2xl font-bold shadow-md transition-all disabled:opacity-50 flex items-center justify-center'
>
{stamping ? (
<span className='animate-spin rounded-full h-7 w-7 border-4 border-white border-t-transparent' />
) : 'AUSSTEMPELN'}
</button>
)}
{/* Sekundärer Button: Pause starten */}
{isOpen && !isOnBreak && (
<button
onClick={breakStart}
disabled={stamping}
className='min-h-[48px] px-6 rounded-xl border border-yellow-300 text-yellow-600 font-semibold text-base active:bg-yellow-50 transition-colors disabled:opacity-50 flex items-center gap-2'
>
<span></span>
Pause starten
</button>
)}
</div>
{/* Wochen-Statistik */}
{balance && (
<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-widest mb-3'>
Aktuelle Woche
</p>
<div className='grid grid-cols-3 gap-2'>
<div className='flex flex-col items-center gap-1'>
<p className='text-xs text-gray-400'>Gearbeitet</p>
<p className='text-xl font-bold text-gray-800'>{fmtH(balance.total_hours_worked).replace('+', '')}</p>
</div>
<div className='flex flex-col items-center gap-1 border-x border-gray-100'>
<p className='text-xs text-gray-400'>Erwartet</p>
<p className='text-xl font-bold text-gray-800'>{fmtH(balance.expected_hours).replace('+', '')}</p>
</div>
<div className='flex flex-col items-center gap-1'>
<p className='text-xs text-gray-400'>Überstunden</p>
<p className={`text-xl font-bold ${
balance.overtime_hours > 0 ? 'text-green-600' :
balance.overtime_hours < 0 ? 'text-red-500' : 'text-gray-800'
}`}>
{fmtH(balance.overtime_hours)}
</p>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,146 @@
import { useEffect, useState, useCallback } from 'react'
import { api } from '../../api/client'
interface TimeEntryOut {
id: string
date: string
start_time: string
end_time: string | null
break_minutes: number
worked_hours: number | null
status: string
note: string | null
}
interface TimeEntryListResponse {
total: number
items: TimeEntryOut[]
}
function fmtTime(iso: string | null): string {
if (!iso) return ''
if (/^\d{2}:\d{2}(:\d{2})?$/.test(iso)) return iso.slice(0, 5)
const d = new Date(iso)
if (isNaN(d.getTime())) return iso.slice(0, 5)
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
function fmtH(h: number | null): string {
if (h === null || h === undefined) return ''
const hrs = Math.floor(h)
const min = Math.round((h - hrs) * 60)
return `${hrs}h ${min}m`
}
const STATUS_LABELS: Record<string, string> = {
open: 'Offen',
pending: 'Zur Prüfung',
approved: 'Genehmigt',
rejected: 'Abgelehnt',
auto: 'Auto',
}
const STATUS_COLORS: Record<string, string> = {
open: 'bg-yellow-100 text-yellow-700',
pending: 'bg-blue-100 text-blue-700',
approved: 'bg-green-100 text-green-700',
rejected: 'bg-red-100 text-red-700',
auto: 'bg-gray-100 text-gray-500',
}
export function MobileTodayScreen() {
const [entries, setEntries] = useState<TimeEntryOut[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const today = new Date().toISOString().slice(0, 10)
const load = useCallback(async () => {
setError(null)
try {
const res = await api.get<TimeEntryListResponse>(`/time/entries?date_from=${today}&date_to=${today}&limit=20`)
setEntries(res.items)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [today])
useEffect(() => { load() }, [load])
const totalWorked = entries.reduce((sum, e) => sum + (e.worked_hours ?? 0), 0)
if (loading) {
return (
<div className='flex flex-col items-center justify-center flex-1 py-20 gap-4'>
<div className='animate-spin rounded-full h-10 w-10 border-4 border-blue-500 border-t-transparent' />
<p className='text-sm text-gray-400'>Wird geladen</p>
</div>
)
}
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>
</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>
<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>
))}
</div>
)}
</div>
)
}