209 lines
8.2 KiB
TypeScript
209 lines
8.2 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
Pressable,
|
||
RefreshControl,
|
||
ScrollView,
|
||
StyleSheet,
|
||
Text,
|
||
TextInput,
|
||
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';
|
||
|
||
function formatEventDate(event: CalendarEvent): string {
|
||
const start = new Date(event.startDate);
|
||
const end = event.endDate ? new Date(event.endDate) : null;
|
||
if (Number.isNaN(start.getTime())) return String(event.startDate || '-');
|
||
|
||
const date = start.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' });
|
||
const startTime = start.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||
const endTime = end && !Number.isNaN(end.getTime())
|
||
? end.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||
: null;
|
||
return `${date} · ${startTime}${endTime ? `–${endTime}` : ''}`;
|
||
}
|
||
|
||
function projectName(event: CalendarEvent): string | null {
|
||
if (!event.project) return null;
|
||
if (typeof event.project === 'object') return event.project.name || (event.project.id ? `Projekt #${event.project.id}` : null);
|
||
return `Projekt #${event.project}`;
|
||
}
|
||
|
||
export default function EventsScreen() {
|
||
const { token } = useAuth();
|
||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||
const [projects, setProjects] = useState<Project[]>([]);
|
||
const [search, setSearch] = useState('');
|
||
const [showPast, setShowPast] = useState(false);
|
||
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 : 'Termine konnten nicht geladen werden.');
|
||
} finally {
|
||
setLoading(false);
|
||
setRefreshing(false);
|
||
}
|
||
}, [token]);
|
||
|
||
useEffect(() => {
|
||
void load(true);
|
||
}, [load]);
|
||
|
||
const visibleEvents = useMemo(() => {
|
||
const now = Date.now();
|
||
const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||
return events
|
||
.filter((event) => showPast || new Date(event.endDate || event.startDate).getTime() >= now)
|
||
.filter((event) => {
|
||
if (terms.length === 0) return true;
|
||
const haystack = [event.name, event.notes, event.eventtype, projectName(event)]
|
||
.map((value) => String(value || '').toLowerCase())
|
||
.join(' ');
|
||
return terms.every((term) => haystack.includes(term));
|
||
})
|
||
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||
}, [events, search, showPast]);
|
||
|
||
async function onRefresh() {
|
||
setRefreshing(true);
|
||
await load(false);
|
||
}
|
||
|
||
return (
|
||
<View style={styles.screen}>
|
||
<View style={styles.toolbar}>
|
||
<TextInput
|
||
placeholder="Termine suchen"
|
||
placeholderTextColor="#9ca3af"
|
||
style={styles.searchInput}
|
||
value={search}
|
||
onChangeText={setSearch}
|
||
/>
|
||
<View style={styles.toolbarActions}>
|
||
<Pressable
|
||
style={[styles.filterButton, showPast ? styles.filterButtonActive : null]}
|
||
onPress={() => setShowPast((value) => !value)}>
|
||
<Text style={[styles.filterButtonText, showPast ? styles.filterButtonTextActive : null]}>Vergangene anzeigen</Text>
|
||
</Pressable>
|
||
<Pressable style={styles.calendarButton} onPress={() => router.push('/more/calendar' as any)}>
|
||
<Text style={styles.calendarButtonText}>Kalender</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
|
||
<ScrollView
|
||
style={styles.list}
|
||
contentContainerStyle={styles.listContent}
|
||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||
{loading ? (
|
||
<View style={styles.loadingBox}>
|
||
<ActivityIndicator />
|
||
<Text style={styles.loadingText}>Termine werden geladen...</Text>
|
||
</View>
|
||
) : null}
|
||
{!loading && visibleEvents.length === 0 ? <Text style={styles.empty}>Keine Termine gefunden.</Text> : null}
|
||
{!loading
|
||
? visibleEvents.map((event) => (
|
||
<Pressable
|
||
key={String(event.id)}
|
||
style={({ pressed }) => [styles.eventRow, pressed ? styles.eventRowPressed : null]}
|
||
onPress={() => router.push(`/more/event/${event.id}` as any)}>
|
||
<View style={[styles.colorBar, { backgroundColor: event.color || PRIMARY }]} />
|
||
<View style={styles.eventMain}>
|
||
<Text style={styles.eventName}>{event.name}</Text>
|
||
<Text style={styles.eventDate}>{formatEventDate(event)}</Text>
|
||
{projectName(event) ? <Text style={styles.eventProject}>{projectName(event)}</Text> : null}
|
||
{event.notes ? <Text style={styles.eventNotes} numberOfLines={2}>{String(event.notes)}</Text> : null}
|
||
</View>
|
||
<Text style={styles.arrow}>›</Text>
|
||
</Pressable>
|
||
))
|
||
: null}
|
||
</ScrollView>
|
||
|
||
<Pressable style={styles.fab} onPress={() => setCreateOpen(true)}>
|
||
<Text style={styles.fabText}>+</Text>
|
||
</Pressable>
|
||
|
||
<EventCreateModal
|
||
visible={createOpen}
|
||
projects={projects}
|
||
onClose={() => setCreateOpen(false)}
|
||
onCreated={() => load(false)}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
screen: { flex: 1, backgroundColor: '#ffffff' },
|
||
toolbar: { padding: 14, gap: 10, borderBottomWidth: 1, borderBottomColor: '#e5e7eb' },
|
||
searchInput: {
|
||
borderWidth: 1,
|
||
borderColor: '#d1d5db',
|
||
borderRadius: 10,
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 10,
|
||
fontSize: 15,
|
||
color: '#111827',
|
||
},
|
||
toolbarActions: { flexDirection: 'row', justifyContent: 'space-between', gap: 8 },
|
||
filterButton: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 999, paddingHorizontal: 10, paddingVertical: 7 },
|
||
filterButtonActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' },
|
||
filterButtonText: { color: '#374151', fontSize: 12, fontWeight: '600' },
|
||
filterButtonTextActive: { color: '#3d7a30' },
|
||
calendarButton: { borderRadius: 8, backgroundColor: PRIMARY, paddingHorizontal: 13, paddingVertical: 7 },
|
||
calendarButtonText: { color: '#ffffff', fontSize: 13, fontWeight: '700' },
|
||
list: { flex: 1 },
|
||
listContent: { paddingBottom: 96 },
|
||
eventRow: { minHeight: 82, flexDirection: 'row', alignItems: 'stretch', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' },
|
||
eventRowPressed: { backgroundColor: '#f3f4f6' },
|
||
colorBar: { width: 5 },
|
||
eventMain: { flex: 1, paddingHorizontal: 12, paddingVertical: 11, gap: 2 },
|
||
eventName: { color: '#111827', fontSize: 15, fontWeight: '700' },
|
||
eventDate: { color: '#374151', fontSize: 13 },
|
||
eventProject: { color: '#3d7a30', fontSize: 12, fontWeight: '600' },
|
||
eventNotes: { color: '#6b7280', fontSize: 12 },
|
||
arrow: { alignSelf: 'center', color: '#9ca3af', fontSize: 25, paddingRight: 12 },
|
||
loadingBox: { paddingVertical: 24, alignItems: 'center', gap: 8 },
|
||
loadingText: { color: '#6b7280' },
|
||
empty: { color: '#6b7280', textAlign: 'center', paddingVertical: 28 },
|
||
error: { color: '#dc2626', fontSize: 13, padding: 14 },
|
||
fab: {
|
||
position: 'absolute',
|
||
right: 18,
|
||
bottom: 20,
|
||
width: 56,
|
||
height: 56,
|
||
borderRadius: 28,
|
||
backgroundColor: PRIMARY,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
shadowColor: '#111827',
|
||
shadowOpacity: 0.25,
|
||
shadowRadius: 8,
|
||
shadowOffset: { width: 0, height: 4 },
|
||
elevation: 5,
|
||
},
|
||
fabText: { color: '#ffffff', fontSize: 30, lineHeight: 30, marginTop: -2 },
|
||
});
|