From 15e21a97e271227a4d68e3b57c29d6325794de30 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 8 Sep 2026 22:49:17 +0200 Subject: [PATCH] KI-AGENT: Terminpicker und Dokumentaufruf verbessert --- backend/src/plugins/auth.ts | 6 +- backend/src/routes/files.ts | 16 ++- mobile/app.json | 3 +- mobile/app/more/customer/[id].tsx | 3 +- mobile/app/more/plant/[id].tsx | 3 +- mobile/app/project/[id].tsx | 3 +- mobile/components/event-create-modal.tsx | 152 +++++++++++------------ mobile/package-lock.json | 24 ++++ mobile/package.json | 1 + mobile/src/lib/file-opening.ts | 9 ++ 10 files changed, 135 insertions(+), 85 deletions(-) create mode 100644 mobile/src/lib/file-opening.ts diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts index 22bd53d..5a7a207 100644 --- a/backend/src/plugins/auth.ts +++ b/backend/src/plugins/auth.ts @@ -84,10 +84,12 @@ export default fp(async (server: FastifyInstance) => { const urlPath = req.url.split("?")[0] const queryToken = (req.query as any)?.downloadToken + const isDownloadTokenRoute = + (urlPath.startsWith("/api/email/attachments/") && urlPath.endsWith("/download")) + || urlPath.startsWith("/api/files/content/") const downloadToken = typeof queryToken === "string" - && urlPath.startsWith("/api/email/attachments/") - && urlPath.endsWith("/download") + && isDownloadTokenRoute ? queryToken : null diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts index 1494510..16765a7 100644 --- a/backend/src/routes/files.ts +++ b/backend/src/routes/files.ts @@ -5,6 +5,7 @@ import { GetObjectCommand } from "@aws-sdk/client-s3" import archiver from "archiver" +import jwt from "jsonwebtoken" import { secrets } from "../utils/secrets" import { saveFile } from "../utils/files" @@ -19,6 +20,17 @@ import { export default async function fileRoutes(server: FastifyInstance) { + const createDownloadUrl = (req: any, fileId: string) => { + const downloadToken = jwt.sign({ + user_id: req.user.user_id, + email: req.user.email, + tenant_id: req.user.tenant_id, + is_admin: Boolean(req.user.is_admin), + }, secrets.JWT_SECRET!, { expiresIn: "15m" }) + + return `/api/files/content/${fileId}?downloadToken=${encodeURIComponent(downloadToken)}` + } + const getPortalCustomerId = async (req: any) => { const tenantId = req.user?.tenant_id const userId = req.user?.user_id @@ -282,7 +294,7 @@ export default async function fileRoutes(server: FastifyInstance) { const file = await loadSingleFileForRequest(req, id) if (!file) return reply.code(404).send({ error: "Not found" }) - return { ...file, url: `/api/files/content/${file.id}` } + return { ...file, url: createDownloadUrl(req, file.id) } } else { // ------------------------------------------------- // MULTIPLE PRESIGNED URLs @@ -300,7 +312,7 @@ export default async function fileRoutes(server: FastifyInstance) { const output = selected.map(file => ({ ...file, - url: `/api/files/content/${file.id}` + url: createDownloadUrl(req, file.id) })) return { files: output } diff --git a/mobile/app.json b/mobile/app.json index 9907f63..9f8bf72 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -70,7 +70,8 @@ }, "disableAndroid": true } - ] + ], + "@react-native-community/datetimepicker" ], "experiments": { "typedRoutes": true, diff --git a/mobile/app/more/customer/[id].tsx b/mobile/app/more/customer/[id].tsx index 5d0a1fe..cba4b28 100644 --- a/mobile/app/more/customer/[id].tsx +++ b/mobile/app/more/customer/[id].tsx @@ -32,6 +32,7 @@ import { uploadCustomerFile, } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; +import { getOpenableFileUrl } from '@/src/lib/file-opening'; const PRIMARY = '#69c350'; @@ -276,7 +277,7 @@ export default function CustomerDetailScreen() { async function onOpenFile(file: ProjectFile) { if (!file.url) return; - await WebBrowser.openBrowserAsync(file.url, { + await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), { presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET, controlsColor: PRIMARY, showTitle: true, diff --git a/mobile/app/more/plant/[id].tsx b/mobile/app/more/plant/[id].tsx index 243fb2f..05000af 100644 --- a/mobile/app/more/plant/[id].tsx +++ b/mobile/app/more/plant/[id].tsx @@ -8,6 +8,7 @@ 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'; const PRIMARY = '#69c350'; @@ -90,7 +91,7 @@ export default function PlantDetailScreen() { async function onOpenFile(file: ProjectFile) { if (!file.url) return; - await WebBrowser.openBrowserAsync(file.url, { + await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), { presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET, controlsColor: PRIMARY, showTitle: true, diff --git a/mobile/app/project/[id].tsx b/mobile/app/project/[id].tsx index dd9ab3c..240fc2a 100644 --- a/mobile/app/project/[id].tsx +++ b/mobile/app/project/[id].tsx @@ -34,6 +34,7 @@ import { updateTask, } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; +import { getOpenableFileUrl } from '@/src/lib/file-opening'; const PRIMARY = '#69c350'; const TASK_STATUS_ORDER: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen']; @@ -193,7 +194,7 @@ export default function ProjectDetailScreen() { async function onOpenFile(file: ProjectFile) { if (!file.url) return; - await WebBrowser.openBrowserAsync(file.url, { + await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), { presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET, controlsColor: PRIMARY, showTitle: true, diff --git a/mobile/components/event-create-modal.tsx b/mobile/components/event-create-modal.tsx index f0a005c..fafd7c7 100644 --- a/mobile/components/event-create-modal.tsx +++ b/mobile/components/event-create-modal.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; +import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker'; import { KeyboardAvoidingView, Modal, @@ -29,11 +30,11 @@ function pad(value: number): string { return String(value).padStart(2, '0'); } -function formatDateInput(date: Date): string { +function formatDate(date: Date): string { return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`; } -function formatTimeInput(date: Date): string { +function formatTime(date: Date): string { return `${pad(date.getHours())}:${pad(date.getMinutes())}`; } @@ -45,35 +46,7 @@ function createDefaults(initialDate?: Date | null) { 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; + return { start, end }; } function relationId(value: unknown): number | null { @@ -98,10 +71,9 @@ export function EventCreateModal({ 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 [start, setStart] = useState(() => createDefaults(initialDate).start); + const [end, setEnd] = useState(() => createDefaults(initialDate).end); + const [activePicker, setActivePicker] = useState<'startDate' | 'startTime' | 'endDate' | 'endTime' | null>(null); const [selectedProjectId, setSelectedProjectId] = useState(initialProjectId); const [projectSearch, setProjectSearch] = useState(''); const [saving, setSaving] = useState(false); @@ -113,10 +85,9 @@ export function EventCreateModal({ setName(''); setNotes(''); setLink(''); - setStartDate(defaults.startDate); - setStartTime(defaults.startTime); - setEndDate(defaults.endDate); - setEndTime(defaults.endTime); + setStart(defaults.start); + setEnd(defaults.end); + setActivePicker(null); setSelectedProjectId(initialProjectId); setProjectSearch(''); setError(null); @@ -138,6 +109,27 @@ export function EventCreateModal({ .slice(0, 8); }, [projectSearch, projects]); + function updatePicker(event: DateTimePickerEvent, selected?: Date) { + if (Platform.OS === 'android') setActivePicker(null); + if (event.type === 'dismissed' || !selected || !activePicker) return; + + const isStart = activePicker.startsWith('start'); + const isDate = activePicker.endsWith('Date'); + const current = new Date(isStart ? start : end); + if (isDate) { + current.setFullYear(selected.getFullYear(), selected.getMonth(), selected.getDate()); + } else { + current.setHours(selected.getHours(), selected.getMinutes(), 0, 0); + } + + if (isStart) { + setStart(current); + if (end <= current) setEnd(new Date(current.getTime() + 60 * 60 * 1000)); + } else { + setEnd(current); + } + } + async function save() { if (!token || saving) return; const title = name.trim(); @@ -146,12 +138,6 @@ export function EventCreateModal({ 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; @@ -198,44 +184,42 @@ export function EventCreateModal({ Beginn - - + setActivePicker('startDate')}> + {formatDate(start)} + + setActivePicker('startTime')}> + {formatTime(start)} + Ende - - + setActivePicker('endDate')}> + {formatDate(end)} + + setActivePicker('endTime')}> + {formatTime(end)} + + {activePicker ? ( + + + {Platform.OS === 'ios' ? ( + setActivePicker(null)}> + Übernehmen + + ) : null} + + ) : null} + Projekt {selectedProject ? ( @@ -324,8 +308,22 @@ const styles = StyleSheet.create({ backgroundColor: '#ffffff', }, row: { flexDirection: 'row', gap: 8 }, + pickerButton: { + minHeight: 44, + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 12, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#ffffff', + }, + 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 }, + pickerDoneText: { color: '#3d7a30', fontSize: 14, fontWeight: '700' }, multiline: { minHeight: 90, textAlignVertical: 'top' }, selectedProject: { flexDirection: 'row', diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 4f670e8..4d30e21 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -9,6 +9,7 @@ "version": "2.0.0", "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-community/datetimepicker": "8.4.4", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", @@ -2892,6 +2893,29 @@ } } }, + "node_modules/@react-native-community/datetimepicker": { + "version": "8.4.4", + "resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.4.4.tgz", + "integrity": "sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": ">=52.0.0", + "react": "*", + "react-native": "*", + "react-native-windows": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "react-native-windows": { + "optional": true + } + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", diff --git a/mobile/package.json b/mobile/package.json index ec9997f..d856f28 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-community/datetimepicker": "8.4.4", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", diff --git a/mobile/src/lib/file-opening.ts b/mobile/src/lib/file-opening.ts new file mode 100644 index 0000000..53dde3a --- /dev/null +++ b/mobile/src/lib/file-opening.ts @@ -0,0 +1,9 @@ +import { getApiBaseUrlSync } from '@/src/lib/server-config'; + +export function getOpenableFileUrl(rawUrl: string): string { + if (/^https?:\/\//i.test(rawUrl)) return rawUrl; + + const apiBaseUrl = getApiBaseUrlSync().replace(/\/+$/, ''); + const path = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`; + return `${apiBaseUrl}${path}`; +}