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:
parent
03cd92719c
commit
956f97b19f
2 changed files with 348 additions and 0 deletions
343
xtablo-expo/app/(app)/task/[id].tsx
Normal file
343
xtablo-expo/app/(app)/task/[id].tsx
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
5
xtablo-expo/app/(app)/task/_layout.tsx
Normal file
5
xtablo-expo/app/(app)/task/_layout.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function TaskLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue