diff --git a/DEVLOG.md b/DEVLOG.md index 5cb6e76..b1aa9bc 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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 ++++++++++++++++++++++++++++++++++++++++++++++ + +--- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 90171f9..f8b8916 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> }> } /> } /> diff --git a/frontend/src/pages/mobile/MobileBottomNav.tsx b/frontend/src/pages/mobile/MobileBottomNav.tsx new file mode 100644 index 0000000..ea3d1a1 --- /dev/null +++ b/frontend/src/pages/mobile/MobileBottomNav.tsx @@ -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: ( + + + + + ), + }, + { + id: 'today', + label: 'Heute', + icon: ( + + + + + + + ), + }, + { + id: 'profile', + label: 'Profil', + icon: ( + + + + + ), + }, + ] + + return ( + + ) +} diff --git a/frontend/src/pages/mobile/MobileLoginPage.tsx b/frontend/src/pages/mobile/MobileLoginPage.tsx new file mode 100644 index 0000000..6c521df --- /dev/null +++ b/frontend/src/pages/mobile/MobileLoginPage.tsx @@ -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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const totpInputRef = useRef(null) + + // Schritt 1: E-Mail + Passwort + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + try { + const res = await api.post('/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('/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 ( +
+ {/* Logo */} +
+
+ TM +
+

TimeMaster

+

+ {partialToken ? 'Zwei-Faktor-Authentifizierung' : 'Bitte melde dich an'} +

+
+ + {/* Fehler-Banner */} + {error && ( +
+ {error} +
+ )} + + {/* Formular */} +
+ + {!partialToken ? ( + /* ── Login-Formular ── */ +
+
+ + 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' + /> +
+
+ + 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' + /> +
+ +
+ + ) : ( + /* ── TOTP-Formular ── */ +
+
+
+ 🔐 +
+

+ Gib den 6-stelligen Code aus deiner Authenticator-App ein. +

+
+ 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' + /> + + +
+ )} +
+ + {/* Link zur Desktop-Version */} + + Desktop-Version öffnen + +
+ ) +} diff --git a/frontend/src/pages/mobile/MobilePage.tsx b/frontend/src/pages/mobile/MobilePage.tsx new file mode 100644 index 0000000..7d08f3c --- /dev/null +++ b/frontend/src/pages/mobile/MobilePage.tsx @@ -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 = { + stamp: 'Zeiterfassung', + today: 'Heute', + profile: 'Profil', +} + +export function MobilePage() { + const { token } = useAuth() + const [screen, setScreen] = useState('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 + + return ( +
+ {/* Header */} +
+
+
+
+ TM +
+ TimeMaster +
+
+

{time}

+

+ {new Date().toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })} +

+
+
+ {/* Screen-Titel */} +
+

{SCREEN_TITLES[screen]}

