KI-AGENT: Kalender, Terminbearbeitung und Dateivorschau verbessern

This commit is contained in:
2026-09-08 23:08:36 +02:00
parent 718ecefd46
commit 2c1047e624
11 changed files with 390 additions and 74 deletions

View File

@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
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';
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() {
const { token, user } = useAuth();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
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>({
tasks: [],
openTasks: 0,
@@ -62,9 +76,11 @@ export default function DashboardScreen() {
setError(null);
try {
const [taskRows, spans] = await Promise.all([
const [taskRows, spans, eventRows, projectRows] = await Promise.all([
fetchTasks(token),
fetchStaffTimeSpans(token, currentUserId),
fetchEvents(token),
fetchProjects(token),
]);
const tasks = taskRows || [];
@@ -92,6 +108,8 @@ export default function DashboardScreen() {
pendingSubmissions,
todayMinutes,
});
setEvents(eventRows);
setProjects(projectRows);
} catch (err) {
setError(err instanceof Error ? err.message : 'Dashboard konnte nicht geladen werden.');
} finally {
@@ -112,6 +130,26 @@ export default function DashboardScreen() {
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 (
<ScrollView
contentContainerStyle={styles.container}
@@ -158,6 +196,60 @@ export default function DashboardScreen() {
</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}>
<Text style={styles.quickActionsTitle}>Schnellzugriff</Text>
<View style={styles.quickActionsRow}>
@@ -175,6 +267,14 @@ export default function DashboardScreen() {
</Pressable>
</View>
</View>
<EventCreateModal
visible={createEventOpen}
projects={projects}
initialDate={selectedCalendarDate}
onClose={() => setCreateEventOpen(false)}
onCreated={() => loadDashboard(false)}
/>
</>
) : null}
</ScrollView>
@@ -232,6 +332,35 @@ const styles = StyleSheet.create({
padding: 12,
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: {
color: '#111827',
fontSize: 15,

View File

@@ -14,7 +14,6 @@ import {
} from 'react-native';
import { router, useLocalSearchParams } from 'expo-router';
import * as DocumentPicker from 'expo-document-picker';
import * as WebBrowser from 'expo-web-browser';
import { BarcodeScanningResult, BarcodeType, CameraView, useCameraPermissions } from 'expo-camera';
import { HistorySection } from '@/components/history-section';
@@ -32,7 +31,7 @@ import {
uploadCustomerFile,
} from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
import { getOpenableFileUrl } from '@/src/lib/file-opening';
import { openAuthenticatedFile } from '@/src/lib/file-opening';
const PRIMARY = '#69c350';
@@ -276,13 +275,13 @@ export default function CustomerDetailScreen() {
}
async function onOpenFile(file: ProjectFile) {
if (!file.url) return;
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
controlsColor: PRIMARY,
showTitle: true,
enableDefaultShareMenuItem: true,
});
if (!token || !file.url) return;
setError(null);
try {
await openAuthenticatedFile(file, token);
} catch (err) {
setError(err instanceof Error ? err.message : 'Das Dokument konnte nicht geöffnet werden.');
}
}
async function onPickAndUpload() {

View File

@@ -3,8 +3,9 @@ import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, T
import { useLocalSearchParams } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
import { EventCreateModal } from '@/components/event-create-modal';
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';
const PRIMARY = '#69c350';
@@ -37,6 +38,9 @@ export default function EventDetailScreen() {
const eventId = Number(params.id);
const { token } = useAuth();
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 [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -47,7 +51,12 @@ export default function EventDetailScreen() {
if (showSpinner) setLoading(true);
setError(null);
try {
setEvent(await fetchEventById(token, eventId));
const [eventRow, projectRows] = await Promise.all([
fetchEventById(token, eventId),
fetchProjects(token),
]);
setEvent(eventRow);
setProjects(projectRows);
} catch (err) {
setError(err instanceof Error ? err.message : 'Der Termin konnte nicht geladen werden.');
} finally {
@@ -72,13 +81,26 @@ export default function EventDetailScreen() {
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
? [
{ label: 'Beginn', value: formatDateTime(event.startDate) },
{ label: 'Ende', value: formatDateTime(event.endDate) },
{ label: 'Projekt', value: relationName(event.project) },
{ label: 'Kunde', value: relationName(event.customer) },
{ label: 'Status', value: String(event.state || '-') },
{ label: 'Wiederholung', value: String(event.repeatInterval || '-') },
]
: [];
@@ -97,6 +119,25 @@ export default function EventDetailScreen() {
<View style={styles.headingRow}>
<View style={[styles.colorMark, { backgroundColor: event.color || PRIMARY }]} />
<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 style={styles.table}>
{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}
</View>
<HistorySection resource="events" resourceId={eventId} />
<EventCreateModal
visible={editOpen}
event={event}
projects={projects}
onClose={() => setEditOpen(false)}
onUpdated={() => load(false)}
/>
</>
) : null}
</ScrollView>
@@ -122,6 +170,15 @@ const styles = StyleSheet.create({
headingRow: { flexDirection: 'row', alignItems: 'center', gap: 9 },
colorMark: { width: 6, height: 28, borderRadius: 3 },
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' },
row: { borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingHorizontal: 10, paddingVertical: 8, gap: 2 },
label: { color: '#6b7280', fontSize: 12, textTransform: 'uppercase', fontWeight: '600' },

View File

@@ -134,6 +134,7 @@ export default function EventsScreen() {
{projectName(event) ? <Text style={styles.eventProject}>{projectName(event)}</Text> : null}
{event.notes ? <Text style={styles.eventNotes} numberOfLines={2}>{String(event.notes)}</Text> : null}
</View>
{event.state === 'Entwurf' ? <Text style={styles.eventDraft}>Entwurf</Text> : null}
<Text style={styles.arrow}>›</Text>
</Pressable>
))
@@ -183,6 +184,7 @@ const styles = StyleSheet.create({
eventDate: { color: '#374151', fontSize: 13 },
eventProject: { color: '#3d7a30', fontSize: 12, fontWeight: '600' },
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 },
loadingBox: { paddingVertical: 24, alignItems: 'center', gap: 8 },
loadingText: { color: '#6b7280' },

View File

@@ -3,12 +3,11 @@ import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, T
import { router, useLocalSearchParams } from 'expo-router';
import * as DocumentPicker from 'expo-document-picker';
import * as ImagePicker from 'expo-image-picker';
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';
import { openAuthenticatedFile } from '@/src/lib/file-opening';
const PRIMARY = '#69c350';
@@ -90,13 +89,13 @@ export default function PlantDetailScreen() {
}
async function onOpenFile(file: ProjectFile) {
if (!file.url) return;
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
controlsColor: PRIMARY,
showTitle: true,
enableDefaultShareMenuItem: true,
});
if (!token || !file.url) return;
setError(null);
try {
await openAuthenticatedFile(file, token);
} catch (err) {
setError(err instanceof Error ? err.message : 'Das Dokument konnte nicht geöffnet werden.');
}
}
async function onPickAndUpload() {

View File

@@ -15,7 +15,6 @@ import {
import { router, useLocalSearchParams } from 'expo-router';
import * as DocumentPicker from 'expo-document-picker';
import * as ImagePicker from 'expo-image-picker';
import * as WebBrowser from 'expo-web-browser';
import { EventCreateModal } from '@/components/event-create-modal';
import { HistorySection } from '@/components/history-section';
@@ -34,7 +33,7 @@ import {
updateTask,
} from '@/src/lib/api';
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 TASK_STATUS_ORDER: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen'];
@@ -193,13 +192,13 @@ export default function ProjectDetailScreen() {
}
async function onOpenFile(file: ProjectFile) {
if (!file.url) return;
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
controlsColor: PRIMARY,
showTitle: true,
enableDefaultShareMenuItem: true,
});
if (!token || !file.url) return;
setError(null);
try {
await openAuthenticatedFile(file, token);
} catch (err) {
setError(err instanceof Error ? err.message : 'Das Dokument konnte nicht geöffnet werden.');
}
}
async function onCompleteTask(task: Task) {