feat(expo): add task detail screen with create/edit/delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-04-15 14:01:53 +02:00
parent 03cd92719c
commit 956f97b19f
No known key found for this signature in database
2 changed files with 348 additions and 0 deletions

View file

@ -0,0 +1,343 @@
import React, { useState, useEffect } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
ScrollView,
StyleSheet,
SafeAreaView,
Alert,
Platform,
} from "react-native";
import { useLocalSearchParams, router } from "expo-router";
import { ArrowLeft, User, Layers, Calendar } from "lucide-react-native";
import DateTimePicker from "@react-native-community/datetimepicker";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useColorScheme } from "@/hooks/useColorScheme";
import { useTasksByTablo, useCreateTask, useUpdateTask, useDeleteTask } from "@/hooks/tasks";
import { useTabloEtapes } from "@/hooks/etapes";
import { useTabloMembers } from "@/hooks/members";
import { TaskStatus, TASK_STATUSES } from "@/types/tasks.types";
import StatusControl from "@/components/tasks/StatusControl";
import AssigneePicker from "@/components/tasks/AssigneePicker";
import EtapePicker from "@/components/tasks/EtapePicker";
export default function TaskDetailScreen() {
const { id, tabloId } = useLocalSearchParams<{ id: string; tabloId: string }>();
const isCreateMode = id === "new";
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const bgColor = useThemeColor({ light: "#f8fafc", dark: "#111827" }, "background");
const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text");
const headerBg = useThemeColor({ light: "#ffffff", dark: "#1f2937" }, "background");
const inputBg = useThemeColor({ light: "#f1f5f9", dark: "#374151" }, "background");
const borderColor = isDark ? "#374151" : "#e5e7eb";
const subtextColor = useThemeColor({ light: "#6b7280", dark: "#9ca3af" }, "text");
// Data
const { data: tasks } = useTasksByTablo(tabloId);
const { data: etapes } = useTabloEtapes(tabloId);
const { data: members } = useTabloMembers(tabloId);
const existingTask = isCreateMode ? null : tasks?.find((t) => t.id === id);
// Form state
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState<TaskStatus>("todo");
const [assigneeId, setAssigneeId] = useState<string | null>(null);
const [parentTaskId, setParentTaskId] = useState<string | null>(null);
const [dueDate, setDueDate] = useState<Date | null>(null);
const [showDatePicker, setShowDatePicker] = useState(false);
// Picker visibility
const [assigneePickerVisible, setAssigneePickerVisible] = useState(false);
const [etapePickerVisible, setEtapePickerVisible] = useState(false);
// Mutations
const { mutate: createTask, isPending: isCreating } = useCreateTask();
const { mutate: updateTask, isPending: isUpdating } = useUpdateTask();
const { mutate: deleteTask, isPending: isDeleting } = useDeleteTask();
const isPending = isCreating || isUpdating || isDeleting;
// Populate form for edit mode
useEffect(() => {
if (existingTask) {
setTitle(existingTask.title);
setDescription(existingTask.description ?? "");
setStatus(existingTask.status);
setAssigneeId(existingTask.assignee_id);
setParentTaskId(existingTask.parent_task_id);
setDueDate(existingTask.due_date ? new Date(existingTask.due_date) : null);
}
}, [existingTask]);
const handleSave = () => {
if (!title.trim() || !tabloId) return;
const dueDateStr = dueDate
? `${dueDate.getFullYear()}-${String(dueDate.getMonth() + 1).padStart(2, "0")}-${String(dueDate.getDate()).padStart(2, "0")}`
: null;
if (isCreateMode) {
createTask(
{
tablo_id: tabloId,
title: title.trim(),
description: description.trim() || null,
status,
assignee_id: assigneeId,
parent_task_id: parentTaskId,
due_date: dueDateStr,
},
{ onSuccess: () => router.back() }
);
} else {
updateTask(
{
id: id!,
tabloId: tabloId!,
title: title.trim(),
description: description.trim() || null,
status,
assignee_id: assigneeId,
parent_task_id: parentTaskId,
due_date: dueDateStr,
},
{ onSuccess: () => router.back() }
);
}
};
const handleDelete = () => {
Alert.alert("Supprimer la tâche", "Cette action est irréversible.", [
{ text: "Annuler", style: "cancel" },
{
text: "Supprimer",
style: "destructive",
onPress: () => {
deleteTask({ id: id!, tabloId: tabloId! }, { onSuccess: () => router.back() });
},
},
]);
};
// Derived display values
const assigneeName = members?.find((m) => m.id === assigneeId)?.name ?? "Non assigné";
const etapeName = etapes?.find((e) => e.id === parentTaskId)?.title ?? "Sans Étape";
const dueDateDisplay = dueDate
? dueDate.toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" })
: "Aucune";
return (
<SafeAreaView style={[styles.container, { backgroundColor: bgColor }]}>
{/* Header */}
<View style={[styles.header, { backgroundColor: headerBg, borderBottomColor: borderColor }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<ArrowLeft size={22} color={textColor} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: textColor }]}>
{isCreateMode ? "Nouvelle tâche" : "Modifier la tâche"}
</Text>
</View>
<ScrollView style={styles.form} keyboardShouldPersistTaps="handled">
{/* Title */}
<Text style={[styles.label, { color: textColor }]}>Titre</Text>
<TextInput
style={[styles.input, { backgroundColor: inputBg, color: textColor }]}
value={title}
onChangeText={setTitle}
placeholder="Titre de la tâche"
placeholderTextColor="#9ca3af"
autoFocus={isCreateMode}
/>
{/* Description */}
<Text style={[styles.label, { color: textColor }]}>Description</Text>
<TextInput
style={[styles.input, styles.textArea, { backgroundColor: inputBg, color: textColor }]}
value={description}
onChangeText={setDescription}
placeholder="Description (optionnel)"
placeholderTextColor="#9ca3af"
multiline
numberOfLines={4}
/>
{/* Status */}
<Text style={[styles.label, { color: textColor }]}>Statut</Text>
<StatusControl value={status} onChange={setStatus} />
{/* Assignee */}
<Text style={[styles.label, { color: textColor }]}>Assigné à</Text>
<TouchableOpacity
style={[styles.pickerButton, { backgroundColor: inputBg }]}
onPress={() => setAssigneePickerVisible(true)}
>
<User size={16} color={subtextColor} />
<Text style={[styles.pickerText, { color: assigneeId ? textColor : subtextColor }]}>
{assigneeName}
</Text>
</TouchableOpacity>
{/* Etape */}
<Text style={[styles.label, { color: textColor }]}>Étape</Text>
<TouchableOpacity
style={[styles.pickerButton, { backgroundColor: inputBg }]}
onPress={() => setEtapePickerVisible(true)}
>
<Layers size={16} color={subtextColor} />
<Text style={[styles.pickerText, { color: parentTaskId ? textColor : subtextColor }]}>
{etapeName}
</Text>
</TouchableOpacity>
{/* Due date */}
<Text style={[styles.label, { color: textColor }]}>Date limite</Text>
<TouchableOpacity
style={[styles.pickerButton, { backgroundColor: inputBg }]}
onPress={() => setShowDatePicker(true)}
>
<Calendar size={16} color={subtextColor} />
<Text style={[styles.pickerText, { color: dueDate ? textColor : subtextColor }]}>
{dueDateDisplay}
</Text>
{dueDate && (
<TouchableOpacity onPress={() => setDueDate(null)}>
<Text style={{ color: "#ef4444", fontSize: 13 }}>Retirer</Text>
</TouchableOpacity>
)}
</TouchableOpacity>
{showDatePicker && (
<DateTimePicker
value={dueDate ?? new Date()}
mode="date"
display={Platform.OS === "ios" ? "spinner" : "default"}
onChange={(event, date) => {
setShowDatePicker(Platform.OS !== "ios");
if (date) setDueDate(date);
}}
/>
)}
{/* Save button */}
<TouchableOpacity
style={[styles.saveButton, !title.trim() && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={!title.trim() || isPending}
>
<Text style={styles.saveButtonText}>
{isPending ? "..." : isCreateMode ? "Créer" : "Enregistrer"}
</Text>
</TouchableOpacity>
{/* Delete button (edit mode only) */}
{!isCreateMode && (
<TouchableOpacity style={styles.deleteButton} onPress={handleDelete} disabled={isPending}>
<Text style={styles.deleteButtonText}>Supprimer la tâche</Text>
</TouchableOpacity>
)}
<View style={{ height: 40 }} />
</ScrollView>
{/* Pickers */}
<AssigneePicker
visible={assigneePickerVisible}
onClose={() => setAssigneePickerVisible(false)}
members={members ?? []}
selectedId={assigneeId}
onSelect={setAssigneeId}
/>
<EtapePicker
visible={etapePickerVisible}
onClose={() => setEtapePickerVisible(false)}
etapes={etapes ?? []}
selectedId={parentTaskId}
onSelect={setParentTaskId}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
gap: 10,
},
backButton: {
padding: 4,
},
headerTitle: {
fontSize: 18,
fontWeight: "700",
},
form: {
flex: 1,
padding: 16,
},
label: {
fontSize: 14,
fontWeight: "600",
marginTop: 16,
marginBottom: 6,
},
input: {
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
},
textArea: {
minHeight: 100,
textAlignVertical: "top",
},
pickerButton: {
flexDirection: "row",
alignItems: "center",
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
gap: 10,
},
pickerText: {
flex: 1,
fontSize: 15,
},
saveButton: {
backgroundColor: "#3b82f6",
borderRadius: 12,
paddingVertical: 14,
alignItems: "center",
marginTop: 24,
},
saveButtonDisabled: {
opacity: 0.5,
},
saveButtonText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "700",
},
deleteButton: {
borderRadius: 12,
paddingVertical: 14,
alignItems: "center",
marginTop: 12,
},
deleteButtonText: {
color: "#ef4444",
fontSize: 15,
fontWeight: "600",
},
});

View file

@ -0,0 +1,5 @@
import { Stack } from "expo-router";
export default function TaskLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}