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