KI-AGENT: Terminpicker und Dokumentaufruf verbessert

This commit is contained in:
2026-09-08 22:49:17 +02:00
parent 358e40d749
commit 15e21a97e2
10 changed files with 135 additions and 85 deletions

View File

@@ -84,10 +84,12 @@ export default fp(async (server: FastifyInstance) => {
const urlPath = req.url.split("?")[0] const urlPath = req.url.split("?")[0]
const queryToken = (req.query as any)?.downloadToken const queryToken = (req.query as any)?.downloadToken
const isDownloadTokenRoute =
(urlPath.startsWith("/api/email/attachments/") && urlPath.endsWith("/download"))
|| urlPath.startsWith("/api/files/content/")
const downloadToken = const downloadToken =
typeof queryToken === "string" typeof queryToken === "string"
&& urlPath.startsWith("/api/email/attachments/") && isDownloadTokenRoute
&& urlPath.endsWith("/download")
? queryToken ? queryToken
: null : null

View File

@@ -5,6 +5,7 @@ import {
GetObjectCommand GetObjectCommand
} from "@aws-sdk/client-s3" } from "@aws-sdk/client-s3"
import archiver from "archiver" import archiver from "archiver"
import jwt from "jsonwebtoken"
import { secrets } from "../utils/secrets" import { secrets } from "../utils/secrets"
import { saveFile } from "../utils/files" import { saveFile } from "../utils/files"
@@ -19,6 +20,17 @@ import {
export default async function fileRoutes(server: FastifyInstance) { 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 getPortalCustomerId = async (req: any) => {
const tenantId = req.user?.tenant_id const tenantId = req.user?.tenant_id
const userId = req.user?.user_id const userId = req.user?.user_id
@@ -282,7 +294,7 @@ export default async function fileRoutes(server: FastifyInstance) {
const file = await loadSingleFileForRequest(req, id) const file = await loadSingleFileForRequest(req, id)
if (!file) return reply.code(404).send({ error: "Not found" }) 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 { } else {
// ------------------------------------------------- // -------------------------------------------------
// MULTIPLE PRESIGNED URLs // MULTIPLE PRESIGNED URLs
@@ -300,7 +312,7 @@ export default async function fileRoutes(server: FastifyInstance) {
const output = selected.map(file => ({ const output = selected.map(file => ({
...file, ...file,
url: `/api/files/content/${file.id}` url: createDownloadUrl(req, file.id)
})) }))
return { files: output } return { files: output }

View File

@@ -70,7 +70,8 @@
}, },
"disableAndroid": true "disableAndroid": true
} }
] ],
"@react-native-community/datetimepicker"
], ],
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,

View File

