406 lines
11 KiB
TypeScript
406 lines
11 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
import { router, useLocalSearchParams } from 'expo-router';
|
|
|
|
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 params = useLocalSearchParams<{ action?: string | string[] }>();
|
|
|
|
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 handledActionRef = useRef<string | null>(null);
|
|
|
|
const active = useMemo(() => entries.find((entry) => !entry.stopped_at) || null, [entries]);
|
|
const incomingAction = useMemo(() => {
|
|
const raw = Array.isArray(params.action) ? params.action[0] : params.action;
|
|
return String(raw || '').toLowerCase();
|
|
}, [params.action]);
|
|
|
|
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);
|
|
}
|
|
|
|
const onStart = useCallback(async () => {
|
|
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);
|
|
}
|
|
}, [currentUserId, token, load]);
|
|
|
|
const onStop = useCallback(async () => {
|
|
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);
|
|
}
|
|
}, [active, currentUserId, token, load]);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
const onSubmitAll = useCallback(async () => {
|
|
if (!token) return;
|
|
|
|
const submitCandidates = entries.filter(
|
|
(entry) => (entry.state === 'draft' || entry.state === 'factual') && !!entry.stopped_at && !!entry.eventIds?.length
|
|
);
|
|
|
|
if (submitCandidates.length === 0) return;
|
|
|
|
setActionLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
for (const entry of submitCandidates) {
|
|
await submitStaffTime(token, entry.eventIds);
|
|
}
|
|
await load(false);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Einreichen fehlgeschlagen.');
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
}, [entries, load, token]);
|
|
|
|
useEffect(() => {
|
|
if (!token || !currentUserId) return;
|
|
if (!incomingAction) return;
|
|
if (handledActionRef.current === incomingAction) return;
|
|
|
|
if (incomingAction === 'start' && !active) {
|
|
handledActionRef.current = incomingAction;
|
|
void onStart().finally(() => router.replace('/(tabs)/time'));
|
|
return;
|
|
}
|
|
|
|
if (incomingAction === 'stop' && active) {
|
|
handledActionRef.current = incomingAction;
|
|
void onStop().finally(() => router.replace('/(tabs)/time'));
|
|
return;
|
|
}
|
|
|
|
if (incomingAction === 'submit') {
|
|
handledActionRef.current = incomingAction;
|
|
void onSubmitAll().finally(() => router.replace('/(tabs)/time'));
|
|
return;
|
|
}
|
|
|
|
handledActionRef.current = incomingAction;
|
|
void router.replace('/(tabs)/time');
|
|
}, [active, currentUserId, incomingAction, onStart, onStop, onSubmitAll, token]);
|
|
|
|
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 ? `Läuft 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 Zeiteinträge 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) : 'läuft...'}
|
|
</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,
|
|
},
|
|
});
|