Undo/Redo Persistence

建立於 差異永不過期
24 刪除
374
191 新增
532
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 { 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";



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);
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>("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]);



// ── 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({
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;
for (const t of present.tasks) {
for (const t of presentRef.current.tasks) {
if (t.sortorder > max) max = t.sortorder;
if (t.sortorder > max) max = t.sortorder;
}
}
return max + 1;
return max + 1;
}, [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) => {
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
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,
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),
),
),
);
);
},
},
[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);
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({
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({
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)),
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
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({
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({
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({
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({
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({
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);
}
}
}
}
}
}
},
},
[projectId, nextSortorder, handleReorder, present, dispatch],
[projectId, nextSortorder, handleReorder, dispatch],
);
);



// ── Undo/Redo handlers ────────────────────────────────────
// ── Undo/Redo handlers ────────────────────────────────────
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],
);
);



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>
);
);
}