diff --git a/docs/superpowers/plans/2026-04-15-expo-tasks-etapes.md b/docs/superpowers/plans/2026-04-15-expo-tasks-etapes.md new file mode 100644 index 0000000..8945eb6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-expo-tasks-etapes.md @@ -0,0 +1,2303 @@ +# Expo Tasks & Etapes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add task and etape (stage) management to the Expo mobile app, with a tablo detail screen, task list grouped by etape, task CRUD, and etape CRUD — all backed by the existing Supabase database. + +**Architecture:** New tablo detail screen as entry point from tablo list. Tasks fetched directly from Supabase `tasks_with_assignee` view. Etapes are tasks with `is_parent: true`. New React Query hooks mirror the web app pattern. Components use existing theme/styling conventions (useThemeColor, Modal, FlatList with RefreshControl). + +**Tech Stack:** Expo Router (file-based routing), React Query v5, Supabase JS client, React Native (Modal, FlatList, Alert), lucide-react-native icons, expo DateTimePicker. + +--- + +## File Structure + +### Types +- **Create:** `xtablo-expo/types/tasks.types.ts` — Task, Etape, TaskInsert, TaskUpdate, TaskStatus types + +### Hooks +- **Create:** `xtablo-expo/hooks/tasks.ts` — useTasksByTablo, useCreateTask, useUpdateTask, useDeleteTask +- **Create:** `xtablo-expo/hooks/etapes.ts` — useTabloEtapes, useCreateEtape, useUpdateEtape, useDeleteEtape +- **Create:** `xtablo-expo/hooks/members.ts` — useTabloMembers (read-only) + +### Screens +- **Create:** `xtablo-expo/app/(app)/tablo/_layout.tsx` — Stack layout for tablo routes +- **Create:** `xtablo-expo/app/(app)/tablo/[id].tsx` — Tablo detail screen with task list +- **Create:** `xtablo-expo/app/(app)/task/_layout.tsx` — Stack layout for task routes +- **Create:** `xtablo-expo/app/(app)/task/[id].tsx` — Task create/edit screen + +### Components +- **Create:** `xtablo-expo/components/tasks/TaskList.tsx` — SectionList with etape sections +- **Create:** `xtablo-expo/components/tasks/TaskRow.tsx` — Single task row +- **Create:** `xtablo-expo/components/tasks/EtapeSection.tsx` — Collapsible etape header +- **Create:** `xtablo-expo/components/tasks/EtapeSheet.tsx` — Modal for etape create/edit +- **Create:** `xtablo-expo/components/tasks/StatusControl.tsx` — Segmented status picker +- **Create:** `xtablo-expo/components/tasks/AssigneePicker.tsx` — Modal for assignee selection +- **Create:** `xtablo-expo/components/tasks/EtapePicker.tsx` — Modal for etape selection + +### Modified Files +- **Modify:** `xtablo-expo/types/database.types.ts` — Add tasks table, tasks_with_assignee view, task_status enum +- **Modify:** `xtablo-expo/app/(app)/_layout.tsx` — Register tablo and task Stack.Screens +- **Modify:** `xtablo-expo/app/(app)/(tabs)/tablos.tsx` — Navigate to tablo detail on tap instead of channel + +--- + +### Task 1: Update Database Types + +**Files:** +- Modify: `xtablo-expo/types/database.types.ts` + +The Expo app's database types file is outdated — it has no `tasks` table, no `tasks_with_assignee` view, and no `task_status` enum. We need to add them. + +- [ ] **Step 1: Add task_status enum to Enums section** + +Open `xtablo-expo/types/database.types.ts`. Find the `Enums` section (line ~360) which currently only has `devis_status`. Add `task_status` after it: + +```typescript + Enums: { + devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired"; + task_status: "todo" | "in_progress" | "in_review" | "done"; + }; +``` + +- [ ] **Step 2: Add tasks table to Tables section** + +In the `Tables` section (after the last existing table, before the closing `};` of Tables), add: + +```typescript + tasks: { + Row: { + assignee_id: string | null; + created_at: string; + deleted_at: string | null; + description: string | null; + due_date: string | null; + id: string; + is_parent: boolean; + parent_task_id: string | null; + position: number; + status: Database["public"]["Enums"]["task_status"]; + tablo_id: string; + title: string; + updated_at: string; + }; + Insert: { + assignee_id?: string | null; + created_at?: string; + deleted_at?: string | null; + description?: string | null; + due_date?: string | null; + id?: string; + is_parent?: boolean; + parent_task_id?: string | null; + position?: number; + status?: Database["public"]["Enums"]["task_status"]; + tablo_id: string; + title: string; + updated_at?: string; + }; + Update: { + assignee_id?: string | null; + created_at?: string; + deleted_at?: string | null; + description?: string | null; + due_date?: string | null; + id?: string; + is_parent?: boolean; + parent_task_id?: string | null; + position?: number; + status?: Database["public"]["Enums"]["task_status"]; + tablo_id?: string; + title?: string; + updated_at?: string; + }; + Relationships: []; + }; +``` + +- [ ] **Step 3: Add tasks_with_assignee view to Views section** + +In the `Views` section, add: + +```typescript + tasks_with_assignee: { + Row: { + assignee_avatar: string | null; + assignee_id: string | null; + assignee_name: string | null; + created_at: string | null; + description: string | null; + due_date: string | null; + id: string | null; + is_parent: boolean | null; + parent_task_id: string | null; + position: number | null; + status: Database["public"]["Enums"]["task_status"] | null; + tablo_id: string | null; + title: string | null; + updated_at: string | null; + }; + Relationships: []; + }; +``` + +- [ ] **Step 4: Verify types compile** + +Run: `cd xtablo-expo && npx tsc --noEmit --pretty 2>&1 | head -20` +Expected: No errors related to tasks types (pre-existing errors are fine). + +- [ ] **Step 5: Commit** + +```bash +git add xtablo-expo/types/database.types.ts +git commit -m "feat(expo): add tasks and tasks_with_assignee database types" +``` + +--- + +### Task 2: Create Task and Etape Types + +**Files:** +- Create: `xtablo-expo/types/tasks.types.ts` + +- [ ] **Step 1: Create the types file** + +Create `xtablo-expo/types/tasks.types.ts`: + +```typescript +import { Database, Tables, TablesInsert, TablesUpdate } from "@/types/database.types"; +import { RemoveNullFromObject } from "@/types/removeNull"; + +export type TaskStatus = Database["public"]["Enums"]["task_status"]; + +export type Task = RemoveNullFromObject< + Tables<"tasks_with_assignee">, + "id" | "tablo_id" | "is_parent" | "title" | "status" | "position" | "created_at" | "updated_at" +>; + +export type Etape = RemoveNullFromObject< + Tables<"tasks">, + "id" | "tablo_id" | "title" | "is_parent" +>; + +export type TaskInsert = TablesInsert<"tasks">; +export type TaskUpdate = TablesUpdate<"tasks">; + +export const TASK_STATUSES: { value: TaskStatus; label: string; color: string }[] = [ + { value: "todo", label: "À faire", color: "#3b82f6" }, + { value: "in_progress", label: "En cours", color: "#eab308" }, + { value: "in_review", label: "Vérification", color: "#a855f7" }, + { value: "done", label: "Terminé", color: "#22c55e" }, +]; +``` + +- [ ] **Step 2: Verify types compile** + +Run: `cd xtablo-expo && npx tsc --noEmit --pretty 2>&1 | grep tasks` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add xtablo-expo/types/tasks.types.ts +git commit -m "feat(expo): add task and etape type definitions" +``` + +--- + +### Task 3: Create Task Hooks + +**Files:** +- Create: `xtablo-expo/hooks/tasks.ts` + +- [ ] **Step 1: Create the hooks file** + +Create `xtablo-expo/hooks/tasks.ts`: + +```typescript +import { supabase } from "@/lib/supabase"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Task, TaskInsert, TaskUpdate } from "@/types/tasks.types"; +import { Alert } from "react-native"; + +export const useTasksByTablo = (tabloId: string | undefined) => { + return useQuery({ + queryKey: ["tasks", "tablo", tabloId], + queryFn: async () => { + const { data, error } = await supabase + .from("tasks_with_assignee") + .select("*") + .eq("tablo_id", tabloId!) + .eq("is_parent", false) + .order("position", { ascending: true }); + + if (error) throw error; + return data as Task[]; + }, + enabled: !!tabloId, + }); +}; + +export const useCreateTask = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (task: TaskInsert) => { + const { data, error } = await supabase + .from("tasks") + .insert({ + tablo_id: task.tablo_id, + title: task.title, + description: task.description ?? null, + status: task.status || "todo", + assignee_id: task.assignee_id ?? null, + position: task.position ?? 0, + parent_task_id: task.parent_task_id ?? null, + is_parent: false, + due_date: task.due_date ?? null, + }) + .select() + .single(); + + if (error) throw error; + return data; + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ["tasks", "tablo", variables.tablo_id] }); + }, + onError: () => { + Alert.alert("Erreur", "Impossible de créer la tâche."); + }, + }); +}; + +export const useUpdateTask = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, tabloId, ...updates }: TaskUpdate & { id: string; tabloId: string }) => { + const { data, error } = await supabase + .from("tasks") + .update(updates) + .eq("id", id) + .select() + .single(); + + if (error) throw error; + return data; + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ["tasks", "tablo", variables.tabloId] }); + }, + onError: () => { + Alert.alert("Erreur", "Impossible de modifier la tâche."); + }, + }); +}; + +export const useDeleteTask = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, tabloId }: { id: string; tabloId: string }) => { + const { error } = await supabase.from("tasks").delete().eq("id", id); + if (error) throw error; + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ["tasks", "tablo", variables.tabloId] }); + }, + onError: () => { + Alert.alert("Erreur", "Impossible de supprimer la tâche."); + }, + }); +}; +``` + +- [ ] **Step 2: Verify types compile** + +Run: `cd xtablo-expo && npx tsc --noEmit --pretty 2>&1 | grep "hooks/tasks"` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add xtablo-expo/hooks/tasks.ts +git commit -m "feat(expo): add task CRUD hooks" +``` + +--- + +### Task 4: Create Etape Hooks + +**Files:** +- Create: `xtablo-expo/hooks/etapes.ts` + +- [ ] **Step 1: Create the hooks file** + +Create `xtablo-expo/hooks/etapes.ts`: + +```typescript +import { supabase } from "@/lib/supabase"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Etape } from "@/types/tasks.types"; +import { Alert } from "react-native"; + +export const useTabloEtapes = (tabloId: string | undefined) => { + return useQuery({ + queryKey: ["tablo-etapes", tabloId], + queryFn: async () => { + const { data, error } = await supabase + .from("tasks") + .select("*") + .eq("tablo_id", tabloId!) + .eq("is_parent", true) + .is("deleted_at", null) + .order("position", { ascending: true }); + + if (error) throw error; + return data as Etape[]; + }, + enabled: !!tabloId, + }); +}; + +export const useCreateEtape = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ tabloId, title, description, position, due_date }: { + tabloId: string; + title: string; + description?: string | null; + position?: number; + due_date?: string | null; + }) => { + const { data, error } = await supabase + .from("tasks") + .insert({ + tablo_id: tabloId, + title, + description: description ?? null, + position: position ?? 0, + is_parent: true, + status: "todo", + due_date: due_date ?? null, + }) + .select() + .single(); + + if (error) throw error; + return data; + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ["tablo-etapes", variables.tabloId] }); + }, + onError: () => { + Alert.alert("Erreur", "Impossible de créer l'étape."); + }, + }); +}; + +export const useUpdateEtape = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, tabloId, ...updates }: { + id: string; + tabloId: string; + title?: string; + description?: string | null; + position?: number; + due_date?: string | null; + }) => { + const { data, error } = await supabase + .from("tasks") + .update(updates) + .eq("id", id) + .select() + .single(); + + if (error) throw error; + return data; + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ["tablo-etapes", variables.tabloId] }); + }, + onError: () => { + Alert.alert("Erreur", "Impossible de modifier l'étape."); + }, + }); +}; + +export const useDeleteEtape = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, tabloId }: { id: string; tabloId: string }) => { + const { error } = await supabase.from("tasks").delete().eq("id", id); + if (error) throw error; + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ["tablo-etapes", variables.tabloId] }); + queryClient.invalidateQueries({ queryKey: ["tasks", "tablo", variables.tabloId] }); + }, + onError: () => { + Alert.alert("Erreur", "Impossible de supprimer l'étape."); + }, + }); +}; +``` + +- [ ] **Step 2: Verify types compile** + +Run: `cd xtablo-expo && npx tsc --noEmit --pretty 2>&1 | grep "hooks/etapes"` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add xtablo-expo/hooks/etapes.ts +git commit -m "feat(expo): add etape CRUD hooks" +``` + +--- + +### Task 5: Create Members Hook + +**Files:** +- Create: `xtablo-expo/hooks/members.ts` + +- [ ] **Step 1: Create the hook file** + +The web app fetches members via REST API at `/api/v1/tablos/members/:tabloId`. The Expo app should do the same. + +Create `xtablo-expo/hooks/members.ts`: + +```typescript +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { useAuthStore } from "@/stores/auth"; + +export type TabloMember = { + id: string; + name: string; + is_admin: boolean; + email: string; + avatar_url: string | null; +}; + +export const useTabloMembers = (tabloId: string | undefined) => { + const session = useAuthStore((state) => state.session); + + return useQuery({ + queryKey: ["tablo-members", tabloId], + queryFn: async () => { + const { data } = await api.get<{ members: TabloMember[] }>( + `/api/v1/tablos/members/${tabloId}`, + { + headers: { + Authorization: `Bearer ${session?.access_token}`, + }, + } + ); + return data.members; + }, + enabled: !!tabloId && !!session, + }); +}; +``` + +- [ ] **Step 2: Verify types compile** + +Run: `cd xtablo-expo && npx tsc --noEmit --pretty 2>&1 | grep "hooks/members"` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add xtablo-expo/hooks/members.ts +git commit -m "feat(expo): add tablo members read-only hook" +``` + +--- + +### Task 6: Create StatusControl Component + +**Files:** +- Create: `xtablo-expo/components/tasks/StatusControl.tsx` + +- [ ] **Step 1: Create the component** + +Create `xtablo-expo/components/tasks/StatusControl.tsx`: + +```typescript +import React from "react"; +import { View, TouchableOpacity, Text, StyleSheet } from "react-native"; +import { TaskStatus, TASK_STATUSES } from "@/types/tasks.types"; +import { useThemeColor } from "@/hooks/useThemeColor"; + +type StatusControlProps = { + value: TaskStatus; + onChange: (status: TaskStatus) => void; +}; + +export default function StatusControl({ value, onChange }: StatusControlProps) { + const bgColor = useThemeColor({ light: "#f1f5f9", dark: "#1f2937" }, "background"); + + return ( + + {TASK_STATUSES.map((status) => { + const isActive = value === status.value; + return ( + onChange(status.value)} + > + + {status.label} + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + borderRadius: 10, + padding: 3, + gap: 2, + }, + segment: { + flex: 1, + paddingVertical: 8, + borderRadius: 8, + alignItems: "center", + }, + label: { + fontSize: 11, + fontWeight: "600", + }, +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add xtablo-expo/components/tasks/StatusControl.tsx +git commit -m "feat(expo): add StatusControl segmented picker component" +``` + +--- + +### Task 7: Create TaskRow Component + +**Files:** +- Create: `xtablo-expo/components/tasks/TaskRow.tsx` + +- [ ] **Step 1: Create the component** + +Create `xtablo-expo/components/tasks/TaskRow.tsx`: + +```typescript +import React from "react"; +import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; +import { Task, TASK_STATUSES } from "@/types/tasks.types"; +import { useThemeColor } from "@/hooks/useThemeColor"; +import { useColorScheme } from "@/hooks/useColorScheme"; +import { Calendar } from "lucide-react-native"; + +type TaskRowProps = { + task: Task; + onPress: (task: Task) => void; +}; + +export default function TaskRow({ task, onPress }: TaskRowProps) { + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text"); + const subtextColor = useThemeColor({ light: "#6b7280", dark: "#9ca3af" }, "text"); + const borderColor = isDark ? "#374151" : "#e5e7eb"; + + const statusColor = TASK_STATUSES.find((s) => s.value === task.status)?.color ?? "#9ca3af"; + const isOverdue = task.due_date && new Date(task.due_date) < new Date(); + + return ( + onPress(task)} + activeOpacity={0.7} + > + + + + {task.title} + + + {task.due_date && ( + + + + {new Date(task.due_date).toLocaleDateString("fr-FR", { + day: "numeric", + month: "short", + })} + + + )} + + + {task.assignee_name ? ( + + + {task.assignee_name.charAt(0).toUpperCase()} + + + ) : ( + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + gap: 12, + }, + statusDot: { + width: 10, + height: 10, + borderRadius: 5, + }, + content: { + flex: 1, + gap: 2, + }, + title: { + fontSize: 15, + fontWeight: "500", + }, + meta: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + dueDateBadge: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, + dueDate: { + fontSize: 12, + }, + avatar: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: "#3b82f6", + alignItems: "center", + justifyContent: "center", + }, + avatarEmpty: { + backgroundColor: "transparent", + borderWidth: 1.5, + borderColor: "#d1d5db", + borderStyle: "dashed", + }, + avatarText: { + color: "#ffffff", + fontSize: 13, + fontWeight: "600", + }, +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add xtablo-expo/components/tasks/TaskRow.tsx +git commit -m "feat(expo): add TaskRow component" +``` + +--- + +### Task 8: Create EtapeSection Component + +**Files:** +- Create: `xtablo-expo/components/tasks/EtapeSection.tsx` + +- [ ] **Step 1: Create the component** + +Create `xtablo-expo/components/tasks/EtapeSection.tsx`: + +```typescript +import React, { useState } from "react"; +import { View, Text, TouchableOpacity, StyleSheet, Alert } from "react-native"; +import { ChevronDown, ChevronRight, Plus, MoreHorizontal } from "lucide-react-native"; +import { useThemeColor } from "@/hooks/useThemeColor"; +import { useColorScheme } from "@/hooks/useColorScheme"; +import { Etape } from "@/types/tasks.types"; + +type EtapeSectionProps = { + etape: Etape | null; // null = "Sans Étape" + taskCount: number; + isCollapsed: boolean; + onToggle: () => void; + onEdit?: (etape: Etape) => void; + onDelete?: (etape: Etape) => void; + children: React.ReactNode; +}; + +export default function EtapeSection({ + etape, + taskCount, + isCollapsed, + onToggle, + onEdit, + onDelete, + children, +}: EtapeSectionProps) { + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text"); + const bgColor = useThemeColor({ light: "#f1f5f9", dark: "#1e293b" }, "background"); + const iconColor = isDark ? "#9ca3af" : "#6b7280"; + + const title = etape ? etape.title : "Sans Étape"; + const ChevronIcon = isCollapsed ? ChevronRight : ChevronDown; + + const handleLongPress = () => { + if (!etape) return; + Alert.alert(etape.title, undefined, [ + { text: "Modifier", onPress: () => onEdit?.(etape) }, + { + text: "Supprimer", + style: "destructive", + onPress: () => { + Alert.alert( + "Supprimer l'étape", + "Les tâches associées seront déplacées dans \"Sans Étape\".", + [ + { text: "Annuler", style: "cancel" }, + { text: "Supprimer", style: "destructive", onPress: () => onDelete?.(etape) }, + ] + ); + }, + }, + { text: "Annuler", style: "cancel" }, + ]); + }; + + return ( + + + + + {title} + + + {taskCount} + + + {!isCollapsed && children} + + ); +} + +const styles = StyleSheet.create({ + header: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 10, + paddingHorizontal: 16, + gap: 8, + }, + title: { + flex: 1, + fontSize: 14, + fontWeight: "700", + }, + badge: { + backgroundColor: "#6b7280", + borderRadius: 10, + paddingHorizontal: 8, + paddingVertical: 2, + }, + badgeText: { + color: "#ffffff", + fontSize: 12, + fontWeight: "600", + }, +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add xtablo-expo/components/tasks/EtapeSection.tsx +git commit -m "feat(expo): add EtapeSection collapsible header component" +``` + +--- + +### Task 9: Create TaskList Component + +**Files:** +- Create: `xtablo-expo/components/tasks/TaskList.tsx` + +- [ ] **Step 1: Create the component** + +This is the main list component that combines etape sections with task rows. + +Create `xtablo-expo/components/tasks/TaskList.tsx`: + +```typescript +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", + }, +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add xtablo-expo/components/tasks/TaskList.tsx +git commit -m "feat(expo): add TaskList component with etape grouping" +``` + +--- + +### Task 10: Create EtapeSheet Component + +**Files:** +- Create: `xtablo-expo/components/tasks/EtapeSheet.tsx` + +- [ ] **Step 1: Create the component** + +This modal is used for creating and editing etapes. + +Create `xtablo-expo/components/tasks/EtapeSheet.tsx`: + +```typescript +import React, { useState, useEffect } from "react"; +import { + View, + Text, + TextInput, + TouchableOpacity, + Modal, + KeyboardAvoidingView, + Platform, + StyleSheet, +} from "react-native"; +import { X } from "lucide-react-native"; +import { useThemeColor } from "@/hooks/useThemeColor"; +import { useColorScheme } from "@/hooks/useColorScheme"; +import { Etape } from "@/types/tasks.types"; +import { useCreateEtape, useUpdateEtape } from "@/hooks/etapes"; + +type EtapeSheetProps = { + visible: boolean; + onClose: () => void; + tabloId: string; + etape?: Etape | null; // null = create mode +}; + +export default function EtapeSheet({ visible, onClose, tabloId, etape }: EtapeSheetProps) { + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + const bgColor = useThemeColor({ light: "#ffffff", dark: "#1f2937" }, "background"); + const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text"); + const inputBg = useThemeColor({ light: "#f1f5f9", dark: "#374151" }, "background"); + const borderColor = isDark ? "#374151" : "#e5e7eb"; + + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + + const { mutate: createEtape, isPending: isCreating } = useCreateEtape(); + const { mutate: updateEtape, isPending: isUpdating } = useUpdateEtape(); + const isPending = isCreating || isUpdating; + + const isEditMode = !!etape; + + useEffect(() => { + if (etape) { + setTitle(etape.title); + setDescription(etape.description ?? ""); + } else { + setTitle(""); + setDescription(""); + } + }, [etape, visible]); + + const handleSave = () => { + if (!title.trim()) return; + + if (isEditMode && etape) { + updateEtape( + { + id: etape.id, + tabloId, + title: title.trim(), + description: description.trim() || null, + }, + { onSuccess: onClose } + ); + } else { + createEtape( + { + tabloId, + title: title.trim(), + description: description.trim() || null, + }, + { onSuccess: onClose } + ); + } + }; + + return ( + + + + + + {isEditMode ? "Modifier l'étape" : "Nouvelle étape"} + + + + + + + + Titre + + + Description + + + + + + {isPending ? "..." : isEditMode ? "Enregistrer" : "Créer"} + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: "flex-end", + backgroundColor: "rgba(0,0,0,0.4)", + }, + sheet: { + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + paddingBottom: 34, + }, + sheetHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + sheetTitle: { + fontSize: 17, + fontWeight: "700", + }, + sheetBody: { + padding: 16, + gap: 12, + }, + label: { + fontSize: 14, + fontWeight: "600", + }, + input: { + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 15, + }, + textArea: { + minHeight: 80, + textAlignVertical: "top", + }, + saveButton: { + marginHorizontal: 16, + backgroundColor: "#3b82f6", + borderRadius: 12, + paddingVertical: 14, + alignItems: "center", + }, + saveButtonDisabled: { + opacity: 0.5, + }, + saveButtonText: { + color: "#ffffff", + fontSize: 16, + fontWeight: "700", + }, +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add xtablo-expo/components/tasks/EtapeSheet.tsx +git commit -m "feat(expo): add EtapeSheet modal for etape create/edit" +``` + +--- + +### Task 11: Create AssigneePicker and EtapePicker Components + +**Files:** +- Create: `xtablo-expo/components/tasks/AssigneePicker.tsx` +- Create: `xtablo-expo/components/tasks/EtapePicker.tsx` + +- [ ] **Step 1: Create AssigneePicker** + +Create `xtablo-expo/components/tasks/AssigneePicker.tsx`: + +```typescript +import React from "react"; +import { + View, + Text, + TouchableOpacity, + Modal, + FlatList, + StyleSheet, +} from "react-native"; +import { X, Check } from "lucide-react-native"; +import { useThemeColor } from "@/hooks/useThemeColor"; +import { useColorScheme } from "@/hooks/useColorScheme"; +import { TabloMember } from "@/hooks/members"; + +type AssigneePickerProps = { + visible: boolean; + onClose: () => void; + members: TabloMember[]; + selectedId: string | null; + onSelect: (memberId: string | null) => void; +}; + +export default function AssigneePicker({ + visible, + onClose, + members, + selectedId, + onSelect, +}: AssigneePickerProps) { + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + const bgColor = useThemeColor({ light: "#ffffff", dark: "#1f2937" }, "background"); + const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text"); + const subtextColor = useThemeColor({ light: "#6b7280", dark: "#9ca3af" }, "text"); + const borderColor = isDark ? "#374151" : "#e5e7eb"; + + const handleSelect = (id: string | null) => { + onSelect(id); + onClose(); + }; + + return ( + + + + + Assigné à + + + + + + handleSelect(null)} + > + + Non assigné + {selectedId === null && } + + + item.id} + renderItem={({ item }) => ( + handleSelect(item.id)} + > + + + {item.name.charAt(0).toUpperCase()} + + + {item.name} + {selectedId === item.id && } + + )} + /> + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: "flex-end", + backgroundColor: "rgba(0,0,0,0.4)", + }, + sheet: { + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + maxHeight: "60%", + paddingBottom: 34, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + title: { + fontSize: 17, + fontWeight: "700", + }, + row: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + gap: 12, + }, + avatar: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: "#3b82f6", + alignItems: "center", + justifyContent: "center", + }, + avatarEmpty: { + backgroundColor: "transparent", + borderWidth: 1.5, + borderColor: "#d1d5db", + borderStyle: "dashed", + }, + avatarText: { + color: "#ffffff", + fontSize: 14, + fontWeight: "600", + }, + name: { + flex: 1, + fontSize: 15, + }, +}); +``` + +- [ ] **Step 2: Create EtapePicker** + +Create `xtablo-expo/components/tasks/EtapePicker.tsx`: + +```typescript +import React from "react"; +import { + View, + Text, + TouchableOpacity, + Modal, + FlatList, + StyleSheet, +} from "react-native"; +import { X, Check } from "lucide-react-native"; +import { useThemeColor } from "@/hooks/useThemeColor"; +import { useColorScheme } from "@/hooks/useColorScheme"; +import { Etape } from "@/types/tasks.types"; + +type EtapePickerProps = { + visible: boolean; + onClose: () => void; + etapes: Etape[]; + selectedId: string | null; + onSelect: (etapeId: string | null) => void; +}; + +export default function EtapePicker({ + visible, + onClose, + etapes, + selectedId, + onSelect, +}: EtapePickerProps) { + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + const bgColor = useThemeColor({ light: "#ffffff", dark: "#1f2937" }, "background"); + const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text"); + const subtextColor = useThemeColor({ light: "#6b7280", dark: "#9ca3af" }, "text"); + const borderColor = isDark ? "#374151" : "#e5e7eb"; + + const handleSelect = (id: string | null) => { + onSelect(id); + onClose(); + }; + + return ( + + + + + Étape + + + + + + handleSelect(null)} + > + Sans Étape + {selectedId === null && } + + + item.id} + renderItem={({ item }) => ( + handleSelect(item.id)} + > + {item.title} + {selectedId === item.id && } + + )} + /> + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: "flex-end", + backgroundColor: "rgba(0,0,0,0.4)", + }, + sheet: { + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + maxHeight: "50%", + paddingBottom: 34, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + title: { + fontSize: 17, + fontWeight: "700", + }, + row: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 14, + paddingHorizontal: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + name: { + flex: 1, + fontSize: 15, + }, +}); +``` + +- [ ] **Step 3: Commit** + +```bash +git add xtablo-expo/components/tasks/AssigneePicker.tsx xtablo-expo/components/tasks/EtapePicker.tsx +git commit -m "feat(expo): add AssigneePicker and EtapePicker modal components" +``` + +--- + +### Task 12: Create Tablo Detail Screen + +**Files:** +- Create: `xtablo-expo/app/(app)/tablo/_layout.tsx` +- Create: `xtablo-expo/app/(app)/tablo/[id].tsx` +- Modify: `xtablo-expo/app/(app)/_layout.tsx` — Register tablo Stack.Screen + +- [ ] **Step 1: Create tablo layout** + +Create `xtablo-expo/app/(app)/tablo/_layout.tsx`: + +```typescript +import { Stack } from "expo-router"; + +export default function TabloLayout() { + return ; +} +``` + +- [ ] **Step 2: Create tablo detail screen** + +Create `xtablo-expo/app/(app)/tablo/[id].tsx`: + +```typescript +import React, { useState } from "react"; +import { View, Text, TouchableOpacity, StyleSheet, SafeAreaView } from "react-native"; +import { useLocalSearchParams, router } from "expo-router"; +import { ArrowLeft, Plus } from "lucide-react-native"; +import { useThemeColor } from "@/hooks/useThemeColor"; +import { useColorScheme } from "@/hooks/useColorScheme"; +import { useTablosList } from "@/hooks/tablos"; +import { Etape } from "@/types/tasks.types"; +import { ColorMap } from "@/constants/colors"; +import TaskList from "@/components/tasks/TaskList"; +import EtapeSheet from "@/components/tasks/EtapeSheet"; + +export default function TabloDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + 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 borderColor = isDark ? "#374151" : "#e5e7eb"; + + const { data: tablos } = useTablosList(); + const tablo = tablos?.find((t) => t.id === id); + + const [etapeSheetVisible, setEtapeSheetVisible] = useState(false); + const [editingEtape, setEditingEtape] = useState(null); + + const handleEditEtape = (etape: Etape) => { + setEditingEtape(etape); + setEtapeSheetVisible(true); + }; + + const handleCreateEtape = () => { + setEditingEtape(null); + setEtapeSheetVisible(true); + }; + + const handleCloseEtapeSheet = () => { + setEtapeSheetVisible(false); + setEditingEtape(null); + }; + + if (!tablo || !id) { + return ( + + Tablo introuvable + + ); + } + + const tabloColor = tablo.color ? ColorMap[tablo.color] ?? "#3b82f6" : "#3b82f6"; + + return ( + + {/* Header */} + + router.back()} style={styles.backButton}> + + + + + {tablo.name} + + + + {/* Task list */} + + + {/* FAB - create task */} + router.push(`/task/new?tabloId=${id}`)} + > + + + + {/* Etape sheet */} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 12, + paddingVertical: 14, + borderBottomWidth: StyleSheet.hairlineWidth, + gap: 10, + }, + backButton: { + padding: 4, + }, + colorIndicator: { + width: 14, + height: 14, + borderRadius: 4, + }, + tabloName: { + flex: 1, + fontSize: 18, + fontWeight: "700", + }, + fab: { + position: "absolute", + bottom: 24, + right: 20, + width: 54, + height: 54, + borderRadius: 27, + backgroundColor: "#3b82f6", + alignItems: "center", + justifyContent: "center", + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 6, + elevation: 6, + }, +}); +``` + +- [ ] **Step 3: Register tablo route in app layout** + +Open `xtablo-expo/app/(app)/_layout.tsx` and add the tablo Stack.Screen. Change from: + +```typescript + + + + +``` + +To: + +```typescript + + + + + + +``` + +- [ ] **Step 4: Verify it compiles** + +Run: `cd xtablo-expo && npx tsc --noEmit --pretty 2>&1 | head -20` +Expected: No new errors. + +- [ ] **Step 5: Commit** + +```bash +git add xtablo-expo/app/(app)/tablo/ xtablo-expo/app/(app)/_layout.tsx +git commit -m "feat(expo): add tablo detail screen with task list and etape management" +``` + +--- + +### Task 13: Create Task Detail Screen + +**Files:** +- Create: `xtablo-expo/app/(app)/task/_layout.tsx` +- Create: `xtablo-expo/app/(app)/task/[id].tsx` + +- [ ] **Step 1: Create task layout** + +Create `xtablo-expo/app/(app)/task/_layout.tsx`: + +```typescript +import { Stack } from "expo-router"; + +export default function TaskLayout() { + return ; +} +``` + +- [ ] **Step 2: Create task detail screen** + +Create `xtablo-expo/app/(app)/task/[id].tsx`: + +```typescript +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("todo"); + const [assigneeId, setAssigneeId] = useState(null); + const [parentTaskId, setParentTaskId] = useState(null); + const [dueDate, setDueDate] = useState(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 ( + + {/* Header */} + + router.back()} style={styles.backButton}> + + + + {isCreateMode ? "Nouvelle tâche" : "Modifier la tâche"} + + + + + {/* Title */} + Titre + + + {/* Description */} + Description + + + {/* Status */} + Statut + + + {/* Assignee */} + Assigné à + setAssigneePickerVisible(true)} + > + + + {assigneeName} + + + + {/* Etape */} + Étape + setEtapePickerVisible(true)} + > + + + {etapeName} + + + + {/* Due date */} + Date limite + setShowDatePicker(true)} + > + + + {dueDateDisplay} + + {dueDate && ( + setDueDate(null)}> + Retirer + + )} + + + {showDatePicker && ( + { + setShowDatePicker(Platform.OS !== "ios"); + if (date) setDueDate(date); + }} + /> + )} + + {/* Save button */} + + + {isPending ? "..." : isCreateMode ? "Créer" : "Enregistrer"} + + + + {/* Delete button (edit mode only) */} + {!isCreateMode && ( + + Supprimer la tâche + + )} + + + + + {/* Pickers */} + setAssigneePickerVisible(false)} + members={members ?? []} + selectedId={assigneeId} + onSelect={setAssigneeId} + /> + setEtapePickerVisible(false)} + etapes={etapes ?? []} + selectedId={parentTaskId} + onSelect={setParentTaskId} + /> + + ); +} + +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", + }, +}); +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cd xtablo-expo && npx tsc --noEmit --pretty 2>&1 | head -20` +Expected: No new errors. Note: `@react-native-community/datetimepicker` may need to be installed — see Task 14. + +- [ ] **Step 4: Commit** + +```bash +git add xtablo-expo/app/(app)/task/ +git commit -m "feat(expo): add task detail screen with create/edit/delete" +``` + +--- + +### Task 14: Install DateTimePicker Dependency + +**Files:** +- Modify: `xtablo-expo/package.json` + +The task detail screen uses `@react-native-community/datetimepicker` for native date picking. Check if it's already installed. + +- [ ] **Step 1: Check if already installed** + +Run: `cd xtablo-expo && grep datetimepicker package.json` +Expected: Either a match (already installed) or no output (needs install). + +- [ ] **Step 2: Install if missing** + +If not installed, run: +```bash +cd xtablo-expo && npx expo install @react-native-community/datetimepicker +``` + +- [ ] **Step 3: Commit if changed** + +```bash +git add xtablo-expo/package.json xtablo-expo/yarn.lock xtablo-expo/package-lock.json 2>/dev/null +git commit -m "chore(expo): add @react-native-community/datetimepicker dependency" +``` + +--- + +### Task 15: Update Tablos List Navigation + +**Files:** +- Modify: `xtablo-expo/app/(app)/(tabs)/tablos.tsx` + +Currently tapping a tablo navigates to the chat channel. Change it to navigate to the new tablo detail screen. + +- [ ] **Step 1: Find the navigation handler** + +Open `xtablo-expo/app/(app)/(tabs)/tablos.tsx`. Find the `navigateToTablo` function or the `onPress` handler for tablo items. It currently does something like: + +```typescript +router.push(`/channel/${getChannelCid(tablo.id)}`); +``` + +- [ ] **Step 2: Change navigation target** + +Replace the tablo tap handler to navigate to the tablo detail screen instead: + +```typescript +const navigateToTablo = (tablo: UserTablo) => { + router.push(`/tablo/${tablo.id}`); +}; +``` + +Keep any existing "Chat" quick action button that navigates to the channel — just change the main tablo tap target. + +- [ ] **Step 3: Verify the app runs** + +Run: `cd xtablo-expo && npx expo start` +Open on simulator/device. Tap a tablo — should navigate to the new tablo detail screen. + +- [ ] **Step 4: Commit** + +```bash +git add xtablo-expo/app/(app)/(tabs)/tablos.tsx +git commit -m "feat(expo): navigate to tablo detail on tap instead of channel" +``` + +--- + +### Task 16: End-to-End Smoke Test + +No new files. This is a manual verification task. + +- [ ] **Step 1: Start the app** + +Run: `cd xtablo-expo && npx expo start` + +- [ ] **Step 2: Test tablo detail** + +- Open the app +- Tap a tablo → should see tablo detail screen with header and task list +- Verify back button returns to tablos list + +- [ ] **Step 3: Test task creation** + +- Tap the "+" FAB on tablo detail +- Fill in title, set status, pick assignee/etape/due date +- Tap "Créer" +- Verify the task appears in the task list under the correct etape section + +- [ ] **Step 4: Test task editing** + +- Tap an existing task +- Change title, status, assignee +- Tap "Enregistrer" +- Verify changes are reflected in the list + +- [ ] **Step 5: Test task deletion** + +- Tap a task to edit +- Tap "Supprimer la tâche" +- Confirm deletion +- Verify task is removed from the list + +- [ ] **Step 6: Test etape management** + +- Tap "Nouvelle étape" on the task list +- Create an etape with title +- Verify it appears as a section header +- Long-press the etape header → Edit → change title → save +- Long-press the etape header → Delete → confirm +- Verify child tasks move to "Sans Étape" + +- [ ] **Step 7: Test pull-to-refresh** + +- Pull down on the task list +- Verify data refreshes + +- [ ] **Step 8: Test collapse/expand** + +- Tap an etape section header +- Verify tasks collapse +- Tap again → verify they expand