Files
FEDEO/mobile/app/project/[id].tsx
2026-03-16 20:46:26 +01:00

803 lines
23 KiB
TypeScript

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 {
createProjectTask,
fetchProjectById,
fetchProjectFiles,
fetchProjectTasks,
Project,
ProjectFile,
Task,
TaskStatus,
uploadProjectFile,
updateTask,
} from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
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 [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 [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] = await Promise.all([
fetchProjectById(token, projectId),
fetchProjectFiles(token, projectId),
fetchProjectTasks(token, projectId),
]);
setProject(projectData);
setFiles(fileData);
setTasks(taskData);
} 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(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>
<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>
</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',
},
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,
},
});