214 lines
6.2 KiB
TypeScript
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",
|
|
},
|
|
});
|