399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||
import { router } from 'expo-router';
|
||
|
||
import { EventCreateModal } from '@/components/event-create-modal';
|
||
import { CalendarEvent, fetchEvents, fetchProjects, fetchStaffTimeSpans, fetchTasks, Project, Task } from '@/src/lib/api';
|
||
import { useAuth } from '@/src/providers/auth-provider';
|
||
|
||
const PRIMARY = '#69c350';
|
||
|
||
type DashboardData = {
|
||
tasks: Task[];
|
||
openTasks: number;
|
||
inProgressTasks: number;
|
||
activeTimeStart: string | null;
|
||
pendingSubmissions: number;
|
||
todayMinutes: number;
|
||
};
|
||
|
||
function normalizeTaskStatus(value: unknown): 'Offen' | 'In Bearbeitung' | 'Abgeschlossen' {
|
||
if (value === 'In Bearbeitung') return 'In Bearbeitung';
|
||
if (value === 'Abgeschlossen') return 'Abgeschlossen';
|
||
return 'Offen';
|
||
}
|
||
|
||
function formatMinutes(minutes: number): string {
|
||
const h = Math.floor(minutes / 60);
|
||
const m = minutes % 60;
|
||
return `${h}h ${String(m).padStart(2, '0')}m`;
|
||
}
|
||
|
||
function formatDateTime(value: string | null): string {
|
||
if (!value) return '-';
|
||
return new Date(value).toLocaleString('de-DE', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
function dateKey(date: Date): string {
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||
}
|
||
|
||
function formatEventTime(value: string): string {
|
||
const date = new Date(value);
|
||
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
export default function DashboardScreen() {
|
||
const { token, user } = useAuth();
|
||
|
||
const [loading, setLoading] = useState(true);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||
const [projects, setProjects] = useState<Project[]>([]);
|
||
const [createEventOpen, setCreateEventOpen] = useState(false);
|
||
const [selectedCalendarDate, setSelectedCalendarDate] = useState(() => new Date());
|
||
const [data, setData] = useState<DashboardData>({
|
||
tasks: [],
|
||
openTasks: 0,
|
||
inProgressTasks: 0,
|
||
activeTimeStart: null,
|
||
pendingSubmissions: 0,
|
||
todayMinutes: 0,
|
||
});
|
||
|
||
const currentUserId = useMemo(() => (user?.id ? String(user.id) : null), [user]);
|
||
|
||
const loadDashboard = useCallback(
|
||
async (showSpinner = true) => {
|
||
if (!token || !currentUserId) return;
|
||
if (showSpinner) setLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const [taskRows, spans, eventRows, projectRows] = await Promise.all([
|
||
fetchTasks(token),
|
||
fetchStaffTimeSpans(token, currentUserId),
|
||
fetchEvents(token),
|
||
fetchProjects(token),
|
||
]);
|
||
|
||
const tasks = taskRows || [];
|
||
const openTasks = tasks.filter((task) => normalizeTaskStatus(task.categorie) === 'Offen').length;
|
||
const inProgressTasks = tasks.filter(
|
||
(task) => normalizeTaskStatus(task.categorie) === 'In Bearbeitung'
|
||
).length;
|
||
|
||
const activeTime = spans.find((span) => !span.stopped_at) || null;
|
||
const pendingSubmissions = spans.filter(
|
||
(span) => (span.state === 'draft' || span.state === 'factual') && !!span.stopped_at
|
||
).length;
|
||
|
||
const today = new Date();
|
||
const todayIso = today.toISOString().slice(0, 10);
|
||
const todayMinutes = spans
|
||
.filter((span) => span.started_at?.slice(0, 10) === todayIso)
|
||
.reduce((sum, span) => sum + (span.duration_minutes || 0), 0);
|
||
|
||
setData({
|
||
tasks,
|
||
openTasks,
|
||
inProgressTasks,
|
||
activeTimeStart: activeTime?.started_at || null,
|
||
pendingSubmissions,
|
||
todayMinutes,
|
||
});
|
||
setEvents(eventRows);
|
||
setProjects(projectRows);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Dashboard konnte nicht geladen werden.');
|
||
} finally {
|
||
setLoading(false);
|
||
setRefreshing(false);
|
||
}
|
||
},
|
||
[currentUserId, token]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!token || !currentUserId) return;
|
||
void loadDashboard(true);
|
||
}, [currentUserId, loadDashboard, token]);
|
||
|
||
async function onRefresh() {
|
||
setRefreshing(true);
|
||
await loadDashboard(false);
|
||
}
|
||
|
||
const calendarDays = useMemo(() => {
|
||
const start = new Date();
|
||
start.setHours(0, 0, 0, 0);
|
||
return Array.from({ length: 7 }, (_, index) => {
|
||
const date = new Date(start);
|
||
date.setDate(start.getDate() + index);
|
||
return date;
|
||
});
|
||
}, []);
|
||
|
||
const selectedEvents = useMemo(() => {
|
||
const selectedKey = dateKey(selectedCalendarDate);
|
||
return events
|
||
.filter((event) => {
|
||
const start = new Date(event.startDate);
|
||
return !Number.isNaN(start.getTime()) && dateKey(start) === selectedKey;
|
||
})
|
||
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||
}, [events, selectedCalendarDate]);
|
||
|
||
return (
|
||
<ScrollView
|
||
contentContainerStyle={styles.container}
|
||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||
|
||
{loading ? (
|
||
<View style={styles.loadingBox}>
|
||
<ActivityIndicator />
|
||
<Text style={styles.loadingText}>Dashboard wird geladen...</Text>
|
||
</View>
|
||
) : null}
|
||
|
||
{!loading ? (
|
||
<>
|
||
<View style={styles.row}>
|
||
<View style={[styles.metricCard, styles.metricCardPrimary]}>
|
||
<Text style={styles.metricLabelPrimary}>Aktive Zeit</Text>
|
||
<Text style={styles.metricValuePrimary}>
|
||
{data.activeTimeStart ? `Seit ${formatDateTime(data.activeTimeStart)}` : 'Nicht aktiv'}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.row}>
|
||
<View style={styles.metricCard}>
|
||
<Text style={styles.metricLabel}>Offene Aufgaben</Text>
|
||
<Text style={styles.metricValue}>{data.openTasks}</Text>
|
||
</View>
|
||
<View style={styles.metricCard}>
|
||
<Text style={styles.metricLabel}>In Bearbeitung</Text>
|
||
<Text style={styles.metricValue}>{data.inProgressTasks}</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.row}>
|
||
<View style={styles.metricCard}>
|
||
<Text style={styles.metricLabel}>Heute erfasst</Text>
|
||
<Text style={styles.metricValue}>{formatMinutes(data.todayMinutes)}</Text>
|
||
</View>
|
||
<View style={styles.metricCard}>
|
||
<Text style={styles.metricLabel}>Zum Einreichen</Text>
|
||
<Text style={styles.metricValue}>{data.pendingSubmissions}</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.calendarCard}>
|
||
<View style={styles.calendarHeader}>
|
||
<View>
|
||
<Text style={styles.calendarTitle}>Kalender</Text>
|
||
<Text style={styles.calendarSubtitle}>
|
||
{selectedCalendarDate.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long' })}
|
||
</Text>
|
||
</View>
|
||
<Pressable onPress={() => router.push('/more/calendar' as any)}>
|
||
<Text style={styles.calendarLink}>Monat öffnen</Text>
|
||
</Pressable>
|
||
</View>
|
||
|
||
<View style={styles.calendarDays}>
|
||
{calendarDays.map((date) => {
|
||
const key = dateKey(date);
|
||
const selected = key === dateKey(selectedCalendarDate);
|
||
const count = events.filter((event) => {
|
||
const start = new Date(event.startDate);
|
||
return !Number.isNaN(start.getTime()) && dateKey(start) === key;
|
||
}).length;
|
||
return (
|
||
<Pressable
|
||
key={key}
|
||
style={[styles.calendarDay, selected ? styles.calendarDaySelected : null]}
|
||
onPress={() => setSelectedCalendarDate(date)}>
|
||
<Text style={[styles.calendarWeekday, selected ? styles.calendarDayTextSelected : null]}>
|
||
{date.toLocaleDateString('de-DE', { weekday: 'short' }).replace('.', '')}
|
||
</Text>
|
||
<Text style={[styles.calendarDayNumber, selected ? styles.calendarDayTextSelected : null]}>{date.getDate()}</Text>
|
||
<View style={[styles.calendarDot, count > 0 ? styles.calendarDotActive : null]} />
|
||
</Pressable>
|
||
);
|
||
})}
|
||
</View>
|
||
|
||
{selectedEvents.length === 0 ? <Text style={styles.calendarEmpty}>Keine Termine an diesem Tag.</Text> : null}
|
||
{selectedEvents.slice(0, 3).map((event) => (
|
||
<Pressable
|
||
key={String(event.id)}
|
||
style={styles.calendarEvent}
|
||
onPress={() => router.push(`/more/event/${event.id}` as any)}>
|
||
<View style={[styles.calendarEventMark, { backgroundColor: event.color || PRIMARY }]} />
|
||
<Text style={styles.calendarEventTime}>{formatEventTime(event.startDate)}</Text>
|
||
<Text style={styles.calendarEventName} numberOfLines={1}>{event.name}</Text>
|
||
{event.state === 'Entwurf' ? <Text style={styles.calendarEventDraft}>Entwurf</Text> : null}
|
||
<Text style={styles.calendarEventArrow}>›</Text>
|
||
</Pressable>
|
||
))}
|
||
<Pressable style={styles.calendarAddButton} onPress={() => setCreateEventOpen(true)}>
|
||
<Text style={styles.calendarAddButtonText}>+ Termin eintragen</Text>
|
||
</Pressable>
|
||
</View>
|
||
|
||
<View style={styles.quickActionsCard}>
|
||
<Text style={styles.quickActionsTitle}>Schnellzugriff</Text>
|
||
<View style={styles.quickActionsRow}>
|
||
<Pressable style={styles.quickActionButton} onPress={() => router.push('/(tabs)/tasks')}>
|
||
<Text style={styles.quickActionText}>Aufgaben</Text>
|
||
</Pressable>
|
||
<Pressable style={styles.quickActionButton} onPress={() => router.push('/(tabs)/projects')}>
|
||
<Text style={styles.quickActionText}>Projekten</Text>
|
||
</Pressable>
|
||
<Pressable style={styles.quickActionButton} onPress={() => router.push('/(tabs)/time')}>
|
||
<Text style={styles.quickActionText}>Zeiten</Text>
|
||
</Pressable>
|
||
<Pressable style={styles.quickActionButton} onPress={() => router.push('/more/inventory?action=scan')}>
|
||
<Text style={styles.quickActionText}>Inventar Scan</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
|
||
<EventCreateModal
|
||
visible={createEventOpen}
|
||
projects={projects}
|
||
initialDate={selectedCalendarDate}
|
||
onClose={() => setCreateEventOpen(false)}
|
||
onCreated={() => loadDashboard(false)}
|
||
/>
|
||
</>
|
||
) : null}
|
||
</ScrollView>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
padding: 16,
|
||
gap: 12,
|
||
backgroundColor: '#f9fafb',
|
||
},
|
||
row: {
|
||
flexDirection: 'row',
|
||
gap: 10,
|
||
},
|
||
metricCard: {
|
||
flex: 1,
|
||
backgroundColor: '#ffffff',
|
||
borderRadius: 12,
|
||
borderWidth: 1,
|
||
borderColor: '#e5e7eb',
|
||
padding: 12,
|
||
gap: 6,
|
||
},
|
||
metricCardPrimary: {
|
||
borderColor: PRIMARY,
|
||
backgroundColor: '#eff9ea',
|
||
},
|
||
metricLabel: {
|
||
color: '#6b7280',
|
||
fontSize: 12,
|
||
textTransform: 'uppercase',
|
||
},
|
||
metricValue: {
|
||
color: '#111827',
|
||
fontSize: 22,
|
||
fontWeight: '700',
|
||
},
|
||
metricLabelPrimary: {
|
||
color: '#3d7a30',
|
||
fontSize: 12,
|
||
textTransform: 'uppercase',
|
||
},
|
||
metricValuePrimary: {
|
||
color: '#2f5f24',
|
||
fontSize: 18,
|
||
fontWeight: '700',
|
||
},
|
||
quickActionsCard: {
|
||
backgroundColor: '#ffffff',
|
||
borderRadius: 12,
|
||
borderWidth: 1,
|
||
borderColor: '#e5e7eb',
|
||
padding: 12,
|
||
gap: 10,
|
||
},
|
||
calendarCard: {
|
||
backgroundColor: '#ffffff',
|
||
borderRadius: 12,
|
||
borderWidth: 1,
|
||
borderColor: '#e5e7eb',
|
||
padding: 12,
|
||
gap: 10,
|
||
},
|
||
calendarHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
|
||
calendarTitle: { color: '#111827', fontSize: 16, fontWeight: '700' },
|
||
calendarSubtitle: { color: '#6b7280', fontSize: 12, marginTop: 2 },
|
||
calendarLink: { color: '#3d7a30', fontSize: 12, fontWeight: '700' },
|
||
calendarDays: { flexDirection: 'row', gap: 4 },
|
||
calendarDay: { flex: 1, minHeight: 62, borderRadius: 9, alignItems: 'center', justifyContent: 'center', gap: 2 },
|
||
calendarDaySelected: { backgroundColor: PRIMARY },
|
||
calendarWeekday: { color: '#6b7280', fontSize: 10, fontWeight: '600', textTransform: 'uppercase' },
|
||
calendarDayNumber: { color: '#111827', fontSize: 16, fontWeight: '700' },
|
||
calendarDayTextSelected: { color: '#ffffff' },
|
||
calendarDot: { width: 5, height: 5, borderRadius: 3, backgroundColor: 'transparent' },
|
||
calendarDotActive: { backgroundColor: '#f59e0b' },
|
||
calendarEmpty: { color: '#6b7280', fontSize: 13, paddingVertical: 4 },
|
||
calendarEvent: { minHeight: 44, flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 9, overflow: 'hidden' },
|
||
calendarEventMark: { width: 4, alignSelf: 'stretch' },
|
||
calendarEventTime: { color: '#3d7a30', fontSize: 12, fontWeight: '700', paddingHorizontal: 8 },
|
||
calendarEventName: { flex: 1, color: '#111827', fontSize: 13, fontWeight: '600' },
|
||
calendarEventDraft: { color: '#92400e', backgroundColor: '#fef3c7', borderRadius: 999, paddingHorizontal: 7, paddingVertical: 3, fontSize: 10, fontWeight: '700' },
|
||
calendarEventArrow: { color: '#9ca3af', fontSize: 21, paddingHorizontal: 7 },
|
||
calendarAddButton: { minHeight: 40, borderRadius: 9, backgroundColor: PRIMARY, alignItems: 'center', justifyContent: 'center' },
|
||
calendarAddButtonText: { color: '#ffffff', fontSize: 13, fontWeight: '700' },
|
||
quickActionsTitle: {
|
||
color: '#111827',
|
||
fontSize: 15,
|
||
fontWeight: '600',
|
||
},
|
||
quickActionsRow: {
|
||
flexDirection: 'row',
|
||
flexWrap: 'wrap',
|
||
gap: 8,
|
||
},
|
||
quickActionButton: {
|
||
minWidth: 120,
|
||
minHeight: 40,
|
||
borderRadius: 10,
|
||
backgroundColor: PRIMARY,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
quickActionText: {
|
||
color: '#ffffff',
|
||
fontWeight: '600',
|
||
},
|
||
error: {
|
||
color: '#dc2626',
|
||
fontSize: 13,
|
||
},
|
||
loadingBox: {
|
||
padding: 16,
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
},
|
||
loadingText: {
|
||
color: '#6b7280',
|
||
},
|
||
});
|