From 2c1047e624037293ba74701b22cd9b558eb6dddb Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 8 Sep 2026 23:08:36 +0200 Subject: [PATCH] KI-AGENT: Kalender, Terminbearbeitung und Dateivorschau verbessern --- mobile/app/(tabs)/index.tsx | 133 ++++++++++++++++++- mobile/app/more/customer/[id].tsx | 17 ++- mobile/app/more/event/[id].tsx | 63 ++++++++- mobile/app/more/events.tsx | 2 + mobile/app/more/plant/[id].tsx | 17 ++- mobile/app/project/[id].tsx | 17 ++- mobile/components/event-create-modal.tsx | 157 +++++++++++++++++------ mobile/package-lock.json | 10 ++ mobile/package.json | 1 + mobile/src/lib/api.ts | 18 ++- mobile/src/lib/file-opening.ts | 29 +++++ 11 files changed, 390 insertions(+), 74 deletions(-) diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index 5f813d78..f8d84fca 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -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(null); + const [events, setEvents] = useState([]); + const [projects, setProjects] = useState([]); + const [createEventOpen, setCreateEventOpen] = useState(false); + const [selectedCalendarDate, setSelectedCalendarDate] = useState(() => new Date()); const [data, setData] = useState({ 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 ( + + + + Kalender + + {selectedCalendarDate.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long' })} + + + router.push('/more/calendar' as any)}> + Monat öffnen + + + + + {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 ( + setSelectedCalendarDate(date)}> + + {date.toLocaleDateString('de-DE', { weekday: 'short' }).replace('.', '')} + + {date.getDate()} + 0 ? styles.calendarDotActive : null]} /> + + ); + })} + + + {selectedEvents.length === 0 ? Keine Termine an diesem Tag. : null} + {selectedEvents.slice(0, 3).map((event) => ( + router.push(`/more/event/${event.id}` as any)}> + + {formatEventTime(event.startDate)} + {event.name} + {event.state === 'Entwurf' ? Entwurf : null} + › + + ))} + setCreateEventOpen(true)}> + + Termin eintragen + + + Schnellzugriff @@ -175,6 +267,14 @@ export default function DashboardScreen() { + + setCreateEventOpen(false)} + onCreated={() => loadDashboard(false)} + /> ) : null} @@ -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, diff --git a/mobile/app/more/customer/[id].tsx b/mobile/app/more/customer/[id].tsx index cba4b28b..e55de140 100644 --- a/mobile/app/more/customer/[id].tsx +++ b/mobile/app/more/customer/[id].tsx @@ -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() { diff --git a/mobile/app/more/event/[id].tsx b/mobile/app/more/event/[id].tsx index 3146996c..15e75582 100644 --- a/mobile/app/more/event/[id].tsx +++ b/mobile/app/more/event/[id].tsx @@ -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(null); + const [projects, setProjects] = useState([]); + const [editOpen, setEditOpen] = useState(false); + const [updatingState, setUpdatingState] = useState(false); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(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() { {event.name} + setEditOpen(true)}> + Bearbeiten + + + + Status + + {(['Entwurf', 'Final'] as EventState[]).map((option) => ( + changeState(option)}> + + {option} + + + ))} + {rows.map((row) => ( @@ -110,6 +151,13 @@ export default function EventDetailScreen() { {event.link ? Link öffnen : null} + setEditOpen(false)} + onUpdated={() => load(false)} + /> ) : null} @@ -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' }, diff --git a/mobile/app/more/events.tsx b/mobile/app/more/events.tsx index f2c849cb..5ebdc7d5 100644 --- a/mobile/app/more/events.tsx +++ b/mobile/app/more/events.tsx @@ -134,6 +134,7 @@ export default function EventsScreen() { {projectName(event) ? {projectName(event)} : null} {event.notes ? {String(event.notes)} : null} + {event.state === 'Entwurf' ? Entwurf : null} › )) @@ -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' }, diff --git a/mobile/app/more/plant/[id].tsx b/mobile/app/more/plant/[id].tsx index 05000af6..8a2d778c 100644 --- a/mobile/app/more/plant/[id].tsx +++ b/mobile/app/more/plant/[id].tsx @@ -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() { diff --git a/mobile/app/project/[id].tsx b/mobile/app/project/[id].tsx index 240fc2ae..7b9d65cd 100644 --- a/mobile/app/project/[id].tsx +++ b/mobile/app/project/[id].tsx @@ -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) { diff --git a/mobile/components/event-create-modal.tsx b/mobile/components/event-create-modal.tsx index fafd7c77..e8c07d2c 100644 --- a/mobile/components/event-create-modal.tsx +++ b/mobile/components/event-create-modal.tsx @@ -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; + onCreated?: (event: CalendarEvent) => void | Promise; + onUpdated?: (event: CalendarEvent) => void | Promise; }; 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('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({ - Neuer Termin + {event ? 'Termin bearbeiten' : 'Neuer Termin'} Beginn - setActivePicker('startDate')}> + openPicker('startDate')}> {formatDate(start)} - setActivePicker('startTime')}> + openPicker('startTime')}> {formatTime(start)} Ende - setActivePicker('endDate')}> + openPicker('endDate')}> {formatDate(end)} - setActivePicker('endTime')}> + openPicker('endTime')}> {formatTime(end)} - {activePicker ? ( - - - {Platform.OS === 'ios' ? ( - setActivePicker(null)}> - Übernehmen - - ) : null} - - ) : null} + Status + + {(['Entwurf', 'Final'] as EventState[]).map((option) => ( + setState(option)}> + + {option} + + + ))} + Projekt {selectedProject ? ( @@ -279,11 +293,45 @@ export function EventCreateModal({ Abbrechen - {saving ? 'Speichere...' : 'Anlegen'} + {saving ? 'Speichere...' : event ? 'Speichern' : 'Anlegen'} + + {activePicker && Platform.OS === 'ios' ? ( + + setActivePicker(null)} /> + + + + {activePicker.startsWith('start') ? 'Beginn' : 'Ende'} · {activePicker.endsWith('Date') ? 'Datum' : 'Uhrzeit'} + + setActivePicker(null)}> + Fertig + + + + + + ) : null} + + {activePicker && Platform.OS === 'android' ? ( + + ) : null} ); @@ -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', diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 4d30e214..53d3fd61 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -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", diff --git a/mobile/package.json b/mobile/package.json index d856f28e..6172915d 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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", diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index 1f5ad587..ee0bbdec 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -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 { @@ -735,6 +737,18 @@ export async function createEvent( }); } +export async function updateEvent( + token: string, + eventId: number, + payload: Partial> +): Promise { + return apiRequest(`/api/resource/events/${eventId}`, { + method: 'PUT', + token, + body: payload, + }); +} + export async function fetchEventById(token: string, eventId: number): Promise { return apiRequest(`/api/resource/events/${eventId}`, { token }); } diff --git a/mobile/src/lib/file-opening.ts b/mobile/src/lib/file-opening.ts index 53dde3a9..a794cb17 100644 --- a/mobile/src/lib/file-opening.ts +++ b/mobile/src/lib/file-opening.ts @@ -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 { + 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), + }); +}