You need at least two tasks to define dependencies.
`;
return;
}
tasks.forEach(task => {
const otherTasks = tasks.filter(t => t.id !== task.id);
let options = otherTasks.map(ot =>
`
`
).join('');
const depRow = document.createElement('div');
depRow.className = 'grid grid-cols-1 md:grid-cols-3 gap-4 items-center border p-4 rounded-lg';
depRow.innerHTML = `
`;
dependencyListContainer.appendChild(depRow);
});
// Attach event listeners to the new select elements
document.querySelectorAll('.dependency-select').forEach(select => {
select.addEventListener('change', (e) => {
const taskId = parseInt(e.target.dataset.taskId);
const selectedOptions = Array.from(e.target.selectedOptions).map(opt => parseInt(opt.value));
const taskToUpdate = tasks.find(t => t.id === taskId);
if (taskToUpdate) {
taskToUpdate.dependencies = selectedOptions;
}
});
});
};
const renderGanttChart = () => {
if (!ganttChartSVG || !totalDurationEl) return;
const { scheduledTasks, criticalPath, maxEndDate } = calculateGanttData();
if (scheduledTasks.length === 0) {
ganttChartSVG.innerHTML = `
No tasks to display. Please add tasks and define dependencies.`;
totalDurationEl.textContent = '0 days';
return;
}
totalDurationEl.textContent = `${maxEndDate} days`;
const margin = { top: 40, right: 20, bottom: 20, left: 150 };
const chartHeight = scheduledTasks.length * 40 + margin.top + margin.bottom;
const chartWidth = ganttChartSVG.parentElement.clientWidth;
ganttChartSVG.setAttribute('height', chartHeight);
ganttChartSVG.innerHTML = ''; // Clear previous chart
const xScale = (val) => margin.left + (val / maxEndDate) * (chartWidth - margin.left - margin.right);
// Draw grid lines and day markers
for (let i = 0; i <= maxEndDate; i++) {
const x = xScale(i);
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', x);
line.setAttribute('x2', x);
line.setAttribute('y1', margin.top);
line.setAttribute('y2', chartHeight - margin.bottom);
line.setAttribute('class', 'gantt-grid-line');
ganttChartSVG.appendChild(line);
if (i % (Math.ceil(maxEndDate / 20)) === 0) { // Avoid cluttering the axis
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', x);
text.setAttribute('y', margin.top - 10);
text.setAttribute('text-anchor', 'middle');
text.setAttribute('class', 'gantt-axis-text');
text.textContent = `Day ${i}`;
ganttChartSVG.appendChild(text);
}
}
// Draw tasks
scheduledTasks.forEach((task, index) => {
const y = margin.top + index * 40;
const isCritical = criticalPath.includes(task.id);
// Task bar
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('x', xScale(task.start));
rect.setAttribute('y', y);
rect.setAttribute('width', xScale(task.end) - xScale(task.start));
rect.setAttribute('height', 30);
rect.setAttribute('rx', 3);
rect.setAttribute('ry', 3);
rect.setAttribute('class', `gantt-task-bar ${isCritical ? 'critical' : ''}`);
ganttChartSVG.appendChild(rect);
// Task label inside bar
const textInBar = document.createElementNS('http://www.w3.org/2000/svg', 'text');
textInBar.setAttribute('x', xScale(task.start) + 5);
textInBar.setAttribute('y', y + 20);
textInBar.setAttribute('class', 'gantt-task-label');
textInBar.textContent = task.name;
ganttChartSVG.appendChild(textInBar);
// Task name on Y-axis
const yAxisLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
yAxisLabel.setAttribute('x', margin.left - 10);
yAxisLabel.setAttribute('y', y + 20);
yAxisLabel.setAttribute('text-anchor', 'end');
yAxisLabel.setAttribute('class', 'gantt-axis-text');
yAxisLabel.textContent = task.name.length > 20 ? task.name.substring(0, 18) + '...' : task.name;
ganttChartSVG.appendChild(yAxisLabel);
});
};
const renderAll = () => {
renderTaskList();
renderDependencyList();
renderGanttChart();
};
// --- CORE LOGIC --- //
const calculateGanttData = () => {
if (tasks.length === 0) {
return { scheduledTasks: [], criticalPath: [], maxEndDate: 0 };
}
// Deep copy to avoid modifying original tasks
let processingTasks = JSON.parse(JSON.stringify(tasks));
// Add start/end properties
processingTasks.forEach(t => {
t.start = 0;
t.end = 0;
});
// Topological sort to handle dependencies
let scheduledTasks = [];
let queue = processingTasks.filter(t => t.dependencies.length === 0);
let processingMap = new Map(processingTasks.map(t => [t.id, t]));
while (queue.length > 0) {
let current = queue.shift();
scheduledTasks.push(current);
processingTasks.forEach(otherTask => {
const depIndex = otherTask.dependencies.indexOf(current.id);
if (depIndex > -1) {
otherTask.dependencies.splice(depIndex, 1);
if (otherTask.dependencies.length === 0) {
queue.push(otherTask);
}
}
});
}
// Check for circular dependencies
if (scheduledTasks.length !== tasks.length) {
console.error("Circular dependency detected!");
// Handle this gracefully in UI
ganttChartSVG.innerHTML = `
Error: Circular dependency detected. Please review dependencies.`;
return { scheduledTasks: [], criticalPath: [], maxEndDate: 0 };
}
// Calculate Early Start (ES) and Early Finish (EF)
scheduledTasks.forEach(task => {
let maxDependencyEnd = 0;
const originalTask = tasks.find(t => t.id === task.id);
if (originalTask && originalTask.dependencies.length > 0) {
originalTask.dependencies.forEach(depId => {
const depTask = scheduledTasks.find(st => st.id === depId);
if (depTask) {
maxDependencyEnd = Math.max(maxDependencyEnd, depTask.end);
}
});
}
task.start = maxDependencyEnd;
task.end = task.start + task.duration;
});
const maxEndDate = Math.max(0, ...scheduledTasks.map(t => t.end));
// Calculate Late Start (LS) and Late Finish (LF) for critical path
scheduledTasks.forEach(t => { t.lf = maxEndDate; t.ls = maxEndDate; });
for (let i = scheduledTasks.length - 1; i >= 0; i--) {
const task = scheduledTasks[i];
const originalTask = tasks.find(t => t.id === task.id);
// Find tasks that depend on the current task
const successors = tasks.filter(t => t.dependencies.includes(task.id));
if (successors.length === 0) {
task.lf = maxEndDate;
} else {
let minSuccessorStart = Infinity;
successors.forEach(succIdObj => {
const succTask = scheduledTasks.find(st => st.id === succIdObj.id);
if (succTask) {
minSuccessorStart = Math.min(minSuccessorStart, succTask.ls);
}
});
task.lf = minSuccessorStart;
}
task.ls = task.lf - task.duration;
}
// Identify critical path (where ES === LS)
const criticalPath = scheduledTasks.filter(t => t.start === t.ls).map(t => t.id);
return { scheduledTasks, criticalPath, maxEndDate };
};
// --- EVENT HANDLERS --- //
taskForm.addEventListener('submit', (e) => {
e.preventDefault();
const id = parseInt(taskIdInput.value);
const name = taskNameInput.value.trim();
const duration = parseInt(taskDurationInput.value);
const cost = parseFloat(taskCostInput.value) || 0;
if (!name || isNaN(duration) || duration <= 0) {
alert('Please provide a valid task name and a positive duration.');
return;
}
if (id) { // Update existing task
const task = tasks.find(t => t.id === id);
if (task) {
task.name = name;
task.duration = duration;
task.cost = cost;
}
} else { // Add new task
const newId = tasks.length > 0 ? Math.max(...tasks.map(t => t.id)) + 1 : 1;
tasks.push({ id: newId, name, duration, cost, dependencies: [] });
}
taskForm.reset();
taskIdInput.value = '';
renderAll();
});
clearFormBtn.addEventListener('click', () => {
taskForm.reset();
taskIdInput.value = '';
});
// Make edit/delete functions globally accessible from inline onclick
window.editTask = (id) => {
const task = tasks.find(t => t.id === id);
if (task) {
taskIdInput.value = task.id;
taskNameInput.value = task.name;
taskDurationInput.value = task.duration;
taskCostInput.value = task.cost;
taskNameInput.focus();
}
};
window.deleteTask = (id) => {
if (confirm('Are you sure you want to delete this task? This will also remove it from any dependencies.')) {
tasks = tasks.filter(t => t.id !== id);
// Also remove this id from any other task's dependency list
tasks.forEach(task => {
task.dependencies = task.dependencies.filter(depId => depId !== id);
});
renderAll();
}
};
downloadPdfBtn.addEventListener('click', () => {
const { jsPDF } = window.jspdf;
const ganttOutput = document.getElementById('gantt-chart-output');
const { scheduledTasks, criticalPath, maxEndDate } = calculateGanttData();
if (!ganttOutput || tasks.length === 0) {
alert("There is no chart to download.");
return;
}
const pdf = new jsPDF({
orientation: 'landscape',
unit: 'pt',
format: 'a4'
});
// Add Title
pdf.setFontSize(18);
pdf.text("Project Task Dependencies Report", 40, 40);
pdf.setFontSize(12);
pdf.text(`Total Project Duration: ${maxEndDate} days`, 40, 60);
// Add Gantt Chart Image
html2canvas(ganttOutput, { scale: 2 }).then(canvas => {
const imgData = canvas.toDataURL('image/png');
const imgProps = pdf.getImageProperties(imgData);
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = (imgProps.height * pdfWidth) / imgProps.width;
pdf.addImage(imgData, 'PNG', 40, 80, pdfWidth - 80, pdfHeight - 80);
// Add a new page for the task table
pdf.addPage();
pdf.setFontSize(18);
pdf.text("Task Details", 40, 40);
// Add Task Table
const tableData = scheduledTasks.map(task => {
const deps = tasks.find(t => t.id === task.id)?.dependencies || [];
const depNames = deps.map(depId => tasks.find(t => t.id === depId)?.name || '').join(', ');
return [
task.id,
task.name,
task.duration,
`$${task.cost.toLocaleString()}`,
task.start,
task.end,
depNames
];
});
pdf.autoTable({
startY: 60,
head: [['ID', 'Task Name', 'Duration', 'Cost', 'Start Day', 'End Day', 'Dependencies']],
body: tableData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185] }
});
pdf.save('Project_Dependencies_Report.pdf');
});
});
// --- TAB NAVIGATION LOGIC --- //
const updateTabs = () => {
tabContents.forEach(content => content.classList.add('hidden'));
tabButtons.forEach(button => button.classList.remove('active'));
const activeTabId = `${tabs[currentTab]}-tab`;
const activeContent = document.getElementById(activeTabId);
const activeButton = document.querySelector(`.tab-btn[data-tab="${tabs[currentTab]}"]`);
if(activeContent) activeContent.classList.remove('hidden');
if(activeButton) activeButton.classList.add('active');
prevBtn.disabled = currentTab === 0;
nextBtn.disabled = currentTab === tabs.length - 1;
// Re-render relevant content when switching to a tab
if (tabs[currentTab] === 'dependencies') {
renderDependencyList();
} else if (tabs[currentTab] === 'gantt') {
renderGanttChart();
}
};
tabButtons.forEach((button, index) => {
button.addEventListener('click', () => {
currentTab = index;
updateTabs();
});
});
prevBtn.addEventListener('click', () => {
if (currentTab > 0) {
currentTab--;
updateTabs();
}
});
nextBtn.addEventListener('click', () => {
if (currentTab < tabs.length - 1) {
currentTab++;
updateTabs();
}
});
// --- FINAL SETUP --- //
loadSampleData(); // Load sample data on initial load
updateTabs(); // Set initial tab state
// Add autoTable plugin for jsPDF
(function (jsPDFAPI) {
// ... [autoTable plugin code - this is a large block, so for brevity it's assumed to be loaded]
// In a real scenario, this would be a separate script tag. For this single file, we'll simulate its presence.
// A simplified version for demonstration:
jsPDF.API.autoTable = function(options) {
console.log("jsPDF.autoTable called with:", options);
// This is where the real autoTable logic would generate the table.
// For now, let's just draw a basic representation.
let doc = this;
let cursorY = options.startY || 40;
const head = options.head[0];
const body = options.body;
const colWidth = (doc.internal.pageSize.getWidth() - 80) / head.length;
doc.setFontSize(10);
doc.setFont(undefined, 'bold');
head.forEach((h, i) => {
doc.rect(40 + i * colWidth, cursorY, colWidth, 20, 'S');
doc.text(h, 45 + i * colWidth, cursorY + 14);
});
cursorY += 20;
doc.setFont(undefined, 'normal');
body.forEach(row => {
row.forEach((cell, i) => {
doc.rect(40 + i * colWidth, cursorY, colWidth, 20, 'S');
doc.text(String(cell), 45 + i * colWidth, cursorY + 14);
});
cursorY += 20;
});
return doc;
};
})(jsPDF.API);
});