@@ -32,6 +32,7 @@ import {
uploadCustomerFile, uploadCustomerFile,
} from '@/src/lib/api'; } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider'; import { useAuth } from '@/src/providers/auth-provider';
import { getOpenableFileUrl } from '@/src/lib/file-opening';
const PRIMARY = '#69c350'; const PRIMARY = '#69c350';
@@ -276,7 +277,7 @@ export default function CustomerDetailScreen() {
async function onOpenFile(file: ProjectFile) { async function onOpenFile(file: ProjectFile) {
if (!file.url) return; if (!file.url) return;
await WebBrowser.openBrowserAsync(file.url, { await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET, presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
controlsColor: PRIMARY, controlsColor: PRIMARY,
showTitle: true, showTitle: true,

View File

@@ -8,6 +8,7 @@ import * as WebBrowser from 'expo-web-browser';
import { HistorySection } from '@/components/history-section'; import { HistorySection } from '@/components/history-section';
import { fetchPlantById, fetchPlantFiles, Plant, ProjectFile, uploadPlantFile } from '@/src/lib/api'; import { fetchPlantById, fetchPlantFiles, Plant, ProjectFile, uploadPlantFile } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider'; import { useAuth } from '@/src/providers/auth-provider';
import { getOpenableFileUrl } from '@/src/lib/file-opening';
const PRIMARY = '#69c350'; const PRIMARY = '#69c350';
@@ -90,7 +91,7 @@ export default function PlantDetailScreen() {
async function onOpenFile(file: ProjectFile) { async function onOpenFile(file: ProjectFile) {
if (!file.url) return; if (!file.url) return;
await WebBrowser.openBrowserAsync(file.url, { await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET, presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
controlsColor: PRIMARY, controlsColor: PRIMARY,
showTitle: true, showTitle: true,

View File

@@ -34,6 +34,7 @@ import {
updateTask, updateTask,
} from '@/src/lib/api'; } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider'; import { useAuth } from '@/src/providers/auth-provider';
import { getOpenableFileUrl } from '@/src/lib/file-opening';
const PRIMARY = '#69c350'; const PRIMARY = '#69c350';
const TASK_STATUS_ORDER: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen']; const TASK_STATUS_ORDER: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen'];
@@ -193,7 +194,7 @@ export default function ProjectDetailScreen() {
async function onOpenFile(file: ProjectFile) { async function onOpenFile(file: ProjectFile) {
if (!file.url) return; if (!file.url) return;
await WebBrowser.openBrowserAsync(file.url, { await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET, presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
controlsColor: PRIMARY, controlsColor: PRIMARY,
showTitle: true, showTitle: true,

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
import { import {
KeyboardAvoidingView, KeyboardAvoidingView,
Modal, Modal,
@@ -29,11 +30,11 @@ function pad(value: number): string {
return String(value).padStart(2, '0'); 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()}`; 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())}`; return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
} }
@@ -45,35 +46,7 @@ function createDefaults(initialDate?: Date | null) {
start.setHours(9, 0, 0, 0); start.setHours(9, 0, 0, 0);
} }
const end = new Date(start.getTime() + 60 * 60 * 1000); const end = new Date(start.getTime() + 60 * 60 * 1000);
return { return { start, end };
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 { function relationId(value: unknown): number | null {
@@ -98,10 +71,9 @@ export function EventCreateModal({
const [name, setName] = useState(''); const [name, setName] = useState('');
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
const [link, setLink] = useState(''); const [link, setLink] = useState('');
const [startDate, setStartDate] = useState(''); const [start, setStart] = useState(() => createDefaults(initialDate).start);
const [startTime, setStartTime] = useState(''); const [end, setEnd] = useState(() => createDefaults(initialDate).end);
const [endDate, setEndDate] = useState(''); const [activePicker, setActivePicker] = useState<'startDate' | 'startTime' | 'endDate' | 'endTime' | null>(null);
const [endTime, setEndTime] = useState('');
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(initialProjectId); const [selectedProjectId, setSelectedProjectId] = useState<number | null>(initialProjectId);
const [projectSearch, setProjectSearch] = useState(''); const [projectSearch, setProjectSearch] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -113,10 +85,9 @@ export function EventCreateModal({
setName(''); setName('');
setNotes(''); setNotes('');
setLink(''); setLink('');
setStartDate(defaults.startDate); setStart(defaults.start);
setStartTime(defaults.startTime); setEnd(defaults.end);
setEndDate(defaults.endDate); setActivePicker(null);
setEndTime(defaults.endTime);
setSelectedProjectId(initialProjectId); setSelectedProjectId(initialProjectId);
setProjectSearch(''); setProjectSearch('');
setError(null); setError(null);
@@ -138,6 +109,27 @@ export function EventCreateModal({
.slice(0, 8); .slice(0, 8);
}, [projectSearch, projects]); }, [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() { async function save() {
if (!token || saving) return; if (!token || saving) return;
const title = name.trim(); const title = name.trim();
@@ -146,12 +138,6 @@ export function EventCreateModal({
return; 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) { if (end <= start) {
setError('Das Ende muss nach dem Beginn liegen.'); setError('Das Ende muss nach dem Beginn liegen.');
return; return;
@@ -198,44 +184,42 @@ export function EventCreateModal({
<Text style={styles.label}>Beginn</Text> <Text style={styles.label}>Beginn</Text>
<View style={styles.row}> <View style={styles.row}>
<TextInput <Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('startDate')}>
placeholder="TT.MM.JJJJ" <Text style={styles.pickerButtonText}>{formatDate(start)}</Text>
placeholderTextColor="#9ca3af" </Pressable>
style={[styles.input, styles.dateInput]} <Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('startTime')}>
value={startDate} <Text style={styles.pickerButtonText}>{formatTime(start)}</Text>
onChangeText={setStartDate} </Pressable>
keyboardType="numbers-and-punctuation"
/>
<TextInput
placeholder="HH:MM"
placeholderTextColor="#9ca3af"
style={[styles.input, styles.timeInput]}
value={startTime}
onChangeText={setStartTime}
keyboardType="numbers-and-punctuation"
/>
</View> </View>
<Text style={styles.label}>Ende</Text> <Text style={styles.label}>Ende</Text>
<View style={styles.row}> <View style={styles.row}>
<TextInput <Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('endDate')}>
placeholder="TT.MM.JJJJ" <Text style={styles.pickerButtonText}>{formatDate(end)}</Text>
placeholderTextColor="#9ca3af" </Pressable>
style={[styles.input, styles.dateInput]} <Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('endTime')}>
value={endDate} <Text style={styles.pickerButtonText}>{formatTime(end)}</Text>
onChangeText={setEndDate} </Pressable>
keyboardType="numbers-and-punctuation"
/>
<TextInput
placeholder="HH:MM"
placeholderTextColor="#9ca3af"
style={[styles.input, styles.timeInput]}
value={endTime}
onChangeText={setEndTime}
keyboardType="numbers-and-punctuation"
/>
</View> </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}>Projekt</Text> <Text style={styles.label}>Projekt</Text>
{selectedProject ? ( {selectedProject ? (
<View style={styles.selectedProject}> <View style={styles.selectedProject}>
@@ -324,8 +308,22 @@ const styles = StyleSheet.create({
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
}, },
row: { flexDirection: 'row', gap: 8 }, 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 }, dateInput: { flex: 1 },
timeInput: { width: 92 }, 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' }, multiline: { minHeight: 90, textAlignVertical: 'top' },
selectedProject: { selectedProject: {
flexDirection: 'row', flexDirection: 'row',

View File

@@ -9,6 +9,7 @@
"version": "2.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-native-community/datetimepicker": "8.4.4",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@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": { "node_modules/@react-native/assets-registry": {
"version": "0.81.5", "version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",

View File

@@ -22,6 +22,7 @@
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-native-community/datetimepicker": "8.4.4",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",

View File

@@ -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}`;
}