Diff
checker
Testo
Testo
Immagini
Documenti
Excel
Cartelle
Legal
Enterprise
Applicazione per desktop
Prezzi
Accedi
Scarica Diffchecker Desktop
Confronta il testo
Trova la differenza tra due file di testo
Strumenti
Cronologia
Editor live
Nascondi spazi bianchi
Comprimi invariate
Senza a capo
Layout
Diviso
Unificato
Livello di dettaglio
Intelligente
Parola
Carattere
Stili testo
Modifica aspetto
Evidenziazione sintassi
Scegli sintassi
Ignora
Trasforma testo
Vai alla prima modifica
Modifica input
Diffchecker Desktop
Il modo più sicuro per usare Diffchecker. Ottieni l'app Diffchecker Desktop: i tuoi diff non lasciano mai il tuo computer!
Ottieni Desktop
Fresh Redux Snapshot Access - 1
Creato
3 mesi fa
Il diff non scade mai
Eliminare
Esporta
Condividere
Spiegare
32 rimozioni
Linee
Totale
Rimosso
Caratteri
Totale
Rimosso
Per continuare a utilizzare questa funzione, aggiorna a
Diff
checker
Pro
Visualizza prezzi
374 linee
Copia tutti
25 aggiunte
Linee
Totale
Aggiunto
Caratteri
Totale
Aggiunto
Per continuare a utilizzare questa funzione, aggiorna a
Diff
checker
Pro
Visualizza prezzi
379 linee
Copia tutti
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";
import type { SerializedTask, SerializedLink } from "@/features/gantt/store/types";
import type { 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";
interface ProjectGanttProps {
interface ProjectGanttProps {
projectId: string;
projectId: string;
readOnly?: boolean;
readOnly?: boolean;
}
}
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);
const dispatch = useAppDispatch();
const dispatch = useAppDispatch();
const { past, present, future } = useAppSelector((s) => s.gantt);
const { past, present, future } = useAppSelector((s) => s.gantt);
Copia
Copiato
Copia
Copiato
const presentRef = useRef(present);
presentRef.current = present;
const [zoom, setZoom] = useState<ZoomLevel>("week");
const [zoom, setZoom] = useState<ZoomLevel>("week");
// 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]);
// ── Hydrate from DB ───────────────────────────────────────
// ── Hydrate from DB ───────────────────────────────────────
const prevProjectId = useRef<string | null>(null);
const prevProjectId = useRef<string | null>(null);
useEffect(() => {
useEffect(() => {
if (projectId !== prevProjectId.current) {
if (projectId !== prevProjectId.current) {
dispatch(reset());
dispatch(reset());
prevProjectId.current = projectId;
prevProjectId.current = projectId;
}
}
}, [projectId, dispatch]);
}, [projectId, dispatch]);
useEffect(() => {
useEffect(() => {
if (!isLoading && (dbTasks.length > 0 || dbLinks.length > 0)) {
if (!isLoading && (dbTasks.length > 0 || dbLinks.length > 0)) {
dispatch(
dispatch(
hydrate({
hydrate({
Copia
Copiato
Copia
Copiato
tasks: dbTasks.map(serializeTask),
tasks: dbTasks.map(serializeTask),
links: dbLinks.map(serializeLink),
links: dbLinks.map(serializeLink),
}),
}),
);
);
}
}
}, [dbTasks, dbLinks, isLoading, dispatch]);
}, [dbTasks, dbLinks, isLoading, dispatch]);
// ── Helpers ────────────────────────────────────────────────
// ── Helpers ────────────────────────────────────────────────
const nextSortorder = useCallback((): number => {
const nextSortorder = useCallback((): number => {
let max = 0;
let max = 0;
Copia
Copiato
Copia
Copiato
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;
Copia
Copiato
Copia
Copiato
}, [
present.tasks
]);
}, [
]);
// ── Row reorder ────────────────────────────────────────────
// ── Row reorder ────────────────────────────────────────────
const handleReorder = useCallback(
const handleReorder = useCallback(
async (movedTask: Task) => {
async (movedTask: Task) => {
Copia
Copiato
Copia
Copiato
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;
// Build the current ordered list
// Build the current ordered list
Copia
Copiato
Copia
Copiato
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);
// Update parent if changed
// Update parent if changed
const updatedItem: SerializedTask = {
const updatedItem: SerializedTask = {
...movedItem,
...movedItem,
parent: newParent ?? movedItem.parent,
parent: newParent ?? movedItem.parent,
};
};
// 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;
}
}
}
}
currentTasks.splice(insertIdx, 0, updatedItem);
currentTasks.splice(insertIdx, 0, updatedItem);
// 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,
}));
}));
// Commit to Redux (with history)
// Commit to Redux (with history)
dispatch(
dispatch(
commit({
commit({
tasks: reordered,
tasks: reordered,
Copia
Copiato
Copia
Copiato
links:
present
.links,
links:
currentSnapshot
.links,
}),
}),
);
);
// 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,
}));
}));
// 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),
),
),
);
);
},
},
Copia
Copiato
Copia
Copiato
[
present,
dispatch],
[
dispatch],
);
);
// ── 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,
) => {
) => {
console.log("[Gantt data.save]", entity, action, id, item);
console.log("[Gantt data.save]", entity, action, id, item);
Copia
Copiato
Copia
Copiato
const currentSnapshot = presentRef.current;
// ── TASK CRUD ────────────────────────────────────────
// ── TASK CRUD ────────────────────────────────────────
if (entity === "task") {
if (entity === "task") {
const task = item as Task;
const task = item as Task;
// 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;
}
}
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;
dispatch(
dispatch(
commit({
commit({
Copia
Copiato
Copia
Copiato
tasks: [...
present
.tasks, newTask],
tasks: [...
currentSnapshot
.tasks, newTask],
links:
present
.links,
links:
currentSnapshot
.links,
}),
}),
);
);
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();
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({
Copia
Copiato
Copia
Copiato
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)),
Copia
Copiato
Copia
Copiato
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);
}
}
if (action === "update") {
if (action === "update") {
const updated = serializeTask(task);
const updated = serializeTask(task);
// Preserve sortorder from existing task
// Preserve sortorder from existing task
Copia
Copiato
Copia
Copiato
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;
dispatch(
dispatch(
commit({
commit({
Copia
Copiato
Copia
Copiato
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,
}),
}),
);
);
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);
}
}
}
}
if (action === "delete") {
if (action === "delete") {
dispatch(
dispatch(
commit({
commit({
Copia
Copiato
Copia
Copiato
tasks:
present
.tasks.filter((t) => t.id !== id),
tasks:
currentSnapshot
.tasks.filter((t) => t.id !== id),
links:
present
.links,
links:
currentSnapshot
.links,
}),
}),
);
);
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);
}
}
}
}
}
}
// ── LINK CRUD ────────────────────────────────────────
// ── LINK CRUD ────────────────────────────────────────
if (entity === "link") {
if (entity === "link") {
const link = item as Link;
const link = item as Link;
if (action === "create") {
if (action === "create") {
const newLink = serializeLink(link);
const newLink = serializeLink(link);
dispatch(
dispatch(
commit({
commit({
Copia
Copiato
Copia
Copiato
tasks:
present
.tasks,
tasks:
currentSnapshot
.tasks,
links: [...
present
.links, newLink],
links: [...
currentSnapshot
.links, newLink],
}),
}),
);
);
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();
if (data) {
if (data) {
const realId = data.id;
const realId = data.id;
dispatch(
dispatch(
patch({
patch({
Copia
Copiato
Copia
Copiato
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);
}
}
}
}
if (action === "delete") {
if (action === "delete") {
dispatch(
dispatch(
commit({
commit({
Copia
Copiato
Copia
Copiato
tasks:
present
.tasks,
tasks:
currentSnapshot
.tasks,
links:
present
.links.filter((l) => l.id !== id),
links:
currentSnapshot
.links.filter((l) => l.id !== id),
}),
}),
);
);
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);
}
}
}
}
}
}
},
},
Copia
Copiato
Copia
Copiato
[projectId, nextSortorder, handleReorder,
present,
dispatch],
[projectId, nextSortorder, handleReorder,
dispatch],
);
);
// ── Undo/Redo handlers ────────────────────────────────────
// ── Undo/Redo handlers ────────────────────────────────────
const handleUndo = useCallback(() => dispatch(undo()), [dispatch]);
const handleUndo = useCallback(() => dispatch(undo()), [dispatch]);
const handleRedo = useCallback(() => dispatch(redo()), [dispatch]);
const handleRedo = useCallback(() => dispatch(redo()), [dispatch]);
// ── 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],
);
);
const ganttTheme = appTheme === "dark" ? "dark" : "terrace";
const ganttTheme = appTheme === "dark" ? "dark" : "terrace";
if (isLoading) {
if (isLoading) {
return <Skeleton className="w-full h-full rounded-lg" />;
return <Skeleton className="w-full h-full rounded-lg" />;
}
}
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>;
}
}
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>
);
);
Copia
Copiato
Copia
Copiato
}
Diff salvati
Testo originale
Apri file
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> ); }
Testo modificato
Apri file
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 presentRef = useRef(present); presentRef.current = present; 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 presentRef.current.tasks) { if (t.sortorder > max) max = t.sortorder; } return max + 1; }, []); // ── 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, ) => { console.log("[Gantt data.save]", entity, action, id, item); 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(() => 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> );
Trovare la differenza