xtablo-source/xtablo-expo/components/tasks/TaskList.tsx
2026-04-29 15:45:31 +02:00

214 lines
6.2 KiB
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 { PRIMARY } from "@/constants/colors";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useTasksByTablo } from "@/hooks/tasks";
import { useTabloEtapes, 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[]): { value: 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 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: 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={PRIMARY} />
<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: PRIMARY,
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",
},
});