feat(expo): add TaskList component with etape grouping
This commit is contained in:
parent
58bbcf840b
commit
a00d95f8be
1 changed files with 219 additions and 0 deletions
219
xtablo-expo/components/tasks/TaskList.tsx
Normal file
219
xtablo-expo/components/tasks/TaskList.tsx
Normal file
|
|
@ -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<Set<string>>(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 (
|
||||
<View style={styles.centered}>
|
||||
<Text style={{ color: subtextColor }}>Chargement...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
>
|
||||
{/* Create etape button */}
|
||||
<TouchableOpacity style={styles.addEtapeButton} onPress={onCreateEtape}>
|
||||
<Plus size={16} color="#3b82f6" />
|
||||
<Text style={styles.addEtapeText}>Nouvelle étape</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{groups.map((group) => {
|
||||
const sectionKey = group.etape?.id ?? "no-etape";
|
||||
const isCollapsed = collapsedSections.has(sectionKey);
|
||||
const statusGroups = sortTasksByStatus(group.tasks);
|
||||
|
||||
return (
|
||||
<EtapeSection
|
||||
key={sectionKey}
|
||||
etape={group.etape}
|
||||
taskCount={group.tasks.length}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={() => toggleSection(sectionKey)}
|
||||
onEdit={group.etape ? onEditEtape : undefined}
|
||||
onDelete={group.etape ? handleDeleteEtape : undefined}
|
||||
>
|
||||
{statusGroups.map((sg) => (
|
||||
<View key={sg.value}>
|
||||
<View style={styles.statusHeader}>
|
||||
<View style={[styles.statusDot, { backgroundColor: sg.color }]} />
|
||||
<Text style={[styles.statusLabel, { color: subtextColor }]}>
|
||||
{sg.label} ({sg.tasks.length})
|
||||
</Text>
|
||||
</View>
|
||||
{sg.tasks.map((task) => (
|
||||
<TaskRow key={task.id} task={task} onPress={handleTaskPress} />
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
{group.tasks.length === 0 && (
|
||||
<View style={styles.emptySection}>
|
||||
<Text style={{ color: subtextColor, fontSize: 13 }}>Aucune tâche</Text>
|
||||
</View>
|
||||
)}
|
||||
</EtapeSection>
|
||||
);
|
||||
})}
|
||||
|
||||
{groups.length === 0 && (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyTitle, { color: textColor }]}>Aucune tâche</Text>
|
||||
<Text style={{ color: subtextColor, textAlign: "center" }}>
|
||||
Créez votre première tâche avec le bouton +
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue