From a00d95f8becf2557ec7fa2339241fab7963daf40 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 09:48:55 +0200 Subject: [PATCH] feat(expo): add TaskList component with etape grouping --- xtablo-expo/components/tasks/TaskList.tsx | 219 ++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 xtablo-expo/components/tasks/TaskList.tsx diff --git a/xtablo-expo/components/tasks/TaskList.tsx b/xtablo-expo/components/tasks/TaskList.tsx new file mode 100644 index 0000000..156706e --- /dev/null +++ b/xtablo-expo/components/tasks/TaskList.tsx @@ -0,0 +1,219 @@ +import React, { useMemo, useState } from "react"; +import { View, Text, ScrollView, RefreshControl, StyleSheet, TouchableOpacity } from "react-native"; +import { router } from "expo-router"; +import { Plus } from "lucide-react-native"; +import { useThemeColor } from "@/hooks/useThemeColor"; +import { useColorScheme } from "@/hooks/useColorScheme"; +import { useTasksByTablo } from "@/hooks/tasks"; +import { useTabloEtapes } from "@/hooks/etapes"; +import { useDeleteTask } from "@/hooks/tasks"; +import { useDeleteEtape } from "@/hooks/etapes"; +import { Task, Etape, TASK_STATUSES, TaskStatus } from "@/types/tasks.types"; +import EtapeSection from "./EtapeSection"; +import TaskRow from "./TaskRow"; + +type TaskListProps = { + tabloId: string; + onEditEtape: (etape: Etape) => void; + onCreateEtape: () => void; +}; + +type GroupedTasks = { + etape: Etape | null; + tasks: Task[]; +}; + +function groupTasksByEtape(tasks: Task[], etapes: Etape[]): GroupedTasks[] { + const groups: GroupedTasks[] = []; + + // One section per etape, ordered by position + for (const etape of etapes) { + const etapeTasks = tasks.filter((t) => t.parent_task_id === etape.id); + groups.push({ etape, tasks: etapeTasks }); + } + + // "Sans Étape" section for orphaned tasks + const orphaned = tasks.filter((t) => !t.parent_task_id); + if (orphaned.length > 0 || etapes.length === 0) { + groups.push({ etape: null, tasks: orphaned }); + } + + return groups; +} + +function sortTasksByStatus(tasks: Task[]): { status: TaskStatus; label: string; color: string; tasks: Task[] }[] { + return TASK_STATUSES + .map((s) => ({ + ...s, + tasks: tasks.filter((t) => t.status === s.value), + })) + .filter((g) => g.tasks.length > 0); +} + +export default function TaskList({ tabloId, onEditEtape, onCreateEtape }: TaskListProps) { + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text"); + const subtextColor = useThemeColor({ light: "#6b7280", dark: "#9ca3af" }, "text"); + + const { data: tasks, isLoading: tasksLoading, refetch: refetchTasks } = useTasksByTablo(tabloId); + const { data: etapes, isLoading: etapesLoading, refetch: refetchEtapes } = useTabloEtapes(tabloId); + const { mutate: deleteTask } = useDeleteTask(); + const { mutate: deleteEtape } = useDeleteEtape(); + + const [collapsedSections, setCollapsedSections] = useState>(new Set()); + const [refreshing, setRefreshing] = useState(false); + + const groups = useMemo( + () => groupTasksByEtape(tasks ?? [], etapes ?? []), + [tasks, etapes] + ); + + const toggleSection = (key: string) => { + setCollapsedSections((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const onRefresh = async () => { + setRefreshing(true); + await Promise.all([refetchTasks(), refetchEtapes()]); + setRefreshing(false); + }; + + const handleTaskPress = (task: Task) => { + router.push(`/task/${task.id}?tabloId=${tabloId}`); + }; + + const handleDeleteEtape = (etape: Etape) => { + deleteEtape({ id: etape.id, tabloId }); + }; + + const isLoading = tasksLoading || etapesLoading; + + if (isLoading && !tasks) { + return ( + + Chargement... + + ); + } + + return ( + + } + > + {/* Create etape button */} + + + Nouvelle étape + + + {groups.map((group) => { + const sectionKey = group.etape?.id ?? "no-etape"; + const isCollapsed = collapsedSections.has(sectionKey); + const statusGroups = sortTasksByStatus(group.tasks); + + return ( + toggleSection(sectionKey)} + onEdit={group.etape ? onEditEtape : undefined} + onDelete={group.etape ? handleDeleteEtape : undefined} + > + {statusGroups.map((sg) => ( + + + + + {sg.label} ({sg.tasks.length}) + + + {sg.tasks.map((task) => ( + + ))} + + ))} + {group.tasks.length === 0 && ( + + Aucune tâche + + )} + + ); + })} + + {groups.length === 0 && ( + + Aucune tâche + + Créez votre première tâche avec le bouton + + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + centered: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingTop: 40, + }, + addEtapeButton: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: 16, + paddingVertical: 10, + }, + addEtapeText: { + color: "#3b82f6", + fontSize: 14, + fontWeight: "600", + }, + statusHeader: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: 16, + paddingVertical: 6, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + statusLabel: { + fontSize: 12, + fontWeight: "600", + textTransform: "uppercase", + }, + emptySection: { + paddingHorizontal: 16, + paddingVertical: 12, + }, + emptyState: { + alignItems: "center", + paddingTop: 60, + gap: 8, + }, + emptyTitle: { + fontSize: 17, + fontWeight: "600", + }, +});