first commit

This commit is contained in:
Your NamebaishaliHolocron
2026-06-15 12:57:03 +05:30
commit b9ac5ae0b2
398 changed files with 49583 additions and 0 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>;

View File

@@ -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,
};
};

View File

@@ -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;

View File

@@ -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;