Time Page
This commit is contained in:
347
mobile/app/(tabs)/time.tsx
Normal file
347
mobile/app/(tabs)/time.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user