Diff
checker
Text
Text
Images
Documents
Excel
Folders
Legal
Enterprise
Desktop
Pricing
Sign in
Download Diffchecker Desktop
Compare text
Find the difference between two text files
Tools
History
Real-time editor
Hide whitespace changes
Hide unchanged lines
Disable line wrap
Layout
Split
Unified
Diff precision
Smart
Word
Char
Text styles
Change appearance
Syntax highlighting
Choose syntax
Ignore
Transform text
Go to first change
Edit input
Diffchecker Desktop
The most secure way to run Diffchecker. Get the Diffchecker Desktop app: your diffs never leave your computer!
Get Desktop
Fresh Redux Snapshot Access - 1
Created
3 months ago
Diff never expires
Clear
Export
Share
Explain
32 removals
Lines
Total
Removed
Characters
Total
Removed
To continue using this feature, upgrade to
Diff
checker
Pro
View Pricing
374 lines
Copy
25 additions
Lines
Total
Added
Characters
Total
Added
To continue using this feature, upgrade to
Diff
checker
Pro
View Pricing
379 lines
Copy
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);
Copy
Copied
Copy
Copied
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({
Copy
Copied
Copy
Copied
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;
Copy
Copied
Copy
Copied
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;
Copy
Copied
Copy
Copied
}, [
present.tasks
]);
}, [
]);
// ── Row reorder ────────────────────────────────────────────
// ── Row reorder ────────────────────────────────────────────
const handleReorder = useCallback(
const handleReorder = useCallback(
async (movedTask: Task) => {
async (movedTask: Task) => {
Copy
Copied
Copy
Copied
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
Copy
Copied
Copy
Copied
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,
Copy
Copied
Copy
Copied
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),
),
),
);
);
},
},
Copy
Copied
Copy
Copied
[
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);
Copy
Copied
Copy
Copied
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({
Copy
Copied
Copy
Copied
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({
Copy
Copied
Copy
Copied
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)),
Copy
Copied
Copy
Copied
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
Copy
Copied
Copy
Copied
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({
Copy
Copied
Copy
Copied
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({
Copy
Copied
Copy
Copied
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({
Copy
Copied
Copy
Copied
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({
Copy
Copied
Copy
Copied
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({
Copy
Copied
Copy
Copied
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);
}
}
}
}
}
}
},
},
Copy
Copied
Copy
Copied
[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>
);
);
Copy
Copied
Copy
Copied
}
Saved diffs
Original text
Open 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> ); }
Changed text
Open 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> );
Find difference