diff --git a/mobile/app/(tabs)/explore.tsx b/mobile/app/(tabs)/explore.tsx index c911d62..94c6742 100644 --- a/mobile/app/(tabs)/explore.tsx +++ b/mobile/app/(tabs)/explore.tsx @@ -32,6 +32,18 @@ const ITEMS = [ subtitle: '', href: '/more/plants', }, + { + key: 'events', + title: 'Termine', + subtitle: 'Termine anzeigen und eintragen', + href: '/more/events', + }, + { + key: 'calendar', + title: 'Kalender', + subtitle: 'Monatsübersicht und Tagesplanung', + href: '/more/calendar', + }, { key: 'inventory', title: 'Kundeninventar', diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 230b103..2140b40 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -109,6 +109,33 @@ export default function RootLayout() { headerTintColor: '#111827', }} /> + + + { + 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([]); + const [projects, setProjects] = useState([]); + const [createOpen, setCreateOpen] = useState(false); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(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(); + 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 ( + }> + + changeMonth(-1)}> + { setMonth(new Date(today.getFullYear(), today.getMonth(), 1)); setSelectedDate(today); }}> + {month.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })} + Heute + + changeMonth(1)}> + + + {error ? {error} : null} + {loading ? : null} + + + + {WEEKDAYS.map((weekday) => {weekday})} + + + {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 ( + selectDay(date)}> + + {date.getDate()} + + + {dayEvents.slice(0, 3).map((event) => ( + + ))} + + + ); + })} + + + + + + + {selectedDate.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long' })} + {selectedEvents.length} {selectedEvents.length === 1 ? 'Termin' : 'Termine'} + + setCreateOpen(true)}>+ Termin + + {selectedEvents.length === 0 ? Keine Termine an diesem Tag. : null} + {selectedEvents.map((event) => ( + router.push(`/more/event/${event.id}` as any)}> + + + {formatTime(event.startDate)} + {event.name} + {typeof event.project === 'object' && event.project?.name ? {event.project.name} : null} + + + + ))} + + + setCreateOpen(false)} + onCreated={() => load(false)} + /> + + ); +} + +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 }, +}); diff --git a/mobile/app/more/event/[id].tsx b/mobile/app/more/event/[id].tsx new file mode 100644 index 0000000..3146996 --- /dev/null +++ b/mobile/app/more/event/[id].tsx @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useLocalSearchParams } from 'expo-router'; +import * as WebBrowser from 'expo-web-browser'; + +import { HistorySection } from '@/components/history-section'; +import { CalendarEvent, fetchEventById } from '@/src/lib/api'; +import { useAuth } from '@/src/providers/auth-provider'; + +const PRIMARY = '#69c350'; + +function formatDateTime(value: unknown): string { + if (!value) return '-'; + const date = new Date(String(value)); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString('de-DE', { + weekday: 'short', + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function relationName(value: unknown): string { + if (!value) return '-'; + if (typeof value === 'object') { + const relation = value as { id?: number | string; name?: string }; + return String(relation.name || relation.id || '-'); + } + return String(value); +} + +export default function EventDetailScreen() { + const params = useLocalSearchParams<{ id?: string }>(); + const eventId = Number(params.id); + const { token } = useAuth(); + const [event, setEvent] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const validId = useMemo(() => Number.isFinite(eventId) && eventId > 0, [eventId]); + + const load = useCallback(async (showSpinner = true) => { + if (!token || !validId) return; + if (showSpinner) setLoading(true); + setError(null); + try { + setEvent(await fetchEventById(token, eventId)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Der Termin konnte nicht geladen werden.'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [eventId, token, validId]); + + useEffect(() => { + void load(true); + }, [load]); + + async function onRefresh() { + setRefreshing(true); + await load(false); + } + + async function openLink() { + if (!event?.link) return; + const raw = String(event.link).trim(); + const url = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`; + await WebBrowser.openBrowserAsync(url, { controlsColor: PRIMARY, showTitle: true }); + } + + const rows = event + ? [ + { label: 'Beginn', value: formatDateTime(event.startDate) }, + { label: 'Ende', value: formatDateTime(event.endDate) }, + { label: 'Projekt', value: relationName(event.project) }, + { label: 'Kunde', value: relationName(event.customer) }, + { label: 'Status', value: String(event.state || '-') }, + { label: 'Wiederholung', value: String(event.repeatInterval || '-') }, + ] + : []; + + return ( + }> + {error ? {error} : null} + {loading ? ( + Termin wird geladen... + ) : null} + {!loading && event ? ( + <> + + + + {event.name} + + + {rows.map((row) => ( + + {row.label} + {row.value} + + ))} + + {event.notes ? Notizen{event.notes} : null} + {event.link ? Link öffnen : null} + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + container: { padding: 16, gap: 12, backgroundColor: '#f9fafb' }, + card: { backgroundColor: '#ffffff', borderRadius: 12, borderWidth: 1, borderColor: '#e5e7eb', padding: 12, gap: 10 }, + headingRow: { flexDirection: 'row', alignItems: 'center', gap: 9 }, + colorMark: { width: 6, height: 28, borderRadius: 3 }, + title: { flex: 1, color: '#111827', fontSize: 19, fontWeight: '700' }, + table: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, overflow: 'hidden' }, + row: { borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingHorizontal: 10, paddingVertical: 8, gap: 2 }, + label: { color: '#6b7280', fontSize: 12, textTransform: 'uppercase', fontWeight: '600' }, + value: { color: '#111827', fontSize: 14, fontWeight: '500' }, + notesBox: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, padding: 10, backgroundColor: '#fafafa', gap: 4 }, + notesLabel: { color: '#6b7280', fontSize: 12, fontWeight: '600', textTransform: 'uppercase' }, + notes: { color: '#374151', fontSize: 14 }, + linkButton: { backgroundColor: PRIMARY, borderRadius: 9, minHeight: 40, alignItems: 'center', justifyContent: 'center' }, + linkButtonText: { color: '#ffffff', fontWeight: '700' }, + loadingBox: { paddingVertical: 24, alignItems: 'center', gap: 8 }, + loadingText: { color: '#6b7280' }, + error: { color: '#dc2626', fontSize: 13 }, +}); diff --git a/mobile/app/more/events.tsx b/mobile/app/more/events.tsx new file mode 100644 index 0000000..f2c849c --- /dev/null +++ b/mobile/app/more/events.tsx @@ -0,0 +1,208 @@ +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([]); + const [projects, setProjects] = useState([]); + 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(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 ( + + + + + setShowPast((value) => !value)}> + Vergangene anzeigen + + router.push('/more/calendar' as any)}> + Kalender + + + + + }> + {error ? {error} : null} + {loading ? ( + + + Termine werden geladen... + + ) : null} + {!loading && visibleEvents.length === 0 ? Keine Termine gefunden. : null} + {!loading + ? visibleEvents.map((event) => ( + [styles.eventRow, pressed ? styles.eventRowPressed : null]} + onPress={() => router.push(`/more/event/${event.id}` as any)}> + + + {event.name} + {formatEventDate(event)} + {projectName(event) ? {projectName(event)} : null} + {event.notes ? {String(event.notes)} : null} + + + + )) + : null} + + + setCreateOpen(true)}> + + + + + setCreateOpen(false)} + onCreated={() => load(false)} + /> + + ); +} + +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 }, +}); diff --git a/mobile/app/project/[id].tsx b/mobile/app/project/[id].tsx index d5e5e4b..dd9ab3c 100644 --- a/mobile/app/project/[id].tsx +++ b/mobile/app/project/[id].tsx @@ -17,10 +17,13 @@ import * as DocumentPicker from 'expo-document-picker'; import * as ImagePicker from 'expo-image-picker'; import * as WebBrowser from 'expo-web-browser'; +import { EventCreateModal } from '@/components/event-create-modal'; import { HistorySection } from '@/components/history-section'; import { + CalendarEvent, createProjectTask, fetchProjectById, + fetchProjectEvents, fetchProjectFiles, fetchProjectTasks, Project, @@ -70,12 +73,14 @@ export default function ProjectDetailScreen() { const [project, setProject] = useState(null); const [files, setFiles] = useState([]); const [tasks, setTasks] = useState([]); + const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [uploading, setUploading] = useState(false); const [creatingTask, setCreatingTask] = useState(false); const [updatingTaskId, setUpdatingTaskId] = useState(null); const [createTaskModalOpen, setCreateTaskModalOpen] = useState(false); + const [createEventModalOpen, setCreateEventModalOpen] = useState(false); const [createTaskError, setCreateTaskError] = useState(null); const [newTaskName, setNewTaskName] = useState(''); const [newTaskDescription, setNewTaskDescription] = useState(''); @@ -122,14 +127,16 @@ export default function ProjectDetailScreen() { setError(null); try { - const [projectData, fileData, taskData] = await Promise.all([ + const [projectData, fileData, taskData, eventData] = await Promise.all([ fetchProjectById(token, projectId), fetchProjectFiles(token, projectId), fetchProjectTasks(token, projectId), + fetchProjectEvents(token, projectId), ]); setProject(projectData); setFiles(fileData); setTasks(taskData); + setEvents(eventData.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())); } catch (err) { setError(err instanceof Error ? err.message : 'Projektdaten konnten nicht geladen werden.'); } finally { @@ -369,6 +376,35 @@ export default function ProjectDetailScreen() { + + + Termine ({events.length}) + setCreateEventModalOpen(true)}> + Neuer Termin + + + {events.length === 0 ? ( + Keine Termine für dieses Projekt vorhanden. + ) : ( + events.map((event) => ( + router.push(`/more/event/${event.id}` as any)}> + + + {event.name} + + {formatDateTime(event.startDate)} + {event.endDate ? ` – ${formatDateTime(event.endDate)}` : ''} + + + + + )) + )} + + Aufgaben ({tasks.length}) @@ -508,6 +544,14 @@ export default function ProjectDetailScreen() { + + setCreateEventModalOpen(false)} + onCreated={() => load(false)} + /> ); } @@ -591,6 +635,38 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '700', }, + eventRow: { + minHeight: 60, + flexDirection: 'row', + alignItems: 'stretch', + borderWidth: 1, + borderColor: '#e5e7eb', + borderRadius: 9, + overflow: 'hidden', + }, + eventColor: { + width: 5, + }, + eventMain: { + flex: 1, + padding: 9, + gap: 2, + }, + eventName: { + color: '#111827', + fontSize: 14, + fontWeight: '700', + }, + eventDate: { + color: '#6b7280', + fontSize: 12, + }, + eventArrow: { + alignSelf: 'center', + color: '#9ca3af', + fontSize: 24, + paddingRight: 9, + }, sectionHeaderActions: { alignItems: 'flex-end', gap: 8, diff --git a/mobile/components/event-create-modal.tsx b/mobile/components/event-create-modal.tsx new file mode 100644 index 0000000..f0a005c --- /dev/null +++ b/mobile/components/event-create-modal.tsx @@ -0,0 +1,377 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; + +import { CalendarEvent, createEvent, Project } from '@/src/lib/api'; +import { useAuth } from '@/src/providers/auth-provider'; + +const PRIMARY = '#69c350'; + +type EventCreateModalProps = { + visible: boolean; + projects?: Project[]; + initialProjectId?: number | null; + initialDate?: Date | null; + onClose: () => void; + onCreated: (event: CalendarEvent) => void | Promise; +}; + +function pad(value: number): string { + return String(value).padStart(2, '0'); +} + +function formatDateInput(date: Date): string { + return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`; +} + +function formatTimeInput(date: Date): string { + return `${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function createDefaults(initialDate?: Date | null) { + const start = initialDate ? new Date(initialDate) : new Date(); + if (!initialDate) { + start.setMinutes(Math.ceil(start.getMinutes() / 15) * 15, 0, 0); + } else { + start.setHours(9, 0, 0, 0); + } + const end = new Date(start.getTime() + 60 * 60 * 1000); + return { + startDate: formatDateInput(start), + startTime: formatTimeInput(start), + endDate: formatDateInput(end), + endTime: formatTimeInput(end), + }; +} + +function parseLocalDate(dateValue: string, timeValue: string): Date | null { + const match = dateValue.trim().match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/); + const timeMatch = timeValue.trim().match(/^(\d{1,2}):(\d{2})$/); + if (!match || !timeMatch) return null; + + const day = Number(match[1]); + const month = Number(match[2]); + const year = Number(match[3]); + const hours = Number(timeMatch[1]); + const minutes = Number(timeMatch[2]); + if (hours > 23 || minutes > 59) return null; + + const date = new Date(year, month - 1, day, hours, minutes, 0, 0); + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return null; + } + return date; +} + +function relationId(value: unknown): number | null { + if (!value) return null; + if (typeof value === 'object') { + const id = (value as { id?: number | string }).id; + return id ? Number(id) : null; + } + const id = Number(value); + return Number.isFinite(id) ? id : null; +} + +export function EventCreateModal({ + visible, + projects = [], + initialProjectId = null, + initialDate = null, + onClose, + onCreated, +}: EventCreateModalProps) { + const { token } = useAuth(); + const [name, setName] = useState(''); + const [notes, setNotes] = useState(''); + const [link, setLink] = useState(''); + const [startDate, setStartDate] = useState(''); + const [startTime, setStartTime] = useState(''); + const [endDate, setEndDate] = useState(''); + const [endTime, setEndTime] = useState(''); + const [selectedProjectId, setSelectedProjectId] = useState(initialProjectId); + const [projectSearch, setProjectSearch] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!visible) return; + const defaults = createDefaults(initialDate); + setName(''); + setNotes(''); + setLink(''); + setStartDate(defaults.startDate); + setStartTime(defaults.startTime); + setEndDate(defaults.endDate); + setEndTime(defaults.endTime); + setSelectedProjectId(initialProjectId); + setProjectSearch(''); + setError(null); + }, [initialDate, initialProjectId, visible]); + + const selectedProject = useMemo( + () => projects.find((project) => Number(project.id) === selectedProjectId) || null, + [projects, selectedProjectId] + ); + + const projectOptions = useMemo(() => { + const terms = projectSearch.trim().toLowerCase().split(/\s+/).filter(Boolean); + return projects + .filter((project) => { + if (terms.length === 0) return true; + const haystack = `${project.name || ''} ${project.projectNumber || ''}`.toLowerCase(); + return terms.every((term) => haystack.includes(term)); + }) + .slice(0, 8); + }, [projectSearch, projects]); + + async function save() { + if (!token || saving) return; + const title = name.trim(); + if (!title) { + setError('Bitte einen Namen für den Termin eingeben.'); + return; + } + + const start = parseLocalDate(startDate, startTime); + const end = parseLocalDate(endDate, endTime); + if (!start || !end) { + setError('Bitte Datum und Uhrzeit im angegebenen Format eingeben.'); + return; + } + if (end <= start) { + setError('Das Ende muss nach dem Beginn liegen.'); + return; + } + + setSaving(true); + setError(null); + try { + const event = await createEvent(token, { + name: title, + startDate: start.toISOString(), + endDate: end.toISOString(), + project: selectedProjectId, + customer: relationId(selectedProject?.customer), + notes: notes.trim() || null, + link: link.trim() || null, + state: 'Final', + repeatInterval: 'Keine Wiederholung', + }); + await onCreated(event); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Der Termin konnte nicht gespeichert werden.'); + } finally { + setSaving(false); + } + } + + return ( + + + + + Neuer Termin + + + + Beginn + + + + + + Ende + + + + + + Projekt + {selectedProject ? ( + + + {selectedProject.name} + {selectedProject.projectNumber ? ( + Nr. {selectedProject.projectNumber} + ) : null} + + setSelectedProjectId(null)}> + Ändern + + + ) : ( + <> + + {projectOptions.map((project) => ( + setSelectedProjectId(Number(project.id))}> + {project.name} + {project.projectNumber || `#${project.id}`} + + ))} + + )} + + + + {error ? {error} : null} + + + + + Abbrechen + + + {saving ? 'Speichere...' : 'Anlegen'} + + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { flex: 1, backgroundColor: 'rgba(17, 24, 39, 0.45)', justifyContent: 'center', padding: 20 }, + keyboardWrap: { width: '100%' }, + card: { maxHeight: '92%', backgroundColor: '#ffffff', borderRadius: 14, padding: 16, gap: 12 }, + title: { color: '#111827', fontSize: 18, fontWeight: '700' }, + scroll: { flexShrink: 1 }, + form: { gap: 10, paddingBottom: 4 }, + label: { color: '#374151', fontSize: 13, fontWeight: '700' }, + input: { + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + color: '#111827', + backgroundColor: '#ffffff', + }, + row: { flexDirection: 'row', gap: 8 }, + dateInput: { flex: 1 }, + timeInput: { width: 92 }, + multiline: { minHeight: 90, textAlignVertical: 'top' }, + selectedProject: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderWidth: 1, + borderColor: '#b9e3ae', + backgroundColor: '#eff9ea', + borderRadius: 10, + padding: 10, + gap: 8, + }, + selectedProjectMain: { flex: 1 }, + selectedProjectName: { color: '#111827', fontSize: 14, fontWeight: '700' }, + selectedProjectNumber: { color: '#6b7280', fontSize: 12 }, + removeProject: { color: '#3d7a30', fontSize: 13, fontWeight: '700' }, + projectOption: { + flexDirection: 'row', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: '#e5e7eb', + paddingVertical: 8, + gap: 8, + }, + projectOptionName: { flex: 1, color: '#111827', fontSize: 14, fontWeight: '600' }, + projectOptionNumber: { color: '#6b7280', fontSize: 12 }, + error: { color: '#dc2626', fontSize: 13 }, + actions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8 }, + secondaryButton: { + minHeight: 40, + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 14, + alignItems: 'center', + justifyContent: 'center', + }, + secondaryButtonText: { color: '#374151', fontWeight: '600' }, + primaryButton: { + minHeight: 40, + borderRadius: 10, + paddingHorizontal: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: PRIMARY, + }, + primaryButtonText: { color: '#ffffff', fontWeight: '700' }, + disabled: { opacity: 0.6 }, +}); diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index c312109..87de4a4 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -54,6 +54,23 @@ export type Project = { [key: string]: unknown; }; +export type CalendarEvent = { + id: number; + name: string; + startDate: string; + endDate?: string | null; + eventtype?: string | null; + state?: string | null; + repeatInterval?: string | null; + project?: number | { id?: number; name?: string } | null; + customer?: number | { id?: number; name?: string } | null; + notes?: string | null; + link?: string | null; + color?: string | null; + archived?: boolean; + [key: string]: unknown; +}; + export type ProjectFile = { id: string; name?: string | null; @@ -886,6 +903,49 @@ export async function createProject( }); } +export async function fetchEvents(token: string, includeArchived = false): Promise { + const events = await apiRequest('/api/resource/events', { token }); + if (includeArchived) return events || []; + return (events || []).filter((event) => !event.archived); +} + +export async function createEvent( + token: string, + payload: { + name: string; + startDate: string; + endDate: string; + project?: number | null; + customer?: number | null; + notes?: string | null; + link?: string | null; + eventtype?: string; + state?: 'Entwurf' | 'Final'; + repeatInterval?: string; + } +): Promise { + return apiRequest('/api/resource/events', { + method: 'POST', + token, + body: payload, + }); +} + +export async function fetchEventById(token: string, eventId: number): Promise { + return apiRequest(`/api/resource/events/${eventId}`, { token }); +} + +function resolveEventProjectId(event: CalendarEvent): number | null { + if (!event.project) return null; + if (typeof event.project === 'object') return event.project.id ? Number(event.project.id) : null; + return Number(event.project); +} + +export async function fetchProjectEvents(token: string, projectId: number): Promise { + const events = await fetchEvents(token); + return events.filter((event) => resolveEventProjectId(event) === Number(projectId)); +} + export async function fetchCustomers(token: string, includeArchived = false): Promise { const customers = await apiRequest('/api/resource/customers', { token }); if (includeArchived) return customers || [];