Files
FEDEO/mobile/app/project/[id].tsx

883 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
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';
import {
CalendarEvent,
createProjectTask,
fetchProjectById,
fetchProjectEvents,
fetchProjectFiles,
fetchProjectTasks,
Project,
ProjectFile,
Task,
TaskStatus,
uploadProjectFile,
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'];
function normalizeTaskStatus(status: unknown): TaskStatus {
if (status === 'In Bearbeitung' || status === 'Abgeschlossen') return status;
return 'Offen';
}
function formatDateTime(value: unknown): string {
if (!value) return '-';
const date = new Date(String(value));
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function getRefName(value: unknown): string {
if (!value) return '-';
if (typeof value === 'object') {
const record = value as { name?: string; full_name?: string; email?: string; id?: string | number };
return String(record.name || record.full_name || record.email || record.id || '-');
}
return String(value);
}
export default function ProjectDetailScreen() {
const params = useLocalSearchParams<{ id?: string }>();
const projectId = Number(params.id);
const { token, user } = useAuth();
const [project, setProject] = useState<Project | null>(null);
const [files, setFiles] = useState<ProjectFile[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [uploading, setUploading] = useState(false);
const [creatingTask, setCreatingTask] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<number | null>(null);
const [createTaskModalOpen, setCreateTaskModalOpen] = useState(false);
const [createEventModalOpen, setCreateEventModalOpen] = useState(false);
const [createTaskError, setCreateTaskError] = useState<string | null>(null);
const [newTaskName, setNewTaskName] = useState('');
const [newTaskDescription, setNewTaskDescription] = useState('');
const [showCompletedTasks, setShowCompletedTasks] = useState(false);
const [error, setError] = useState<string | null>(null);
const validProjectId = useMemo(() => Number.isFinite(projectId) && projectId > 0, [projectId]);
const currentUserId = useMemo(() => (user?.id ? String(user.id) : null), [user]);
const infoRows = useMemo(() => {
if (!project) return [];
return [
{ label: 'Projektname', value: project.name || '-' },
{ label: 'Projekt-Nr.', value: String(project.projectNumber || '-') },
{ label: 'Aktive Phase', value: String(project.active_phase || '-') },
{ label: 'Kunde', value: getRefName(project.customer) },
{ label: 'Objekt', value: getRefName(project.plant) },
{ label: 'Vertrag', value: getRefName(project.contract) },
{ label: 'Projekttyp', value: getRefName(project.projecttype || project.projectType) },
{ label: 'Kunden-Ref.', value: String(project.customerRef || '-') },
{ label: 'Leistung', value: String(project.measure || '-') },
{ label: 'Erstellt', value: formatDateTime(project.createdAt || project.created_at) },
{ label: 'Aktualisiert', value: formatDateTime(project.updatedAt || project.updated_at) },
];
}, [project]);
const visibleTasks = useMemo(() => {
return (tasks || [])
.filter((task) => {
const status = normalizeTaskStatus(task.categorie);
return showCompletedTasks || status !== 'Abgeschlossen';
})
.sort((a, b) => {
const statusDiff =
TASK_STATUS_ORDER.indexOf(normalizeTaskStatus(a.categorie)) -
TASK_STATUS_ORDER.indexOf(normalizeTaskStatus(b.categorie));
if (statusDiff !== 0) return statusDiff;
return Number(b.id) - Number(a.id);
});
}, [showCompletedTasks, tasks]);
const load = useCallback(async (showSpinner = true) => {
if (!token || !validProjectId) return;
if (showSpinner) setLoading(true);
setError(null);
try {
const [projectData, fileData, taskData, eventData] = await Promise.all([
fetchProjectById(token, projectId),
fetchProjectFiles(token, projectId),
fetchProjectTasks(token, projectId),
fetchProjectEvents(token, projectId),
]);
setProject(projectData);
setFiles(fileData);
setTasks(taskData);
setEvents(eventData.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()));
} catch (err) {
setError(err instanceof Error ? err.message : 'Projektdaten konnten nicht geladen werden.');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [projectId, token, validProjectId]);
useEffect(() => {
if (!token || !validProjectId) return;
void load(true);
}, [load, token, validProjectId]);
async function onRefresh() {
setRefreshing(true);
await load(false);
}
function closeCreateTaskModal() {
setCreateTaskModalOpen(false);
setCreateTaskError(null);
setNewTaskName('');
setNewTaskDescription('');
}
async function onCreateTask() {
if (!token || !validProjectId || creatingTask) return;
const name = newTaskName.trim();
if (!name) {
setCreateTaskError('Bitte einen Aufgabentitel eingeben.');
return;
}
setCreatingTask(true);
setCreateTaskError(null);
setError(null);
try {
await createProjectTask(token, {
projectId,
name,
description: newTaskDescription.trim() || null,
userId: currentUserId,
});
closeCreateTaskModal();
await load(false);
} catch (err) {
setCreateTaskError(err instanceof Error ? err.message : 'Aufgabe konnte nicht erstellt werden.');
} finally {
setCreatingTask(false);
}
}
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,
});
}
async function onCompleteTask(task: Task) {
if (!token || !task?.id) return;
if (normalizeTaskStatus(task.categorie) === 'Abgeschlossen') return;
setUpdatingTaskId(Number(task.id));
setError(null);
try {
await updateTask(token, Number(task.id), { categorie: 'Abgeschlossen' });
setTasks((prev) =>
prev.map((item) => (Number(item.id) === Number(task.id) ? { ...item, categorie: 'Abgeschlossen' } : item))
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Aufgabe konnte nicht abgeschlossen werden.');
} finally {
setUpdatingTaskId(null);
}
}
async function onPickAndUpload() {
if (!token || !validProjectId || uploading) return;
const result = await DocumentPicker.getDocumentAsync({
multiple: false,
copyToCacheDirectory: true,
type: ['image/*', 'application/pdf', '*/*'],
});
if (result.canceled || !result.assets?.length) return;
const asset = result.assets[0];
const filename = asset.name || `upload-${Date.now()}`;
setUploading(true);
setError(null);
try {
await uploadProjectFile(token, {
projectId,
uri: asset.uri,
filename,
mimeType: asset.mimeType || 'application/octet-stream',
});
await load(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen.');
} finally {
setUploading(false);
}
}
async function uploadImageFromUri(uri: string, filename: string, mimeType?: string) {
if (!token || !validProjectId) return;
setUploading(true);
setError(null);
try {
await uploadProjectFile(token, {
projectId,
uri,
filename,
mimeType: mimeType || 'image/jpeg',
});
await load(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen.');
} finally {
setUploading(false);
}
}
async function onPickImage() {
if (!token || !validProjectId || uploading) return;
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
setError('Bitte erlaube den Zugriff auf deine Fotos.');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
quality: 0.85,
allowsEditing: false,
});
if (result.canceled || !result.assets?.length) return;
const asset = result.assets[0];
const filename = asset.fileName || `bild-${Date.now()}.jpg`;
await uploadImageFromUri(asset.uri, filename, asset.mimeType || 'image/jpeg');
}
async function onTakePhoto() {
if (!token || !validProjectId || uploading) return;
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (!permission.granted) {
setError('Bitte erlaube den Zugriff auf die Kamera.');
return;
}
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ['images'],
quality: 0.85,
allowsEditing: false,
});
if (result.canceled || !result.assets?.length) return;
const asset = result.assets[0];
const filename = asset.fileName || `foto-${Date.now()}.jpg`;
await uploadImageFromUri(asset.uri, filename, asset.mimeType || 'image/jpeg');
}
if (!validProjectId) {
return (
<View style={styles.centered}>
<Text style={styles.error}>Ungültige Projekt-ID.</Text>
</View>
);
}
return (
<ScrollView
contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Projekt wird geladen...</Text>
</View>
) : null}
{!loading && project ? (
<>
<View style={styles.card}>
<View style={styles.sectionHeader}>
<Text style={styles.title}>Informationen</Text>
<Pressable
style={styles.smallPrimaryButton}
onPress={() =>
router.push({
pathname: '/more/wiki',
params: {
entityType: 'projects',
entityId: String(projectId),
title: `Projekt-Wiki: ${String(project.name || projectId)}`,
},
})
}>
<Text style={styles.smallPrimaryButtonText}>Wiki</Text>
</Pressable>
</View>
<View style={styles.infoTable}>
{infoRows.map((row) => (
<View key={row.label} style={styles.infoRow}>
<Text style={styles.infoLabel}>{row.label}</Text>
<Text style={styles.infoValue}>{row.value}</Text>
</View>
))}
</View>
{project.notes ? (
<View style={styles.notesBox}>
<Text style={styles.notesLabel}>Notizen</Text>
<Text style={styles.notes}>{String(project.notes)}</Text>
</View>
) : null}
</View>
<HistorySection resource="projects" resourceId={projectId} />
<View style={styles.card}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Termine ({events.length})</Text>
<Pressable style={styles.smallPrimaryButton} onPress={() => setCreateEventModalOpen(true)}>
<Text style={styles.smallPrimaryButtonText}>Neuer Termin</Text>
</Pressable>
</View>
{events.length === 0 ? (
<Text style={styles.empty}>Keine Termine für dieses Projekt vorhanden.</Text>
) : (
events.map((event) => (
<Pressable
key={String(event.id)}
style={styles.eventRow}
onPress={() => router.push(`/more/event/${event.id}` as any)}>
<View style={[styles.eventColor, { backgroundColor: event.color || PRIMARY }]} />
<View style={styles.eventMain}>
<Text style={styles.eventName}>{event.name}</Text>
<Text style={styles.eventDate}>
{formatDateTime(event.startDate)}
{event.endDate ? ` ${formatDateTime(event.endDate)}` : ''}
</Text>
</View>
<Text style={styles.eventArrow}></Text>
</Pressable>
))
)}
</View>
<View style={styles.card}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Aufgaben ({tasks.length})</Text>
<View style={styles.sectionHeaderActions}>
<Pressable
style={[styles.filterChip, showCompletedTasks ? styles.filterChipActive : null]}
onPress={() => setShowCompletedTasks((prev) => !prev)}>
<Text style={[styles.filterChipText, showCompletedTasks ? styles.filterChipTextActive : null]}>
Abgeschlossene anzeigen
</Text>
</Pressable>
<Pressable style={styles.smallPrimaryButton} onPress={() => setCreateTaskModalOpen(true)}>
<Text style={styles.smallPrimaryButtonText}>Neue Aufgabe</Text>
</Pressable>
</View>
</View>
{visibleTasks.length === 0 ? (
<Text style={styles.empty}>Keine Aufgaben vorhanden.</Text>
) : (
visibleTasks.map((task) => {
const status = normalizeTaskStatus(task.categorie);
const isUpdatingTask = updatingTaskId === Number(task.id);
const canComplete = status !== 'Abgeschlossen';
return (
<View key={String(task.id)} style={styles.taskRow}>
<View style={styles.taskRowMain}>
<Text style={styles.taskTitle} numberOfLines={2}>
{task.name || '-'}
</Text>
{task.description ? (
<Text style={styles.taskDescription} numberOfLines={3}>
{String(task.description)}
</Text>
) : null}
</View>
<View style={styles.taskRowFooter}>
<Text style={styles.statusBadge}>{status}</Text>
{canComplete ? (
<Pressable
style={[styles.completeButton, isUpdatingTask ? styles.buttonDisabled : null]}
onPress={() => onCompleteTask(task)}
disabled={isUpdatingTask}>
<Text style={styles.completeButtonText}>{isUpdatingTask ? '...' : 'Abhaken'}</Text>
</Pressable>
) : null}
</View>
</View>
);
})
)}
</View>
<View style={styles.card}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Dokumente ({files.length})</Text>
</View>
<View style={styles.uploadActions}>
<Pressable
style={[styles.uploadButton, uploading ? styles.buttonDisabled : null]}
onPress={onTakePhoto}
disabled={uploading}>
<Text style={styles.uploadButtonText}>{uploading ? 'Upload...' : 'Foto aufnehmen'}</Text>
</Pressable>
<Pressable
style={[styles.uploadButton, uploading ? styles.buttonDisabled : null]}
onPress={onPickImage}
disabled={uploading}>
<Text style={styles.uploadButtonText}>{uploading ? 'Upload...' : 'Bild auswählen'}</Text>
</Pressable>
<Pressable
style={[styles.uploadButton, uploading ? styles.buttonDisabled : null]}
onPress={onPickAndUpload}
disabled={uploading}>
<Text style={styles.uploadButtonText}>{uploading ? 'Upload...' : 'Dokument hochladen'}</Text>
</Pressable>
</View>
{files.length === 0 ? (
<Text style={styles.empty}>Noch keine Dokumente vorhanden.</Text>
) : (
files.map((file) => (
<Pressable key={file.id} style={styles.fileRow} onPress={() => onOpenFile(file)}>
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={2}>
{file.name || file.path?.split('/').pop() || file.id}
</Text>
<Text style={styles.fileMeta}>{file.mimeType || 'Datei'}</Text>
</View>
</Pressable>
))
)}
</View>
</>
) : null}
<Modal visible={createTaskModalOpen} transparent animationType="fade" onRequestClose={closeCreateTaskModal}>
<View style={styles.modalOverlay}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.modalKeyboardWrap}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Neue Projekt-Aufgabe</Text>
<TextInput
placeholder="Titel"
placeholderTextColor="#9ca3af"
style={styles.input}
value={newTaskName}
onChangeText={setNewTaskName}
/>
<TextInput
placeholder="Beschreibung (optional)"
placeholderTextColor="#9ca3af"
style={[styles.input, styles.inputMultiline]}
multiline
value={newTaskDescription}
onChangeText={setNewTaskDescription}
/>
{createTaskError ? <Text style={styles.error}>{createTaskError}</Text> : null}
<View style={styles.modalActions}>
<Pressable
style={[styles.secondaryButton, creatingTask ? styles.buttonDisabled : null]}
onPress={closeCreateTaskModal}
disabled={creatingTask}>
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
</Pressable>
<Pressable
style={[styles.primaryButton, creatingTask ? styles.buttonDisabled : null]}
onPress={onCreateTask}
disabled={creatingTask}>
<Text style={styles.primaryButtonText}>{creatingTask ? 'Speichere...' : 'Anlegen'}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
<EventCreateModal
visible={createEventModalOpen}
projects={project ? [project] : []}
initialProjectId={projectId}
onClose={() => setCreateEventModalOpen(false)}
onCreated={() => load(false)}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
gap: 12,
backgroundColor: '#f9fafb',
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
card: {
backgroundColor: '#ffffff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
padding: 12,
gap: 8,
},
title: {
color: '#111827',
fontSize: 18,
fontWeight: '700',
},
infoTable: {
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 10,
overflow: 'hidden',
},
infoRow: {
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
paddingHorizontal: 10,
paddingVertical: 8,
gap: 2,
backgroundColor: '#ffffff',
},
infoLabel: {
color: '#6b7280',
fontSize: 12,
textTransform: 'uppercase',
fontWeight: '600',
},
infoValue: {
color: '#111827',
fontSize: 14,
fontWeight: '500',
},
notesBox: {
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 10,
padding: 10,
backgroundColor: '#fafafa',
gap: 4,
},
notesLabel: {
color: '#6b7280',
fontSize: 12,
fontWeight: '600',
textTransform: 'uppercase',
},
notes: {
color: '#374151',
fontSize: 14,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 10,
},
sectionTitle: {
color: '#111827',
fontSize: 16,
fontWeight: '700',
},
eventRow: {
minHeight: 60,
flexDirection: 'row',
alignItems: 'stretch',
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 9,
overflow: 'hidden',
},
eventColor: {
width: 5,
},
eventMain: {
flex: 1,
padding: 9,
gap: 2,
},
eventName: {
color: '#111827',
fontSize: 14,
fontWeight: '700',
},
eventDate: {
color: '#6b7280',
fontSize: 12,
},
eventArrow: {
alignSelf: 'center',
color: '#9ca3af',
fontSize: 24,
paddingRight: 9,
},
sectionHeaderActions: {
alignItems: 'flex-end',
gap: 8,
},
filterChip: {
borderRadius: 999,
borderWidth: 1,
borderColor: '#d1d5db',
paddingHorizontal: 10,
paddingVertical: 6,
backgroundColor: '#ffffff',
},
filterChipActive: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
filterChipText: {
color: '#374151',
fontSize: 12,
fontWeight: '500',
},
filterChipTextActive: {
color: '#3d7a30',
},
smallPrimaryButton: {
minHeight: 34,
borderRadius: 8,
backgroundColor: PRIMARY,
paddingHorizontal: 11,
alignItems: 'center',
justifyContent: 'center',
},
smallPrimaryButtonText: {
color: '#ffffff',
fontWeight: '700',
fontSize: 12,
},
taskRow: {
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 10,
padding: 10,
gap: 8,
},
taskRowMain: {
gap: 4,
},
taskTitle: {
color: '#111827',
fontSize: 14,
fontWeight: '600',
},
taskDescription: {
color: '#374151',
fontSize: 13,
},
statusBadge: {
alignSelf: 'flex-start',
color: '#3d7a30',
backgroundColor: '#eff9ea',
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 4,
fontSize: 12,
overflow: 'hidden',
},
taskRowFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 8,
},
completeButton: {
minHeight: 28,
borderRadius: 8,
borderWidth: 1,
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
paddingHorizontal: 10,
alignItems: 'center',
justifyContent: 'center',
},
completeButtonText: {
color: '#3d7a30',
fontSize: 12,
fontWeight: '700',
},
uploadButton: {
minHeight: 38,
borderRadius: 9,
backgroundColor: PRIMARY,
paddingHorizontal: 12,
alignItems: 'center',
justifyContent: 'center',
},
uploadActions: {
gap: 8,
},
uploadButtonText: {
color: '#ffffff',
fontWeight: '700',
fontSize: 13,
},
fileRow: {
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 10,
padding: 10,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 10,
},
fileInfo: {
flex: 1,
minWidth: 0,
},
fileName: {
color: '#111827',
fontSize: 14,
fontWeight: '600',
},
fileMeta: {
color: '#6b7280',
fontSize: 12,
marginTop: 2,
},
empty: {
color: '#6b7280',
fontSize: 13,
paddingVertical: 4,
},
loadingBox: {
paddingVertical: 20,
alignItems: 'center',
gap: 8,
},
loadingText: {
color: '#6b7280',
},
error: {
color: '#dc2626',
fontSize: 13,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.35)',
justifyContent: 'center',
padding: 16,
},
modalKeyboardWrap: {
width: '100%',
},
modalCard: {
backgroundColor: '#ffffff',
borderRadius: 14,
padding: 14,
gap: 10,
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
color: '#111827',
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
color: '#111827',
backgroundColor: '#ffffff',
},
inputMultiline: {
minHeight: 72,
textAlignVertical: 'top',
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 8,
marginTop: 2,
},
secondaryButton: {
minHeight: 42,
borderRadius: 10,
paddingHorizontal: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#e5e7eb',
},
secondaryButtonText: {
color: '#111827',
fontWeight: '600',
},
primaryButton: {
minHeight: 42,
borderRadius: 10,
paddingHorizontal: 14,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonText: {
color: '#ffffff',
fontWeight: '600',
},
buttonDisabled: {
opacity: 0.6,
},
});