KI-AGENT: Mobile Termine und Kalender ergänzen

This commit is contained in:
2026-09-08 22:05:18 +02:00
parent 5e1631e2d3
commit 92c354c1b9
8 changed files with 1122 additions and 1 deletions

View File

@@ -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',

View File

@@ -109,6 +109,33 @@ export default function RootLayout() {
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/events"
options={{
title: 'Termine',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/calendar"
options={{
title: 'Kalender',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/event/[id]"
options={{
title: 'Termin',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/plant/[id]"
options={{

View File

@@ -0,0 +1,224 @@
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 },
});

View File

@@ -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<CalendarEvent | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(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 (
<ScrollView
contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}><ActivityIndicator /><Text style={styles.loadingText}>Termin wird geladen...</Text></View>
) : null}
{!loading && event ? (
<>
<View style={styles.card}>
<View style={styles.headingRow}>
<View style={[styles.colorMark, { backgroundColor: event.color || PRIMARY }]} />
<Text style={styles.title}>{event.name}</Text>
</View>
<View style={styles.table}>
{rows.map((row) => (
<View key={row.label} style={styles.row}>
<Text style={styles.label}>{row.label}</Text>
<Text style={styles.value}>{row.value}</Text>
</View>
))}
</View>
{event.notes ? <View style={styles.notesBox}><Text style={styles.notesLabel}>Notizen</Text><Text style={styles.notes}>{event.notes}</Text></View> : null}
{event.link ? <Pressable style={styles.linkButton} onPress={openLink}><Text style={styles.linkButtonText}>Link öffnen</Text></Pressable> : null}
</View>
<HistorySection resource="events" resourceId={eventId} />
</>
) : null}
</ScrollView>
);
}
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 },
});

208
mobile/app/more/events.tsx Normal file
View File

@@ -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<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 },
});

View File

@@ -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<Project | null>(null);
const [files, setFiles] = useState<ProjectFile[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [uploading, setUploading] = useState(false);
const [creatingTask, setCreatingTask] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<number | null>(null);
const [createTaskModalOpen, setCreateTaskModalOpen] = useState(false);
const [createEventModalOpen, setCreateEventModalOpen] = useState(false);
const [createTaskError, setCreateTaskError] = useState<string | null>(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() {
<HistorySection resource="projects" resourceId={projectId} />
<View style={styles.card}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Termine ({events.length})</Text>
<Pressable style={styles.smallPrimaryButton} onPress={() => setCreateEventModalOpen(true)}>
<Text style={styles.smallPrimaryButtonText}>Neuer Termin</Text>
</Pressable>
</View>
{events.length === 0 ? (
<Text style={styles.empty}>Keine Termine für dieses Projekt vorhanden.</Text>
) : (
events.map((event) => (
<Pressable
key={String(event.id)}
style={styles.eventRow}
onPress={() => router.push(`/more/event/${event.id}` as any)}>
<View style={[styles.eventColor, { backgroundColor: event.color || PRIMARY }]} />
<View style={styles.eventMain}>
<Text style={styles.eventName}>{event.name}</Text>
<Text style={styles.eventDate}>
{formatDateTime(event.startDate)}
{event.endDate ? ` ${formatDateTime(event.endDate)}` : ''}
</Text>
</View>
<Text style={styles.eventArrow}></Text>
</Pressable>
))
)}
</View>
<View style={styles.card}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Aufgaben ({tasks.length})</Text>
@@ -508,6 +544,14 @@ export default function ProjectDetailScreen() {
</KeyboardAvoidingView>
</View>
</Modal>
<EventCreateModal
visible={createEventModalOpen}
projects={project ? [project] : []}
initialProjectId={projectId}
onClose={() => setCreateEventModalOpen(false)}
onCreated={() => load(false)}
/>
</ScrollView>
);
}
@@ -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,