Files
FEDEO/mobile/app/more/calendar.tsx

225 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 },
});