From 59392a723cad98d8312f23ad3e01dcc14186a217 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Thu, 19 Feb 2026 18:33:24 +0100 Subject: [PATCH] Time Page --- mobile/app/(tabs)/time.tsx | 347 +++++++++++++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 mobile/app/(tabs)/time.tsx diff --git a/mobile/app/(tabs)/time.tsx b/mobile/app/(tabs)/time.tsx new file mode 100644 index 0000000..3ca0907 --- /dev/null +++ b/mobile/app/(tabs)/time.tsx @@ -0,0 +1,347 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; + +import { + createStaffTimeEvent, + fetchStaffTimeSpans, + StaffTimeSpan, + submitStaffTime, +} from '@/src/lib/api'; +import { useAuth } from '@/src/providers/auth-provider'; + +const PRIMARY = '#69c350'; + +function formatDateTime(value: string | null): string { + if (!value) return '-'; + const d = new Date(value); + return d.toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatDuration(minutes: number): string { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return `${h}h ${String(m).padStart(2, '0')}m`; +} + +function getStateLabel(state: string): string { + if (state === 'approved') return 'Genehmigt'; + if (state === 'submitted') return 'Eingereicht'; + if (state === 'rejected') return 'Abgelehnt'; + if (state === 'factual') return 'Faktisch'; + return 'Entwurf'; +} + +function getTypeLabel(type: string): string { + if (type === 'vacation') return 'Urlaub'; + if (type === 'sick') return 'Krankheit'; + if (type === 'holiday') return 'Feiertag'; + if (type === 'other') return 'Sonstiges'; + return 'Arbeitszeit'; +} + +export default function TimeTrackingScreen() { + const { token, user } = useAuth(); + + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [actionLoading, setActionLoading] = useState(false); + const [error, setError] = useState(null); + + const currentUserId = useMemo(() => (user?.id ? String(user.id) : null), [user]); + + const active = useMemo(() => entries.find((entry) => !entry.stopped_at) || null, [entries]); + + const load = useCallback( + async (showSpinner = true) => { + if (!token || !currentUserId) return; + if (showSpinner) setLoading(true); + setError(null); + + try { + const spans = await fetchStaffTimeSpans(token, currentUserId); + setEntries(spans); + } catch (err) { + setError(err instanceof Error ? err.message : 'Zeiten konnten nicht geladen werden.'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, + [currentUserId, token] + ); + + useEffect(() => { + if (!currentUserId || !token) return; + void load(true); + }, [currentUserId, load, token]); + + async function onRefresh() { + setRefreshing(true); + await load(false); + } + + async function onStart() { + if (!token || !currentUserId) return; + setActionLoading(true); + setError(null); + + try { + await createStaffTimeEvent(token, { + eventtype: 'work_start', + eventtime: new Date().toISOString(), + user_id: currentUserId, + description: 'Arbeitszeit gestartet', + }); + await load(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Starten fehlgeschlagen.'); + } finally { + setActionLoading(false); + } + } + + async function onStop() { + if (!token || !currentUserId || !active) return; + setActionLoading(true); + setError(null); + + try { + await createStaffTimeEvent(token, { + eventtype: 'work_end', + eventtime: new Date().toISOString(), + user_id: currentUserId, + }); + await load(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Stoppen fehlgeschlagen.'); + } finally { + setActionLoading(false); + } + } + + async function onSubmit(entry: StaffTimeSpan) { + if (!token || !entry.eventIds?.length) return; + setActionLoading(true); + setError(null); + + try { + await submitStaffTime(token, entry.eventIds); + await load(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Einreichen fehlgeschlagen.'); + } finally { + setActionLoading(false); + } + } + + return ( + }> + + Aktive Zeit + + {active ? `Laeuft seit ${formatDateTime(active.started_at)}` : 'Keine aktive Zeit'} + + + + {active ? ( + + Stop + + ) : ( + + Start + + )} + + + + {error ? {error} : null} + + {loading ? ( + + + Zeiten werden geladen... + + ) : null} + + {!loading && entries.length === 0 ? Keine Zeiteintraege vorhanden. : null} + + {!loading && + entries.map((entry) => { + const canSubmit = (entry.state === 'draft' || entry.state === 'factual') && !!entry.stopped_at; + + return ( + + + {getTypeLabel(entry.type)} + {getStateLabel(entry.state)} + + + Start: {formatDateTime(entry.started_at)} + + Ende: {entry.stopped_at ? formatDateTime(entry.stopped_at) : 'laeuft...'} + + Dauer: {formatDuration(entry.duration_minutes)} + + {entry.description ? {entry.description} : null} + + {canSubmit ? ( + + onSubmit(entry)} + disabled={actionLoading}> + Einreichen + + + ) : null} + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: 16, + gap: 12, + backgroundColor: '#f9fafb', + }, + statusCard: { + backgroundColor: '#ffffff', + borderRadius: 12, + padding: 12, + borderWidth: 1, + borderColor: '#e5e7eb', + gap: 8, + }, + statusLabel: { + color: '#6b7280', + fontSize: 12, + textTransform: 'uppercase', + }, + statusValue: { + color: '#111827', + fontSize: 16, + fontWeight: '600', + }, + statusActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + }, + startButton: { + minHeight: 40, + borderRadius: 10, + backgroundColor: PRIMARY, + paddingHorizontal: 14, + alignItems: 'center', + justifyContent: 'center', + }, + startButtonText: { + color: '#ffffff', + fontWeight: '700', + }, + stopButton: { + minHeight: 40, + borderRadius: 10, + backgroundColor: '#dc2626', + paddingHorizontal: 14, + alignItems: 'center', + justifyContent: 'center', + }, + stopButtonText: { + color: '#ffffff', + fontWeight: '700', + }, + error: { + color: '#dc2626', + fontSize: 13, + }, + loadingBox: { + padding: 16, + alignItems: 'center', + gap: 8, + }, + loadingText: { + color: '#6b7280', + }, + empty: { + color: '#6b7280', + textAlign: 'center', + paddingVertical: 16, + }, + entryCard: { + backgroundColor: '#ffffff', + borderRadius: 12, + padding: 12, + borderWidth: 1, + borderColor: '#e5e7eb', + gap: 6, + }, + entryHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + gap: 8, + }, + entryType: { + color: '#111827', + fontWeight: '600', + }, + entryState: { + color: '#3d7a30', + fontWeight: '600', + backgroundColor: '#eff9ea', + borderRadius: 999, + paddingHorizontal: 8, + paddingVertical: 3, + fontSize: 12, + overflow: 'hidden', + }, + entryTime: { + color: '#4b5563', + fontSize: 13, + }, + entryDescription: { + color: '#374151', + fontSize: 14, + }, + entryActions: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginTop: 4, + }, + actionButton: { + minHeight: 36, + borderRadius: 8, + backgroundColor: '#0ea5e9', + paddingHorizontal: 10, + alignItems: 'center', + justifyContent: 'center', + }, + actionButtonText: { + color: '#ffffff', + fontWeight: '600', + fontSize: 12, + }, + buttonDisabled: { + opacity: 0.6, + }, +});