Diff
checker
文本
文本
圖像
文檔
Excel
文件夾
Legal
Enterprise
桌面版
定價
登入
下載 Diffchecker 桌面版
比較文本
尋找兩個文字檔案之間的差異
工具
歷史
即時編輯器
隱藏空白變更
摺疊未變更行
關閉換行
檢視
拆分
統一
比對精度
智能
單詞
字符
文字樣式
變更外觀
語法突出顯示
選擇語法
忽略
文字轉換
前往第一個差異
編輯輸入
Diffchecker Desktop
執行Diffchecker最安全的方式。取得Diffchecker桌面應用程式:您的差異永遠不會離開您的電腦!
取得桌面版
Undo/Redo Persistence
建立於
3 個月前
差異永不過期
清除
匯出
分享
解釋
24 刪除
行
總計
刪除
字符
總計
刪除
要繼續使用此功能,請升級到
Diff
checker
Pro
查看價格
374 行
全部複製
191 新增
行
總計
新增
字符
總計
新增
要繼續使用此功能,請升級到
Diff
checker
Pro
查看價格
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 present
Ref.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>
);
);
複製
已複製
複製
已複製
}
已保存差異
原始文本
開啟檔案
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> ); }
更改後文本
開啟檔案
import { useMemo, useRef, useState, useEffect, useCallback } from "react"; import Gantt, { type GanttConfig, type Link, type ReactGanttRef, type Task, } from "@dhtmlx/trial-react-gantt"; import "@dhtmlx/trial-react-gantt/dist/react-gantt.css"; import { useTheme } from "@/hooks/use-theme"; import { useGanttData } from "@/features/gantt/api/useGanttData"; import { Skeleton } from "@/components/ui/skeleton"; import { supabase } from "@/integrations/supabase/client"; import { isRealUUID, buildTaskInsert, buildTaskUpdate, buildLinkInsert, } from "@/features/gantt/utils/payload"; import { useAppDispatch, useAppSelector } from "@/features/gantt/store"; import { hydrate, reset, commit, patch, undo, redo, } from "@/features/gantt/store/ganttSlice"; import { serializeTask, serializeLink, deserializeTask, deserializeLink, } from "@/features/gantt/store/serialization"; import type { GanttSnapshot, SerializedTask, SerializedLink } from "@/features/gantt/store/types"; import { GanttToolbar } from "./GanttToolbar"; import { ZOOM_LEVELS, type ZoomLevel } from "@/features/gantt/utils/zoom"; interface ProjectGanttProps { projectId: string; readOnly?: boolean; } export default function ProjectGantt({ projectId, readOnly = false }: ProjectGanttProps) { const ganttRef = useRef<ReactGanttRef>(null); const { theme: appTheme } = useTheme(); const { tasks: dbTasks, links: dbLinks, isLoading, error } = useGanttData(projectId); const dispatch = useAppDispatch(); const { past, present, future } = useAppSelector((s) => s.gantt); const presentRef = useRef(present); presentRef.current = present; const pastRef = useRef(past); pastRef.current = past; const futureRef = useRef(future); futureRef.current = future; const [zoom, setZoom] = useState<ZoomLevel>("day"); // Derive live Gantt arrays from Redux (deserialize dates) const tasks: Task[] = useMemo(() => present.tasks.map(deserializeTask), [present.tasks]); const links: Link[] = useMemo(() => present.links.map(deserializeLink), [present.links]); // ── Hydrate from DB ─────────────────────────────────────── const prevProjectId = useRef<string | null>(null); useEffect(() => { if (projectId !== prevProjectId.current) { dispatch(reset()); prevProjectId.current = projectId; } }, [projectId, dispatch]); useEffect(() => { if (!isLoading && (dbTasks.length > 0 || dbLinks.length > 0)) { dispatch( hydrate({ tasks: dbTasks.map(serializeTask), links: dbLinks.map(serializeLink), }), ); } }, [dbTasks, dbLinks, isLoading, dispatch]); // ── Helpers ──────────────────────────────────────────────── const nextSortorder = useCallback((): number => { let max = 0; for (const t of presentRef.current.tasks) { if (t.sortorder > max) max = t.sortorder; } return max + 1; }, []); const persistSnapshot = useCallback( async (previousSnapshot: GanttSnapshot, nextSnapshot: GanttSnapshot) => { const nextTaskRows = nextSnapshot.tasks .filter((task) => isRealUUID(String(task.id))) .map((task) => ({ id: String(task.id), project_id: projectId, text: task.text, start_date: task.start_date, duration: task.duration, progress: task.progress, parent_id: isRealUUID(String(task.parent)) ? String(task.parent) : null, sortorder: task.sortorder, type: task.type ?? "task", assignee_user_id: task.assignee_user_id ?? null, })); const previousTaskIds = new Set( previousSnapshot.tasks .filter((task) => isRealUUID(String(task.id))) .map((task) => String(task.id)), ); const nextTaskIds = new Set(nextTaskRows.map((task) => task.id)); const deletedTaskIds = [...previousTaskIds].filter((id) => !nextTaskIds.has(id)); if (nextTaskRows.length > 0) { const { error } = await supabase.from("tasks").upsert(nextTaskRows); if (error) throw error; } if (deletedTaskIds.length > 0) { const { error } = await supabase.from("tasks").delete().in("id", deletedTaskIds); if (error) throw error; } const previousLinks = new Map( previousSnapshot.links .filter((link) => isRealUUID(String(link.id))) .map((link) => [String(link.id), link] as const), ); const nextLinks = new Map( nextSnapshot.links .filter( (link) => isRealUUID(String(link.id)) && isRealUUID(String(link.source)) && isRealUUID(String(link.target)), ) .map((link) => [String(link.id), link] as const), ); const deletedLinkIds = [...previousLinks.keys()].filter((id) => !nextLinks.has(id)); const insertedLinks = [...nextLinks.entries()] .filter(([id]) => !previousLinks.has(id)) .map(([, link]) => ({ id: String(link.id), project_id: projectId, source: String(link.source), target: String(link.target), type: String(link.type), })); if (deletedLinkIds.length > 0) { const { error } = await supabase.from("links").delete().in("id", deletedLinkIds); if (error) throw error; } if (insertedLinks.length > 0) { const { error } = await supabase.from("links").insert(insertedLinks); if (error) throw error; } }, [projectId], ); // ── Row reorder ──────────────────────────────────────────── const handleReorder = useCallback( async (movedTask: Task) => { const currentSnapshot = presentRef.current; const moved = movedTask as any; const targetId = moved.target; const newParent = moved.parent; // Build the current ordered list const currentTasks = [...currentSnapshot.tasks]; // Remove the moved task from its current position const movedIdx = currentTasks.findIndex((t) => t.id === moved.id); if (movedIdx === -1) return; const [movedItem] = currentTasks.splice(movedIdx, 1); // Update parent if changed const updatedItem: SerializedTask = { ...movedItem, parent: newParent ?? movedItem.parent, }; // Find target position let insertIdx: number; if (!targetId) { // Dropped at the end insertIdx = currentTasks.length; } else { const targetIdx = currentTasks.findIndex((t) => t.id === targetId); if (targetIdx === -1) { insertIdx = currentTasks.length; } else { insertIdx = targetIdx; } } currentTasks.splice(insertIdx, 0, updatedItem); // Recompute sortorder for all tasks const reordered = currentTasks.map((t, i) => ({ ...t, sortorder: i + 1, })); // Commit to Redux (with history) dispatch( commit({ tasks: reordered, links: currentSnapshot.links, }), ); // Persist all affected sortorders + parent_id to Supabase const updates = reordered .filter((t) => isRealUUID(String(t.id))) .map((t) => ({ id: String(t.id), sortorder: t.sortorder, parent_id: isRealUUID(String(t.parent)) ? String(t.parent) : null, })); // Batch update via individual calls (Supabase doesn't support batch upsert on non-PK) await Promise.all( updates.map(({ id, sortorder, parent_id }) => supabase .from("tasks") .update({ sortorder, parent_id }) .eq("id", id), ), ); }, [dispatch], ); // ── CRUD handler ─────────────────────────────────────────── const handleSave = useCallback( async ( entity: string, action: string, item: any, id: string | number, ) => { const currentSnapshot = presentRef.current; // ── TASK CRUD ──────────────────────────────────────── if (entity === "task") { const task = item as Task; // Detect row reorder: task update with a `target` property if (action === "update" && "target" in (item as any)) { await handleReorder(task); return; } if (action === "create") { const sortorder = nextSortorder(); const newTask = serializeTask({ ...task, sortorder } as any); newTask.sortorder = sortorder; dispatch( commit({ tasks: [...currentSnapshot.tasks, newTask], links: currentSnapshot.links, }), ); const payload = buildTaskInsert(task, projectId, sortorder); const { data, error: err } = await supabase .from("tasks") .insert(payload) .select("id") .single(); if (data) { const realId = data.id; // Patch IDs in-place (no history push) dispatch( patch({ tasks: currentSnapshot.tasks .concat(newTask) .map((t) => (t.id === task.id ? { ...t, id: realId } : t)), links: currentSnapshot.links.map((l) => ({ ...l, source: l.source === task.id ? realId : l.source, target: l.target === task.id ? realId : l.target, })), }), ); } if (err) console.error("Task insert failed:", err); } if (action === "update") { const updated = serializeTask(task); // Preserve sortorder from existing task const existing = currentSnapshot.tasks.find((t) => t.id === id); if (existing) updated.sortorder = existing.sortorder; dispatch( commit({ tasks: currentSnapshot.tasks.map((t) => (t.id === id ? updated : t)), links: currentSnapshot.links, }), ); if (isRealUUID(String(id))) { const payload = buildTaskUpdate(task); const { error: err } = await supabase .from("tasks") .update(payload) .eq("id", String(id)); if (err) console.error("Task update failed:", err); } } if (action === "delete") { dispatch( commit({ tasks: currentSnapshot.tasks.filter((t) => t.id !== id), links: currentSnapshot.links, }), ); if (isRealUUID(String(id))) { const { error: err } = await supabase .from("tasks") .delete() .eq("id", String(id)); if (err) console.error("Task delete failed:", err); } } } // ── LINK CRUD ──────────────────────────────────────── if (entity === "link") { const link = item as Link; if (action === "create") { const newLink = serializeLink(link); dispatch( commit({ tasks: currentSnapshot.tasks, links: [...currentSnapshot.links, newLink], }), ); const payload = buildLinkInsert(link, projectId); if (payload) { const { data, error: err } = await supabase .from("links") .insert(payload) .select("id") .single(); if (data) { const realId = data.id; dispatch( patch({ links: currentSnapshot.links .concat(newLink) .map((l) => (l.id === link.id ? { ...l, id: realId } : l)), }), ); } if (err) console.error("Link insert failed:", err); } } if (action === "delete") { dispatch( commit({ tasks: currentSnapshot.tasks, links: currentSnapshot.links.filter((l) => l.id !== id), }), ); if (isRealUUID(String(id))) { const { error: err } = await supabase .from("links") .delete() .eq("id", String(id)); if (err) console.error("Link delete failed:", err); } } } }, [projectId, nextSortorder, handleReorder, dispatch], ); // ── Undo/Redo handlers ──────────────────────────────────── const handleUndo = useCallback(async () => { const previousSnapshot = presentRef.current; const targetSnapshot = pastRef.current[pastRef.current.length - 1]; if (!targetSnapshot) return; dispatch(undo()); try { await persistSnapshot(previousSnapshot, targetSnapshot); } catch (err) { console.error("Undo persistence failed:", err); } }, [dispatch, persistSnapshot]); const handleRedo = useCallback(async () => { const previousSnapshot = presentRef.current; const targetSnapshot = futureRef.current[0]; if (!targetSnapshot) return; dispatch(redo()); try { await persistSnapshot(previousSnapshot, targetSnapshot); } catch (err) { console.error("Redo persistence failed:", err); } }, [dispatch, persistSnapshot]); // ── Config ───────────────────────────────────────────────── const config: GanttConfig = useMemo( () => ({ grid_width: 340, row_height: 36, bar_height: 24, scales: ZOOM_LEVELS[zoom].scales, columns: [ { name: "text", label: "Task", tree: true, width: "*" }, { name: "start_date", label: "Start", align: "center", width: 90 }, { name: "duration", label: "Days", align: "center", width: 60 }, ...(readOnly ? [] : [{ name: "add", label: "", width: 44 }]), ], drag_move: !readOnly, drag_resize: !readOnly, readonly: readOnly, order_branch: !readOnly ? "marker" : undefined, order_branch_free: !readOnly, }), [readOnly, zoom], ); const ganttTheme = appTheme === "dark" ? "dark" : "terrace"; if (isLoading) { return <Skeleton className="w-full h-full rounded-lg" />; } if (error) { return <p className="text-destructive">Failed to load Gantt data.</p>; } return ( <div className="flex flex-col w-full h-full"> <GanttToolbar zoom={zoom} onZoomChange={setZoom} canUndo={past.length > 0} canRedo={future.length > 0} onUndo={handleUndo} onRedo={handleRedo} readOnly={readOnly} /> <div className="flex-1 min-h-0"> <Gantt ref={ganttRef} tasks={tasks} links={links} config={config} theme={ganttTheme} data={{ save: handleSave }} /> </div> </div> );
尋找差異