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