Time Page

This commit is contained in:
2026-02-19 18:33:24 +01:00
parent c782492ab5
commit 59392a723c

347
mobile/app/(tabs)/time.tsx Normal file
View File

@@ -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<StaffTimeSpan[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<ScrollView
contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
<View style={styles.statusCard}>
<Text style={styles.statusLabel}>Aktive Zeit</Text>
<Text style={styles.statusValue}>
{active ? `Laeuft seit ${formatDateTime(active.started_at)}` : 'Keine aktive Zeit'}
</Text>
<View style={styles.statusActions}>
{active ? (
<Pressable
style={[styles.stopButton, actionLoading ? styles.buttonDisabled : null]}
onPress={onStop}
disabled={actionLoading}>
<Text style={styles.stopButtonText}>Stop</Text>
</Pressable>
) : (
<Pressable
style={[styles.startButton, actionLoading ? styles.buttonDisabled : null]}
onPress={onStart}
disabled={actionLoading}>
<Text style={styles.startButtonText}>Start</Text>
</Pressable>
)}
</View>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Zeiten werden geladen...</Text>
</View>
) : null}
{!loading && entries.length === 0 ? <Text style={styles.empty}>Keine Zeiteintraege vorhanden.</Text> : null}
{!loading &&
entries.map((entry) => {
const canSubmit = (entry.state === 'draft' || entry.state === 'factual') && !!entry.stopped_at;
return (
<View key={`${entry.id}-${entry.started_at}`} style={styles.entryCard}>
<View style={styles.entryHeader}>
<Text style={styles.entryType}>{getTypeLabel(entry.type)}</Text>
<Text style={styles.entryState}>{getStateLabel(entry.state)}</Text>
</View>
<Text style={styles.entryTime}>Start: {formatDateTime(entry.started_at)}</Text>
<Text style={styles.entryTime}>
Ende: {entry.stopped_at ? formatDateTime(entry.stopped_at) : 'laeuft...'}
</Text>
<Text style={styles.entryTime}>Dauer: {formatDuration(entry.duration_minutes)}</Text>
{entry.description ? <Text style={styles.entryDescription}>{entry.description}</Text> : null}
{canSubmit ? (
<View style={styles.entryActions}>
<Pressable
style={[styles.actionButton, actionLoading ? styles.buttonDisabled : null]}
onPress={() => onSubmit(entry)}
disabled={actionLoading}>
<Text style={styles.actionButtonText}>Einreichen</Text>
</Pressable>
</View>
) : null}
</View>
);
})}
</ScrollView>
);
}
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,
},
});