KI-AGENT: Terminpicker und Dokumentaufruf verbessert
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -70,7 +70,8 @@
|
||||
},
|
||||
"disableAndroid": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"@react-native-community/datetimepicker"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
24
mobile/package-lock.json
generated
24
mobile/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
9
mobile/src/lib/file-opening.ts
Normal file
9
mobile/src/lib/file-opening.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user