first commit
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
CustomAlertDialog,
|
||||
Form,
|
||||
FormInput,
|
||||
FormMultiComboboxInput,
|
||||
Button,
|
||||
} from "ikoncomponents";
|
||||
import { Trash2, Pencil, Plus, Save } from "lucide-react";
|
||||
import { GroupItem } from "@/app/main/planning/projects/types/project";
|
||||
|
||||
// --- Zod Schema ---
|
||||
const taskGroupSchema = z.object({
|
||||
groupName: z.string().min(1, "Group name is required"),
|
||||
taskIds: z.array(z.string()).min(1, "Select at least one task"),
|
||||
});
|
||||
type TaskGroupForm = z.infer<typeof taskGroupSchema>;
|
||||
|
||||
const defaultValues: TaskGroupForm = {
|
||||
groupName: "",
|
||||
taskIds: [],
|
||||
};
|
||||
|
||||
interface TaskGroupModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
scheduleTaskItems: Array<{ value: string; label: string }>;
|
||||
initialGroups: Record<string, GroupItem>;
|
||||
onSave: (groups: Record<string, GroupItem>) => void;
|
||||
}
|
||||
|
||||
const TaskGroupModal: React.FC<TaskGroupModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
scheduleTaskItems,
|
||||
initialGroups,
|
||||
onSave,
|
||||
}) => {
|
||||
const [localGroups, setLocalGroups] = useState<Record<string, GroupItem>>(initialGroups || {});
|
||||
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
||||
const [alertState, setAlertState] = useState<{
|
||||
open: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
}>({ open: false, title: "" });
|
||||
|
||||
const form = useForm<TaskGroupForm>({
|
||||
resolver: zodResolver(taskGroupSchema),
|
||||
defaultValues,
|
||||
});
|
||||
const { reset, setValue, getValues } = form;
|
||||
|
||||
const closeAlert = () => setAlertState(s => ({ ...s, open: false }));
|
||||
const showAlert = (title: string, description?: string) =>
|
||||
setAlertState({ open: true, title, description, confirmText: "OK", onConfirm: closeAlert });
|
||||
const showConfirm = (title: string, description: string, onConfirm: () => void) =>
|
||||
setAlertState({ open: true, title, description, confirmText: "Confirm", cancelText: "Cancel", onConfirm: () => { closeAlert(); onConfirm(); }, onCancel: closeAlert });
|
||||
|
||||
// Tasks already assigned to any group OTHER than the one being edited
|
||||
const assignedElsewhere = new Set<string>(
|
||||
Object.entries(localGroups)
|
||||
.filter(([gId]) => gId !== editingGroupId)
|
||||
.flatMap(([, g]) => (g.taskIds || []).map(String))
|
||||
);
|
||||
|
||||
// Available tasks: exclude those already in another group
|
||||
const availableTaskItems = scheduleTaskItems.filter(i => !assignedElsewhere.has(i.value));
|
||||
|
||||
// All available task IDs as strings for "Select All"
|
||||
const allTaskValues = availableTaskItems.map(i => i.value);
|
||||
|
||||
// Prepend "Select All" item
|
||||
const comboItems = [
|
||||
{ value: "__ALL__", label: "Select All" },
|
||||
...availableTaskItems,
|
||||
];
|
||||
|
||||
const handleTaskSelect = (values: string[]) => {
|
||||
if (values.includes("__ALL__")) {
|
||||
const currentIds: string[] = getValues("taskIds") || [];
|
||||
const allSelected = allTaskValues.every(id => currentIds.includes(id));
|
||||
setValue("taskIds", allSelected ? [] : allTaskValues);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddUpdate = () => {
|
||||
const values = getValues();
|
||||
if (!values.groupName.trim()) {
|
||||
showAlert("Validation Error", "Group name is required.");
|
||||
return;
|
||||
}
|
||||
const taskIds = (values.taskIds || []).filter(v => v !== "__ALL__");
|
||||
if (taskIds.length === 0) {
|
||||
showAlert("Validation Error", "Please select at least one task.");
|
||||
return;
|
||||
}
|
||||
|
||||
const existingEntry = Object.entries(localGroups).find(
|
||||
([gId, g]) => g.groupName.trim().toLowerCase() === values.groupName.trim().toLowerCase() && gId !== editingGroupId
|
||||
);
|
||||
if (existingEntry) {
|
||||
showAlert("Duplicate Name", `A group named "${values.groupName}" already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingGroupId) {
|
||||
setLocalGroups(prev => ({
|
||||
...prev,
|
||||
[editingGroupId]: {
|
||||
...prev[editingGroupId],
|
||||
groupName: values.groupName.trim(),
|
||||
taskIds: taskIds.map(Number),
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
const newId = String(Date.now());
|
||||
setLocalGroups(prev => ({
|
||||
...prev,
|
||||
[newId]: {
|
||||
groupName: values.groupName.trim(),
|
||||
taskIds: taskIds.map(Number),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
setEditingGroupId(null);
|
||||
reset(defaultValues);
|
||||
};
|
||||
|
||||
const handleEdit = (groupId: string) => {
|
||||
const group = localGroups[groupId];
|
||||
if (!group) return;
|
||||
setEditingGroupId(groupId);
|
||||
reset({
|
||||
groupName: group.groupName,
|
||||
taskIds: (group.taskIds || []).map(String),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (groupId: string) => {
|
||||
const group = localGroups[groupId];
|
||||
showConfirm(
|
||||
"Delete Group",
|
||||
`Are you sure you want to delete group "${group?.groupName}"?`,
|
||||
() => {
|
||||
setLocalGroups(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[groupId];
|
||||
return updated;
|
||||
});
|
||||
if (editingGroupId === groupId) {
|
||||
setEditingGroupId(null);
|
||||
reset(defaultValues);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setEditingGroupId(null);
|
||||
reset(defaultValues);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(localGroups);
|
||||
};
|
||||
|
||||
const resolveTaskNames = (taskIds: number[]) =>
|
||||
taskIds
|
||||
.map(id => scheduleTaskItems.find(i => i.value === String(id))?.label || String(id))
|
||||
.join(", ");
|
||||
|
||||
const groupEntries = Object.entries(localGroups);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="w-[90vw] max-w-[90vw] max-h-[80vh] overflow-y-auto"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Task Group</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormInput
|
||||
formControl={form.control}
|
||||
name="groupName"
|
||||
label="Task Group Name"
|
||||
placeholder="e.g. T1"
|
||||
/>
|
||||
<FormMultiComboboxInput
|
||||
formControl={form.control}
|
||||
name="taskIds"
|
||||
label="Schedule Tasks"
|
||||
placeholder="Select tasks..."
|
||||
items={comboItems}
|
||||
onSelect={handleTaskSelect}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-4 text-sm"
|
||||
onClick={handleAddUpdate}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1.5" />
|
||||
{editingGroupId ? "Update Group" : "Add Group"}
|
||||
</Button>
|
||||
{editingGroupId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 px-4 text-sm"
|
||||
onClick={handleClear}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{groupEntries.length > 0 && (
|
||||
<div className="border border-border rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 font-medium text-muted-foreground w-1/4">Task Group</th>
|
||||
<th className="text-left px-3 py-2 font-medium text-muted-foreground">Schedule Tasks</th>
|
||||
<th className="px-3 py-2 w-20 text-center font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupEntries.map(([groupId, group], index) => (
|
||||
<tr
|
||||
key={groupId}
|
||||
className={`border-t border-border ${editingGroupId === groupId ? "bg-muted/30" : ""} ${index % 2 === 0 ? "" : "bg-muted/10"}`}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{group.groupName}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{group.taskIds?.length > 0
|
||||
? resolveTaskNames(group.taskIds)
|
||||
: <span className="italic text-xs">No tasks</span>
|
||||
}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(groupId)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(groupId)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupEntries.length === 0 && (
|
||||
<div className="flex justify-center items-center h-20 text-sm text-muted-foreground border border-dashed border-border rounded-md">
|
||||
No groups yet. Add one above.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} className="h-8 px-4 text-sm">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleSave} className="h-8 px-4 text-sm">
|
||||
<Save className="w-3.5 h-3.5 mr-1.5" /> Save Groups
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{alertState.open && (
|
||||
<CustomAlertDialog
|
||||
title={alertState.title}
|
||||
description={alertState.description}
|
||||
confirmText={alertState.confirmText}
|
||||
cancelText={alertState.cancelText}
|
||||
onConfirm={alertState.onConfirm}
|
||||
onCancel={alertState.onCancel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskGroupModal;
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Button } from "ikoncomponents";
|
||||
import { Plus, Edit } from "lucide-react";
|
||||
import ScheduleGanttShowComponent from "./schedule_show_gantt_component";
|
||||
import ScheduleFormComponent from "./schedule_form_component/schedule_form_definition";
|
||||
|
||||
interface ScheduleTabProps {
|
||||
projectIdentifier: string;
|
||||
}
|
||||
|
||||
function ScheduleTab({ projectIdentifier }: ScheduleTabProps) {
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [isUpdateMode, setIsUpdateMode] = useState(false);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const toggleModal = () => {
|
||||
setModalOpen((prev) => !prev);
|
||||
if (!isModalOpen) {
|
||||
setIsUpdateMode(false);
|
||||
setSelectedTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormClose = () => {
|
||||
setModalOpen(false);
|
||||
setIsUpdateMode(false);
|
||||
setSelectedTaskId(null);
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleTaskSelect = (taskId: number) => {
|
||||
setSelectedTaskId(taskId);
|
||||
setIsUpdateMode(true);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)] w-full overflow-hidden px-2">
|
||||
{/* Button bar */}
|
||||
<div className="flex justify-end gap-2 mb-4 shrink-0">
|
||||
<Button onClick={toggleModal} className="shrink-0">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Schedule
|
||||
</Button>
|
||||
|
||||
{selectedTaskId !== null && (
|
||||
<Button onClick={() => handleTaskSelect(selectedTaskId)} className="shrink-0">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Update Schedule
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScheduleFormComponent
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleFormClose}
|
||||
projectIdentifier={projectIdentifier}
|
||||
editTaskId={isUpdateMode ? selectedTaskId : undefined}
|
||||
/>
|
||||
|
||||
<div className="w-full min-w-0 overflow-hidden">
|
||||
<ScheduleGanttShowComponent
|
||||
key={refreshKey}
|
||||
projectIdentifier={projectIdentifier}
|
||||
onTaskSelect={handleTaskSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScheduleTab;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ScheduleSchema = z.object({
|
||||
taskName: z.string().min(1, "Task name is required"),
|
||||
startDate: z.string().min(1, "Start date is required"),
|
||||
endDate: z.string().min(1, "End date is required"),
|
||||
duration: z.number().min(0, "Duration must be positive"),
|
||||
predecessor: z.string().optional(),
|
||||
lag: z.number().optional(),
|
||||
dependencyType: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ScheduleFormData = z.infer<typeof ScheduleSchema>;
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
import {
|
||||
addMonths,
|
||||
differenceInDays,
|
||||
differenceInMonths,
|
||||
format,
|
||||
parseISO,
|
||||
subMonths,
|
||||
addDays,
|
||||
subDays,
|
||||
isValid,
|
||||
isBefore,
|
||||
startOfToday,
|
||||
} from "date-fns";
|
||||
import { useState } from "react";
|
||||
|
||||
// --- Pure Calculation Functions ---
|
||||
|
||||
/**
|
||||
* Calculates End Date based on Start Date and Duration (Months).
|
||||
* Logic: End Date = (Start Date + Duration) - 1 Day.
|
||||
*/
|
||||
export const calculateTaskEndDate = (
|
||||
startDate: string,
|
||||
duration: number
|
||||
): string => {
|
||||
if (!startDate || isNaN(duration)) return "";
|
||||
const taskStart = parseISO(startDate);
|
||||
if (!isValid(taskStart)) return "";
|
||||
|
||||
const wholeMonths = Math.floor(duration);
|
||||
const fractionalMonths = duration % 1;
|
||||
const fractionalDays = Math.round(fractionalMonths * 30);
|
||||
|
||||
let tempDate = addMonths(taskStart, wholeMonths);
|
||||
tempDate = addDays(tempDate, fractionalDays);
|
||||
const taskEnd = subDays(tempDate, 1); // Inclusive logic
|
||||
|
||||
return isValid(taskEnd) ? format(taskEnd, "yyyy-MM-dd") : "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates Start Date based on End Date and Duration.
|
||||
* Logic: Start Date = (End Date + 1 Day) - Duration.
|
||||
*/
|
||||
export const calculateTaskStartDate = (
|
||||
endDate: string,
|
||||
duration: number
|
||||
): string => {
|
||||
if (!endDate || isNaN(duration)) return "";
|
||||
const taskEnd = parseISO(endDate);
|
||||
if (!isValid(taskEnd)) return "";
|
||||
|
||||
const wholeMonths = Math.floor(duration);
|
||||
const fractionalMonths = duration % 1;
|
||||
const fractionalDays = Math.round(fractionalMonths * 30);
|
||||
|
||||
let tempDate = addDays(taskEnd, 1);
|
||||
tempDate = subDays(tempDate, fractionalDays);
|
||||
const taskStart = subMonths(tempDate, wholeMonths);
|
||||
|
||||
return isValid(taskStart) ? format(taskStart, "yyyy-MM-dd") : "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates Duration based on Start and End dates.
|
||||
*/
|
||||
export const calculateTaskDuration = (
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): number => {
|
||||
if (!startDate || !endDate) return 0;
|
||||
|
||||
const taskStart = parseISO(startDate);
|
||||
const taskEnd = parseISO(endDate);
|
||||
|
||||
if (!isValid(taskStart) || !isValid(taskEnd)) return 0;
|
||||
if (isBefore(taskEnd, taskStart)) return 0;
|
||||
|
||||
const exclusiveEnd = addDays(taskEnd, 1);
|
||||
const months = differenceInMonths(exclusiveEnd, taskStart);
|
||||
const tempDateAfterMonths = addMonths(taskStart, months);
|
||||
const days = differenceInDays(exclusiveEnd, tempDateAfterMonths);
|
||||
|
||||
const duration = months + days / 30;
|
||||
return parseFloat(duration.toFixed(2));
|
||||
};
|
||||
|
||||
// --- Cascading Updates & Dependency Logic ---
|
||||
|
||||
export const calculateDependentDates = (
|
||||
predStart: string,
|
||||
predEnd: string,
|
||||
dependencyType: string,
|
||||
lag: number,
|
||||
duration: number
|
||||
): { startDate: string; endDate: string } | null => {
|
||||
if (!predStart || !predEnd) return null;
|
||||
|
||||
const pStart = parseISO(predStart);
|
||||
const pEnd = parseISO(predEnd);
|
||||
|
||||
if (!isValid(pStart) || !isValid(pEnd)) return null;
|
||||
|
||||
const lagDays = Math.round((lag || 0) * 30);
|
||||
|
||||
if (dependencyType === "1") {
|
||||
// Finish-to-Start
|
||||
const newStart = addDays(pEnd, 1 + lagDays);
|
||||
const startDateStr = format(newStart, "yyyy-MM-dd");
|
||||
const endDateStr = calculateTaskEndDate(startDateStr, duration);
|
||||
return { startDate: startDateStr, endDate: endDateStr };
|
||||
} else if (dependencyType === "3") {
|
||||
// Start-to-Start
|
||||
const newStart = addDays(pStart, lagDays);
|
||||
const startDateStr = format(newStart, "yyyy-MM-dd");
|
||||
const endDateStr = calculateTaskEndDate(startDateStr, duration);
|
||||
return { startDate: startDateStr, endDate: endDateStr };
|
||||
} else if (dependencyType === "0") {
|
||||
// Finish-to-Finish
|
||||
const newEnd = addDays(pEnd, lagDays);
|
||||
const endDateStr = format(newEnd, "yyyy-MM-dd");
|
||||
const startDateStr = calculateTaskStartDate(endDateStr, duration);
|
||||
return { startDate: startDateStr, endDate: endDateStr };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively updates dependent tasks in the list.
|
||||
*/
|
||||
export const recalculateCascadingDates = (
|
||||
allTasks: any[],
|
||||
rootTaskId: number
|
||||
): any[] => {
|
||||
const tasksMap = new Map(allTasks.map((t) => [t.id, { ...t }]));
|
||||
|
||||
const processTask = (parentId: number) => {
|
||||
const parent = tasksMap.get(parentId);
|
||||
if (!parent) return;
|
||||
|
||||
const children = Array.from(tasksMap.values()).filter(
|
||||
(t) => String(t.taskPredecessor) === String(parentId)
|
||||
);
|
||||
|
||||
children.forEach((child) => {
|
||||
const depType = String(child.dependencyType || "1");
|
||||
const lag = child.delayDuration || 0;
|
||||
const duration = child.taskDuration || 0;
|
||||
|
||||
const newDates = calculateDependentDates(
|
||||
parent.taskStart,
|
||||
parent.taskEnd,
|
||||
depType,
|
||||
lag,
|
||||
duration
|
||||
);
|
||||
|
||||
if (newDates) {
|
||||
const updatedChild = {
|
||||
...child,
|
||||
taskStart: newDates.startDate,
|
||||
taskEnd: newDates.endDate,
|
||||
};
|
||||
tasksMap.set(child.id, updatedChild);
|
||||
processTask(child.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
processTask(rootTaskId);
|
||||
return Array.from(tasksMap.values());
|
||||
};
|
||||
|
||||
// --- Recursive Deletion Helper ---
|
||||
|
||||
export const getRecursiveChildIds = (
|
||||
taskId: number,
|
||||
allTasks: any[]
|
||||
): number[] => {
|
||||
let idsToDelete: number[] = [taskId];
|
||||
const children = allTasks.filter(
|
||||
(t) => t.taskPredecessor && String(t.taskPredecessor) === String(taskId)
|
||||
);
|
||||
|
||||
for (const child of children) {
|
||||
const childIds = getRecursiveChildIds(child.id, allTasks);
|
||||
idsToDelete = [...idsToDelete, ...childIds];
|
||||
}
|
||||
return Array.from(new Set(idsToDelete));
|
||||
};
|
||||
|
||||
// --- Validation Logic ---
|
||||
|
||||
export type ValidationResult = { isValid: boolean; message?: string };
|
||||
|
||||
export const validateTaskDeletion = (taskStartDate: string): ValidationResult => {
|
||||
const start = parseISO(taskStartDate);
|
||||
const today = startOfToday();
|
||||
if (isBefore(start, today)) {
|
||||
return { isValid: false, message: "You can only delete future tasks." };
|
||||
}
|
||||
return { isValid: true };
|
||||
};
|
||||
|
||||
export const validateTaskForm = (
|
||||
formData: any,
|
||||
existingTasks: any[],
|
||||
currentTaskId: number | null
|
||||
): ValidationResult => {
|
||||
if (!formData.taskName?.trim()) {
|
||||
return { isValid: false, message: "Please enter the Schedule Name" };
|
||||
}
|
||||
|
||||
const isDuplicate = existingTasks.some(
|
||||
(t) =>
|
||||
t.taskName === formData.taskName &&
|
||||
String(t.id) !== String(currentTaskId || "")
|
||||
);
|
||||
if (isDuplicate) {
|
||||
return { isValid: false, message: "This task name already exists" };
|
||||
}
|
||||
|
||||
if (formData.duration < 0) {
|
||||
return { isValid: false, message: "Task Duration cannot be negative." };
|
||||
}
|
||||
|
||||
if (
|
||||
formData.predecessor &&
|
||||
formData.predecessor !== "-1" &&
|
||||
(!formData.dependencyType || formData.dependencyType === "-1")
|
||||
) {
|
||||
return { isValid: false, message: "Please select dependency type" };
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
};
|
||||
|
||||
// --- Custom Hook ---
|
||||
|
||||
export const useScheduleDateLogic = () => {
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
const setValuesRaw = (s: string, e: string, d: number) => {
|
||||
setStartDate(s);
|
||||
setEndDate(e);
|
||||
setDuration(d);
|
||||
};
|
||||
|
||||
const handleStartDateChange = (value: string) => {
|
||||
setStartDate(value);
|
||||
if (value) {
|
||||
const updatedEndDate = calculateTaskEndDate(value, duration);
|
||||
setEndDate(updatedEndDate);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndDateChange = (value: string) => {
|
||||
setEndDate(value);
|
||||
if (startDate && value) {
|
||||
if (isBefore(parseISO(value), parseISO(startDate))) {
|
||||
return;
|
||||
}
|
||||
const updatedDuration = calculateTaskDuration(startDate, value);
|
||||
setDuration(Math.max(0, updatedDuration));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDurationChange = (value: number) => {
|
||||
setDuration(value);
|
||||
if (startDate) {
|
||||
const updatedEndDate = calculateTaskEndDate(startDate, value);
|
||||
setEndDate(updatedEndDate);
|
||||
}
|
||||
};
|
||||
|
||||
const resetDates = () => {
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setDuration(0);
|
||||
};
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
duration,
|
||||
handleStartDateChange,
|
||||
handleEndDateChange,
|
||||
handleDurationChange,
|
||||
resetDates,
|
||||
setValuesRaw,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,807 @@
|
||||
"use client";
|
||||
import React, { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Dialog, DialogContent, DialogTitle, DialogFooter, DialogHeader,
|
||||
CustomAlertDialog, Form,
|
||||
FormInput, FormComboboxInput, FormTextarea, FormDateInput,
|
||||
Button,
|
||||
} from "ikoncomponents";
|
||||
import { isBefore, parseISO, format } from "date-fns";
|
||||
import { formatDate } from "@/app/utils/mapping-functions/date-format";
|
||||
|
||||
import {
|
||||
calculateDependentDates,
|
||||
calculateTaskEndDate,
|
||||
calculateTaskDuration,
|
||||
validateTaskForm,
|
||||
validateTaskDeletion,
|
||||
getRecursiveChildIds,
|
||||
recalculateCascadingDates,
|
||||
} from "./ScheduleFormFunctionality";
|
||||
|
||||
import { getScheduleApi, saveScheduleApi } from "@/app/utils/api/projectApi";
|
||||
import { TaskDto, GroupItem } from "@/app/main/planning/projects/types/project";
|
||||
|
||||
import {
|
||||
Gantt,
|
||||
GanttWeekView,
|
||||
GanttMonthView,
|
||||
GanttTaskClickEvent,
|
||||
GanttExpandChangeEvent,
|
||||
GanttViewTimelineHeaderCellProps,
|
||||
} from "@progress/kendo-react-gantt";
|
||||
|
||||
import { Plus, Save, Eraser, Trash2 } from "lucide-react";
|
||||
import TaskGroupModal from "../../create_task_group_component/create_task_group_definition";
|
||||
|
||||
// --- Zod Schema ---
|
||||
const scheduleTaskSchema = z.object({
|
||||
taskName: z.string(),
|
||||
startDate: z.date().nullable(),
|
||||
endDate: z.date().nullable(),
|
||||
duration: z.number().min(0),
|
||||
predecessor: z.string(),
|
||||
lag: z.number(),
|
||||
dependencyType: z.string(),
|
||||
color: z.string(),
|
||||
description: z.string(),
|
||||
});
|
||||
type ScheduleTaskForm = z.infer<typeof scheduleTaskSchema>;
|
||||
|
||||
const defaultTaskValues: ScheduleTaskForm = {
|
||||
taskName: "",
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
duration: 0,
|
||||
predecessor: "",
|
||||
lag: 0,
|
||||
dependencyType: "",
|
||||
color: "#84d5f7",
|
||||
description: "",
|
||||
};
|
||||
|
||||
interface ScheduleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectIdentifier: string;
|
||||
editTaskId?: number | null;
|
||||
}
|
||||
|
||||
// Month view header cell: shows "May, 2026" instead of just "May"
|
||||
const MonthViewHeaderCell: React.FC<GanttViewTimelineHeaderCellProps> = ({ range, rowIndex, text }) => {
|
||||
if (rowIndex === 0) {
|
||||
return <span>{format(range.start, "MMM, yyyy")}</span>;
|
||||
}
|
||||
return <span>{text}</span>;
|
||||
};
|
||||
|
||||
// --- Kendo Gantt Configuration ---
|
||||
const taskModelFields = {
|
||||
id: "id",
|
||||
start: "start",
|
||||
end: "end",
|
||||
title: "title",
|
||||
percentComplete: "percentComplete",
|
||||
isRollup: "isRollup",
|
||||
isExpanded: "isExpanded",
|
||||
children: "children",
|
||||
};
|
||||
|
||||
const dependencyModelFields = {
|
||||
id: "id",
|
||||
fromId: "fromId",
|
||||
toId: "toId",
|
||||
type: "type",
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ field: "title", title: "Schedule", width: 160, expandable: true },
|
||||
{ field: "start", title: "Start Date", width: 120, format: "{0:dd-MMM-yyyy}" },
|
||||
{ field: "end", title: "End Date", width: 120, format: "{0:dd-MMM-yyyy}" },
|
||||
{ field: "_description", title: "Description", width: 180 },
|
||||
];
|
||||
|
||||
const ScheduleFormComponent: React.FC<ScheduleModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
projectIdentifier,
|
||||
editTaskId: editTaskIdProp,
|
||||
}) => {
|
||||
const [editTaskId, setEditTaskId] = useState<number | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [dialogState, setDialogState] = useState<{
|
||||
open: boolean; title: string; description?: string;
|
||||
confirmText?: string; cancelText?: string;
|
||||
onConfirm?: () => void; onCancel?: () => void;
|
||||
}>({ open: false, title: "" });
|
||||
const [tooltipTask, setTooltipTask] = useState<{ title: string; start: Date; end: Date; taskDuration: number | null } | null>(null);
|
||||
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });
|
||||
const [isStartDisabled, setIsStartDisabled] = useState(false);
|
||||
const [isEndDisabled, setIsEndDisabled] = useState(false);
|
||||
const [isDependencyDisabled, setIsDependencyDisabled] = useState(true);
|
||||
const [isLagDisabled, setIsLagDisabled] = useState(true);
|
||||
const [scheduleData, setScheduleData] = useState<Array<any>>([]);
|
||||
const [groupData, setGroupData] = useState<Record<string, GroupItem>>({});
|
||||
const [isTaskGroupModalOpen, setIsTaskGroupModalOpen] = useState(false);
|
||||
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Ref to prevent date recalculation useEffects from firing on programmatic setValue/reset
|
||||
const skipDateRecalc = useRef(false);
|
||||
|
||||
const form = useForm<ScheduleTaskForm>({
|
||||
resolver: zodResolver(scheduleTaskSchema),
|
||||
defaultValues: defaultTaskValues,
|
||||
});
|
||||
const { watch, setValue, getValues, reset } = form;
|
||||
const startDate = watch("startDate");
|
||||
const endDate = watch("endDate");
|
||||
const predecessor = watch("predecessor");
|
||||
const dependencyType = watch("dependencyType");
|
||||
const watchedLag = watch("lag");
|
||||
|
||||
// --- Dialog helpers ---
|
||||
const closeDialog = () => setDialogState(s => ({ ...s, open: false }));
|
||||
const showAlert = (title: string, description?: string) => {
|
||||
setDialogState({ open: true, title, description, confirmText: "OK", onConfirm: closeDialog });
|
||||
};
|
||||
const showConfirm = (title: string, description: string, onConfirm: () => void) => {
|
||||
setDialogState({ open: true, title, description, confirmText: "Confirm", cancelText: "Cancel", onConfirm: () => { closeDialog(); onConfirm(); }, onCancel: closeDialog });
|
||||
};
|
||||
|
||||
// When user picks a new start date → recalculate end date from duration
|
||||
useEffect(() => {
|
||||
if (skipDateRecalc.current) return;
|
||||
if (!startDate) return;
|
||||
const dur = getValues("duration");
|
||||
if (dur > 0) {
|
||||
const newEnd = calculateTaskEndDate(format(startDate, "yyyy-MM-dd"), dur);
|
||||
if (newEnd) setValue("endDate", parseISO(newEnd));
|
||||
} else {
|
||||
setValue("endDate", startDate);
|
||||
}
|
||||
}, [startDate]);
|
||||
|
||||
// When user picks a new end date → validate, then recalculate duration
|
||||
useEffect(() => {
|
||||
if (skipDateRecalc.current) return;
|
||||
if (!endDate) return;
|
||||
const start = getValues("startDate") as Date | null;
|
||||
if (start && isBefore(endDate, start)) {
|
||||
showAlert("Invalid Date", "Task end date cannot be before task start date.");
|
||||
skipDateRecalc.current = true;
|
||||
setValue("endDate", start);
|
||||
setTimeout(() => { skipDateRecalc.current = false; }, 0);
|
||||
return;
|
||||
}
|
||||
if (start) {
|
||||
const newDur = calculateTaskDuration(format(start, "yyyy-MM-dd"), format(endDate, "yyyy-MM-dd"));
|
||||
setValue("duration", newDur);
|
||||
}
|
||||
}, [endDate]);
|
||||
|
||||
// --- 0. Sync editTaskIdProp → form ---
|
||||
useEffect(() => {
|
||||
if (!editTaskIdProp || scheduleData.length === 0) return;
|
||||
const task = scheduleData.find(t => t.id === editTaskIdProp);
|
||||
if (!task) return;
|
||||
setEditTaskId(task.id);
|
||||
skipDateRecalc.current = true;
|
||||
reset({
|
||||
taskName: task.taskName,
|
||||
startDate: task.taskStart ? parseISO(task.taskStart) : null,
|
||||
endDate: task.taskEnd ? parseISO(task.taskEnd) : null,
|
||||
duration: task.taskDuration || 0,
|
||||
predecessor: task.taskPredecessor || "",
|
||||
dependencyType: task.dependencyType !== undefined && task.dependencyType !== -1 ? String(task.dependencyType) : "",
|
||||
lag: task.delayDuration || 0,
|
||||
color: task.taskColour || "#84d5f7",
|
||||
description: task.taskDescription || "",
|
||||
});
|
||||
setTimeout(() => { skipDateRecalc.current = false; }, 0);
|
||||
}, [editTaskIdProp, scheduleData]);
|
||||
|
||||
// --- 1. Fetch Existing Data ---
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!isOpen) {
|
||||
setScheduleData([]);
|
||||
setGroupData({});
|
||||
setEditTaskId(null);
|
||||
setIsStartDisabled(false);
|
||||
setIsEndDisabled(false);
|
||||
setIsDependencyDisabled(true);
|
||||
setIsLagDisabled(true);
|
||||
skipDateRecalc.current = true;
|
||||
reset(defaultTaskValues);
|
||||
setTimeout(() => { skipDateRecalc.current = false; }, 0);
|
||||
return;
|
||||
}
|
||||
if (!projectIdentifier) return;
|
||||
try {
|
||||
const scheduleResponse = await getScheduleApi(projectIdentifier);
|
||||
setScheduleData(scheduleResponse?.task || []);
|
||||
setGroupData(scheduleResponse?.group || {});
|
||||
} catch (error) {
|
||||
console.error("Error fetching schedule data", error);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [projectIdentifier, isOpen, reset]);
|
||||
|
||||
// --- 3. Handle Dependency/Lag Changes ---
|
||||
// watchedLag is in the dep array so changing lag auto-recalculates dates
|
||||
useEffect(() => {
|
||||
const duration = getValues("duration");
|
||||
if (predecessor && dependencyType && scheduleData.length > 0) {
|
||||
const predTask = scheduleData.find(t => String(t.id) === predecessor);
|
||||
if (predTask) {
|
||||
const predTaskEnd = predTask.taskEnd || calculateTaskEndDate(predTask.taskStart, predTask.taskDuration || 0);
|
||||
const calculated = calculateDependentDates(predTask.taskStart, predTaskEnd, dependencyType, watchedLag, duration);
|
||||
if (calculated) {
|
||||
skipDateRecalc.current = true;
|
||||
setValue("startDate", parseISO(calculated.startDate));
|
||||
setValue("endDate", parseISO(calculated.endDate));
|
||||
setTimeout(() => { skipDateRecalc.current = false; }, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [predecessor, dependencyType, watchedLag, scheduleData]);
|
||||
|
||||
// --- 4. Logic to Enable/Disable Fields ---
|
||||
useEffect(() => {
|
||||
if (!predecessor || predecessor === "-1" || predecessor === "") {
|
||||
setIsDependencyDisabled(true);
|
||||
setIsLagDisabled(true);
|
||||
setIsStartDisabled(false);
|
||||
setIsEndDisabled(false);
|
||||
if (dependencyType !== "-1" && dependencyType !== "") setValue("dependencyType", "-1");
|
||||
} else {
|
||||
setIsDependencyDisabled(false);
|
||||
setIsLagDisabled(false);
|
||||
if (dependencyType === "" || dependencyType === "-1") {
|
||||
setValue("dependencyType", "1"); // Default to Finish - Start
|
||||
} else if (dependencyType === "0") {
|
||||
// FF: start date is driven by end date
|
||||
setIsEndDisabled(true);
|
||||
setIsStartDisabled(false);
|
||||
} else {
|
||||
// FS / SS: end date is driven by start date
|
||||
setIsStartDisabled(true);
|
||||
setIsEndDisabled(false);
|
||||
}
|
||||
}
|
||||
}, [predecessor, dependencyType]);
|
||||
|
||||
// --- Tooltip handlers ---
|
||||
const handleGanttMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setTooltipPos({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
const handleGanttMouseOver = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
let el = e.target as HTMLElement | null;
|
||||
while (el) {
|
||||
if (el.classList.contains("k-task-single") || el.classList.contains("k-task-milestone")) break;
|
||||
if (el.classList.contains("k-gantt-rows") || el === e.currentTarget) { el = null; break; }
|
||||
el = el.parentElement;
|
||||
}
|
||||
if (!el) { setTooltipTask(null); return; }
|
||||
const contentEl = el.querySelector(".k-task-content") || el;
|
||||
const title = contentEl.textContent?.trim() || "";
|
||||
const found = allTasksFlat.find(t => t.title === title) || null;
|
||||
setTooltipTask(found);
|
||||
};
|
||||
|
||||
// --- Gantt expand/collapse ---
|
||||
const handleGanttExpandChange = (e: GanttExpandChangeEvent) => {
|
||||
const id = String(e.dataItem?.id);
|
||||
setCollapsedGroupIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// --- Gantt click ---
|
||||
const handleGanttTaskClick = (e: GanttTaskClickEvent) => {
|
||||
const task = e.dataItem;
|
||||
if (!task || task._isGroup) return;
|
||||
setEditTaskId(task.id);
|
||||
skipDateRecalc.current = true;
|
||||
reset({
|
||||
taskName: task.title,
|
||||
startDate: task.start,
|
||||
endDate: task.end,
|
||||
duration: task.taskDuration || 0,
|
||||
predecessor: task._predecessor ? String(task._predecessor) : "",
|
||||
dependencyType: task._depType !== undefined && task._depType !== null && task._depType !== -1 ? String(task._depType) : "",
|
||||
lag: task._lag || 0,
|
||||
color: task.color || "#84d5f7",
|
||||
description: task._description || "",
|
||||
});
|
||||
setTimeout(() => { skipDateRecalc.current = false; }, 0);
|
||||
};
|
||||
|
||||
// --- Add / Update Task ---
|
||||
const handleAddOrUpdateTask = (isMilestone: boolean = false) => {
|
||||
const values = getValues();
|
||||
const startStr = values.startDate ? format(values.startDate, "yyyy-MM-dd") : "";
|
||||
const endStr = values.endDate ? format(values.endDate, "yyyy-MM-dd") : "";
|
||||
|
||||
if (values.startDate && values.endDate && isBefore(values.endDate, values.startDate)) {
|
||||
showAlert("Invalid Dates", "Task end date cannot be before task start date.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMilestone && (!startStr || !endStr || startStr !== endStr)) {
|
||||
showAlert("Invalid Milestone", "A milestone must have a start date and end date that are the same.");
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = {
|
||||
taskName: values.taskName,
|
||||
duration: values.duration,
|
||||
predecessor: values.predecessor,
|
||||
dependencyType: values.dependencyType,
|
||||
isMilestone,
|
||||
startDate: startStr,
|
||||
endDate: endStr,
|
||||
};
|
||||
const validation = validateTaskForm(formData, scheduleData, editTaskId);
|
||||
if (!validation.isValid) { showAlert("Validation Error", validation.message); return; }
|
||||
|
||||
if (editTaskId) {
|
||||
let updatedList = scheduleData.map(task => {
|
||||
if (task.id === editTaskId) {
|
||||
return {
|
||||
...task,
|
||||
taskName: values.taskName,
|
||||
taskDuration: values.duration,
|
||||
taskPredecessor: values.predecessor || "",
|
||||
dependencyType: values.dependencyType !== "" ? parseInt(values.dependencyType) : -1,
|
||||
taskColour: values.color,
|
||||
delayDuration: values.lag,
|
||||
taskDescription: values.description,
|
||||
taskStart: startStr,
|
||||
taskEnd: endStr,
|
||||
milestoneTask: isMilestone,
|
||||
};
|
||||
}
|
||||
return task;
|
||||
});
|
||||
updatedList = recalculateCascadingDates(updatedList, editTaskId);
|
||||
setScheduleData(updatedList);
|
||||
showAlert("Updated", "Task updated successfully.");
|
||||
} else {
|
||||
const newTask: TaskDto = {
|
||||
id: Date.now(),
|
||||
parentId: 0,
|
||||
taskName: values.taskName,
|
||||
taskDuration: values.duration,
|
||||
taskPredecessor: values.predecessor || "",
|
||||
dependencyType: values.dependencyType !== "" ? parseInt(values.dependencyType) : -1,
|
||||
taskColour: values.color,
|
||||
delayDuration: values.lag,
|
||||
taskDescription: values.description,
|
||||
taskStart: startStr,
|
||||
taskEnd: endStr,
|
||||
milestoneTask: isMilestone,
|
||||
};
|
||||
setScheduleData(prev => [...prev, newTask]);
|
||||
showAlert("Added", "Task added successfully.");
|
||||
}
|
||||
clearForm();
|
||||
};
|
||||
|
||||
// --- Delete Task ---
|
||||
const handleDeleteTask = () => {
|
||||
if (!editTaskId) return;
|
||||
const startDateVal = getValues("startDate");
|
||||
const validation = validateTaskDeletion(startDateVal ? format(startDateVal, "yyyy-MM-dd") : "");
|
||||
if (!validation.isValid) { showAlert("Validation Error", validation.message); return; }
|
||||
const idsToDelete = getRecursiveChildIds(editTaskId, scheduleData);
|
||||
const confirmMsg = idsToDelete.length > 1
|
||||
? `This task has child tasks. Deleting it will remove ${idsToDelete.length} tasks in total. Continue?`
|
||||
: "Are you sure you want to delete this task?";
|
||||
showConfirm("Delete Task", confirmMsg, () => {
|
||||
setScheduleData(prev => prev.filter(t => !idsToDelete.includes(t.id)));
|
||||
clearForm();
|
||||
});
|
||||
};
|
||||
|
||||
// --- Clear Form ---
|
||||
const clearForm = () => {
|
||||
setEditTaskId(null);
|
||||
skipDateRecalc.current = true;
|
||||
reset(defaultTaskValues);
|
||||
setTimeout(() => { skipDateRecalc.current = false; }, 0);
|
||||
setIsStartDisabled(false);
|
||||
setIsEndDisabled(false);
|
||||
setIsDependencyDisabled(true);
|
||||
setIsLagDisabled(true);
|
||||
};
|
||||
|
||||
// --- Final Save ---
|
||||
const handleFinalSave = async () => {
|
||||
if (!projectIdentifier) return;
|
||||
if (scheduleData.length === 0) { showAlert("No Tasks", "Please add some tasks before saving."); return; }
|
||||
setIsSaving(true);
|
||||
const finalTasks = scheduleData.map(t => ({ ...t }));
|
||||
const schedulePayload = {
|
||||
task: finalTasks,
|
||||
dependency: scheduleData
|
||||
.filter(t => t.taskPredecessor && t.taskPredecessor !== "" && t.taskPredecessor !== "-1")
|
||||
.map((t, index) => ({
|
||||
id: Date.now() + index,
|
||||
predecessorId: parseInt(t.taskPredecessor || "0"),
|
||||
dependencyType: t.dependencyType,
|
||||
})),
|
||||
group: groupData,
|
||||
};
|
||||
try {
|
||||
await saveScheduleApi(projectIdentifier, schedulePayload);
|
||||
setDialogState({ open: true, title: "Saved", description: "Schedule saved successfully.", confirmText: "OK", onConfirm: () => { closeDialog(); onClose(); } });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error("Failed to save schedule:", msg);
|
||||
showAlert("Error", `Failed to save schedule: ${msg}`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// --- Preview Data ---
|
||||
type GanttRow = {
|
||||
id: number | string;
|
||||
title: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
percentComplete: number;
|
||||
isExpanded: boolean;
|
||||
color: string;
|
||||
isRollup?: boolean;
|
||||
_isGroup: boolean;
|
||||
_predecessor: string | null;
|
||||
_depType: number | null;
|
||||
_lag: number;
|
||||
_description: string;
|
||||
taskDuration: number | null;
|
||||
children?: GanttRow[];
|
||||
};
|
||||
|
||||
const parseLocalDate = (dateStr: string | null | undefined): Date | null => {
|
||||
if (!dateStr) return null;
|
||||
if (dateStr.includes("T") || dateStr.includes(" ")) {
|
||||
const d = new Date(dateStr);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
const parts = dateStr.split("-").map(Number);
|
||||
if (parts.length < 3 || parts.some(isNaN)) return null;
|
||||
return new Date(parts[0], parts[1] - 1, parts[2], 12, 0, 0);
|
||||
};
|
||||
|
||||
const previewTasks = useMemo((): GanttRow[] => {
|
||||
const taskRowById = new Map<number, GanttRow>();
|
||||
scheduleData.forEach(t => {
|
||||
const s = parseLocalDate(t.taskStart);
|
||||
let e = parseLocalDate(t.taskEnd);
|
||||
if (!s || isNaN(s.getTime())) return;
|
||||
if (!e || isNaN(e.getTime())) e = new Date(s.getTime() + 86400000);
|
||||
taskRowById.set(t.id, {
|
||||
id: t.id,
|
||||
title: t.taskName,
|
||||
start: s,
|
||||
end: e,
|
||||
percentComplete: 0,
|
||||
isExpanded: true,
|
||||
color: t.taskColour || "#84d5f7",
|
||||
_predecessor: t.taskPredecessor,
|
||||
_depType: t.dependencyType,
|
||||
_lag: t.delayDuration,
|
||||
_description: t.taskDescription,
|
||||
taskDuration: t.taskDuration,
|
||||
_isGroup: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
const groupedTaskIds = new Set<number>();
|
||||
Object.values(groupData).forEach(g => (g.taskIds || []).forEach(tid => groupedTaskIds.add(tid)));
|
||||
|
||||
const groupRows: GanttRow[] = Object.entries(groupData)
|
||||
.map(([gId, g]): GanttRow | null => {
|
||||
const children = (g.taskIds || [])
|
||||
.map(tid => taskRowById.get(tid))
|
||||
.filter((t): t is GanttRow => !!t && !isNaN(t.start.getTime()) && !isNaN(t.end.getTime()));
|
||||
if (children.length === 0) return null;
|
||||
const minStart = new Date(Math.min(...children.map(t => t.start.getTime())));
|
||||
const maxEnd = new Date(Math.max(...children.map(t => t.end.getTime())));
|
||||
return {
|
||||
id: `group_${gId}`,
|
||||
title: g.groupName,
|
||||
start: minStart,
|
||||
end: maxEnd,
|
||||
percentComplete: 0,
|
||||
isExpanded: !collapsedGroupIds.has(`group_${gId}`),
|
||||
color: g.color || "#a0aec0",
|
||||
isRollup: true,
|
||||
_isGroup: true,
|
||||
_predecessor: null,
|
||||
_depType: null,
|
||||
_lag: 0,
|
||||
_description: "",
|
||||
taskDuration: null,
|
||||
children,
|
||||
};
|
||||
})
|
||||
.filter((r): r is GanttRow => r !== null);
|
||||
|
||||
const ungroupedRows = Array.from(taskRowById.values())
|
||||
.filter(t => !groupedTaskIds.has(t.id as number) && !isNaN(t.start.getTime()) && !isNaN(t.end.getTime()));
|
||||
|
||||
return [...groupRows, ...ungroupedRows].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
}, [scheduleData, groupData, collapsedGroupIds]);
|
||||
|
||||
// Flat list of ALL task rows — used for CSS, tooltip, dependencies
|
||||
const allTasksFlat = useMemo((): GanttRow[] => {
|
||||
const result: GanttRow[] = [];
|
||||
const flatten = (items: GanttRow[]) => items.forEach(t => {
|
||||
result.push(t);
|
||||
if (t.children?.length) flatten(t.children);
|
||||
});
|
||||
flatten(previewTasks);
|
||||
return result;
|
||||
}, [previewTasks]);
|
||||
|
||||
const previewDependencies = useMemo(() => {
|
||||
return allTasksFlat
|
||||
.filter(t => t._predecessor)
|
||||
.map((t, idx) => {
|
||||
const fromId = Number(t._predecessor);
|
||||
if (isNaN(fromId)) return null;
|
||||
const depType = parseInt(String(t._depType));
|
||||
return { id: idx + 10000, fromId, toId: t.id, type: !isNaN(depType) ? depType : 1 };
|
||||
})
|
||||
.filter(Boolean) as any[];
|
||||
}, [allTasksFlat]);
|
||||
|
||||
const ganttColorCss = useMemo(() =>
|
||||
allTasksFlat.map(t =>
|
||||
`[data-task-id="${t.id}"].k-task-single,[data-task-id="${t.id}"].k-task-milestone{background-color:${t.color}!important;border-color:${t.color}!important;}`
|
||||
).join(""),
|
||||
[allTasksFlat]);
|
||||
|
||||
const predecessorItems = scheduleData
|
||||
.filter((t: any) => t.id !== editTaskId)
|
||||
.map((t: any) => ({ value: String(t.id), label: t.taskName }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[90vw] w-full max-w-[1400px] p-0 gap-0 rounded-lg overflow-hidden shadow-2xl block bg-background text-foreground border border-border"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="px-6 py-4 border-b border-border">
|
||||
<DialogTitle className="text-xl font-bold">Schedule</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="p-6 space-y-6 overflow-y-auto max-h-[80vh]">
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<FormInput
|
||||
formControl={form.control}
|
||||
name="taskName"
|
||||
label="Schedule Name"
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
<FormDateInput
|
||||
formControl={form.control}
|
||||
name="startDate"
|
||||
label="Task Start Date"
|
||||
placeholder="Pick start date"
|
||||
dateFormat="dd-MMM-yyyy"
|
||||
disabled={isStartDisabled}
|
||||
/>
|
||||
<FormDateInput
|
||||
formControl={form.control}
|
||||
name="endDate"
|
||||
label="Task End Date"
|
||||
placeholder="Pick end date"
|
||||
dateFormat="dd-MMM-yyyy"
|
||||
disabled={isEndDisabled}
|
||||
/>
|
||||
<FormInput
|
||||
formControl={form.control}
|
||||
name="duration"
|
||||
label="Duration (M)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0"
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
if (isNaN(val)) return;
|
||||
setValue("duration", val);
|
||||
if (!isEndDisabled) {
|
||||
const start = getValues("startDate") as Date | null;
|
||||
if (start) {
|
||||
const newEnd = calculateTaskEndDate(format(start, "yyyy-MM-dd"), val);
|
||||
if (newEnd) setValue("endDate", parseISO(newEnd));
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormComboboxInput
|
||||
formControl={form.control}
|
||||
name="predecessor"
|
||||
label="Predecessor"
|
||||
placeholder="Select Predecessor..."
|
||||
items={predecessorItems}
|
||||
/>
|
||||
<FormInput
|
||||
formControl={form.control}
|
||||
name="lag"
|
||||
label="Lag (M)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0"
|
||||
disabled={isLagDisabled}
|
||||
onChange={(e) => setValue("lag", parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
<FormComboboxInput
|
||||
formControl={form.control}
|
||||
name="dependencyType"
|
||||
label="Dependency Type"
|
||||
placeholder="No Dependency Type"
|
||||
disabled={isDependencyDisabled}
|
||||
items={[
|
||||
{ value: "1", label: "Finish - Start" },
|
||||
{ value: "0", label: "Finish - Finish" },
|
||||
{ value: "3", label: "Start - Start" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<label className="text-sm font-medium leading-none">Color</label>
|
||||
<div className="w-full h-9 rounded-md relative overflow-hidden border border-input">
|
||||
<input
|
||||
type="color"
|
||||
className="absolute -top-2 -left-2 w-[150%] h-[200%] cursor-pointer p-0 m-0 bg-transparent"
|
||||
value={watch("color")}
|
||||
onChange={(e) => setValue("color", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<FormTextarea
|
||||
formControl={form.control}
|
||||
name="description"
|
||||
label="Description"
|
||||
placeholder="Enter description..."
|
||||
className="min-h-[80px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2 border-b border-border pb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-4 rounded shadow-sm text-sm font-medium"
|
||||
onClick={() => handleAddOrUpdateTask(false)}
|
||||
>
|
||||
{editTaskId ? <><Save className="w-3.5 h-3.5 mr-1.5" /> Update Task</> : <><Plus className="w-3.5 h-3.5 mr-1.5" /> Add Task</>}
|
||||
</Button>
|
||||
{editTaskId ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="h-8 px-4 rounded shadow-sm text-sm font-medium"
|
||||
onClick={handleDeleteTask}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1.5" /> Delete Task
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-4 rounded shadow-sm text-sm font-medium"
|
||||
onClick={() => handleAddOrUpdateTask(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1.5" /> Add Milestone
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-4 rounded shadow-sm text-sm font-medium"
|
||||
onClick={clearForm}
|
||||
>
|
||||
<Eraser className="w-3.5 h-3.5 mr-1.5" /> Clear Form
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded bg-muted/30 min-h-[320px] flex flex-col overflow-hidden">
|
||||
{previewTasks.length > 0 ? (
|
||||
<div
|
||||
className="gantt-no-subheader flex-1 w-full h-full relative"
|
||||
onMouseMove={handleGanttMouseMove}
|
||||
onMouseOver={handleGanttMouseOver}
|
||||
onMouseLeave={() => setTooltipTask(null)}
|
||||
>
|
||||
<style>{`.gantt-no-subheader .k-gantt-header tr:nth-child(2){display:none!important}${ganttColorCss}`}</style>
|
||||
<Gantt
|
||||
style={{ height: "100%", width: "100%", minHeight: "300px" }}
|
||||
taskData={previewTasks}
|
||||
taskModelFields={taskModelFields}
|
||||
dependencyData={previewDependencies}
|
||||
dependencyModelFields={dependencyModelFields}
|
||||
columns={columns}
|
||||
onTaskClick={handleGanttTaskClick}
|
||||
onExpandChange={handleGanttExpandChange}
|
||||
>
|
||||
<GanttWeekView />
|
||||
<GanttMonthView timelineHeaderCell={MonthViewHeaderCell} />
|
||||
</Gantt>
|
||||
{tooltipTask && (
|
||||
<div
|
||||
className="fixed z-[9999] pointer-events-none bg-background border border-border rounded-md shadow-lg px-3 py-2 min-w-[160px]"
|
||||
style={{ left: tooltipPos.x, top: tooltipPos.y - 8, transform: "translate(-50%, -100%)" }}
|
||||
>
|
||||
<div className="text-xs font-semibold text-foreground mb-1">{tooltipTask.title}</div>
|
||||
<div className="text-xs text-muted-foreground">Start: {formatDate(format(tooltipTask.start, "yyyy-MM-dd"))}</div>
|
||||
<div className="text-xs text-muted-foreground">End: {formatDate(format(tooltipTask.end, "yyyy-MM-dd"))}</div>
|
||||
<div className="text-xs text-muted-foreground">Duration: {tooltipTask.taskDuration != null ? `${tooltipTask.taskDuration} M` : "—"}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center items-center h-48 text-gray-400 text-sm">No tasks to show</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-6 py-4 border-t border-border bg-background flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsTaskGroupModalOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" /> Create Task Group
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleFinalSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : <><Save className="w-4 h-4 mr-2" />Save</>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{dialogState.open && (
|
||||
<CustomAlertDialog
|
||||
title={dialogState.title}
|
||||
description={dialogState.description}
|
||||
confirmText={dialogState.confirmText}
|
||||
cancelText={dialogState.cancelText}
|
||||
onConfirm={dialogState.onConfirm}
|
||||
onCancel={dialogState.onCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isTaskGroupModalOpen && (
|
||||
<TaskGroupModal
|
||||
isOpen={isTaskGroupModalOpen}
|
||||
onClose={() => setIsTaskGroupModalOpen(false)}
|
||||
scheduleTaskItems={scheduleData.map(t => ({ value: String(t.id), label: t.taskName }))}
|
||||
initialGroups={groupData}
|
||||
onSave={(groups) => {
|
||||
setGroupData(groups);
|
||||
setIsTaskGroupModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleFormComponent;
|
||||
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { formatDate } from "@/app/utils/mapping-functions/date-format";
|
||||
import {
|
||||
Gantt,
|
||||
GanttWeekView,
|
||||
GanttMonthView,
|
||||
GanttTaskClickEvent,
|
||||
GanttExpandChangeEvent,
|
||||
GanttViewTimelineHeaderCellProps,
|
||||
} from "@progress/kendo-react-gantt";
|
||||
import { getScheduleApi } from "@/app/utils/api/projectApi";
|
||||
|
||||
interface TaskDto {
|
||||
id: number;
|
||||
parentId?: number;
|
||||
taskName?: string;
|
||||
taskStart?: string;
|
||||
taskEnd?: string;
|
||||
taskDuration?: number | null;
|
||||
taskDescription?: string;
|
||||
taskColour?: string;
|
||||
taskPredecessor?: string;
|
||||
dependencyType?: number;
|
||||
}
|
||||
|
||||
interface GroupItem {
|
||||
taskIds?: number[];
|
||||
groupName?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface ScheduleGanttShowProps {
|
||||
projectIdentifier: string;
|
||||
onTaskSelect?: (taskId: number) => void;
|
||||
}
|
||||
|
||||
const taskModelFields = {
|
||||
id: "id",
|
||||
start: "start",
|
||||
end: "end",
|
||||
title: "title",
|
||||
percentComplete: "percentComplete",
|
||||
isRollup: "isRollup",
|
||||
isExpanded: "isExpanded",
|
||||
children: "children",
|
||||
};
|
||||
|
||||
const dependencyModelFields = {
|
||||
id: "id",
|
||||
fromId: "fromId",
|
||||
toId: "toId",
|
||||
type: "type",
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ field: "title", title: "Schedule", width: 130, expandable: true },
|
||||
{ field: "start", title: "Start Date", width: 120, format: "{0:dd-MMM-yyyy}" },
|
||||
{ field: "end", title: "End Date", width: 120, format: "{0:dd-MMM-yyyy}" },
|
||||
{ field: "taskDescription", title: "Description", width: 180 },
|
||||
];
|
||||
|
||||
type GanttTaskRow = {
|
||||
id: number | string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
title: string;
|
||||
percentComplete: number;
|
||||
isRollup: boolean;
|
||||
isExpanded: boolean;
|
||||
taskDuration: number | null;
|
||||
taskDescription: string;
|
||||
color: string;
|
||||
rawPredecessor?: string;
|
||||
rawDepType?: number;
|
||||
_isGroup?: boolean;
|
||||
children?: GanttTaskRow[];
|
||||
};
|
||||
|
||||
// Month view header cell: shows "May, 2026" instead of just "May"
|
||||
const MonthViewHeaderCell: React.FC<GanttViewTimelineHeaderCellProps> = ({ range, rowIndex, text }) => {
|
||||
if (rowIndex === 0) {
|
||||
return <span>{format(range.start, "MMM, yyyy")}</span>;
|
||||
}
|
||||
return <span>{text}</span>;
|
||||
};
|
||||
|
||||
const ScheduleGanttShowComponent: React.FC<ScheduleGanttShowProps> = ({
|
||||
projectIdentifier,
|
||||
onTaskSelect,
|
||||
}) => {
|
||||
const [scheduleData, setScheduleData] = useState<GanttTaskRow[]>([]);
|
||||
const [dependencyData, setDependencyData] = useState<{ id: number; fromId: number; toId: number | string; type: number }[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [tooltipTask, setTooltipTask] = useState<GanttTaskRow | null>(null);
|
||||
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });
|
||||
const [allTasksFlat, setAllTasksFlat] = useState<GanttTaskRow[]>([]);
|
||||
|
||||
const handleGanttMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setTooltipPos({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
const handleGanttMouseOver = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
let el = e.target as HTMLElement | null;
|
||||
while (el) {
|
||||
if (el.classList.contains("k-task-single") || el.classList.contains("k-task-milestone")) break;
|
||||
if (el.classList.contains("k-gantt-rows") || el === e.currentTarget) { el = null; break; }
|
||||
el = el.parentElement;
|
||||
}
|
||||
if (!el) { setTooltipTask(null); return; }
|
||||
const contentEl = el.querySelector(".k-task-content") || el;
|
||||
const title = contentEl.textContent?.trim() || "";
|
||||
const found = allTasksFlat.find(t => t.title === title) || null;
|
||||
setTooltipTask(found);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!projectIdentifier) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const scheduleResponse = await getScheduleApi(projectIdentifier);
|
||||
|
||||
console.log("scheduleResponse", scheduleResponse);
|
||||
|
||||
|
||||
const backendTasks: TaskDto[] = scheduleResponse?.task || [];
|
||||
const backendGroups: Record<string, GroupItem> = scheduleResponse?.group || {};
|
||||
|
||||
// Map raw tasks to GanttTaskRow objects
|
||||
const parseLocalDate = (dateStr: string | null | undefined): Date | null => {
|
||||
if (!dateStr) return null;
|
||||
if (dateStr.includes("T") || dateStr.includes(" ")) {
|
||||
const d = new Date(dateStr);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
const parts = dateStr.split("-").map(Number);
|
||||
if (parts.length < 3 || parts.some(isNaN)) return null;
|
||||
return new Date(parts[0], parts[1] - 1, parts[2], 12, 0, 0);
|
||||
};
|
||||
|
||||
const taskById = new Map<number, GanttTaskRow>();
|
||||
backendTasks.forEach((task: TaskDto) => {
|
||||
const startDate = parseLocalDate(task.taskStart);
|
||||
if (!startDate || isNaN(startDate.getTime())) return;
|
||||
let endDate = parseLocalDate(task.taskEnd);
|
||||
if (!endDate || isNaN(endDate.getTime())) {
|
||||
endDate = new Date(startDate.getTime() + 86400000);
|
||||
}
|
||||
taskById.set(task.id, {
|
||||
id: task.id,
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
title: task.taskName || "Untitled Task",
|
||||
percentComplete: 0,
|
||||
isRollup: false,
|
||||
isExpanded: true,
|
||||
taskDuration: task.taskDuration ?? null,
|
||||
taskDescription: task.taskDescription || "",
|
||||
color: task.taskColour || "#84d5f7",
|
||||
rawPredecessor: task.taskPredecessor,
|
||||
rawDepType: task.dependencyType,
|
||||
_isGroup: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Determine grouped task IDs
|
||||
const groupedTaskIds = new Set<number>();
|
||||
Object.values(backendGroups).forEach(g =>
|
||||
(g.taskIds || []).forEach(tid => groupedTaskIds.add(tid))
|
||||
);
|
||||
|
||||
// Build group parent rows with nested children
|
||||
const groupRows: GanttTaskRow[] = Object.entries(backendGroups)
|
||||
.map(([gId, g]): GanttTaskRow | null => {
|
||||
const children = (g.taskIds || [])
|
||||
.map(tid => taskById.get(tid))
|
||||
.filter((t): t is GanttTaskRow => !!t && !isNaN(t.start.getTime()) && !isNaN(t.end.getTime()));
|
||||
if (children.length === 0) return null;
|
||||
const minStart = new Date(Math.min(...children.map(t => t.start.getTime())));
|
||||
const maxEnd = new Date(Math.max(...children.map(t => t.end.getTime())));
|
||||
return {
|
||||
id: `group_${gId}`,
|
||||
start: minStart,
|
||||
end: maxEnd,
|
||||
title: g.groupName || `Group ${gId}`,
|
||||
percentComplete: 0,
|
||||
isRollup: true,
|
||||
isExpanded: true,
|
||||
taskDuration: null,
|
||||
taskDescription: "",
|
||||
color: g.color || "#a0aec0",
|
||||
_isGroup: true,
|
||||
children,
|
||||
};
|
||||
})
|
||||
.filter((r): r is GanttTaskRow => r !== null);
|
||||
|
||||
// Ungrouped tasks
|
||||
const ungroupedTasks = Array.from(taskById.values())
|
||||
.filter(t => !groupedTaskIds.has(t.id as number) && !isNaN(t.start.getTime()) && !isNaN(t.end.getTime()));
|
||||
|
||||
const combined = [...groupRows, ...ungroupedTasks].sort(
|
||||
(a, b) => a.start.getTime() - b.start.getTime()
|
||||
);
|
||||
|
||||
// Build flat list for CSS/tooltip
|
||||
const flat: GanttTaskRow[] = [];
|
||||
const flatten = (items: GanttTaskRow[]) => items.forEach(t => {
|
||||
flat.push(t);
|
||||
if (t.children?.length) flatten(t.children);
|
||||
});
|
||||
flatten(combined);
|
||||
|
||||
setScheduleData(combined);
|
||||
setAllTasksFlat(flat);
|
||||
|
||||
// Dependencies (only between real tasks)
|
||||
const formattedDependencies = Array.from(taskById.values())
|
||||
.filter(t => t.rawPredecessor && t.rawPredecessor !== "none" && t.rawPredecessor !== "" && t.rawPredecessor !== "-1")
|
||||
.map((t, index) => {
|
||||
const fromId = Number(t.rawPredecessor);
|
||||
if (isNaN(fromId)) return null;
|
||||
return {
|
||||
id: index + 1000,
|
||||
fromId,
|
||||
toId: t.id,
|
||||
type: t.rawDepType !== undefined && t.rawDepType !== -1 ? t.rawDepType : 1,
|
||||
};
|
||||
})
|
||||
.filter((d): d is { id: number; fromId: number; toId: number | string; type: number } => d !== null);
|
||||
|
||||
setDependencyData(formattedDependencies);
|
||||
} catch (error) {
|
||||
console.error("Error fetching schedule data for Gantt:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [projectIdentifier]);
|
||||
|
||||
const handleTaskClick = (event: GanttTaskClickEvent) => {
|
||||
if (onTaskSelect && event.dataItem && !event.dataItem._isGroup) {
|
||||
onTaskSelect(event.dataItem.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpandChange = (e: GanttExpandChangeEvent) => {
|
||||
const id = String(e.dataItem?.id);
|
||||
setScheduleData(prev => prev.map(row =>
|
||||
String(row.id) === id ? { ...row, isExpanded: !row.isExpanded } : row
|
||||
));
|
||||
};
|
||||
|
||||
const colorCss = useMemo(() =>
|
||||
allTasksFlat
|
||||
.map(t => `[data-task-id="${t.id}"].k-task-single,[data-task-id="${t.id}"].k-task-milestone{background-color:${t.color}!important;border-color:${t.color}!important;}`)
|
||||
.join(""),
|
||||
[allTasksFlat]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center border rounded-md h-[400px] text-gray-500">
|
||||
Loading Chart...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (scheduleData.length === 0) {
|
||||
return (
|
||||
<div className="flex justify-center items-center border rounded-md h-[400px] text-gray-500">
|
||||
No tasks to show. Click <strong className="mx-1">Add Schedule</strong> to add tasks.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<div
|
||||
className="gantt-no-subheader w-full h-full overflow-x-auto border rounded-md relative"
|
||||
style={{ minHeight: "420px" }}
|
||||
onMouseMove={handleGanttMouseMove}
|
||||
onMouseOver={handleGanttMouseOver}
|
||||
onMouseLeave={() => setTooltipTask(null)}
|
||||
>
|
||||
<style>{`.gantt-no-subheader .k-gantt-header tr:nth-child(2){display:none!important}${colorCss}`}</style>
|
||||
<Gantt
|
||||
style={{ height: "100%", width: "100%", minHeight: "420px" }}
|
||||
taskData={scheduleData}
|
||||
taskModelFields={taskModelFields}
|
||||
dependencyData={dependencyData}
|
||||
dependencyModelFields={dependencyModelFields}
|
||||
columns={columns}
|
||||
onTaskClick={handleTaskClick}
|
||||
onExpandChange={handleExpandChange}
|
||||
>
|
||||
<GanttWeekView />
|
||||
<GanttMonthView timelineHeaderCell={MonthViewHeaderCell} />
|
||||
</Gantt>
|
||||
{tooltipTask && (
|
||||
<div
|
||||
className="fixed z-[9999] pointer-events-none bg-background border border-border rounded-md shadow-lg px-3 py-2 min-w-[160px]"
|
||||
style={{ left: tooltipPos.x, top: tooltipPos.y - 8, transform: "translate(-50%, -100%)" }}
|
||||
>
|
||||
<div className="text-xs font-semibold text-foreground mb-1">{tooltipTask.title}</div>
|
||||
<div className="text-xs text-muted-foreground">Start: {formatDate(format(tooltipTask.start, "yyyy-MM-dd"))}</div>
|
||||
<div className="text-xs text-muted-foreground">End: {formatDate(format(tooltipTask.end, "yyyy-MM-dd"))}</div>
|
||||
<div className="text-xs text-muted-foreground">Duration: {tooltipTask.taskDuration != null ? `${tooltipTask.taskDuration} M` : "—"}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleGanttShowComponent;
|
||||
Reference in New Issue
Block a user