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 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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<number | null>(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({
<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"
/>
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('startDate')}>
<Text style={styles.pickerButtonText}>{formatDate(start)}</Text>
</Pressable>
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('startTime')}>
<Text style={styles.pickerButtonText}>{formatTime(start)}</Text>
</Pressable>
</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"
/>
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('endDate')}>
<Text style={styles.pickerButtonText}>{formatDate(end)}</Text>
</Pressable>
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('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}>Projekt</Text>
{selectedProject ? (
<View style={styles.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',

View File

@@ -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",

View File

@@ -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",

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