+
+
+ + {/* Content */} +
+ {screen === 'stamp' && } + {screen === 'today' && } + {screen === 'profile' && } +
+ + {/* Bottom Navigation */} + +
+ ) +} diff --git a/frontend/src/pages/mobile/MobileProfileScreen.tsx b/frontend/src/pages/mobile/MobileProfileScreen.tsx new file mode 100644 index 0000000..0477cd5 --- /dev/null +++ b/frontend/src/pages/mobile/MobileProfileScreen.tsx @@ -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 = { + SUPER_ADMIN: 'Super Admin', + COMPANY_ADMIN: 'Administrator', + HR: 'HR', + MANAGER: 'Manager', + EMPLOYEE: 'Mitarbeiter', +} + +export function MobileProfileScreen() { + const { logout } = useAuth() + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(async () => { + setError(null) + try { + const me = await api.get('/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 ( +
+
+

Wird geladen…

+
+ ) + } + + const initials = user + ? `${user.first_name[0] ?? ''}${user.last_name[0] ?? ''}`.toUpperCase() + : '?' + + return ( +
+ {error && ( +
+ {error} +
+ )} + + {user && ( + <> + {/* Avatar + Name */} +
+
+ {initials} +
+
+

+ {user.first_name} {user.last_name} +

+

{user.email}

+ + {ROLE_LABELS[user.role] ?? user.role} + +
+
+ + {/* Detail-Felder */} +
+
+ E-Mail + {user.email} +
+
+ Rolle + {ROLE_LABELS[user.role] ?? user.role} +
+ {user.personnel_number && ( +
+ Personalnummer + {user.personnel_number} +
+ )} +
+ + {/* Desktop-Version Link */} + + Desktop-Version öffnen + + + + + + {/* Abmelden */} + + + )} +
+ ) +} diff --git a/frontend/src/pages/mobile/MobileStampScreen.tsx b/frontend/src/pages/mobile/MobileStampScreen.tsx new file mode 100644 index 0000000..b5f5426 --- /dev/null +++ b/frontend/src/pages/mobile/MobileStampScreen.tsx @@ -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(null) + const [balance, setBalance] = useState(null) + const [loading, setLoading] = useState(true) + const [stamping, setStamping] = useState(false) + const [error, setError] = useState(null) + const [warnings, setWarnings] = useState([]) + + const [liveSeconds, setLiveSeconds] = useState(0) + const [breakSeconds, setBreakSeconds] = useState(0) + const tickerRef = useRef | null>(null) + const breakTickerRef = useRef | null>(null) + + const load = useCallback(async () => { + setError(null) + try { + const monday = getMondayOfCurrentWeek() + const [dash, bal] = await Promise.all([ + api.get('/dashboard/me'), + api.get(`/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('/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('/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 ( +
+
+

Wird geladen…

+
+ ) + } + + return ( +
+ {/* Fehler */} + {error && ( +
+ {error} +
+ )} + + {/* Warnungen */} + {warnings.length > 0 && ( +
+

Hinweise

+
    + {warnings.map((w, i) =>
  • {w}
  • )} +
+
+ )} + + {/* Status-Karte */} +
+ {/* Status-Label */} +
+ + {isOnBreak + ? `In Pause seit ${fmtTime(dashboard?.break_start ?? null)}` + : isOpen + ? `Eingestempelt seit ${fmtTime(dashboard?.today_start ?? null)}` + : 'Nicht eingestempelt' + } +
+ + {/* Live-Uhr */} + {isOpen && ( +
+ {isOnBreak ? ( + <> +

Pause

+

{fmtMS(breakSeconds)}

+

+ Pause gesamt: {(dashboard?.break_minutes ?? 0) + Math.floor(breakSeconds / 60)} min +

+ + ) : ( + <> +

Arbeitszeit

+

{fmtHMS(liveSeconds)}

+ {(dashboard?.break_minutes ?? 0) > 0 && ( +

Pause: {dashboard!.break_minutes} min

+ )} + + )} +
+ )} + + {/* Haupt-Button */} + {!isOpen ? ( + + ) : isOnBreak ? ( + + ) : ( + + )} + + {/* Sekundärer Button: Pause starten */} + {isOpen && !isOnBreak && ( + + )} +
+ + {/* Wochen-Statistik */} + {balance && ( +
+

+ Aktuelle Woche +

+
+
+

Gearbeitet

+

{fmtH(balance.total_hours_worked).replace('+', '')}

+
+
+

Erwartet

+

{fmtH(balance.expected_hours).replace('+', '')}

+
+
+

Überstunden

+

0 ? 'text-green-600' : + balance.overtime_hours < 0 ? 'text-red-500' : 'text-gray-800' + }`}> + {fmtH(balance.overtime_hours)} +

+
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/mobile/MobileTodayScreen.tsx b/frontend/src/pages/mobile/MobileTodayScreen.tsx new file mode 100644 index 0000000..4d743af --- /dev/null +++ b/frontend/src/pages/mobile/MobileTodayScreen.tsx @@ -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 = { + open: 'Offen', + pending: 'Zur Prüfung', + approved: 'Genehmigt', + rejected: 'Abgelehnt', + auto: 'Auto', +} + +const STATUS_COLORS: Record = { + 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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const today = new Date().toISOString().slice(0, 10) + + const load = useCallback(async () => { + setError(null) + try { + const res = await api.get(`/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 ( +
+
+

Wird geladen…

+
+ ) + } + + return ( +
+ {/* Header-Karte mit Datum + Gesamtzeit */} +
+
+

Heute

+

+ {new Date(today + 'T00:00:00').toLocaleDateString('de-DE', { + weekday: 'long', day: '2-digit', month: 'long', + })} +

+
+
+

Gesamt

+

{fmtH(totalWorked)}

+
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Eintrags-Liste */} + {entries.length === 0 ? ( +
+

📋

+

Noch keine Einträge heute

+

Stempel dich ein, um loszulegen

+
+ ) : ( +
+ {entries.map((entry, idx) => ( +
+
+
+

Eintrag {idx + 1}

+
+ {fmtTime(entry.start_time)} + + {fmtTime(entry.end_time)} +
+ {entry.break_minutes > 0 && ( +

Pause: {entry.break_minutes} min

+ )} + {entry.note && ( +

{entry.note}

+ )} +
+
+

{fmtH(entry.worked_hours)}

+ + {STATUS_LABELS[entry.status] ?? entry.status} + +
+
+
+ ))} +
+ )} +
+ ) +}