KI-AGENT: Kalender, Terminbearbeitung und Dateivorschau verbessern

This commit is contained in:
2026-09-08 23:08:36 +02:00
parent 718ecefd46
commit 2c1047e624
11 changed files with 390 additions and 74 deletions

View File

@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { router } from 'expo-router';
import { fetchStaffTimeSpans, fetchTasks, Task } from '@/src/lib/api';
import { EventCreateModal } from '@/components/event-create-modal';
import { CalendarEvent, fetchEvents, fetchProjects, fetchStaffTimeSpans, fetchTasks, Project, Task } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
@@ -38,12 +39,25 @@ function formatDateTime(value: string | null): string {
});
}
function dateKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatEventTime(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 DashboardScreen() {
const { token, user } = useAuth();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [createEventOpen, setCreateEventOpen] = useState(false);
const [selectedCalendarDate, setSelectedCalendarDate] = useState(() => new Date());
const [data, setData] = useState<DashboardData>({
tasks: [],
openTasks: 0,
@@ -62,9 +76,11 @@ export default function DashboardScreen() {
setError(null);
try {
const [taskRows, spans] = await Promise.all([
const [taskRows, spans, eventRows, projectRows] = await Promise.all([
fetchTasks(token),
fetchStaffTimeSpans(token, currentUserId),
fetchEvents(token),
fetchProjects(token),
]);
const tasks = taskRows || [];
@@ -92,6 +108,8 @@ export default function DashboardScreen() {
pendingSubmissions,
todayMinutes,
});
setEvents(eventRows);
setProjects(projectRows);
} catch (err) {
setError(err instanceof Error ? err.message : 'Dashboard konnte nicht geladen werden.');
} finally {
@@ -112,6 +130,26 @@ export default function DashboardScreen() {
await loadDashboard(false);
}
const calendarDays = useMemo(() => {
const start = new Date();
start.setHours(0, 0, 0, 0);
return Array.from({ length: 7 }, (_, index) => {
const date = new Date(start);
date.setDate(start.getDate() + index);
return date;
});
}, []);
const selectedEvents = useMemo(() => {
const selectedKey = dateKey(selectedCalendarDate);
return events
.filter((event) => {
const start = new Date(event.startDate);
return !Number.isNaN(start.getTime()) && dateKey(start) === selectedKey;
})
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
}, [events, selectedCalendarDate]);
return (
<ScrollView
contentContainerStyle={styles.container}
@@ -158,6 +196,60 @@ export default function DashboardScreen() {
</View>
</View>
<View style={styles.calendarCard}>
<View style={styles.calendarHeader}>
<View>
<Text style={styles.calendarTitle}>Kalender</Text>
<Text style={styles.calendarSubtitle}>
{selectedCalendarDate.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long' })}
</Text>
</View>
<Pressable onPress={() => router.push('/more/calendar' as any)}>
<Text style={styles.calendarLink}>Monat öffnen</Text>
</Pressable>
</View>
<View style={styles.calendarDays}>
{calendarDays.map((date) => {
const key = dateKey(date);
const selected = key === dateKey(selectedCalendarDate);
const count = events.filter((event) => {
const start = new Date(event.startDate);
return !Number.isNaN(start.getTime()) && dateKey(start) === key;
}).length;
return (
<Pressable
key={key}
style={[styles.calendarDay, selected ? styles.calendarDaySelected : null]}
onPress={() => setSelectedCalendarDate(date)}>
<Text style={[styles.calendarWeekday, selected ? styles.calendarDayTextSelected : null]}>
{date.toLocaleDateString('de-DE', { weekday: 'short' }).replace('.', '')}
</Text>
<Text style={[styles.calendarDayNumber, selected ? styles.calendarDayTextSelected : null]}>{date.getDate()}</Text>
<View style={[styles.calendarDot, count > 0 ? styles.calendarDotActive : null]} />
</Pressable>
);
})}
</View>
{selectedEvents.length === 0 ? <Text style={styles.calendarEmpty}>Keine Termine an diesem Tag.</Text> : null}
{selectedEvents.slice(0, 3).map((event) => (
<Pressable
key={String(event.id)}
style={styles.calendarEvent}
onPress={() => router.push(`/more/event/${event.id}` as any)}>
<View style={[styles.calendarEventMark, { backgroundColor: event.color || PRIMARY }]} />
<Text style={styles.calendarEventTime}>{formatEventTime(event.startDate)}</Text>
<Text style={styles.calendarEventName} numberOfLines={1}>{event.name}</Text>
{event.state === 'Entwurf' ? <Text style={styles.calendarEventDraft}>Entwurf</Text> : null}
<Text style={styles.calendarEventArrow}>›</Text>
</Pressable>
))}
<Pressable style={styles.calendarAddButton} onPress={() => setCreateEventOpen(true)}>
<Text style={styles.calendarAddButtonText}>+ Termin eintragen</Text>
</Pressable>
</View>
<View style={styles.quickActionsCard}>
<Text style={styles.quickActionsTitle}>Schnellzugriff</Text>
<View style={styles.quickActionsRow}>
@@ -175,6 +267,14 @@ export default function DashboardScreen() {
</Pressable>
</View>
</View>
<EventCreateModal
visible={createEventOpen}
projects={projects}
initialDate={selectedCalendarDate}
onClose={() => setCreateEventOpen(false)}
onCreated={() => loadDashboard(false)}
/>
</>
) : null}
</ScrollView>
@@ -232,6 +332,35 @@ const styles = StyleSheet.create({
padding: 12,
gap: 10,
},
calendarCard: {
backgroundColor: '#ffffff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
padding: 12,
gap: 10,
},
calendarHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
calendarTitle: { color: '#111827', fontSize: 16, fontWeight: '700' },
calendarSubtitle: { color: '#6b7280', fontSize: 12, marginTop: 2 },
calendarLink: { color: '#3d7a30', fontSize: 12, fontWeight: '700' },
calendarDays: { flexDirection: 'row', gap: 4 },
calendarDay: { flex: 1, minHeight: 62, borderRadius: 9, alignItems: 'center', justifyContent: 'center', gap: 2 },
calendarDaySelected: { backgroundColor: PRIMARY },
calendarWeekday: { color: '#6b7280', fontSize: 10, fontWeight: '600', textTransform: 'uppercase' },
calendarDayNumber: { color: '#111827', fontSize: 16, fontWeight: '700' },
calendarDayTextSelected: { color: '#ffffff' },
calendarDot: { width: 5, height: 5, borderRadius: 3, backgroundColor: 'transparent' },
calendarDotActive: { backgroundColor: '#f59e0b' },
calendarEmpty: { color: '#6b7280', fontSize: 13, paddingVertical: 4 },
calendarEvent: { minHeight: 44, flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 9, overflow: 'hidden' },
calendarEventMark: { width: 4, alignSelf: 'stretch' },
calendarEventTime: { color: '#3d7a30', fontSize: 12, fontWeight: '700', paddingHorizontal: 8 },
calendarEventName: { flex: 1, color: '#111827', fontSize: 13, fontWeight: '600' },
calendarEventDraft: { color: '#92400e', backgroundColor: '#fef3c7', borderRadius: 999, paddingHorizontal: 7, paddingVertical: 3, fontSize: 10, fontWeight: '700' },
calendarEventArrow: { color: '#9ca3af', fontSize: 21, paddingHorizontal: 7 },
calendarAddButton: { minHeight: 40, borderRadius: 9, backgroundColor: PRIMARY, alignItems: 'center', justifyContent: 'center' },
calendarAddButtonText: { color: '#ffffff', fontSize: 13, fontWeight: '700' },
quickActionsTitle: {
color: '#111827',
fontSize: 15,

View File

@@ -14,7 +14,6 @@ import {
} from 'react-native';
import { router, useLocalSearchParams } from 'expo-router';
import * as DocumentPicker from 'expo-document-picker';
import * as WebBrowser from 'expo-web-browser';
import { BarcodeScanningResult, BarcodeType, CameraView, useCameraPermissions } from 'expo-camera';
import { HistorySection } from '@/components/history-section';
@@ -32,7 +31,7 @@ import {
uploadCustomerFile,
} from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
import { getOpenableFileUrl } from '@/src/lib/file-opening';
import { openAuthenticatedFile } from '@/src/lib/file-opening';
const PRIMARY = '#69c350';
@@ -276,13 +275,13 @@ export default function CustomerDetailScreen() {
}
async function onOpenFile(file: ProjectFile) {
if (!file.url) return;
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
controlsColor: PRIMARY,
showTitle: true,
enableDefaultShareMenuItem: true,
});
if (!token || !file.url) return;
setError(null);
try {
await openAuthenticatedFile(file, token);
} catch (err) {
setError(err instanceof Error ? err.message : 'Das Dokument konnte nicht geöffnet werden.');
}
}
async function onPickAndUpload() {

View File

@@ -3,8 +3,9 @@ import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, T
import { useLocalSearchParams } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
import { EventCreateModal } from '@/components/event-create-modal';
import { HistorySection } from '@/components/history-section';
import { CalendarEvent, fetchEventById } from '@/src/lib/api';
import { CalendarEvent, EventState, fetchEventById, fetchProjects, Project, updateEvent } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
@@ -37,6 +38,9 @@ export default function EventDetailScreen() {
const eventId = Number(params.id);
const { token } = useAuth();
const [event, setEvent] = useState<CalendarEvent | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [editOpen, setEditOpen] = useState(false);
const [updatingState, setUpdatingState] = useState(false);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -47,7 +51,12 @@ export default function EventDetailScreen() {
if (showSpinner) setLoading(true);
setError(null);
try {
setEvent(await fetchEventById(token, eventId));
const [eventRow, projectRows] = await Promise.all([
fetchEventById(token, eventId),
fetchProjects(token),
]);
setEvent(eventRow);
setProjects(projectRows);
} catch (err) {
setError(err instanceof Error ? err.message : 'Der Termin konnte nicht geladen werden.');
} finally {
@@ -72,13 +81,26 @@ export default function EventDetailScreen() {
await WebBrowser.openBrowserAsync(url, { controlsColor: PRIMARY, showTitle: true });
}
async function changeState(nextState: EventState) {
if (!token || !event || updatingState || event.state === nextState) return;
setUpdatingState(true);
setError(null);
try {
const updated = await updateEvent(token, eventId, { state: nextState });
setEvent((current) => current ? { ...current, state: updated.state } : updated);
} catch (err) {
setError(err instanceof Error ? err.message : 'Der Status konnte nicht geändert werden.');
} finally {
setUpdatingState(false);
}
}
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 || '-') },
]
: [];
@@ -97,6 +119,25 @@ export default function EventDetailScreen() {
<View style={styles.headingRow}>
<View style={[styles.colorMark, { backgroundColor: event.color || PRIMARY }]} />
<Text style={styles.title}>{event.name}</Text>
<Pressable style={styles.editButton} onPress={() => setEditOpen(true)}>
<Text style={styles.editButtonText}>Bearbeiten</Text>
</Pressable>
</View>
<View style={styles.statusSection}>
<Text style={styles.statusLabel}>Status</Text>
<View style={styles.statusRow}>
{(['Entwurf', 'Final'] as EventState[]).map((option) => (
<Pressable
key={option}
disabled={updatingState}
style={[styles.statusButton, event.state === option ? styles.statusButtonActive : null]}
onPress={() => changeState(option)}>
<Text style={[styles.statusButtonText, event.state === option ? styles.statusButtonTextActive : null]}>
{option}
</Text>
</Pressable>
))}
</View>
</View>
<View style={styles.table}>
{rows.map((row) => (
@@ -110,6 +151,13 @@ export default function EventDetailScreen() {
{event.link ? <Pressable style={styles.linkButton} onPress={openLink}><Text style={styles.linkButtonText}>Link öffnen</Text></Pressable> : null}
</View>
<HistorySection resource="events" resourceId={eventId} />
<EventCreateModal
visible={editOpen}
event={event}
projects={projects}
onClose={() => setEditOpen(false)}
onUpdated={() => load(false)}
/>
</>
) : null}
</ScrollView>
@@ -122,6 +170,15 @@ const styles = StyleSheet.create({
headingRow: { flexDirection: 'row', alignItems: 'center', gap: 9 },
colorMark: { width: 6, height: 28, borderRadius: 3 },
title: { flex: 1, color: '#111827', fontSize: 19, fontWeight: '700' },
editButton: { borderRadius: 8, backgroundColor: PRIMARY, paddingHorizontal: 10, paddingVertical: 7 },
editButtonText: { color: '#ffffff', fontSize: 12, fontWeight: '700' },
statusSection: { gap: 6 },
statusLabel: { color: '#6b7280', fontSize: 12, textTransform: 'uppercase', fontWeight: '600' },
statusRow: { flexDirection: 'row', gap: 8 },
statusButton: { flex: 1, minHeight: 40, borderRadius: 9, borderWidth: 1, borderColor: '#d1d5db', alignItems: 'center', justifyContent: 'center' },
statusButtonActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' },
statusButtonText: { color: '#6b7280', fontSize: 13, fontWeight: '700' },
statusButtonTextActive: { color: '#3d7a30' },
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' },

View File

@@ -134,6 +134,7 @@ export default function EventsScreen() {
{projectName(event) ? <Text style={styles.eventProject}>{projectName(event)}</Text> : null}
{event.notes ? <Text style={styles.eventNotes} numberOfLines={2}>{String(event.notes)}</Text> : null}
</View>
{event.state === 'Entwurf' ? <Text style={styles.eventDraft}>Entwurf</Text> : null}
<Text style={styles.arrow}>›</Text>
</Pressable>
))
@@ -183,6 +184,7 @@ const styles = StyleSheet.create({
eventDate: { color: '#374151', fontSize: 13 },
eventProject: { color: '#3d7a30', fontSize: 12, fontWeight: '600' },
eventNotes: { color: '#6b7280', fontSize: 12 },
eventDraft: { alignSelf: 'center', color: '#92400e', backgroundColor: '#fef3c7', borderRadius: 999, paddingHorizontal: 8, paddingVertical: 4, fontSize: 10, fontWeight: '700' },
arrow: { alignSelf: 'center', color: '#9ca3af', fontSize: 25, paddingRight: 12 },
loadingBox: { paddingVertical: 24, alignItems: 'center', gap: 8 },
loadingText: { color: '#6b7280' },

View File

@@ -3,12 +3,11 @@ import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, T
import { router, useLocalSearchParams } from 'expo-router';
import * as DocumentPicker from 'expo-document-picker';
import * as ImagePicker from 'expo-image-picker';
import * as WebBrowser from 'expo-web-browser';
import { HistorySection } from '@/components/history-section';
import { fetchPlantById, fetchPlantFiles, Plant, ProjectFile, uploadPlantFile } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
import { getOpenableFileUrl } from '@/src/lib/file-opening';
import { openAuthenticatedFile } from '@/src/lib/file-opening';
const PRIMARY = '#69c350';
@@ -90,13 +89,13 @@ export default function PlantDetailScreen() {
}
async function onOpenFile(file: ProjectFile) {
if (!file.url) return;
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
controlsColor: PRIMARY,
showTitle: true,
enableDefaultShareMenuItem: true,
});
if (!token || !file.url) return;
setError(null);
try {
await openAuthenticatedFile(file, token);
} catch (err) {
setError(err instanceof Error ? err.message : 'Das Dokument konnte nicht geöffnet werden.');
}
}
async function onPickAndUpload() {

View File

@@ -15,7 +15,6 @@ import {
import { router, useLocalSearchParams } from 'expo-router';
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';
@@ -34,7 +33,7 @@ import {
updateTask,
} from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
import { getOpenableFileUrl } from '@/src/lib/file-opening';
import { openAuthenticatedFile } from '@/src/lib/file-opening';
const PRIMARY = '#69c350';
const TASK_STATUS_ORDER: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen'];
@@ -193,13 +192,13 @@ export default function ProjectDetailScreen() {
}
async function onOpenFile(file: ProjectFile) {
if (!file.url) return;
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
controlsColor: PRIMARY,
showTitle: true,
enableDefaultShareMenuItem: true,
});
if (!token || !file.url) return;
setError(null);
try {
await openAuthenticatedFile(file, token);
} catch (err) {
setError(err instanceof Error ? err.message : 'Das Dokument konnte nicht geöffnet werden.');
}
}
async function onCompleteTask(task: Task) {

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
import {
KeyboardAvoidingView,
Keyboard,
Modal,
Platform,
Pressable,
@@ -12,7 +13,7 @@ import {
View,
} from 'react-native';
import { CalendarEvent, createEvent, Project } from '@/src/lib/api';
import { CalendarEvent, createEvent, EventState, Project, updateEvent } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
@@ -22,8 +23,10 @@ type EventCreateModalProps = {
projects?: Project[];
initialProjectId?: number | null;
initialDate?: Date | null;
event?: CalendarEvent | null;
onClose: () => void;
onCreated: (event: CalendarEvent) => void | Promise<void>;
onCreated?: (event: CalendarEvent) => void | Promise<void>;
onUpdated?: (event: CalendarEvent) => void | Promise<void>;
};
function pad(value: number): string {
@@ -64,13 +67,16 @@ export function EventCreateModal({
projects = [],
initialProjectId = null,
initialDate = null,
event = null,
onClose,
onCreated,
onUpdated,
}: EventCreateModalProps) {
const { token } = useAuth();
const [name, setName] = useState('');
const [notes, setNotes] = useState('');
const [link, setLink] = useState('');
const [state, setState] = useState<EventState>('Final');
const [start, setStart] = useState(() => createDefaults(initialDate).start);
const [end, setEnd] = useState(() => createDefaults(initialDate).end);
const [activePicker, setActivePicker] = useState<'startDate' | 'startTime' | 'endDate' | 'endTime' | null>(null);
@@ -82,16 +88,19 @@ export function EventCreateModal({
useEffect(() => {
if (!visible) return;
const defaults = createDefaults(initialDate);
setName('');
setNotes('');
setLink('');
setStart(defaults.start);
setEnd(defaults.end);
const eventStart = event?.startDate ? new Date(event.startDate) : null;
const eventEnd = event?.endDate ? new Date(event.endDate) : null;
setName(event?.name || '');
setNotes(String(event?.notes || ''));
setLink(String(event?.link || ''));
setState(event?.state === 'Entwurf' ? 'Entwurf' : 'Final');
setStart(eventStart && !Number.isNaN(eventStart.getTime()) ? eventStart : defaults.start);
setEnd(eventEnd && !Number.isNaN(eventEnd.getTime()) ? eventEnd : defaults.end);
setActivePicker(null);
setSelectedProjectId(initialProjectId);
setSelectedProjectId(relationId(event?.project) ?? initialProjectId);
setProjectSearch('');
setError(null);
}, [initialDate, initialProjectId, visible]);
}, [event, initialDate, initialProjectId, visible]);
const selectedProject = useMemo(
() => projects.find((project) => Number(project.id) === selectedProjectId) || null,
@@ -130,6 +139,11 @@ export function EventCreateModal({
}
}
function openPicker(picker: 'startDate' | 'startTime' | 'endDate' | 'endTime') {
Keyboard.dismiss();
setActivePicker(picker);
}
async function save() {
if (!token || saving) return;
const title = name.trim();
@@ -146,18 +160,22 @@ export function EventCreateModal({
setSaving(true);
setError(null);
try {
const event = await createEvent(token, {
const payload = {
name: title,
startDate: start.toISOString(),
endDate: end.toISOString(),
project: selectedProjectId,
customer: relationId(selectedProject?.customer),
customer: relationId(selectedProject?.customer) ?? relationId(event?.customer),
notes: notes.trim() || null,
link: link.trim() || null,
state: 'Final',
repeatInterval: 'Keine Wiederholung',
});
await onCreated(event);
state,
repeatInterval: event?.repeatInterval || 'Keine Wiederholung',
};
const savedEvent = event
? await updateEvent(token, Number(event.id), payload)
: await createEvent(token, payload);
if (event) await onUpdated?.(savedEvent);
else await onCreated?.(savedEvent);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Der Termin konnte nicht gespeichert werden.');
@@ -171,7 +189,7 @@ export function EventCreateModal({
<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>
<Text style={styles.title}>{event ? 'Termin bearbeiten' : 'Neuer Termin'}</Text>
<ScrollView style={styles.scroll} contentContainerStyle={styles.form} keyboardShouldPersistTaps="handled">
<TextInput
autoFocus
@@ -184,41 +202,37 @@ export function EventCreateModal({
<Text style={styles.label}>Beginn</Text>
<View style={styles.row}>
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('startDate')}>
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => openPicker('startDate')}>
<Text style={styles.pickerButtonText}>{formatDate(start)}</Text>
</Pressable>
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('startTime')}>
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => openPicker('startTime')}>
<Text style={styles.pickerButtonText}>{formatTime(start)}</Text>
</Pressable>
</View>
<Text style={styles.label}>Ende</Text>
<View style={styles.row}>
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('endDate')}>
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => openPicker('endDate')}>
<Text style={styles.pickerButtonText}>{formatDate(end)}</Text>
</Pressable>
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('endTime')}>
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => openPicker('endTime')}>
<Text style={styles.pickerButtonText}>{formatTime(end)}</Text>
</Pressable>
</View>
{activePicker ? (
<View style={styles.pickerWrap}>
<DateTimePicker
value={activePicker.startsWith('start') ? start : end}
mode={activePicker.endsWith('Date') ? 'date' : 'time'}
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
locale="de-DE"
minuteInterval={5}
onChange={updatePicker}
/>
{Platform.OS === 'ios' ? (
<Pressable style={styles.pickerDoneButton} onPress={() => setActivePicker(null)}>
<Text style={styles.pickerDoneText}>Übernehmen</Text>
</Pressable>
) : null}
</View>
) : null}
<Text style={styles.label}>Status</Text>
<View style={styles.statusRow}>
{(['Entwurf', 'Final'] as EventState[]).map((option) => (
<Pressable
key={option}
style={[styles.statusButton, state === option ? styles.statusButtonActive : null]}
onPress={() => setState(option)}>
<Text style={[styles.statusButtonText, state === option ? styles.statusButtonTextActive : null]}>
{option}
</Text>
</Pressable>
))}
</View>
<Text style={styles.label}>Projekt</Text>
{selectedProject ? (
@@ -279,11 +293,45 @@ export function EventCreateModal({
<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>
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : event ? 'Speichern' : 'Anlegen'}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
{activePicker && Platform.OS === 'ios' ? (
<View style={styles.pickerOverlay}>
<Pressable style={styles.pickerBackdrop} onPress={() => setActivePicker(null)} />
<View style={styles.pickerSheet}>
<View style={styles.pickerHeader}>
<Text style={styles.pickerTitle}>
{activePicker.startsWith('start') ? 'Beginn' : 'Ende'} · {activePicker.endsWith('Date') ? 'Datum' : 'Uhrzeit'}
</Text>
<Pressable style={styles.pickerDoneButton} onPress={() => setActivePicker(null)}>
<Text style={styles.pickerDoneText}>Fertig</Text>
</Pressable>
</View>
<DateTimePicker
value={activePicker.startsWith('start') ? start : end}
mode={activePicker.endsWith('Date') ? 'date' : 'time'}
display="spinner"
locale="de-DE"
minuteInterval={5}
onChange={updatePicker}
/>
</View>
</View>
) : null}
{activePicker && Platform.OS === 'android' ? (
<DateTimePicker
value={activePicker.startsWith('start') ? start : end}
mode={activePicker.endsWith('Date') ? 'date' : 'time'}
display="default"
minuteInterval={5}
onChange={updatePicker}
/>
) : null}
</View>
</Modal>
);
@@ -321,9 +369,38 @@ const styles = StyleSheet.create({
pickerButtonText: { color: '#111827', fontSize: 15, fontWeight: '600' },
dateInput: { flex: 1 },
timeInput: { width: 92 },
pickerWrap: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, padding: 8 },
pickerDoneButton: { alignSelf: 'flex-end', paddingHorizontal: 10, paddingVertical: 7 },
pickerOverlay: { ...StyleSheet.absoluteFillObject, justifyContent: 'flex-end', zIndex: 10 },
pickerBackdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(17, 24, 39, 0.25)' },
pickerSheet: {
backgroundColor: '#ffffff',
borderTopLeftRadius: 18,
borderTopRightRadius: 18,
paddingHorizontal: 12,
paddingTop: 8,
paddingBottom: 22,
shadowColor: '#111827',
shadowOpacity: 0.2,
shadowRadius: 10,
shadowOffset: { width: 0, height: -4 },
},
pickerHeader: { minHeight: 42, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
pickerTitle: { color: '#374151', fontSize: 14, fontWeight: '700' },
pickerDoneButton: { paddingHorizontal: 10, paddingVertical: 7 },
pickerDoneText: { color: '#3d7a30', fontSize: 14, fontWeight: '700' },
statusRow: { flexDirection: 'row', gap: 8 },
statusButton: {
flex: 1,
minHeight: 42,
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#ffffff',
},
statusButtonActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' },
statusButtonText: { color: '#6b7280', fontSize: 14, fontWeight: '700' },
statusButtonTextActive: { color: '#3d7a30' },
multiline: { minHeight: 90, textAlignVertical: 'top' },
selectedProject: {
flexDirection: 'row',

View File

@@ -27,6 +27,7 @@
"expo-router": "~6.0.24",
"expo-secure-store": "^15.0.8",
"expo-share-intent": "^5.1.1",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
@@ -6835,6 +6836,15 @@
"node": ">=10"
}
},
"node_modules/expo-sharing": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz",
"integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-splash-screen": {
"version": "31.0.13",
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz",

View File

@@ -40,6 +40,7 @@
"expo-router": "~6.0.24",
"expo-secure-store": "^15.0.8",
"expo-share-intent": "^5.1.1",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",

View File

@@ -54,13 +54,15 @@ export type Project = {
[key: string]: unknown;
};
export type EventState = 'Entwurf' | 'Final';
export type CalendarEvent = {
id: number;
name: string;
startDate: string;
endDate?: string | null;
eventtype?: string | null;
state?: string | null;
state?: EventState | null;
repeatInterval?: string | null;
project?: number | { id?: number; name?: string } | null;
customer?: number | { id?: number; name?: string } | null;
@@ -724,7 +726,7 @@ export async function createEvent(
notes?: string | null;
link?: string | null;
eventtype?: string;
state?: 'Entwurf' | 'Final';
state?: EventState;
repeatInterval?: string;
}
): Promise<CalendarEvent> {
@@ -735,6 +737,18 @@ export async function createEvent(
});
}
export async function updateEvent(
token: string,
eventId: number,
payload: Partial<Pick<CalendarEvent, 'name' | 'startDate' | 'endDate' | 'project' | 'customer' | 'notes' | 'link' | 'eventtype' | 'state' | 'repeatInterval'>>
): Promise<CalendarEvent> {
return apiRequest<CalendarEvent>(`/api/resource/events/${eventId}`, {
method: 'PUT',
token,
body: payload,
});
}
export async function fetchEventById(token: string, eventId: number): Promise<CalendarEvent> {
return apiRequest<CalendarEvent>(`/api/resource/events/${eventId}`, { token });
}

View File

@@ -1,3 +1,7 @@
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import type { ProjectFile } from '@/src/lib/api';
import { getApiBaseUrlSync } from '@/src/lib/server-config';
export function getOpenableFileUrl(rawUrl: string): string {
@@ -7,3 +11,28 @@ export function getOpenableFileUrl(rawUrl: string): string {
const path = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`;
return `${apiBaseUrl}${path}`;
}
function safeFilename(file: ProjectFile): string {
const fallback = `dokument-${file.id}`;
const rawName = String(file.name || file.path?.split('/').pop() || fallback);
return rawName.replace(/[^a-zA-Z0-9äöüÄÖÜß._-]/g, '_');
}
export async function openAuthenticatedFile(file: ProjectFile, token: string): Promise<void> {
if (!file.url) throw new Error('Für dieses Dokument ist kein Download verfügbar.');
const target = new File(Paths.cache, `${file.id}-${safeFilename(file)}`);
const downloaded = await File.downloadFileAsync(getOpenableFileUrl(file.url), target, {
headers: { Authorization: `Bearer ${token}` },
idempotent: true,
});
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Die Dokumentvorschau ist auf diesem Gerät nicht verfügbar.');
}
await Sharing.shareAsync(downloaded.uri, {
mimeType: file.mimeType || undefined,
dialogTitle: safeFilename(file),
});
}