Diff
checker
Text
Text
Bilder
Dokumente
Excel
Ordner
Legal
Enterprise
Desktop-App
Preise
Einloggen
Diffchecker Desktop herunterladen
Texte vergleichen
Finde den Unterschied zwischen zwei Textdateien
Werkzeuge
Verlauf
Live-Editor
Leerzeichen ausblenden
Gleiches ausblenden
Zeilenumbruch aus
Ansicht
Zweispaltig
Einspaltig
Vergleichsgenauigkeit
Intelligent
Wort
Zeichen
Textstile
Darstellung ändern
Syntaxhervorhebung
Syntax auswählen
Ignorieren
Text umwandeln
Zur ersten Änderung
Eingabe bearbeiten
Diffchecker Desktop
Der sicherste Weg, Diffchecker zu nutzen. Hol dir die Desktop-App: Deine Diffs verlassen nie deinen Computer!
Desktop holen
Fresh Redux Snapshot Access - 1
Erstellt
vor 3 Monaten
Diff läuft nie ab
Löschen
Exportieren
Teilen
Erklären
32 Entfernungen
Zeilen
Gesamt
Entfernt
Zeichen
Gesamt
Entfernt
Um diese Funktion weiterhin zu nutzen, aktualisiere auf
Diff
checker
Pro
Preise anzeigen
374 Zeilen
Kopieren
25 Hinzufügungen
Zeilen
Gesamt
Hinzugefügt
Zeichen
Gesamt
Hinzugefügt
Um diese Funktion weiterhin zu nutzen, aktualisiere auf
Diff
checker
Pro
Preise anzeigen
379 Zeilen
Kopieren
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);
Kopieren
Kopiert
Kopieren
Kopiert
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({
Kopieren
Kopiert
Kopieren
Kopiert
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;
Kopieren
Kopiert
Kopieren
Kopiert
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;
Kopieren
Kopiert
Kopieren
Kopiert
}, [
present.tasks
]);
}, [
]);
// ── Row reorder ────────────────────────────────────────────
// ── Row reorder ────────────────────────────────────────────
const handleReorder = useCallback(
const handleReorder = useCallback(
async (movedTask: Task) => {
async (movedTask: Task) => {
Kopieren
Kopiert
Kopieren
Kopiert
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
Kopieren
Kopiert
Kopieren
Kopiert
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,
Kopieren
Kopiert
Kopieren
Kopiert
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),
),
),
);
);
},
},
Kopieren
Kopiert
Kopieren
Kopiert
[
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);
Kopieren
Kopiert
Kopieren
Kopiert
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({
Kopieren
Kopiert
Kopieren
Kopiert
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({
Kopieren
Kopiert
Kopieren
Kopiert
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)),
Kopieren
Kopiert
Kopieren
Kopiert
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
Kopieren
Kopiert
Kopieren
Kopiert
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({
Kopieren
Kopiert
Kopieren
Kopiert
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({
Kopieren
Kopiert
Kopieren
Kopiert
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({
Kopieren
Kopiert
Kopieren
Kopiert
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({
Kopieren
Kopiert
Kopieren
Kopiert
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({
Kopieren
Kopiert
Kopieren
Kopiert
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);
}
}
}
}
}
}
},
},
Kopieren
Kopiert
Kopieren
Kopiert
[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>
);
);
Kopieren
Kopiert
Kopieren
Kopiert
}
Gespeicherte Diffs
Originaltext
Datei öffnen
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> ); }
Bearbeitung
Datei öffnen
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> );
Unterschied finden