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 || [];