dev #251
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
import { router } from 'expo-router';
|
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';
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
|
||||||
const PRIMARY = '#69c350';
|
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() {
|
export default function DashboardScreen() {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [createEventOpen, setCreateEventOpen] = useState(false);
|
||||||
|
const [selectedCalendarDate, setSelectedCalendarDate] = useState(() => new Date());
|
||||||
const [data, setData] = useState<DashboardData>({
|
const [data, setData] = useState<DashboardData>({
|
||||||
tasks: [],
|
tasks: [],
|
||||||
openTasks: 0,
|
openTasks: 0,
|
||||||
@@ -62,9 +76,11 @@ export default function DashboardScreen() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [taskRows, spans] = await Promise.all([
|
const [taskRows, spans, eventRows, projectRows] = await Promise.all([
|
||||||
fetchTasks(token),
|
fetchTasks(token),
|
||||||
fetchStaffTimeSpans(token, currentUserId),
|
fetchStaffTimeSpans(token, currentUserId),
|
||||||
|
fetchEvents(token),
|
||||||
|
fetchProjects(token),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const tasks = taskRows || [];
|
const tasks = taskRows || [];
|
||||||
@@ -92,6 +108,8 @@ export default function DashboardScreen() {
|
|||||||
pendingSubmissions,
|
pendingSubmissions,
|
||||||
todayMinutes,
|
todayMinutes,
|
||||||
});
|
});
|
||||||
|
setEvents(eventRows);
|
||||||
|
setProjects(projectRows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Dashboard konnte nicht geladen werden.');
|
setError(err instanceof Error ? err.message : 'Dashboard konnte nicht geladen werden.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -112,6 +130,26 @@ export default function DashboardScreen() {
|
|||||||
await loadDashboard(false);
|
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 (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.container}
|
contentContainerStyle={styles.container}
|
||||||
@@ -158,6 +196,60 @@ export default function DashboardScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.calendarCard}>
|
||||||
|
<View style={styles.calendarHeader}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.calendarTitle}>Kalender</Text>
|
||||||
|
<Text style={styles.calendarSubtitle}>
|
||||||
|
{selectedCalendarDate.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long' })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={() => router.push('/more/calendar' as any)}>
|
||||||
|
<Text style={styles.calendarLink}>Monat öffnen</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.calendarDays}>
|
||||||
|
{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 (
|
||||||
|
<Pressable
|
||||||
|
key={key}
|
||||||
|
style={[styles.calendarDay, selected ? styles.calendarDaySelected : null]}
|
||||||
|
onPress={() => setSelectedCalendarDate(date)}>
|
||||||
|
<Text style={[styles.calendarWeekday, selected ? styles.calendarDayTextSelected : null]}>
|
||||||
|
{date.toLocaleDateString('de-DE', { weekday: 'short' }).replace('.', '')}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.calendarDayNumber, selected ? styles.calendarDayTextSelected : null]}>{date.getDate()}</Text>
|
||||||
|
<View style={[styles.calendarDot, count > 0 ? styles.calendarDotActive : null]} />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{selectedEvents.length === 0 ? <Text style={styles.calendarEmpty}>Keine Termine an diesem Tag.</Text> : null}
|
||||||
|
{selectedEvents.slice(0, 3).map((event) => (
|
||||||
|
<Pressable
|
||||||
|
key={String(event.id)}
|
||||||
|
style={styles.calendarEvent}
|
||||||
|
onPress={() => router.push(`/more/event/${event.id}` as any)}>
|
||||||
|
<View style={[styles.calendarEventMark, { backgroundColor: event.color || PRIMARY }]} />
|
||||||
|
<Text style={styles.calendarEventTime}>{formatEventTime(event.startDate)}</Text>
|
||||||
|
<Text style={styles.calendarEventName} numberOfLines={1}>{event.name}</Text>
|
||||||
|
{event.state === 'Entwurf' ? <Text style={styles.calendarEventDraft}>Entwurf</Text> : null}
|
||||||
|
<Text style={styles.calendarEventArrow}>›</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
<Pressable style={styles.calendarAddButton} onPress={() => setCreateEventOpen(true)}>
|
||||||
|
<Text style={styles.calendarAddButtonText}>+ Termin eintragen</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.quickActionsCard}>
|
<View style={styles.quickActionsCard}>
|
||||||
<Text style={styles.quickActionsTitle}>Schnellzugriff</Text>
|
<Text style={styles.quickActionsTitle}>Schnellzugriff</Text>
|
||||||
<View style={styles.quickActionsRow}>
|
<View style={styles.quickActionsRow}>
|
||||||
@@ -175,6 +267,14 @@ export default function DashboardScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<EventCreateModal
|
||||||
|
visible={createEventOpen}
|
||||||
|
projects={projects}
|
||||||
|
initialDate={selectedCalendarDate}
|
||||||
|
onClose={() => setCreateEventOpen(false)}
|
||||||
|
onCreated={() => loadDashboard(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -232,6 +332,35 @@ const styles = StyleSheet.create({
|
|||||||
padding: 12,
|
padding: 12,
|
||||||
gap: 10,
|
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: {
|
quickActionsTitle: {
|
||||||
color: '#111827',
|
color: '#111827',
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { router, useLocalSearchParams } from 'expo-router';
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
import * as DocumentPicker from 'expo-document-picker';
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
|
||||||
import { BarcodeScanningResult, BarcodeType, CameraView, useCameraPermissions } from 'expo-camera';
|
import { BarcodeScanningResult, BarcodeType, CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
|
||||||
import { HistorySection } from '@/components/history-section';
|
import { HistorySection } from '@/components/history-section';
|
||||||
@@ -32,7 +31,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';
|
import { openAuthenticatedFile } from '@/src/lib/file-opening';
|
||||||
|
|
||||||
const PRIMARY = '#69c350';
|
const PRIMARY = '#69c350';
|
||||||
|
|
||||||
@@ -276,13 +275,13 @@ export default function CustomerDetailScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onOpenFile(file: ProjectFile) {
|
async function onOpenFile(file: ProjectFile) {
|
||||||
if (!file.url) return;
|
if (!token || !file.url) return;
|
||||||
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
|
setError(null);
|
||||||
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
|
try {
|
||||||
controlsColor: PRIMARY,
|
await openAuthenticatedFile(file, token);
|
||||||
showTitle: true,
|
} catch (err) {
|
||||||
enableDefaultShareMenuItem: true,
|
setError(err instanceof Error ? err.message : 'Das Dokument konnte nicht geöffnet werden.');
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onPickAndUpload() {
|
async function onPickAndUpload() {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, T
|
|||||||
import { useLocalSearchParams } from 'expo-router';
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
|
||||||
|
import { EventCreateModal } from '@/components/event-create-modal';
|
||||||
import { HistorySection } from '@/components/history-section';
|
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';
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
|
||||||
const PRIMARY = '#69c350';
|
const PRIMARY = '#69c350';
|
||||||
@@ -37,6 +38,9 @@ export default function EventDetailScreen() {
|
|||||||
const eventId = Number(params.id);
|
const eventId = Number(params.id);
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const [event, setEvent] = useState<CalendarEvent | null>(null);
|
const [event, setEvent] = useState<CalendarEvent | null>(null);
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [updatingState, setUpdatingState] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -47,7 +51,12 @@ export default function EventDetailScreen() {
|
|||||||
if (showSpinner) setLoading(true);
|
if (showSpinner) setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setEvent(await fetchEventById(token, eventId));
|
const [eventRow, projectRows] = await Promise.all([
|
||||||
|
fetchEventById(token, eventId),
|
||||||
|
fetchProjects(token),
|
||||||
|
]);
|
||||||
|
setEvent(eventRow);
|
||||||
|
setProjects(projectRows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Der Termin konnte nicht geladen werden.');
|
setError(err instanceof Error ? err.message : 'Der Termin konnte nicht geladen werden.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -72,13 +81,26 @@ export default function EventDetailScreen() {
|
|||||||
await WebBrowser.openBrowserAsync(url, { controlsColor: PRIMARY, showTitle: true });
|
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
|
const rows = event
|
||||||
? [
|
? [
|
||||||
{ label: 'Beginn', value: formatDateTime(event.startDate) },
|
{ label: 'Beginn', value: formatDateTime(event.startDate) },
|
||||||
{ label: 'Ende', value: formatDateTime(event.endDate) },
|
{ label: 'Ende', value: formatDateTime(event.endDate) },
|
||||||
{ label: 'Projekt', value: relationName(event.project) },
|
{ label: 'Projekt', value: relationName(event.project) },
|
||||||
{ label: 'Kunde', value: relationName(event.customer) },
|
{ label: 'Kunde', value: relationName(event.customer) },
|
||||||
{ label: 'Status', value: String(event.state || '-') },
|
|
||||||
{ label: 'Wiederholung', value: String(event.repeatInterval || '-') },
|
{ label: 'Wiederholung', value: String(event.repeatInterval || '-') },
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
@@ -97,6 +119,25 @@ export default function EventDetailScreen() {
|
|||||||
<View style={styles.headingRow}>
|
<View style={styles.headingRow}>
|
||||||
<View style={[styles.colorMark, { backgroundColor: event.color || PRIMARY }]} />
|
<View style={[styles.colorMark, { backgroundColor: event.color || PRIMARY }]} />
|
||||||
<Text style={styles.title}>{event.name}</Text>
|
<Text style={styles.title}>{event.name}</Text>
|
||||||
|
<Pressable style={styles.editButton} onPress={() => setEditOpen(true)}>
|
||||||
|
<Text style={styles.editButtonText}>Bearbeiten</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statusSection}>
|
||||||
|
<Text style={styles.statusLabel}>Status</Text>
|
||||||
|
<View style={styles.statusRow}>
|
||||||
|
{(['Entwurf', 'Final'] as EventState[]).map((option) => (
|
||||||
|
<Pressable
|
||||||
|
key={option}
|
||||||
|
disabled={updatingState}
|
||||||
|
style={[styles.statusButton, event.state === option ? styles.statusButtonActive : null]}
|
||||||
|
onPress={() => changeState(option)}>
|
||||||
|
<Text style={[styles.statusButtonText, event.state === option ? styles.statusButtonTextActive : null]}>
|
||||||
|
{option}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.table}>
|
<View style={styles.table}>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
@@ -110,6 +151,13 @@ export default function EventDetailScreen() {
|
|||||||
{event.link ? <Pressable style={styles.linkButton} onPress={openLink}><Text style={styles.linkButtonText}>Link öffnen</Text></Pressable> : null}
|
{event.link ? <Pressable style={styles.linkButton} onPress={openLink}><Text style={styles.linkButtonText}>Link öffnen</Text></Pressable> : null}
|
||||||
</View>
|
</View>
|
||||||
<HistorySection resource="events" resourceId={eventId} />
|
<HistorySection resource="events" resourceId={eventId} />
|
||||||
|
<EventCreateModal
|
||||||
|
visible={editOpen}
|
||||||
|
event={event}
|
||||||
|
projects={projects}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
onUpdated={() => load(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -122,6 +170,15 @@ const styles = StyleSheet.create({
|
|||||||
headingRow: { flexDirection: 'row', alignItems: 'center', gap: 9 },
|
headingRow: { flexDirection: 'row', alignItems: 'center', gap: 9 },
|
||||||
colorMark: { width: 6, height: 28, borderRadius: 3 },
|
colorMark: { width: 6, height: 28, borderRadius: 3 },
|
||||||
title: { flex: 1, color: '#111827', fontSize: 19, fontWeight: '700' },
|
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' },
|
table: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, overflow: 'hidden' },
|
||||||
row: { borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingHorizontal: 10, paddingVertical: 8, gap: 2 },
|
row: { borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingHorizontal: 10, paddingVertical: 8, gap: 2 },
|
||||||
label: { color: '#6b7280', fontSize: 12, textTransform: 'uppercase', fontWeight: '600' },
|
label: { color: '#6b7280', fontSize: 12, textTransform: 'uppercase', fontWeight: '600' },
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export default function EventsScreen() {
|
|||||||
{projectName(event) ? <Text style={styles.eventProject}>{projectName(event)}</Text> : null}
|
{projectName(event) ? <Text style={styles.eventProject}>{projectName(event)}</Text> : null}
|
||||||
{event.notes ? <Text style={styles.eventNotes} numberOfLines={2}>{String(event.notes)}</Text> : null}
|
{event.notes ? <Text style={styles.eventNotes} numberOfLines={2}>{String(event.notes)}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
|
{event.state === 'Entwurf' ? <Text style={styles.eventDraft}>Entwurf</Text> : null}
|
||||||
<Text style={styles.arrow}>›</Text>
|
<Text style={styles.arrow}>›</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))
|
))
|
||||||
@@ -183,6 +184,7 @@ const styles = StyleSheet.create({
|
|||||||
eventDate: { color: '#374151', fontSize: 13 },
|
eventDate: { color: '#374151', fontSize: 13 },
|
||||||
eventProject: { color: '#3d7a30', fontSize: 12, fontWeight: '600' },
|
eventProject: { color: '#3d7a30', fontSize: 12, fontWeight: '600' },
|
||||||
eventNotes: { color: '#6b7280', fontSize: 12 },
|
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 },
|
arrow: { alignSelf: 'center', color: '#9ca3af', fontSize: 25, paddingRight: 12 },
|
||||||
loadingBox: { paddingVertical: 24, alignItems: 'center', gap: 8 },
|
loadingBox: { paddingVertical: 24, alignItems: 'center', gap: 8 },
|
||||||
loadingText: { color: '#6b7280' },
|
loadingText: { color: '#6b7280' },
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, T
|
|||||||
import { router, useLocalSearchParams } from 'expo-router';
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
import * as DocumentPicker from 'expo-document-picker';
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
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';
|
import { openAuthenticatedFile } from '@/src/lib/file-opening';
|
||||||
|
|
||||||
const PRIMARY = '#69c350';
|
const PRIMARY = '#69c350';
|
||||||
|
|
||||||
@@ -90,13 +89,13 @@ export default function PlantDetailScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onOpenFile(file: ProjectFile) {
|
async function onOpenFile(file: ProjectFile) {
|
||||||
if (!file.url) return;
|
if (!token || !file.url) return;
|
||||||
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
|
setError(null);
|
||||||
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
|
try {
|
||||||
controlsColor: PRIMARY,
|
await openAuthenticatedFile(file, token);
|
||||||
showTitle: true,
|
} catch (err) {
|
||||||
enableDefaultShareMenuItem: true,
|
setError(err instanceof Error ? err.message : 'Das Dokument konnte nicht geöffnet werden.');
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onPickAndUpload() {
|
async function onPickAndUpload() {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
import { router, useLocalSearchParams } from 'expo-router';
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
import * as DocumentPicker from 'expo-document-picker';
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
|
||||||
|
|
||||||
import { EventCreateModal } from '@/components/event-create-modal';
|
import { EventCreateModal } from '@/components/event-create-modal';
|
||||||
import { HistorySection } from '@/components/history-section';
|
import { HistorySection } from '@/components/history-section';
|
||||||
@@ -34,7 +33,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';
|
import { openAuthenticatedFile } 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,13 +192,13 @@ export default function ProjectDetailScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onOpenFile(file: ProjectFile) {
|
async function onOpenFile(file: ProjectFile) {
|
||||||
if (!file.url) return;
|
if (!token || !file.url) return;
|
||||||
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
|
setError(null);
|
||||||
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
|
try {
|
||||||
controlsColor: PRIMARY,
|
await openAuthenticatedFile(file, token);
|
||||||
showTitle: true,
|
} catch (err) {
|
||||||
enableDefaultShareMenuItem: true,
|
setError(err instanceof Error ? err.message : 'Das Dokument konnte nicht geöffnet werden.');
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onCompleteTask(task: Task) {
|
async function onCompleteTask(task: Task) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||||
import {
|
import {
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
|
Keyboard,
|
||||||
Modal,
|
Modal,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} 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';
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
|
||||||
const PRIMARY = '#69c350';
|
const PRIMARY = '#69c350';
|
||||||
@@ -22,8 +23,10 @@ type EventCreateModalProps = {
|
|||||||
projects?: Project[];
|
projects?: Project[];
|
||||||
initialProjectId?: number | null;
|
initialProjectId?: number | null;
|
||||||
initialDate?: Date | null;
|
initialDate?: Date | null;
|
||||||
|
event?: CalendarEvent | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreated: (event: CalendarEvent) => void | Promise<void>;
|
onCreated?: (event: CalendarEvent) => void | Promise<void>;
|
||||||
|
onUpdated?: (event: CalendarEvent) => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function pad(value: number): string {
|
function pad(value: number): string {
|
||||||
@@ -64,13 +67,16 @@ export function EventCreateModal({
|
|||||||
projects = [],
|
projects = [],
|
||||||
initialProjectId = null,
|
initialProjectId = null,
|
||||||
initialDate = null,
|
initialDate = null,
|
||||||
|
event = null,
|
||||||
onClose,
|
onClose,
|
||||||
onCreated,
|
onCreated,
|
||||||
|
onUpdated,
|
||||||
}: EventCreateModalProps) {
|
}: EventCreateModalProps) {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
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 [state, setState] = useState<EventState>('Final');
|
||||||
const [start, setStart] = useState(() => createDefaults(initialDate).start);
|
const [start, setStart] = useState(() => createDefaults(initialDate).start);
|
||||||
const [end, setEnd] = useState(() => createDefaults(initialDate).end);
|
const [end, setEnd] = useState(() => createDefaults(initialDate).end);
|
||||||
const [activePicker, setActivePicker] = useState<'startDate' | 'startTime' | 'endDate' | 'endTime' | null>(null);
|
const [activePicker, setActivePicker] = useState<'startDate' | 'startTime' | 'endDate' | 'endTime' | null>(null);
|
||||||
@@ -82,16 +88,19 @@ export function EventCreateModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
const defaults = createDefaults(initialDate);
|
const defaults = createDefaults(initialDate);
|
||||||
setName('');
|
const eventStart = event?.startDate ? new Date(event.startDate) : null;
|
||||||
setNotes('');
|
const eventEnd = event?.endDate ? new Date(event.endDate) : null;
|
||||||
setLink('');
|
setName(event?.name || '');
|
||||||
setStart(defaults.start);
|
setNotes(String(event?.notes || ''));
|
||||||
setEnd(defaults.end);
|
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);
|
setActivePicker(null);
|
||||||
setSelectedProjectId(initialProjectId);
|
setSelectedProjectId(relationId(event?.project) ?? initialProjectId);
|
||||||
setProjectSearch('');
|
setProjectSearch('');
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [initialDate, initialProjectId, visible]);
|
}, [event, initialDate, initialProjectId, visible]);
|
||||||
|
|
||||||
const selectedProject = useMemo(
|
const selectedProject = useMemo(
|
||||||
() => projects.find((project) => Number(project.id) === selectedProjectId) || null,
|
() => 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() {
|
async function save() {
|
||||||
if (!token || saving) return;
|
if (!token || saving) return;
|
||||||
const title = name.trim();
|
const title = name.trim();
|
||||||
@@ -146,18 +160,22 @@ export function EventCreateModal({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const event = await createEvent(token, {
|
const payload = {
|
||||||
name: title,
|
name: title,
|
||||||
startDate: start.toISOString(),
|
startDate: start.toISOString(),
|
||||||
endDate: end.toISOString(),
|
endDate: end.toISOString(),
|
||||||
project: selectedProjectId,
|
project: selectedProjectId,
|
||||||
customer: relationId(selectedProject?.customer),
|
customer: relationId(selectedProject?.customer) ?? relationId(event?.customer),
|
||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
link: link.trim() || null,
|
link: link.trim() || null,
|
||||||
state: 'Final',
|
state,
|
||||||
repeatInterval: 'Keine Wiederholung',
|
repeatInterval: event?.repeatInterval || 'Keine Wiederholung',
|
||||||
});
|
};
|
||||||
await onCreated(event);
|
const savedEvent = event
|
||||||
|
? await updateEvent(token, Number(event.id), payload)
|
||||||
|
: await createEvent(token, payload);
|
||||||
|
if (event) await onUpdated?.(savedEvent);
|
||||||
|
else await onCreated?.(savedEvent);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Der Termin konnte nicht gespeichert werden.');
|
setError(err instanceof Error ? err.message : 'Der Termin konnte nicht gespeichert werden.');
|
||||||
@@ -171,7 +189,7 @@ export function EventCreateModal({
|
|||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.keyboardWrap}>
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.keyboardWrap}>
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.title}>Neuer Termin</Text>
|
<Text style={styles.title}>{event ? 'Termin bearbeiten' : 'Neuer Termin'}</Text>
|
||||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.form} keyboardShouldPersistTaps="handled">
|
<ScrollView style={styles.scroll} contentContainerStyle={styles.form} keyboardShouldPersistTaps="handled">
|
||||||
<TextInput
|
<TextInput
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -184,41 +202,37 @@ export function EventCreateModal({
|
|||||||
|
|
||||||
<Text style={styles.label}>Beginn</Text>
|
<Text style={styles.label}>Beginn</Text>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('startDate')}>
|
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => openPicker('startDate')}>
|
||||||
<Text style={styles.pickerButtonText}>{formatDate(start)}</Text>
|
<Text style={styles.pickerButtonText}>{formatDate(start)}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('startTime')}>
|
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => openPicker('startTime')}>
|
||||||
<Text style={styles.pickerButtonText}>{formatTime(start)}</Text>
|
<Text style={styles.pickerButtonText}>{formatTime(start)}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.label}>Ende</Text>
|
<Text style={styles.label}>Ende</Text>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('endDate')}>
|
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => openPicker('endDate')}>
|
||||||
<Text style={styles.pickerButtonText}>{formatDate(end)}</Text>
|
<Text style={styles.pickerButtonText}>{formatDate(end)}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('endTime')}>
|
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => openPicker('endTime')}>
|
||||||
<Text style={styles.pickerButtonText}>{formatTime(end)}</Text>
|
<Text style={styles.pickerButtonText}>{formatTime(end)}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{activePicker ? (
|
<Text style={styles.label}>Status</Text>
|
||||||
<View style={styles.pickerWrap}>
|
<View style={styles.statusRow}>
|
||||||
<DateTimePicker
|
{(['Entwurf', 'Final'] as EventState[]).map((option) => (
|
||||||
value={activePicker.startsWith('start') ? start : end}
|
<Pressable
|
||||||
mode={activePicker.endsWith('Date') ? 'date' : 'time'}
|
key={option}
|
||||||
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
style={[styles.statusButton, state === option ? styles.statusButtonActive : null]}
|
||||||
locale="de-DE"
|
onPress={() => setState(option)}>
|
||||||
minuteInterval={5}
|
<Text style={[styles.statusButtonText, state === option ? styles.statusButtonTextActive : null]}>
|
||||||
onChange={updatePicker}
|
{option}
|
||||||
/>
|
</Text>
|
||||||
{Platform.OS === 'ios' ? (
|
</Pressable>
|
||||||
<Pressable style={styles.pickerDoneButton} onPress={() => setActivePicker(null)}>
|
))}
|
||||||
<Text style={styles.pickerDoneText}>Übernehmen</Text>
|
</View>
|
||||||
</Pressable>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Text style={styles.label}>Projekt</Text>
|
<Text style={styles.label}>Projekt</Text>
|
||||||
{selectedProject ? (
|
{selectedProject ? (
|
||||||
@@ -279,11 +293,45 @@ export function EventCreateModal({
|
|||||||
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
|
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable style={[styles.primaryButton, saving ? styles.disabled : null]} onPress={save} disabled={saving}>
|
<Pressable style={[styles.primaryButton, saving ? styles.disabled : null]} onPress={save} disabled={saving}>
|
||||||
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Anlegen'}</Text>
|
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : event ? 'Speichern' : 'Anlegen'}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
{activePicker && Platform.OS === 'ios' ? (
|
||||||
|
<View style={styles.pickerOverlay}>
|
||||||
|
<Pressable style={styles.pickerBackdrop} onPress={() => setActivePicker(null)} />
|
||||||
|
<View style={styles.pickerSheet}>
|
||||||
|
<View style={styles.pickerHeader}>
|
||||||
|
<Text style={styles.pickerTitle}>
|
||||||
|
{activePicker.startsWith('start') ? 'Beginn' : 'Ende'} · {activePicker.endsWith('Date') ? 'Datum' : 'Uhrzeit'}
|
||||||
|
</Text>
|
||||||
|
<Pressable style={styles.pickerDoneButton} onPress={() => setActivePicker(null)}>
|
||||||
|
<Text style={styles.pickerDoneText}>Fertig</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<DateTimePicker
|
||||||
|
value={activePicker.startsWith('start') ? start : end}
|
||||||
|
mode={activePicker.endsWith('Date') ? 'date' : 'time'}
|
||||||
|
display="spinner"
|
||||||
|
locale="de-DE"
|
||||||
|
minuteInterval={5}
|
||||||
|
onChange={updatePicker}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activePicker && Platform.OS === 'android' ? (
|
||||||
|
<DateTimePicker
|
||||||
|
value={activePicker.startsWith('start') ? start : end}
|
||||||
|
mode={activePicker.endsWith('Date') ? 'date' : 'time'}
|
||||||
|
display="default"
|
||||||
|
minuteInterval={5}
|
||||||
|
onChange={updatePicker}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
@@ -321,9 +369,38 @@ const styles = StyleSheet.create({
|
|||||||
pickerButtonText: { color: '#111827', fontSize: 15, fontWeight: '600' },
|
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 },
|
pickerOverlay: { ...StyleSheet.absoluteFillObject, justifyContent: 'flex-end', zIndex: 10 },
|
||||||
pickerDoneButton: { alignSelf: 'flex-end', paddingHorizontal: 10, paddingVertical: 7 },
|
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' },
|
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' },
|
multiline: { minHeight: 90, textAlignVertical: 'top' },
|
||||||
selectedProject: {
|
selectedProject: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
10
mobile/package-lock.json
generated
10
mobile/package-lock.json
generated
@@ -27,6 +27,7 @@
|
|||||||
"expo-router": "~6.0.24",
|
"expo-router": "~6.0.24",
|
||||||
"expo-secure-store": "^15.0.8",
|
"expo-secure-store": "^15.0.8",
|
||||||
"expo-share-intent": "^5.1.1",
|
"expo-share-intent": "^5.1.1",
|
||||||
|
"expo-sharing": "~14.0.8",
|
||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-symbols": "~1.0.8",
|
"expo-symbols": "~1.0.8",
|
||||||
@@ -6835,6 +6836,15 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/expo-splash-screen": {
|
||||||
"version": "31.0.13",
|
"version": "31.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"expo-router": "~6.0.24",
|
"expo-router": "~6.0.24",
|
||||||
"expo-secure-store": "^15.0.8",
|
"expo-secure-store": "^15.0.8",
|
||||||
"expo-share-intent": "^5.1.1",
|
"expo-share-intent": "^5.1.1",
|
||||||
|
"expo-sharing": "~14.0.8",
|
||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-symbols": "~1.0.8",
|
"expo-symbols": "~1.0.8",
|
||||||
|
|||||||
@@ -54,13 +54,15 @@ export type Project = {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EventState = 'Entwurf' | 'Final';
|
||||||
|
|
||||||
export type CalendarEvent = {
|
export type CalendarEvent = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate?: string | null;
|
endDate?: string | null;
|
||||||
eventtype?: string | null;
|
eventtype?: string | null;
|
||||||
state?: string | null;
|
state?: EventState | null;
|
||||||
repeatInterval?: string | null;
|
repeatInterval?: string | null;
|
||||||
project?: number | { id?: number; name?: string } | null;
|
project?: number | { id?: number; name?: string } | null;
|
||||||
customer?: number | { id?: number; name?: string } | null;
|
customer?: number | { id?: number; name?: string } | null;
|
||||||
@@ -724,7 +726,7 @@ export async function createEvent(
|
|||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
link?: string | null;
|
link?: string | null;
|
||||||
eventtype?: string;
|
eventtype?: string;
|
||||||
state?: 'Entwurf' | 'Final';
|
state?: EventState;
|
||||||
repeatInterval?: string;
|
repeatInterval?: string;
|
||||||
}
|
}
|
||||||
): Promise<CalendarEvent> {
|
): Promise<CalendarEvent> {
|
||||||
@@ -735,6 +737,18 @@ export async function createEvent(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateEvent(
|
||||||
|
token: string,
|
||||||
|
eventId: number,
|
||||||
|
payload: Partial<Pick<CalendarEvent, 'name' | 'startDate' | 'endDate' | 'project' | 'customer' | 'notes' | 'link' | 'eventtype' | 'state' | 'repeatInterval'>>
|
||||||
|
): Promise<CalendarEvent> {
|
||||||
|
return apiRequest<CalendarEvent>(`/api/resource/events/${eventId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
token,
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchEventById(token: string, eventId: number): Promise<CalendarEvent> {
|
export async function fetchEventById(token: string, eventId: number): Promise<CalendarEvent> {
|
||||||
return apiRequest<CalendarEvent>(`/api/resource/events/${eventId}`, { token });
|
return apiRequest<CalendarEvent>(`/api/resource/events/${eventId}`, { token });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
import { getApiBaseUrlSync } from '@/src/lib/server-config';
|
||||||
|
|
||||||
export function getOpenableFileUrl(rawUrl: string): string {
|
export function getOpenableFileUrl(rawUrl: string): string {
|
||||||
@@ -7,3 +11,28 @@ export function getOpenableFileUrl(rawUrl: string): string {
|
|||||||
const path = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`;
|
const path = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`;
|
||||||
return `${apiBaseUrl}${path}`;
|
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<void> {
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user