KI-AGENT: Mobile Termine und Kalender ergänzen
This commit is contained in:
@@ -32,6 +32,18 @@ const ITEMS = [
|
|||||||
subtitle: '',
|
subtitle: '',
|
||||||
href: '/more/plants',
|
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',
|
key: 'inventory',
|
||||||
title: 'Kundeninventar',
|
title: 'Kundeninventar',
|
||||||
|
|||||||
@@ -109,6 +109,33 @@ export default function RootLayout() {
|
|||||||
headerTintColor: '#111827',
|
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
|
<Stack.Screen
|
||||||
name="more/plant/[id]"
|
name="more/plant/[id]"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
224
mobile/app/more/calendar.tsx
Normal file
224
mobile/app/more/calendar.tsx
Normal 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 },
|
||||||
|
});
|
||||||
137
mobile/app/more/event/[id].tsx
Normal file
137
mobile/app/more/event/[id].tsx
Normal 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
208
mobile/app/more/events.tsx
Normal 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 },
|
||||||
|
});
|
||||||
@@ -17,10 +17,13 @@ import * as DocumentPicker from 'expo-document-picker';
|
|||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
|
||||||
|
import { EventCreateModal } from '@/components/event-create-modal';
|
||||||
import { HistorySection } from '@/components/history-section';
|
import { HistorySection } from '@/components/history-section';
|
||||||
import {
|
import {
|
||||||
|
CalendarEvent,
|
||||||
createProjectTask,
|
createProjectTask,
|
||||||
fetchProjectById,
|
fetchProjectById,
|
||||||
|
fetchProjectEvents,
|
||||||
fetchProjectFiles,
|
fetchProjectFiles,
|
||||||
fetchProjectTasks,
|
fetchProjectTasks,
|
||||||
Project,
|
Project,
|
||||||
@@ -70,12 +73,14 @@ export default function ProjectDetailScreen() {
|
|||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [files, setFiles] = useState<ProjectFile[]>([]);
|
const [files, setFiles] = useState<ProjectFile[]>([]);
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [creatingTask, setCreatingTask] = useState(false);
|
const [creatingTask, setCreatingTask] = useState(false);
|
||||||
const [updatingTaskId, setUpdatingTaskId] = useState<number | null>(null);
|
const [updatingTaskId, setUpdatingTaskId] = useState<number | null>(null);
|
||||||
const [createTaskModalOpen, setCreateTaskModalOpen] = useState(false);
|
const [createTaskModalOpen, setCreateTaskModalOpen] = useState(false);
|
||||||
|
const [createEventModalOpen, setCreateEventModalOpen] = useState(false);
|
||||||
const [createTaskError, setCreateTaskError] = useState<string | null>(null);
|
const [createTaskError, setCreateTaskError] = useState<string | null>(null);
|
||||||
const [newTaskName, setNewTaskName] = useState('');
|
const [newTaskName, setNewTaskName] = useState('');
|
||||||
const [newTaskDescription, setNewTaskDescription] = useState('');
|
const [newTaskDescription, setNewTaskDescription] = useState('');
|
||||||
@@ -122,14 +127,16 @@ export default function ProjectDetailScreen() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [projectData, fileData, taskData] = await Promise.all([
|
const [projectData, fileData, taskData, eventData] = await Promise.all([
|
||||||
fetchProjectById(token, projectId),
|
fetchProjectById(token, projectId),
|
||||||
fetchProjectFiles(token, projectId),
|
fetchProjectFiles(token, projectId),
|
||||||
fetchProjectTasks(token, projectId),
|
fetchProjectTasks(token, projectId),
|
||||||
|
fetchProjectEvents(token, projectId),
|
||||||
]);
|
]);
|
||||||
setProject(projectData);
|
setProject(projectData);
|
||||||
setFiles(fileData);
|
setFiles(fileData);
|
||||||
setTasks(taskData);
|
setTasks(taskData);
|
||||||
|
setEvents(eventData.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Projektdaten konnten nicht geladen werden.');
|
setError(err instanceof Error ? err.message : 'Projektdaten konnten nicht geladen werden.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -369,6 +376,35 @@ export default function ProjectDetailScreen() {
|
|||||||
|
|
||||||
<HistorySection resource="projects" resourceId={projectId} />
|
<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.card}>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
<Text style={styles.sectionTitle}>Aufgaben ({tasks.length})</Text>
|
<Text style={styles.sectionTitle}>Aufgaben ({tasks.length})</Text>
|
||||||
@@ -508,6 +544,14 @@ export default function ProjectDetailScreen() {
|
|||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<EventCreateModal
|
||||||
|
visible={createEventModalOpen}
|
||||||
|
projects={project ? [project] : []}
|
||||||
|
initialProjectId={projectId}
|
||||||
|
onClose={() => setCreateEventModalOpen(false)}
|
||||||
|
onCreated={() => load(false)}
|
||||||
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -591,6 +635,38 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '700',
|
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: {
|
sectionHeaderActions: {
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
|
|||||||
377
mobile/components/event-create-modal.tsx
Normal file
377
mobile/components/event-create-modal.tsx
Normal file
@@ -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<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<number | null>(initialProjectId);
|
||||||
|
const [projectSearch, setProjectSearch] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.keyboardWrap}>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>Neuer Termin</Text>
|
||||||
|
<ScrollView style={styles.scroll} contentContainerStyle={styles.form} keyboardShouldPersistTaps="handled">
|
||||||
|
<TextInput
|
||||||
|
autoFocus
|
||||||
|
placeholder="Name *"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.input}
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Beginn</Text>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<TextInput
|
||||||
|
placeholder="TT.MM.JJJJ"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={[styles.input, styles.dateInput]}
|
||||||
|
value={startDate}
|
||||||
|
onChangeText={setStartDate}
|
||||||
|
keyboardType="numbers-and-punctuation"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
placeholder="HH:MM"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={[styles.input, styles.timeInput]}
|
||||||
|
value={startTime}
|
||||||
|
onChangeText={setStartTime}
|
||||||
|
keyboardType="numbers-and-punctuation"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Ende</Text>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<TextInput
|
||||||
|
placeholder="TT.MM.JJJJ"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={[styles.input, styles.dateInput]}
|
||||||
|
value={endDate}
|
||||||
|
onChangeText={setEndDate}
|
||||||
|
keyboardType="numbers-and-punctuation"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
placeholder="HH:MM"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={[styles.input, styles.timeInput]}
|
||||||
|
value={endTime}
|
||||||
|
onChangeText={setEndTime}
|
||||||
|
keyboardType="numbers-and-punctuation"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Projekt</Text>
|
||||||
|
{selectedProject ? (
|
||||||
|
<View style={styles.selectedProject}>
|
||||||
|
<View style={styles.selectedProjectMain}>
|
||||||
|
<Text style={styles.selectedProjectName}>{selectedProject.name}</Text>
|
||||||
|
{selectedProject.projectNumber ? (
|
||||||
|
<Text style={styles.selectedProjectNumber}>Nr. {selectedProject.projectNumber}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={() => setSelectedProjectId(null)}>
|
||||||
|
<Text style={styles.removeProject}>Ändern</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Projekt suchen (optional)"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.input}
|
||||||
|
value={projectSearch}
|
||||||
|
onChangeText={setProjectSearch}
|
||||||
|
/>
|
||||||
|
{projectOptions.map((project) => (
|
||||||
|
<Pressable
|
||||||
|
key={String(project.id)}
|
||||||
|
style={styles.projectOption}
|
||||||
|
onPress={() => setSelectedProjectId(Number(project.id))}>
|
||||||
|
<Text style={styles.projectOptionName}>{project.name}</Text>
|
||||||
|
<Text style={styles.projectOptionNumber}>{project.projectNumber || `#${project.id}`}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
placeholder="Link (optional)"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.input}
|
||||||
|
value={link}
|
||||||
|
onChangeText={setLink}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="url"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Notizen (optional)"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={[styles.input, styles.multiline]}
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable style={styles.secondaryButton} onPress={onClose} disabled={saving}>
|
||||||
|
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={[styles.primaryButton, saving ? styles.disabled : null]} onPress={save} disabled={saving}>
|
||||||
|
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Anlegen'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -54,6 +54,23 @@ export type Project = {
|
|||||||
[key: string]: unknown;
|
[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 = {
|
export type ProjectFile = {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
@@ -886,6 +903,49 @@ export async function createProject(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchEvents(token: string, includeArchived = false): Promise<CalendarEvent[]> {
|
||||||
|
const events = await apiRequest<CalendarEvent[]>('/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<CalendarEvent> {
|
||||||
|
return apiRequest<CalendarEvent>('/api/resource/events', {
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEventById(token: string, eventId: number): Promise<CalendarEvent> {
|
||||||
|
return apiRequest<CalendarEvent>(`/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<CalendarEvent[]> {
|
||||||
|
const events = await fetchEvents(token);
|
||||||
|
return events.filter((event) => resolveEventProjectId(event) === Number(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchCustomers(token: string, includeArchived = false): Promise<Customer[]> {
|
export async function fetchCustomers(token: string, includeArchived = false): Promise<Customer[]> {
|
||||||
const customers = await apiRequest<Customer[]>('/api/resource/customers', { token });
|
const customers = await apiRequest<Customer[]>('/api/resource/customers', { token });
|
||||||
if (includeArchived) return customers || [];
|
if (includeArchived) return customers || [];
|
||||||
|
|||||||
Reference in New Issue
Block a user