346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
import { pluralize, toast } from "@xtablo/shared";
|
|
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
|
import { Button } from "@xtablo/ui/components/button";
|
|
import { Input } from "@xtablo/ui/components/input";
|
|
import { Progress } from "@xtablo/ui/components/progress";
|
|
import { TypographyH3, TypographyMuted, TypographyP } from "@xtablo/ui/components/typography";
|
|
import { ArrowDown, ArrowUp, Check, Edit2, Loader2, Trash2, X } from "lucide-react";
|
|
import { useCallback, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
useCreateEtape,
|
|
useDeleteEtape,
|
|
useReorderEtapes,
|
|
useTabloEtapes,
|
|
useTasksByTablo,
|
|
useUpdateEtape,
|
|
} from "../hooks/tasks";
|
|
import { getEtapeColor } from "../utils/etapeColors";
|
|
|
|
interface TabloOverviewSectionProps {
|
|
tablo: UserTablo;
|
|
isAdmin: boolean;
|
|
}
|
|
|
|
export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionProps) => {
|
|
const { t } = useTranslation();
|
|
const { data: etapes = [], isLoading: isLoadingEtapes } = useTabloEtapes(tablo.id);
|
|
const { data: tasks = [] } = useTasksByTablo(tablo.id);
|
|
const createEtape = useCreateEtape();
|
|
const updateEtape = useUpdateEtape();
|
|
const deleteEtape = useDeleteEtape();
|
|
const reorderEtapes = useReorderEtapes();
|
|
|
|
const [newEtapeTitle, setNewEtapeTitle] = useState("");
|
|
const [editingEtapeId, setEditingEtapeId] = useState<string | null>(null);
|
|
const [editingTitle, setEditingTitle] = useState("");
|
|
|
|
const canManageEtapes = isAdmin;
|
|
|
|
const sortedEtapes = useMemo(() => [...etapes].sort((a, b) => a.position - b.position), [etapes]);
|
|
|
|
// Calculate overall tablo progress
|
|
const overallProgress = useMemo(() => {
|
|
const totalTasks = tasks.length;
|
|
const doneTasks = tasks.filter((task) => task.status === "done").length;
|
|
const percentage = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0;
|
|
return { total: totalTasks, done: doneTasks, percentage };
|
|
}, [tasks]);
|
|
|
|
// Calculate task counts per etape
|
|
const getEtapeTaskCounts = useCallback(
|
|
(etapeId: string) => {
|
|
const etapeTasks = tasks.filter((task) => task.parent_task_id === etapeId);
|
|
const total = etapeTasks.length;
|
|
const done = etapeTasks.filter((task) => task.status === "done").length;
|
|
const ongoing = etapeTasks.filter(
|
|
(task) => task.status === "in_progress" || task.status === "in_review"
|
|
).length;
|
|
return { total, done, ongoing };
|
|
},
|
|
[tasks]
|
|
);
|
|
|
|
const handleCreateEtape = async () => {
|
|
const title = newEtapeTitle.trim();
|
|
if (!title) {
|
|
toast.add({
|
|
title: "Erreur",
|
|
description: "Le nom de l'Étape est requis",
|
|
type: "error",
|
|
});
|
|
return;
|
|
}
|
|
|
|
await createEtape.mutateAsync({
|
|
tabloId: tablo.id,
|
|
title,
|
|
position: etapes.length,
|
|
});
|
|
setNewEtapeTitle("");
|
|
};
|
|
|
|
const startEditing = useCallback((etapeId: string, currentTitle: string) => {
|
|
setEditingEtapeId(etapeId);
|
|
setEditingTitle(currentTitle);
|
|
}, []);
|
|
|
|
const handleUpdateEtape = async () => {
|
|
if (!editingEtapeId) return;
|
|
const title = editingTitle.trim();
|
|
if (!title) return;
|
|
|
|
await updateEtape.mutateAsync({
|
|
id: editingEtapeId,
|
|
tabloId: tablo.id,
|
|
title,
|
|
});
|
|
setEditingEtapeId(null);
|
|
setEditingTitle("");
|
|
};
|
|
|
|
const handleCancelEdit = () => {
|
|
setEditingEtapeId(null);
|
|
setEditingTitle("");
|
|
};
|
|
|
|
const handleDeleteEtape = async (etapeId: string, etapeTitle: string) => {
|
|
if (
|
|
!window.confirm(
|
|
`Êtes-vous sûr de vouloir supprimer l'Étape "${etapeTitle}" ? Les tâches associées resteront disponibles.`
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
await deleteEtape.mutateAsync({ id: etapeId, tabloId: tablo.id });
|
|
};
|
|
|
|
const handleReorder = async (index: number, direction: "up" | "down") => {
|
|
const nextIndex = direction === "up" ? index - 1 : index + 1;
|
|
if (nextIndex < 0 || nextIndex >= sortedEtapes.length) return;
|
|
|
|
const reordered = [...sortedEtapes];
|
|
const [moved] = reordered.splice(index, 1);
|
|
reordered.splice(nextIndex, 0, moved);
|
|
|
|
const updates = reordered.map((etape, position) => ({
|
|
id: etape.id,
|
|
position,
|
|
}));
|
|
|
|
await reorderEtapes.mutateAsync({ tabloId: tablo.id, updates });
|
|
};
|
|
|
|
const EtapeList = () => {
|
|
if (isLoadingEtapes) {
|
|
return (
|
|
<div className="flex items-center justify-center py-10">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!sortedEtapes.length) {
|
|
return (
|
|
<div className="rounded-lg border border-dashed border-border p-6 text-center">
|
|
<TypographyP className="text-muted-foreground">
|
|
Aucune Étape n'a encore été définie pour ce tablo.
|
|
</TypographyP>
|
|
{canManageEtapes ? (
|
|
<TypographyP className="text-sm text-muted-foreground mt-2">
|
|
Créez votre première Étape pour structurer les tâches du tablo.
|
|
</TypographyP>
|
|
) : (
|
|
<TypographyP className="text-sm text-muted-foreground mt-2">
|
|
Seul le propriétaire du tablo peut ajouter des Étapes.
|
|
</TypographyP>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ul className="space-y-3">
|
|
{sortedEtapes.map((etape, index) => {
|
|
const isEditing = editingEtapeId === etape.id;
|
|
const etapeColor = getEtapeColor(etape.position);
|
|
return (
|
|
<li
|
|
key={etape.id}
|
|
className={`flex items-start gap-3 rounded-lg border px-4 py-3 ${etapeColor.bg} ${etapeColor.border}`}
|
|
>
|
|
{canManageEtapes && (
|
|
<div className="flex flex-col gap-1 pt-1">
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
disabled={index === 0 || reorderEtapes.isPending}
|
|
onClick={() => handleReorder(index, "up")}
|
|
>
|
|
<ArrowUp className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
disabled={index === sortedEtapes.length - 1 || reorderEtapes.isPending}
|
|
onClick={() => handleReorder(index, "down")}
|
|
>
|
|
<ArrowDown className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1">
|
|
{isEditing ? (
|
|
<div className="flex flex-col gap-2">
|
|
<Input
|
|
value={editingTitle}
|
|
onChange={(event) => setEditingTitle(event.target.value)}
|
|
placeholder="Nom de l'Étape"
|
|
autoFocus
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleCancelEdit}
|
|
disabled={updateEtape.isPending}
|
|
>
|
|
<X className="mr-2 h-4 w-4" />
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleUpdateEtape}
|
|
disabled={updateEtape.isPending}
|
|
>
|
|
{updateEtape.isPending ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Check className="mr-2 h-4 w-4" />
|
|
)}
|
|
Enregistrer
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<TypographyP className={`text-base font-medium ${etapeColor.text}`}>
|
|
{etape.title}
|
|
</TypographyP>
|
|
<TypographyMuted className={`text-xs ${etapeColor.text} opacity-70`}>
|
|
Étape {etape.position + 1}
|
|
</TypographyMuted>
|
|
{(() => {
|
|
const { total, done, ongoing } = getEtapeTaskCounts(etape.id);
|
|
return (
|
|
<div className={`flex gap-3 mt-2 text-xs ${etapeColor.text}`}>
|
|
<span className="opacity-70">
|
|
<span className="font-medium opacity-100">{total}</span>{" "}
|
|
{pluralize("tâche", total)}
|
|
</span>
|
|
{ongoing > 0 && (
|
|
<span className="opacity-90">
|
|
<span className="font-medium">{ongoing}</span> en cours
|
|
</span>
|
|
)}
|
|
{done > 0 && (
|
|
<span className="opacity-90">
|
|
<span className="font-medium">{done}</span>{" "}
|
|
{pluralize("terminée", done)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{canManageEtapes && !isEditing && (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => startEditing(etape.id, etape.title)}
|
|
>
|
|
<Edit2 className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => handleDeleteEtape(etape.id, etape.title)}
|
|
disabled={deleteEtape.isPending}
|
|
>
|
|
{deleteEtape.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<TypographyH3 className="text-3xl font-bold text-foreground">Vue d'ensemble</TypographyH3>
|
|
<TypographyMuted className="text-muted-foreground mt-1">
|
|
Configurez les Étapes du tablo pour clarifier les grandes phases de votre projet.
|
|
</TypographyMuted>
|
|
</div>
|
|
|
|
{!canManageEtapes && (
|
|
<div className="mt-4 rounded-md bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
|
Seul le propriétaire du tablo peut modifier les Étapes. Contactez l'administrateur si vous
|
|
avez besoin d'une nouvelle Étape.
|
|
</div>
|
|
)}
|
|
|
|
{/* Overall Progress */}
|
|
{overallProgress.total > 0 && (
|
|
<div className="bg-card border border-border rounded-lg p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div>
|
|
<TypographyP className="text-sm font-medium text-foreground">
|
|
Progression globale
|
|
</TypographyP>
|
|
<TypographyMuted className="text-xs">
|
|
{overallProgress.done} sur {overallProgress.total}{" "}
|
|
{pluralize("tâche", overallProgress.total)}{" "}
|
|
{pluralize("terminée", overallProgress.done)}
|
|
</TypographyMuted>
|
|
</div>
|
|
<div className="text-2xl font-bold text-primary">{overallProgress.percentage}%</div>
|
|
</div>
|
|
<Progress value={overallProgress.done} max={overallProgress.total} className="h-3" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-6">
|
|
<EtapeList />
|
|
</div>
|
|
|
|
{canManageEtapes && (
|
|
<div className="flex w-full sm:w-auto items-center gap-2">
|
|
<Input
|
|
value={newEtapeTitle}
|
|
onChange={(event) => setNewEtapeTitle(event.target.value)}
|
|
placeholder="Nom de l'Étape"
|
|
className="h-9 sm:w-64"
|
|
/>
|
|
<Button onClick={handleCreateEtape} disabled={createEtape.isPending}>
|
|
{t("common:actions.add", "Nouvelle Étape")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|