Diff
checker
Texte
Texte
Images
Documents
Excel
Dossiers
Legal
Enterprise
Application de bureau
Prix
Se connecter
Télécharger Diffchecker Desktop
Comparer le texte
Trouver la différence entre deux fichiers texte
Outils
Historique
Éditeur live
Masquer les espaces
Cacher identiques
Sans retour à la ligne
Vue
Divisé
Unifié
Niveau de précision
Intelligent
Mot
Caractère
Styles de texte
Modifier l’apparence
Coloration syntaxique
Choisir la syntaxe
Ignorer
Transformer le texte
Aller au premier écart
Modifier l'entrée
Diffchecker Desktop
La façon la plus sécurisée d'utiliser Diffchecker. Obtenez l'application Diffchecker Desktop : vos diffs ne quittent jamais votre ordinateur !
Obtenir Desktop
Undo/Redo Persistence
Créé
il y a 3 mois
Le diff n'expire jamais
Effacer
Exporter
Partager
Expliquer
24 suppressions
Lignes
Total
Supprimé
Caractères
Total
Supprimé
Pour continuer à utiliser cette fonctionnalité, passez à
Diff
checker
Pro
Voir les prix
374 lignes
Copier tout
191 ajouts
Lignes
Total
Ajouté
Caractères
Total
Ajouté
Pour continuer à utiliser cette fonctionnalité, passez à
Diff
checker
Pro
Voir les prix
532 lignes
Copier tout
import { useMemo, useRef, useState, useEffect, useCallback } from "react";
import { useMemo, useRef, useState, useEffect, useCallback } from "react";
import Gantt, {
import Gantt, {
type GanttConfig,
type GanttConfig,
type Link,
type Link,
type ReactGanttRef,
type ReactGanttRef,
type Task,
type Task,
} from "@dhtmlx/trial-react-gantt";
} from "@dhtmlx/trial-react-gantt";
import "@dhtmlx/trial-react-gantt/dist/react-gantt.css";
import "@dhtmlx/trial-react-gantt/dist/react-gantt.css";
import { useTheme } from "@/hooks/use-theme";
import { useTheme } from "@/hooks/use-theme";
import { useGanttData } from "@/features/gantt/api/useGanttData";
import { useGanttData } from "@/features/gantt/api/useGanttData";
import { Skeleton } from "@/components/ui/skeleton";
import { Skeleton } from "@/components/ui/skeleton";
import { supabase } from "@/integrations/supabase/client";
import { supabase } from "@/integrations/supabase/client";
import {
import {
isRealUUID,
isRealUUID,
buildTaskInsert,
buildTaskInsert,
buildTaskUpdate,
buildTaskUpdate,
buildLinkInsert,
buildLinkInsert,
} from "@/features/gantt/utils/payload";
} from "@/features/gantt/utils/payload";
import { useAppDispatch, useAppSelector } from "@/features/gantt/store";
import { useAppDispatch, useAppSelector } from "@/features/gantt/store";
import {
import {
hydrate,
hydrate,
reset,
reset,
commit,
commit,
patch,
patch,
undo,
undo,
redo,
redo,
} from "@/features/gantt/store/ganttSlice";
} from "@/features/gantt/store/ganttSlice";
import {
import {
serializeTask,
serializeTask,
serializeLink,
serializeLink,
deserializeTask,
deserializeTask,
deserializeLink,
deserializeLink,
} from "@/features/gantt/store/serialization";
} from "@/features/gantt/store/serialization";
Copier
Copié
Copier
Copié
import type {
SerializedTask, SerializedLink } from "@/features/gantt/store/types";
import type {
GanttSnapshot,
SerializedTask, SerializedLink } from "@/features/gantt/store/types";
import { GanttToolbar } from "./GanttToolbar";
import { GanttToolbar } from "./GanttToolbar";
import { ZOOM_LEVELS, type ZoomLevel } from "@/features/gantt/utils/zoom";
import { ZOOM_LEVELS, type ZoomLevel } from "@/features/gantt/utils/zoom";
Copier
Copié
Copier
Copié
interface ProjectGanttProps {
interface ProjectGanttProps {
projectId: string;
projectId: string;
readOnly?: boolean;
readOnly?: boolean;
}
}
Copier
Copié
Copier
Copié
export default function ProjectGantt({ projectId, readOnly = false }: ProjectGanttProps) {
export default function ProjectGantt({ projectId, readOnly = false }: ProjectGanttProps) {
const ganttRef = useRef<ReactGanttRef>(null);
const ganttRef = useRef<ReactGanttRef>(null);
const { theme: appTheme } = useTheme();
const { theme: appTheme } = useTheme();
const { tasks: dbTasks, links: dbLinks, isLoading, error } = useGanttData(projectId);
const { tasks: dbTasks, links: dbLinks, isLoading, error } = useGanttData(projectId);
Copier
Copié
Copier
Copié
const dispatch = useAppDispatch();
const dispatch = useAppDispatch();
const { past, present, future } = useAppSelector((s) => s.gantt);
const { past, present, future } = useAppSelector((s) => s.gantt);
Copier
Copié
Copier
Copié
const presentRef = useRef(present);
presentRef.current = present;
const pastRef = useRef(past);
pastRef.current = past;
const futureRef = useRef(future);
futureRef.current = future;
Copier
Copié
Copier
Copié
const [zoom, setZoom] = useState<ZoomLevel>("
week
");
const [zoom, setZoom] = useState<ZoomLevel>("
day
");
// Derive live Gantt arrays from Redux (deserialize dates)
// Derive live Gantt arrays from Redux (deserialize dates)
const tasks: Task[] = useMemo(() => present.tasks.map(deserializeTask), [present.tasks]);
const tasks: Task[] = useMemo(() => present.tasks.map(deserializeTask), [present.tasks]);
const links: Link[] = useMemo(() => present.links.map(deserializeLink), [present.links]);
const links: Link[] = useMemo(() => present.links.map(deserializeLink), [present.links]);
Copier
Copié
Copier
Copié
// ── Hydrate from DB ───────────────────────────────────────
// ── Hydrate from DB ───────────────────────────────────────
const prevProjectId = useRef<string | null>(null);
const prevProjectId = useRef<string | null>(null);
Copier
Copié
Copier
Copié
useEffect(() => {
useEffect(() => {
if (projectId !== prevProjectId.current) {
if (projectId !== prevProjectId.current) {
dispatch(reset());
dispatch(reset());
prevProjectId.current = projectId;
prevProjectId.current = projectId;
}
}
}, [projectId, dispatch]);
}, [projectId, dispatch]);
Copier
Copié
Copier
Copié
useEffect(() => {
useEffect(() => {
if (!isLoading && (dbTasks.length > 0 || dbLinks.length > 0)) {
if (!isLoading && (dbTasks.length > 0 || dbLinks.length > 0)) {
dispatch(
dispatch(
hydrate({
hydrate({
tasks: dbTasks.map(serializeTask),
tasks: dbTasks.map(serializeTask),
links: dbLinks.map(serializeLink),
links: dbLinks.map(serializeLink),
}),
}),
);
);
}
}
}, [dbTasks, dbLinks, isLoading, dispatch]);
}, [dbTasks, dbLinks, isLoading, dispatch]);
Copier
Copié
Copier
Copié
// ── Helpers ────────────────────────────────────────────────
// ── Helpers ────────────────────────────────────────────────
const nextSortorder = useCallback((): number => {
const nextSortorder = useCallback((): number => {
let max = 0;
let max = 0;
Copier
Copié
Copier
Copié
for (const t of present
.tasks) {
for (const t of present
Ref.current
.tasks) {
if (t.sortorder > max) max = t.sortorder;
if (t.sortorder > max) max = t.sortorder;
}
}
return max + 1;
return max + 1;
Copier
Copié
Copier
Copié
}, [present.tasks]);
}, []);
const persistSnapshot = useCallback(
async (previousSnapshot: GanttSnapshot, nextSnapshot: GanttSnapshot) => {
const nextTaskRows = nextSnapshot.tasks
.filter((task) => isRealUUID(String(task.id)))
.map((task) => ({
id: String(task.id),
project_id: projectId,
text: task.text,
start_date: task.start_date,
duration: task.duration,
progress: task.progress,
parent_id: isRealUUID(String(task.parent)) ? String(task.parent) : null,
sortorder: task.sortorder,
type: task.type ?? "task",
assignee_user_id: task.assignee_user_id ?? null,
}));
const previousTaskIds = new Set(
previousSnapshot.tasks
.filter((task) => isRealUUID(String(task.id)))
.map((task) => String(task.id)),
);
const nextTaskIds = new Set(nextTaskRows.map((task) => task.id));
const deletedTaskIds = [...previousTaskIds].filter((id) => !nextTaskIds.has(id));
if (nextTaskRows.length > 0) {
const { error } = await supabase.from("tasks").upsert(nextTaskRows);
if (error) throw error;
}
if (deletedTaskIds.length > 0) {
const { error } = await supabase.from("tasks").delete().in("id", deletedTaskIds);
if (error) throw error;
}
const previousLinks = new Map(
previousSnapshot.links
.filter((link) => isRealUUID(String(link.id)))
.map((link) => [String(link.id), link] as const),
);
const nextLinks = new Map(
nextSnapshot.links
.filter(
(link) =>
isRealUUID(String(link.id)) &&
isRealUUID(String(link.source)) &&
isRealUUID(String(link.target)),
)
.map((link) => [String(link.id), link] as const),
);
const deletedLinkIds = [...previousLinks.keys()].filter((id) => !nextLinks.has(id));
const insertedLinks = [...nextLinks.entries()]
.filter(([id]) => !previousLinks.has(id))
.map(([, link]) => ({
id: String(link.id),
project_id: projectId,
source: String(link.source),
target: String(link.target),
type: String(link.type),
}));
if (deletedLinkIds.length > 0) {
const { error } = await supabase.from("links").delete().in("id", deletedLinkIds);
if (error) throw error;
}
if (insertedLinks.length > 0) {
const { error } = await supabase.from("links").insert(insertedLinks);
if (error) throw error;
}
},
[projectId],
);
// ── Row reorder ────────────────────────────────────────────
// ── Row reorder ────────────────────────────────────────────
const handleReorder = useCallback(
const handleReorder = useCallback(
async (movedTask: Task) => {
async (movedTask: Task) => {
Copier
Copié
Copier
Copié
const currentSnapshot = presentRef.current;
const moved = movedTask as any;
const moved = movedTask as any;
const targetId = moved.target;
const targetId = moved.target;
const newParent = moved.parent;
const newParent = moved.parent;
Copier
Copié
Copier
Copié
// Build the current ordered list
// Build the current ordered list
Copier
Copié
Copier
Copié
const currentTasks = [...
present
.tasks];
const currentTasks = [...
currentSnapshot
.tasks];
// Remove the moved task from its current position
// Remove the moved task from its current position
const movedIdx = currentTasks.findIndex((t) => t.id === moved.id);
const movedIdx = currentTasks.findIndex((t) => t.id === moved.id);
if (movedIdx === -1) return;
if (movedIdx === -1) return;
const [movedItem] = currentTasks.splice(movedIdx, 1);
const [movedItem] = currentTasks.splice(movedIdx, 1);
Copier
Copié
Copier
Copié
// Update parent if changed
// Update parent if changed
const updatedItem: SerializedTask = {
const updatedItem: SerializedTask = {
...movedItem,
...movedItem,
parent: newParent ?? movedItem.parent,
parent: newParent ?? movedItem.parent,
};
};
Copier
Copié
Copier
Copié
// Find target position
// Find target position
let insertIdx: number;
let insertIdx: number;
if (!targetId) {
if (!targetId) {
// Dropped at the end
// Dropped at the end
insertIdx = currentTasks.length;
insertIdx = currentTasks.length;
} else {
} else {
const targetIdx = currentTasks.findIndex((t) => t.id === targetId);
const targetIdx = currentTasks.findIndex((t) => t.id === targetId);
if (targetIdx === -1) {
if (targetIdx === -1) {
insertIdx = currentTasks.length;
insertIdx = currentTasks.length;
} else {
} else {
insertIdx = targetIdx;
insertIdx = targetIdx;
}
}
}
}
Copier
Copié
Copier
Copié
currentTasks.splice(insertIdx, 0, updatedItem);
currentTasks.splice(insertIdx, 0, updatedItem);
Copier
Copié
Copier
Copié
// Recompute sortorder for all tasks
// Recompute sortorder for all tasks
const reordered = currentTasks.map((t, i) => ({
const reordered = currentTasks.map((t, i) => ({
...t,
...t,
sortorder: i + 1,
sortorder: i + 1,
}));
}));
Copier
Copié
Copier
Copié
// Commit to Redux (with history)
// Commit to Redux (with history)
dispatch(
dispatch(
commit({
commit({
tasks: reordered,
tasks: reordered,
Copier
Copié
Copier
Copié
links:
present
.links,
links:
currentSnapshot
.links,
}),
}),
);
);
Copier
Copié
Copier
Copié
// Persist all affected sortorders + parent_id to Supabase
// Persist all affected sortorders + parent_id to Supabase
const updates = reordered
const updates = reordered
.filter((t) => isRealUUID(String(t.id)))
.filter((t) => isRealUUID(String(t.id)))
.map((t) => ({
.map((t) => ({
id: String(t.id),
id: String(t.id),
sortorder: t.sortorder,
sortorder: t.sortorder,
parent_id: isRealUUID(String(t.parent)) ? String(t.parent) : null,
parent_id: isRealUUID(String(t.parent)) ? String(t.parent) : null,
}));
}));
Copier
Copié
Copier
Copié
// Batch update via individual calls (Supabase doesn't support batch upsert on non-PK)
// Batch update via individual calls (Supabase doesn't support batch upsert on non-PK)
await Promise.all(
await Promise.all(
updates.map(({ id, sortorder, parent_id }) =>
updates.map(({ id, sortorder, parent_id }) =>
supabase
supabase
.from("tasks")
.from("tasks")
.update({ sortorder, parent_id })
.update({ sortorder, parent_id })
.eq("id", id),
.eq("id", id),
),
),
);
);
},
},
Copier
Copié
Copier
Copié
[
present,
dispatch],
[
dispatch],
);
);
Copier
Copié
Copier
Copié
// ── CRUD handler ───────────────────────────────────────────
// ── CRUD handler ───────────────────────────────────────────
const handleSave = useCallback(
const handleSave = useCallback(
async (
async (
entity: string,
entity: string,
action: string,
action: string,
item: any,
item: any,
id: string | number,
id: string | number,
) => {
) => {
Copier
Copié
Copier
Copié
console.log("[Gantt data.save]", entity, action, id, item);
const currentSnapshot = presentRef.current;
// ── TASK CRUD ────────────────────────────────────────
// ── TASK CRUD ────────────────────────────────────────
if (entity === "task") {
if (entity === "task") {
const task = item as Task;
const task = item as Task;
Copier
Copié
Copier
Copié
// Detect row reorder: task update with a `target` property
// Detect row reorder: task update with a `target` property
if (action === "update" && "target" in (item as any)) {
if (action === "update" && "target" in (item as any)) {
await handleReorder(task);
await handleReorder(task);
return;
return;
}
}
Copier
Copié
Copier
Copié
if (action === "create") {
if (action === "create") {
const sortorder = nextSortorder();
const sortorder = nextSortorder();
const newTask = serializeTask({ ...task, sortorder } as any);
const newTask = serializeTask({ ...task, sortorder } as any);
newTask.sortorder = sortorder;
newTask.sortorder = sortorder;
Copier
Copié
Copier
Copié
dispatch(
dispatch(
commit({
commit({
Copier
Copié
Copier
Copié
tasks: [...
present
.tasks, newTask],
tasks: [...
currentSnapshot
.tasks, newTask],
links:
present
.links,
links:
currentSnapshot
.links,
}),
}),
);
);
Copier
Copié
Copier
Copié
const payload = buildTaskInsert(task, projectId, sortorder);
const payload = buildTaskInsert(task, projectId, sortorder);
const { data, error: err } = await supabase
const { data, error: err } = await supabase
.from("tasks")
.from("tasks")
.insert(payload)
.insert(payload)
.select("id")
.select("id")
.single();
.single();
Copier
Copié
Copier
Copié
if (data) {
if (data) {
const realId = data.id;
const realId = data.id;
// Patch IDs in-place (no history push)
// Patch IDs in-place (no history push)
dispatch(
dispatch(
patch({
patch({
Copier
Copié
Copier
Copié
tasks:
present
.tasks
tasks:
currentSnapshot
.tasks
.concat(newTask)
.concat(newTask)
.map((t) => (t.id === task.id ? { ...t, id: realId } : t)),
.map((t) => (t.id === task.id ? { ...t, id: realId } : t)),
Copier
Copié
Copier
Copié
links:
present
.links.map((l) => ({
links:
currentSnapshot
.links.map((l) => ({
...l,
...l,
source: l.source === task.id ? realId : l.source,
source: l.source === task.id ? realId : l.source,
target: l.target === task.id ? realId : l.target,
target: l.target === task.id ? realId : l.target,
})),
})),
}),
}),
);
);
}
}
if (err) console.error("Task insert failed:", err);
if (err) console.error("Task insert failed:", err);
}
}
Copier
Copié
Copier
Copié
if (action === "update") {
if (action === "update") {
const updated = serializeTask(task);
const updated = serializeTask(task);
// Preserve sortorder from existing task
// Preserve sortorder from existing task
Copier
Copié
Copier
Copié
const existing =
present
.tasks.find((t) => t.id === id);
const existing =
currentSnapshot
.tasks.find((t) => t.id === id);
if (existing) updated.sortorder = existing.sortorder;
if (existing) updated.sortorder = existing.sortorder;
Copier
Copié
Copier
Copié
dispatch(
dispatch(
commit({
commit({
Copier
Copié
Copier
Copié
tasks:
present
.tasks.map((t) => (t.id === id ? updated : t)),
tasks:
currentSnapshot
.tasks.map((t) => (t.id === id ? updated : t)),
links:
present
.links,
links:
currentSnapshot
.links,
}),
}),
);
);
Copier
Copié
Copier
Copié
if (isRealUUID(String(id))) {
if (isRealUUID(String(id))) {
const payload = buildTaskUpdate(task);
const payload = buildTaskUpdate(task);
const { error: err } = await supabase
const { error: err } = await supabase
.from("tasks")
.from("tasks")
.update(payload)
.update(payload)
.eq("id", String(id));
.eq("id", String(id));
if (err) console.error("Task update failed:", err);
if (err) console.error("Task update failed:", err);
}
}
}
}
Copier
Copié
Copier
Copié
if (action === "delete") {
if (action === "delete") {
dispatch(
dispatch(
commit({
commit({
Copier
Copié
Copier
Copié
tasks:
present
.tasks.filter((t) => t.id !== id),
tasks:
currentSnapshot
.tasks.filter((t) => t.id !== id),
links:
present
.links,
links:
currentSnapshot
.links,
}),
}),
);
);
Copier
Copié
Copier
Copié
if (isRealUUID(String(id))) {
if (isRealUUID(String(id))) {
const { error: err } = await supabase
const { error: err } = await supabase
.from("tasks")
.from("tasks")
.delete()
.delete()
.eq("id", String(id));
.eq("id", String(id));
if (err) console.error("Task delete failed:", err);
if (err) console.error("Task delete failed:", err);
}
}
}
}
}
}
Copier
Copié
Copier
Copié
// ── LINK CRUD ────────────────────────────────────────
// ── LINK CRUD ────────────────────────────────────────
if (entity === "link") {
if (entity === "link") {
const link = item as Link;
const link = item as Link;
Copier
Copié
Copier
Copié
if (action === "create") {
if (action === "create") {
const newLink = serializeLink(link);
const newLink = serializeLink(link);
dispatch(
dispatch(
commit({
commit({
Copier
Copié
Copier
Copié
tasks:
present
.tasks,
tasks:
currentSnapshot
.tasks,
links: [...
present
.links, newLink],
links: [...
currentSnapshot
.links, newLink],
}),
}),
);
);
Copier
Copié
Copier
Copié
const payload = buildLinkInsert(link, projectId);
const payload = buildLinkInsert(link, projectId);
if (payload) {
if (payload) {
const { data, error: err } = await supabase
const { data, error: err } = await supabase
.from("links")
.from("links")
.insert(payload)
.insert(payload)
.select("id")
.select("id")
.single();
.single();
Copier
Copié
Copier
Copié
if (data) {
if (data) {
const realId = data.id;
const realId = data.id;
dispatch(
dispatch(
patch({
patch({
Copier
Copié
Copier
Copié
links:
present
.links
links:
currentSnapshot
.links
.concat(newLink)
.concat(newLink)
.map((l) => (l.id === link.id ? { ...l, id: realId } : l)),
.map((l) => (l.id === link.id ? { ...l, id: realId } : l)),
}),
}),
);
);
}
}
if (err) console.error("Link insert failed:", err);
if (err) console.error("Link insert failed:", err);
}
}
}
}
Copier
Copié
Copier
Copié
if (action === "delete") {
if (action === "delete") {
dispatch(
dispatch(
commit({
commit({
Copier
Copié
Copier
Copié
tasks:
present
.tasks,
tasks:
currentSnapshot
.tasks,
links:
present
.links.filter((l) => l.id !== id),
links:
currentSnapshot
.links.filter((l) => l.id !== id),
}),
}),
);
);
Copier
Copié
Copier
Copié
if (isRealUUID(String(id))) {
if (isRealUUID(String(id))) {
const { error: err } = await supabase
const { error: err } = await supabase
.from("links")
.from("links")
.delete()
.delete()
.eq("id", String(id));
.eq("id", String(id));
if (err) console.error("Link delete failed:", err);
if (err) console.error("Link delete failed:", err);
}
}
}
}
}
}
},
},
Copier
Copié
Copier
Copié
[projectId, nextSortorder, handleReorder,
present,
dispatch],
[projectId, nextSortorder, handleReorder,
dispatch],
);
);
Copier
Copié
Copier
Copié
// ── Undo/Redo handlers ────────────────────────────────────
// ── Undo/Redo handlers ────────────────────────────────────
Copier
Copié
Copier
Copié
const handleUndo = useCallback(
() =>
dispatch(undo())
, [dispatch
]);
const handleUndo = useCallback(
async
() =>
{
const handleRedo = useCallback(
() =>
dispatch(redo())
, [dispatch
]);
const previousSnapshot = presentRef.current;
const targetSnapshot = pastRef.current[pastRef.current.length - 1];
if (!targetSnapshot) return;
dispatch(undo());
try {
await persistSnapshot(previousSnapshot, targetSnapshot);
} catch (err) {
console.error("Undo persistence failed:", err);
}
}
, [dispatch
, persistSnapshot
]);
const handleRedo = useCallback(
async
() =>
{
const previousSnapshot = presentRef.current;
const targetSnapshot = futureRef.current[0];
if (!targetSnapshot) return;
dispatch(redo());
try {
await persistSnapshot(previousSnapshot, targetSnapshot);
} catch (err) {
console.error("Redo persistence failed:", err);
}
}
, [dispatch
, persistSnapshot
]);
// ── Config ─────────────────────────────────────────────────
// ── Config ─────────────────────────────────────────────────
const config: GanttConfig = useMemo(
const config: GanttConfig = useMemo(
() => ({
() => ({
grid_width: 340,
grid_width: 340,
row_height: 36,
row_height: 36,
bar_height: 24,
bar_height: 24,
scales: ZOOM_LEVELS[zoom].scales,
scales: ZOOM_LEVELS[zoom].scales,
columns: [
columns: [
{ name: "text", label: "Task", tree: true, width: "*" },
{ name: "text", label: "Task", tree: true, width: "*" },
{ name: "start_date", label: "Start", align: "center", width: 90 },
{ name: "start_date", label: "Start", align: "center", width: 90 },
{ name: "duration", label: "Days", align: "center", width: 60 },
{ name: "duration", label: "Days", align: "center", width: 60 },
...(readOnly ? [] : [{ name: "add", label: "", width: 44 }]),
...(readOnly ? [] : [{ name: "add", label: "", width: 44 }]),
],
],
drag_move: !readOnly,
drag_move: !readOnly,
drag_resize: !readOnly,
drag_resize: !readOnly,
readonly: readOnly,
readonly: readOnly,
order_branch: !readOnly ? "marker" : undefined,
order_branch: !readOnly ? "marker" : undefined,
order_branch_free: !readOnly,
order_branch_free: !readOnly,
}),
}),
[readOnly, zoom],
[readOnly, zoom],
);
);
Copier
Copié
Copier
Copié
const ganttTheme = appTheme === "dark" ? "dark" : "terrace";
const ganttTheme = appTheme === "dark" ? "dark" : "terrace";
Copier
Copié
Copier
Copié
if (isLoading) {
if (isLoading) {
return <Skeleton className="w-full h-full rounded-lg" />;
return <Skeleton className="w-full h-full rounded-lg" />;
}
}
Copier
Copié
Copier
Copié
if (error) {
if (error) {
return <p className="text-destructive">Failed to load Gantt data.</p>;
return <p className="text-destructive">Failed to load Gantt data.</p>;
}
}
Copier
Copié
Copier
Copié
return (
return (
<div className="flex flex-col w-full h-full">
<div className="flex flex-col w-full h-full">
<GanttToolbar
<GanttToolbar
zoom={zoom}
zoom={zoom}
onZoomChange={setZoom}
onZoomChange={setZoom}
canUndo={past.length > 0}
canUndo={past.length > 0}
canRedo={future.length > 0}
canRedo={future.length > 0}
onUndo={handleUndo}
onUndo={handleUndo}
onRedo={handleRedo}
onRedo={handleRedo}
readOnly={readOnly}
readOnly={readOnly}
/>
/>
<div className="flex-1 min-h-0">
<div className="flex-1 min-h-0">
<Gantt
<Gantt
ref={ganttRef}
ref={ganttRef}
tasks={tasks}
tasks={tasks}
links={links}
links={links}
config={config}
config={config}
theme={ganttTheme}
theme={ganttTheme}
data={{ save: handleSave }}
data={{ save: handleSave }}
/>
/>
</div>
</div>
</div>
</div>
);
);
Copier
Copié
Copier
Copié
}
Différences enregistrées
Texte d'origine
Ouvrir un fichier
import { useMemo, useRef, useState, useEffect, useCallback } from "react"; import Gantt, { type GanttConfig, type Link, type ReactGanttRef, type Task, } from "@dhtmlx/trial-react-gantt"; import "@dhtmlx/trial-react-gantt/dist/react-gantt.css"; import { useTheme } from "@/hooks/use-theme"; import { useGanttData } from "@/features/gantt/api/useGanttData"; import { Skeleton } from "@/components/ui/skeleton"; import { supabase } from "@/integrations/supabase/client"; import { isRealUUID, buildTaskInsert, buildTaskUpdate, buildLinkInsert, } from "@/features/gantt/utils/payload"; import { useAppDispatch, useAppSelector } from "@/features/gantt/store"; import { hydrate, reset, commit, patch, undo, redo, } from "@/features/gantt/store/ganttSlice"; import { serializeTask, serializeLink, deserializeTask, deserializeLink, } from "@/features/gantt/store/serialization"; import type { SerializedTask, SerializedLink } from "@/features/gantt/store/types"; import { GanttToolbar } from "./GanttToolbar"; import { ZOOM_LEVELS, type ZoomLevel } from "@/features/gantt/utils/zoom"; interface ProjectGanttProps { projectId: string; readOnly?: boolean; } export default function ProjectGantt({ projectId, readOnly = false }: ProjectGanttProps) { const ganttRef = useRef<ReactGanttRef>(null); const { theme: appTheme } = useTheme(); const { tasks: dbTasks, links: dbLinks, isLoading, error } = useGanttData(projectId); const dispatch = useAppDispatch(); const { past, present, future } = useAppSelector((s) => s.gantt); const [zoom, setZoom] = useState<ZoomLevel>("week"); // Derive live Gantt arrays from Redux (deserialize dates) const tasks: Task[] = useMemo(() => present.tasks.map(deserializeTask), [present.tasks]); const links: Link[] = useMemo(() => present.links.map(deserializeLink), [present.links]); // ── Hydrate from DB ─────────────────────────────────────── const prevProjectId = useRef<string | null>(null); useEffect(() => { if (projectId !== prevProjectId.current) { dispatch(reset()); prevProjectId.current = projectId; } }, [projectId, dispatch]); useEffect(() => { if (!isLoading && (dbTasks.length > 0 || dbLinks.length > 0)) { dispatch( hydrate({ tasks: dbTasks.map(serializeTask), links: dbLinks.map(serializeLink), }), ); } }, [dbTasks, dbLinks, isLoading, dispatch]); // ── Helpers ──────────────────────────────────────────────── const nextSortorder = useCallback((): number => { let max = 0; for (const t of present.tasks) { if (t.sortorder > max) max = t.sortorder; } return max + 1; }, [present.tasks]); // ── Row reorder ──────────────────────────────────────────── const handleReorder = useCallback( async (movedTask: Task) => { const moved = movedTask as any; const targetId = moved.target; const newParent = moved.parent; // Build the current ordered list const currentTasks = [...present.tasks]; // Remove the moved task from its current position const movedIdx = currentTasks.findIndex((t) => t.id === moved.id); if (movedIdx === -1) return; const [movedItem] = currentTasks.splice(movedIdx, 1); // Update parent if changed const updatedItem: SerializedTask = { ...movedItem, parent: newParent ?? movedItem.parent, }; // Find target position let insertIdx: number; if (!targetId) { // Dropped at the end insertIdx = currentTasks.length; } else { const targetIdx = currentTasks.findIndex((t) => t.id === targetId); if (targetIdx === -1) { insertIdx = currentTasks.length; } else { insertIdx = targetIdx; } } currentTasks.splice(insertIdx, 0, updatedItem); // Recompute sortorder for all tasks const reordered = currentTasks.map((t, i) => ({ ...t, sortorder: i + 1, })); // Commit to Redux (with history) dispatch( commit({ tasks: reordered, links: present.links, }), ); // Persist all affected sortorders + parent_id to Supabase const updates = reordered .filter((t) => isRealUUID(String(t.id))) .map((t) => ({ id: String(t.id), sortorder: t.sortorder, parent_id: isRealUUID(String(t.parent)) ? String(t.parent) : null, })); // Batch update via individual calls (Supabase doesn't support batch upsert on non-PK) await Promise.all( updates.map(({ id, sortorder, parent_id }) => supabase .from("tasks") .update({ sortorder, parent_id }) .eq("id", id), ), ); }, [present, dispatch], ); // ── CRUD handler ─────────────────────────────────────────── const handleSave = useCallback( async ( entity: string, action: string, item: any, id: string | number, ) => { console.log("[Gantt data.save]", entity, action, id, item); // ── TASK CRUD ──────────────────────────────────────── if (entity === "task") { const task = item as Task; // Detect row reorder: task update with a `target` property if (action === "update" && "target" in (item as any)) { await handleReorder(task); return; } if (action === "create") { const sortorder = nextSortorder(); const newTask = serializeTask({ ...task, sortorder } as any); newTask.sortorder = sortorder; dispatch( commit({ tasks: [...present.tasks, newTask], links: present.links, }), ); const payload = buildTaskInsert(task, projectId, sortorder); const { data, error: err } = await supabase .from("tasks") .insert(payload) .select("id") .single(); if (data) { const realId = data.id; // Patch IDs in-place (no history push) dispatch( patch({ tasks: present.tasks .concat(newTask) .map((t) => (t.id === task.id ? { ...t, id: realId } : t)), links: present.links.map((l) => ({ ...l, source: l.source === task.id ? realId : l.source, target: l.target === task.id ? realId : l.target, })), }), ); } if (err) console.error("Task insert failed:", err); } if (action === "update") { const updated = serializeTask(task); // Preserve sortorder from existing task const existing = present.tasks.find((t) => t.id === id); if (existing) updated.sortorder = existing.sortorder; dispatch( commit({ tasks: present.tasks.map((t) => (t.id === id ? updated : t)), links: present.links, }), ); if (isRealUUID(String(id))) { const payload = buildTaskUpdate(task); const { error: err } = await supabase .from("tasks") .update(payload) .eq("id", String(id)); if (err) console.error("Task update failed:", err); } } if (action === "delete") { dispatch( commit({ tasks: present.tasks.filter((t) => t.id !== id), links: present.links, }), ); if (isRealUUID(String(id))) { const { error: err } = await supabase .from("tasks") .delete() .eq("id", String(id)); if (err) console.error("Task delete failed:", err); } } } // ── LINK CRUD ──────────────────────────────────────── if (entity === "link") { const link = item as Link; if (action === "create") { const newLink = serializeLink(link); dispatch( commit({ tasks: present.tasks, links: [...present.links, newLink], }), ); const payload = buildLinkInsert(link, projectId); if (payload) { const { data, error: err } = await supabase .from("links") .insert(payload) .select("id") .single(); if (data) { const realId = data.id; dispatch( patch({ links: present.links .concat(newLink) .map((l) => (l.id === link.id ? { ...l, id: realId } : l)), }), ); } if (err) console.error("Link insert failed:", err); } } if (action === "delete") { dispatch( commit({ tasks: present.tasks, links: present.links.filter((l) => l.id !== id), }), ); if (isRealUUID(String(id))) { const { error: err } = await supabase .from("links") .delete() .eq("id", String(id)); if (err) console.error("Link delete failed:", err); } } } }, [projectId, nextSortorder, handleReorder, present, dispatch], ); // ── Undo/Redo handlers ──────────────────────────────────── const handleUndo = useCallback(() => dispatch(undo()), [dispatch]); const handleRedo = useCallback(() => dispatch(redo()), [dispatch]); // ── Config ───────────────────────────────────────────────── const config: GanttConfig = useMemo( () => ({ grid_width: 340, row_height: 36, bar_height: 24, scales: ZOOM_LEVELS[zoom].scales, columns: [ { name: "text", label: "Task", tree: true, width: "*" }, { name: "start_date", label: "Start", align: "center", width: 90 }, { name: "duration", label: "Days", align: "center", width: 60 }, ...(readOnly ? [] : [{ name: "add", label: "", width: 44 }]), ], drag_move: !readOnly, drag_resize: !readOnly, readonly: readOnly, order_branch: !readOnly ? "marker" : undefined, order_branch_free: !readOnly, }), [readOnly, zoom], ); const ganttTheme = appTheme === "dark" ? "dark" : "terrace"; if (isLoading) { return <Skeleton className="w-full h-full rounded-lg" />; } if (error) { return <p className="text-destructive">Failed to load Gantt data.</p>; } return ( <div className="flex flex-col w-full h-full"> <GanttToolbar zoom={zoom} onZoomChange={setZoom} canUndo={past.length > 0} canRedo={future.length > 0} onUndo={handleUndo} onRedo={handleRedo} readOnly={readOnly} /> <div className="flex-1 min-h-0"> <Gantt ref={ganttRef} tasks={tasks} links={links} config={config} theme={ganttTheme} data={{ save: handleSave }} /> </div> </div> ); }
Texte modifié
Ouvrir un fichier
import { useMemo, useRef, useState, useEffect, useCallback } from "react"; import Gantt, { type GanttConfig, type Link, type ReactGanttRef, type Task, } from "@dhtmlx/trial-react-gantt"; import "@dhtmlx/trial-react-gantt/dist/react-gantt.css"; import { useTheme } from "@/hooks/use-theme"; import { useGanttData } from "@/features/gantt/api/useGanttData"; import { Skeleton } from "@/components/ui/skeleton"; import { supabase } from "@/integrations/supabase/client"; import { isRealUUID, buildTaskInsert, buildTaskUpdate, buildLinkInsert, } from "@/features/gantt/utils/payload"; import { useAppDispatch, useAppSelector } from "@/features/gantt/store"; import { hydrate, reset, commit, patch, undo, redo, } from "@/features/gantt/store/ganttSlice"; import { serializeTask, serializeLink, deserializeTask, deserializeLink, } from "@/features/gantt/store/serialization"; import type { GanttSnapshot, SerializedTask, SerializedLink } from "@/features/gantt/store/types"; import { GanttToolbar } from "./GanttToolbar"; import { ZOOM_LEVELS, type ZoomLevel } from "@/features/gantt/utils/zoom"; interface ProjectGanttProps { projectId: string; readOnly?: boolean; } export default function ProjectGantt({ projectId, readOnly = false }: ProjectGanttProps) { const ganttRef = useRef<ReactGanttRef>(null); const { theme: appTheme } = useTheme(); const { tasks: dbTasks, links: dbLinks, isLoading, error } = useGanttData(projectId); const dispatch = useAppDispatch(); const { past, present, future } = useAppSelector((s) => s.gantt); const presentRef = useRef(present); presentRef.current = present; const pastRef = useRef(past); pastRef.current = past; const futureRef = useRef(future); futureRef.current = future; const [zoom, setZoom] = useState<ZoomLevel>("day"); // Derive live Gantt arrays from Redux (deserialize dates) const tasks: Task[] = useMemo(() => present.tasks.map(deserializeTask), [present.tasks]); const links: Link[] = useMemo(() => present.links.map(deserializeLink), [present.links]); // ── Hydrate from DB ─────────────────────────────────────── const prevProjectId = useRef<string | null>(null); useEffect(() => { if (projectId !== prevProjectId.current) { dispatch(reset()); prevProjectId.current = projectId; } }, [projectId, dispatch]); useEffect(() => { if (!isLoading && (dbTasks.length > 0 || dbLinks.length > 0)) { dispatch( hydrate({ tasks: dbTasks.map(serializeTask), links: dbLinks.map(serializeLink), }), ); } }, [dbTasks, dbLinks, isLoading, dispatch]); // ── Helpers ──────────────────────────────────────────────── const nextSortorder = useCallback((): number => { let max = 0; for (const t of presentRef.current.tasks) { if (t.sortorder > max) max = t.sortorder; } return max + 1; }, []); const persistSnapshot = useCallback( async (previousSnapshot: GanttSnapshot, nextSnapshot: GanttSnapshot) => { const nextTaskRows = nextSnapshot.tasks .filter((task) => isRealUUID(String(task.id))) .map((task) => ({ id: String(task.id), project_id: projectId, text: task.text, start_date: task.start_date, duration: task.duration, progress: task.progress, parent_id: isRealUUID(String(task.parent)) ? String(task.parent) : null, sortorder: task.sortorder, type: task.type ?? "task", assignee_user_id: task.assignee_user_id ?? null, })); const previousTaskIds = new Set( previousSnapshot.tasks .filter((task) => isRealUUID(String(task.id))) .map((task) => String(task.id)), ); const nextTaskIds = new Set(nextTaskRows.map((task) => task.id)); const deletedTaskIds = [...previousTaskIds].filter((id) => !nextTaskIds.has(id)); if (nextTaskRows.length > 0) { const { error } = await supabase.from("tasks").upsert(nextTaskRows); if (error) throw error; } if (deletedTaskIds.length > 0) { const { error } = await supabase.from("tasks").delete().in("id", deletedTaskIds); if (error) throw error; } const previousLinks = new Map( previousSnapshot.links .filter((link) => isRealUUID(String(link.id))) .map((link) => [String(link.id), link] as const), ); const nextLinks = new Map( nextSnapshot.links .filter( (link) => isRealUUID(String(link.id)) && isRealUUID(String(link.source)) && isRealUUID(String(link.target)), ) .map((link) => [String(link.id), link] as const), ); const deletedLinkIds = [...previousLinks.keys()].filter((id) => !nextLinks.has(id)); const insertedLinks = [...nextLinks.entries()] .filter(([id]) => !previousLinks.has(id)) .map(([, link]) => ({ id: String(link.id), project_id: projectId, source: String(link.source), target: String(link.target), type: String(link.type), })); if (deletedLinkIds.length > 0) { const { error } = await supabase.from("links").delete().in("id", deletedLinkIds); if (error) throw error; } if (insertedLinks.length > 0) { const { error } = await supabase.from("links").insert(insertedLinks); if (error) throw error; } }, [projectId], ); // ── Row reorder ──────────────────────────────────────────── const handleReorder = useCallback( async (movedTask: Task) => { const currentSnapshot = presentRef.current; const moved = movedTask as any; const targetId = moved.target; const newParent = moved.parent; // Build the current ordered list const currentTasks = [...currentSnapshot.tasks]; // Remove the moved task from its current position const movedIdx = currentTasks.findIndex((t) => t.id === moved.id); if (movedIdx === -1) return; const [movedItem] = currentTasks.splice(movedIdx, 1); // Update parent if changed const updatedItem: SerializedTask = { ...movedItem, parent: newParent ?? movedItem.parent, }; // Find target position let insertIdx: number; if (!targetId) { // Dropped at the end insertIdx = currentTasks.length; } else { const targetIdx = currentTasks.findIndex((t) => t.id === targetId); if (targetIdx === -1) { insertIdx = currentTasks.length; } else { insertIdx = targetIdx; } } currentTasks.splice(insertIdx, 0, updatedItem); // Recompute sortorder for all tasks const reordered = currentTasks.map((t, i) => ({ ...t, sortorder: i + 1, })); // Commit to Redux (with history) dispatch( commit({ tasks: reordered, links: currentSnapshot.links, }), ); // Persist all affected sortorders + parent_id to Supabase const updates = reordered .filter((t) => isRealUUID(String(t.id))) .map((t) => ({ id: String(t.id), sortorder: t.sortorder, parent_id: isRealUUID(String(t.parent)) ? String(t.parent) : null, })); // Batch update via individual calls (Supabase doesn't support batch upsert on non-PK) await Promise.all( updates.map(({ id, sortorder, parent_id }) => supabase .from("tasks") .update({ sortorder, parent_id }) .eq("id", id), ), ); }, [dispatch], ); // ── CRUD handler ─────────────────────────────────────────── const handleSave = useCallback( async ( entity: string, action: string, item: any, id: string | number, ) => { const currentSnapshot = presentRef.current; // ── TASK CRUD ──────────────────────────────────────── if (entity === "task") { const task = item as Task; // Detect row reorder: task update with a `target` property if (action === "update" && "target" in (item as any)) { await handleReorder(task); return; } if (action === "create") { const sortorder = nextSortorder(); const newTask = serializeTask({ ...task, sortorder } as any); newTask.sortorder = sortorder; dispatch( commit({ tasks: [...currentSnapshot.tasks, newTask], links: currentSnapshot.links, }), ); const payload = buildTaskInsert(task, projectId, sortorder); const { data, error: err } = await supabase .from("tasks") .insert(payload) .select("id") .single(); if (data) { const realId = data.id; // Patch IDs in-place (no history push) dispatch( patch({ tasks: currentSnapshot.tasks .concat(newTask) .map((t) => (t.id === task.id ? { ...t, id: realId } : t)), links: currentSnapshot.links.map((l) => ({ ...l, source: l.source === task.id ? realId : l.source, target: l.target === task.id ? realId : l.target, })), }), ); } if (err) console.error("Task insert failed:", err); } if (action === "update") { const updated = serializeTask(task); // Preserve sortorder from existing task const existing = currentSnapshot.tasks.find((t) => t.id === id); if (existing) updated.sortorder = existing.sortorder; dispatch( commit({ tasks: currentSnapshot.tasks.map((t) => (t.id === id ? updated : t)), links: currentSnapshot.links, }), ); if (isRealUUID(String(id))) { const payload = buildTaskUpdate(task); const { error: err } = await supabase .from("tasks") .update(payload) .eq("id", String(id)); if (err) console.error("Task update failed:", err); } } if (action === "delete") { dispatch( commit({ tasks: currentSnapshot.tasks.filter((t) => t.id !== id), links: currentSnapshot.links, }), ); if (isRealUUID(String(id))) { const { error: err } = await supabase .from("tasks") .delete() .eq("id", String(id)); if (err) console.error("Task delete failed:", err); } } } // ── LINK CRUD ──────────────────────────────────────── if (entity === "link") { const link = item as Link; if (action === "create") { const newLink = serializeLink(link); dispatch( commit({ tasks: currentSnapshot.tasks, links: [...currentSnapshot.links, newLink], }), ); const payload = buildLinkInsert(link, projectId); if (payload) { const { data, error: err } = await supabase .from("links") .insert(payload) .select("id") .single(); if (data) { const realId = data.id; dispatch( patch({ links: currentSnapshot.links .concat(newLink) .map((l) => (l.id === link.id ? { ...l, id: realId } : l)), }), ); } if (err) console.error("Link insert failed:", err); } } if (action === "delete") { dispatch( commit({ tasks: currentSnapshot.tasks, links: currentSnapshot.links.filter((l) => l.id !== id), }), ); if (isRealUUID(String(id))) { const { error: err } = await supabase .from("links") .delete() .eq("id", String(id)); if (err) console.error("Link delete failed:", err); } } } }, [projectId, nextSortorder, handleReorder, dispatch], ); // ── Undo/Redo handlers ──────────────────────────────────── const handleUndo = useCallback(async () => { const previousSnapshot = presentRef.current; const targetSnapshot = pastRef.current[pastRef.current.length - 1]; if (!targetSnapshot) return; dispatch(undo()); try { await persistSnapshot(previousSnapshot, targetSnapshot); } catch (err) { console.error("Undo persistence failed:", err); } }, [dispatch, persistSnapshot]); const handleRedo = useCallback(async () => { const previousSnapshot = presentRef.current; const targetSnapshot = futureRef.current[0]; if (!targetSnapshot) return; dispatch(redo()); try { await persistSnapshot(previousSnapshot, targetSnapshot); } catch (err) { console.error("Redo persistence failed:", err); } }, [dispatch, persistSnapshot]); // ── Config ───────────────────────────────────────────────── const config: GanttConfig = useMemo( () => ({ grid_width: 340, row_height: 36, bar_height: 24, scales: ZOOM_LEVELS[zoom].scales, columns: [ { name: "text", label: "Task", tree: true, width: "*" }, { name: "start_date", label: "Start", align: "center", width: 90 }, { name: "duration", label: "Days", align: "center", width: 60 }, ...(readOnly ? [] : [{ name: "add", label: "", width: 44 }]), ], drag_move: !readOnly, drag_resize: !readOnly, readonly: readOnly, order_branch: !readOnly ? "marker" : undefined, order_branch_free: !readOnly, }), [readOnly, zoom], ); const ganttTheme = appTheme === "dark" ? "dark" : "terrace"; if (isLoading) { return <Skeleton className="w-full h-full rounded-lg" />; } if (error) { return <p className="text-destructive">Failed to load Gantt data.</p>; } return ( <div className="flex flex-col w-full h-full"> <GanttToolbar zoom={zoom} onZoomChange={setZoom} canUndo={past.length > 0} canRedo={future.length > 0} onUndo={handleUndo} onRedo={handleRedo} readOnly={readOnly} /> <div className="flex-1 min-h-0"> <Gantt ref={ganttRef} tasks={tasks} links={links} config={config} theme={ganttTheme} data={{ save: handleSave }} /> </div> </div> );
Trouver la différence