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:
@@ -1017,3 +1017,80 @@ Keine Commits in dieser Session.
|
|||||||
- docs/deployment.md | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
- 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 ++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import { KioskDevicesPage } from './pages/KioskDevicesPage'
|
|||||||
import { AuditLogPage } from './pages/AuditLogPage'
|
import { AuditLogPage } from './pages/AuditLogPage'
|
||||||
import { KioskSetupPage } from './pages/KioskSetupPage'
|
import { KioskSetupPage } from './pages/KioskSetupPage'
|
||||||
import { KioskStampPage } from './pages/KioskStampPage'
|
import { KioskStampPage } from './pages/KioskStampPage'
|
||||||
|
import { MobilePage } from './pages/mobile/MobilePage'
|
||||||
|
import { MobileLoginPage } from './pages/mobile/MobileLoginPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -36,6 +38,8 @@ export default function App() {
|
|||||||
<Route path='/auth/reset-password' element={<ResetPasswordPage />} />
|
<Route path='/auth/reset-password' element={<ResetPasswordPage />} />
|
||||||
<Route path='/kiosk/setup' element={<KioskSetupPage />} />
|
<Route path='/kiosk/setup' element={<KioskSetupPage />} />
|
||||||
<Route path='/kiosk' element={<KioskStampPage />} />
|
<Route path='/kiosk' element={<KioskStampPage />} />
|
||||||
|
<Route path='/mobile' element={<MobilePage />} />
|
||||||
|
<Route path='/mobile/login' element={<MobileLoginPage />} />
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route path='/dashboard' element={<DashboardPage />} />
|
<Route path='/dashboard' element={<DashboardPage />} />
|
||||||
<Route path='/time' element={<TimeTrackingPage />} />
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user