225 lines
10 KiB
TypeScript
225 lines
10 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, Project } from '@/src/lib/api';
|
||
import { useAuth } from '@/src/providers/auth-provider';
|
||
|
||
const PRIMARY = '#69c350';
|
||
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||
|
||
function startOfDay(date: Date): Date {
|
||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||
}
|
||
|
||
function dateKey(date: Date): string {
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||
}
|
||
|
||
function buildCalendarDays(month: Date): Date[] {
|
||
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
||
const mondayOffset = (first.getDay() + 6) % 7;
|
||
const start = new Date(first);
|
||
start.setDate(first.getDate() - mondayOffset);
|
||
return Array.from({ length: 42 }, (_, index) => {
|
||
const date = new Date(start);
|
||
date.setDate(start.getDate() + index);
|
||
return date;
|
||
});
|
||
}
|
||
|
||
function formatTime(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 CalendarScreen() {
|
||
const { token } = useAuth();
|
||
const today = useMemo(() => startOfDay(new Date()), []);
|
||
const [month, setMonth] = useState(() => new Date(today.getFullYear(), today.getMonth(), 1));
|
||
const [selectedDate, setSelectedDate] = useState(today);
|
||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||
const [projects, setProjects] = useState<Project[]>([]);
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [loading, setLoading] = useState(true);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const load = useCallback(async (showSpinner = true) => {
|
||
if (!token) return;
|
||
if (showSpinner) setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const [eventRows, projectRows] = await Promise.all([fetchEvents(token), fetchProjects(token)]);
|
||
setEvents(eventRows);
|
||
setProjects(projectRows);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Der Kalender konnte nicht geladen werden.');
|
||
} finally {
|
||
setLoading(false);
|
||
setRefreshing(false);
|
||
}
|
||
}, [token]);
|
||
|
||
useEffect(() => {
|
||
void load(true);
|
||
}, [load]);
|
||
|
||
const eventsByDay = useMemo(() => {
|
||
const map = new Map<string, CalendarEvent[]>();
|
||
events.forEach((event) => {
|
||
const start = new Date(event.startDate);
|
||
if (Number.isNaN(start.getTime())) return;
|
||
const key = dateKey(start);
|
||
const rows = map.get(key) || [];
|
||
rows.push(event);
|
||
map.set(key, rows);
|
||
});
|
||
map.forEach((rows) => rows.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()));
|
||
return map;
|
||
}, [events]);
|
||
|
||
const days = useMemo(() => buildCalendarDays(month), [month]);
|
||
const selectedEvents = eventsByDay.get(dateKey(selectedDate)) || [];
|
||
|
||
function changeMonth(delta: number) {
|
||
const next = new Date(month.getFullYear(), month.getMonth() + delta, 1);
|
||
setMonth(next);
|
||
setSelectedDate(next);
|
||
}
|
||
|
||
function selectDay(date: Date) {
|
||
setSelectedDate(date);
|
||
if (date.getMonth() !== month.getMonth() || date.getFullYear() !== month.getFullYear()) {
|
||
setMonth(new Date(date.getFullYear(), date.getMonth(), 1));
|
||
}
|
||
}
|
||
|
||
async function onRefresh() {
|
||
setRefreshing(true);
|
||
await load(false);
|
||
}
|
||
|
||
return (
|
||
<ScrollView
|
||
style={styles.screen}
|
||
contentContainerStyle={styles.container}
|
||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||
<View style={styles.monthHeader}>
|
||
<Pressable style={styles.navButton} onPress={() => changeMonth(-1)}><Text style={styles.navButtonText}>‹</Text></Pressable>
|
||
<Pressable onPress={() => { setMonth(new Date(today.getFullYear(), today.getMonth(), 1)); setSelectedDate(today); }}>
|
||
<Text style={styles.monthTitle}>{month.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })}</Text>
|
||
<Text style={styles.todayHint}>Heute</Text>
|
||
</Pressable>
|
||
<Pressable style={styles.navButton} onPress={() => changeMonth(1)}><Text style={styles.navButtonText}>›</Text></Pressable>
|
||
</View>
|
||
|
||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||
{loading ? <ActivityIndicator style={styles.loader} /> : null}
|
||
|
||
<View style={styles.calendarCard}>
|
||
<View style={styles.weekRow}>
|
||
{WEEKDAYS.map((weekday) => <Text key={weekday} style={styles.weekday}>{weekday}</Text>)}
|
||
</View>
|
||
<View style={styles.daysGrid}>
|
||
{days.map((date) => {
|
||
const key = dateKey(date);
|
||
const dayEvents = eventsByDay.get(key) || [];
|
||
const outside = date.getMonth() !== month.getMonth();
|
||
const selected = key === dateKey(selectedDate);
|
||
const isToday = key === dateKey(today);
|
||
return (
|
||
<Pressable key={key} style={[styles.dayCell, selected ? styles.dayCellSelected : null]} onPress={() => selectDay(date)}>
|
||
<Text style={[styles.dayNumber, outside ? styles.dayNumberOutside : null, isToday ? styles.dayNumberToday : null]}>
|
||
{date.getDate()}
|
||
</Text>
|
||
<View style={styles.eventDots}>
|
||
{dayEvents.slice(0, 3).map((event) => (
|
||
<View key={String(event.id)} style={[styles.eventDot, { backgroundColor: event.color || PRIMARY }]} />
|
||
))}
|
||
</View>
|
||
</Pressable>
|
||
);
|
||
})}
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.agendaCard}>
|
||
<View style={styles.agendaHeader}>
|
||
<View>
|
||
<Text style={styles.agendaTitle}>{selectedDate.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long' })}</Text>
|
||
<Text style={styles.agendaCount}>{selectedEvents.length} {selectedEvents.length === 1 ? 'Termin' : 'Termine'}</Text>
|
||
</View>
|
||
<Pressable style={styles.addButton} onPress={() => setCreateOpen(true)}><Text style={styles.addButtonText}>+ Termin</Text></Pressable>
|
||
</View>
|
||
{selectedEvents.length === 0 ? <Text style={styles.empty}>Keine Termine an diesem Tag.</Text> : null}
|
||
{selectedEvents.map((event) => (
|
||
<Pressable key={String(event.id)} style={styles.eventRow} onPress={() => router.push(`/more/event/${event.id}` as any)}>
|
||
<View style={[styles.colorBar, { backgroundColor: event.color || PRIMARY }]} />
|
||
<View style={styles.eventMain}>
|
||
<Text style={styles.eventTime}>{formatTime(event.startDate)}</Text>
|
||
<Text style={styles.eventName}>{event.name}</Text>
|
||
{typeof event.project === 'object' && event.project?.name ? <Text style={styles.eventProject}>{event.project.name}</Text> : null}
|
||
</View>
|
||
<Text style={styles.arrow}>›</Text>
|
||
</Pressable>
|
||
))}
|
||
</View>
|
||
|
||
<EventCreateModal
|
||
visible={createOpen}
|
||
projects={projects}
|
||
initialDate={selectedDate}
|
||
onClose={() => setCreateOpen(false)}
|
||
onCreated={() => load(false)}
|
||
/>
|
||
</ScrollView>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
screen: { flex: 1, backgroundColor: '#f9fafb' },
|
||
container: { padding: 12, gap: 12, paddingBottom: 28 },
|
||
monthHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||
navButton: { width: 42, height: 42, borderRadius: 21, backgroundColor: '#ffffff', alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: '#e5e7eb' },
|
||
navButtonText: { color: '#111827', fontSize: 29, lineHeight: 31 },
|
||
monthTitle: { color: '#111827', fontSize: 18, fontWeight: '700', textAlign: 'center' },
|
||
todayHint: { color: '#3d7a30', fontSize: 11, fontWeight: '600', textAlign: 'center', marginTop: 2 },
|
||
calendarCard: { backgroundColor: '#ffffff', borderRadius: 12, borderWidth: 1, borderColor: '#e5e7eb', overflow: 'hidden' },
|
||
weekRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingVertical: 7 },
|
||
weekday: { width: '14.2857%', textAlign: 'center', color: '#6b7280', fontSize: 12, fontWeight: '700' },
|
||
daysGrid: { flexDirection: 'row', flexWrap: 'wrap' },
|
||
dayCell: { width: '14.2857%', height: 52, borderRightWidth: 1, borderBottomWidth: 1, borderColor: '#f0f1f2', alignItems: 'center', paddingTop: 6 },
|
||
dayCellSelected: { backgroundColor: '#eff9ea' },
|
||
dayNumber: { color: '#111827', fontSize: 13, fontWeight: '600' },
|
||
dayNumberOutside: { color: '#b6bbc3' },
|
||
dayNumberToday: { color: '#3d7a30', fontWeight: '900' },
|
||
eventDots: { flexDirection: 'row', gap: 2, marginTop: 5 },
|
||
eventDot: { width: 5, height: 5, borderRadius: 3 },
|
||
agendaCard: { backgroundColor: '#ffffff', borderRadius: 12, borderWidth: 1, borderColor: '#e5e7eb', padding: 12, gap: 8 },
|
||
agendaHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
|
||
agendaTitle: { color: '#111827', fontSize: 16, fontWeight: '700' },
|
||
agendaCount: { color: '#6b7280', fontSize: 12, marginTop: 2 },
|
||
addButton: { backgroundColor: PRIMARY, borderRadius: 8, minHeight: 36, paddingHorizontal: 11, alignItems: 'center', justifyContent: 'center' },
|
||
addButtonText: { color: '#ffffff', fontSize: 13, fontWeight: '700' },
|
||
eventRow: { minHeight: 62, flexDirection: 'row', alignItems: 'stretch', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 9, overflow: 'hidden' },
|
||
colorBar: { width: 5 },
|
||
eventMain: { flex: 1, padding: 9 },
|
||
eventTime: { color: '#3d7a30', fontSize: 12, fontWeight: '700' },
|
||
eventName: { color: '#111827', fontSize: 14, fontWeight: '700' },
|
||
eventProject: { color: '#6b7280', fontSize: 12, marginTop: 2 },
|
||
arrow: { alignSelf: 'center', color: '#9ca3af', fontSize: 24, paddingRight: 9 },
|
||
empty: { color: '#6b7280', fontSize: 13, paddingVertical: 8 },
|
||
error: { color: '#dc2626', fontSize: 13 },
|
||
loader: { paddingVertical: 12 },
|
||
});
|