// Maintenance Scheduling Color Palette const COLOR_IDEAL = '#8AE234'; // Green - in ideal window const COLOR_IDEAL_BG = '#8AE23433'; // Green with transparency const COLOR_ACCEPTABLE = '#FCAF3E'; // Orange - after ideal, before due const COLOR_ACCEPTABLE_BG = '#FCAF3E33'; // Orange with transparency const COLOR_OVERDUE = '#EF2929'; // Red - past due date const COLOR_OVERDUE_BG = '#EF292999'; // Red with transparency const BRAND_GREEN = '#10b981'; // SolverForge brand green const HIGHLIGHT_COLOR = 'rgba(99, 102, 241, 0.15)'; // Indigo highlight var autoRefreshIntervalId = null; let highlightedCrewId = null; let demoDataId = null; let scheduleId = null; let loadedSchedule = null; const byCrewPanel = document.getElementById("byCrewPanel"); const byCrewTimelineOptions = { timeAxis: {scale: "day"}, orientation: {axis: "top"}, stack: false, xss: {disabled: true}, // Items are XSS safe through JQuery zoomMin: 3 * 1000 * 60 * 60 * 24 // Three day in milliseconds }; var byCrewGroupData = new vis.DataSet(); var byCrewItemData = new vis.DataSet(); var byCrewTimeline = new vis.Timeline(byCrewPanel, byCrewItemData, byCrewGroupData, byCrewTimelineOptions); const byJobPanel = document.getElementById("byJobPanel"); const byJobTimelineOptions = { timeAxis: {scale: "day"}, orientation: {axis: "top"}, xss: {disabled: true}, // Items are XSS safe through JQuery zoomMin: 3 * 1000 * 60 * 60 * 24 // Three day in milliseconds }; var byJobGroupData = new vis.DataSet(); var byJobItemData = new vis.DataSet(); var byJobTimeline = new vis.Timeline(byJobPanel, byJobItemData, byJobGroupData, byJobTimelineOptions); $(document).ready(function () { replaceQuickstartSolverForgeAutoHeaderFooter(); $("#solveButton").click(function () { solve(); }); $("#stopSolvingButton").click(function () { stopSolving(); }); $("#analyzeButton").click(function () { analyze(); }); // HACK to allow vis-timeline to work within Bootstrap tabs $("#byCrewTab").on('shown.bs.tab', function (event) { byCrewTimeline.redraw(); }) $("#byJobTab").on('shown.bs.tab', function (event) { byJobTimeline.redraw(); }) // Timeline item click handlers byCrewTimeline.on('select', function(properties) { if (properties.items.length > 0) { const itemId = properties.items[0]; // Ignore background items (they have underscore in ID like "job1_readyToIdealEnd") if (!String(itemId).includes('_')) { showJobDetails(itemId); } byCrewTimeline.setSelection([]); // Clear selection } }); byJobTimeline.on('select', function(properties) { if (properties.items.length > 0) { const itemId = properties.items[0]; if (!String(itemId).includes('_')) { showJobDetails(itemId); } byJobTimeline.setSelection([]); } }); setupAjax(); fetchDemoData(); }); function setupAjax() { $.ajaxSetup({ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json,text/plain', // plain text is required by solve() returning UUID of the solver job } }); // Extend jQuery to support $.put() and $.delete() jQuery.each(["put", "delete"], function (i, method) { jQuery[method] = function (url, data, callback, type) { if (jQuery.isFunction(data)) { type = type || callback; callback = data; data = undefined; } return jQuery.ajax({ url: url, type: method, dataType: type, data: data, success: callback }); }; }); } function fetchDemoData() { $.get("/demo-data", function (data) { data.forEach(item => { $("#testDataButton").append($('' + item + '')); $("#" + item + "TestData").click(function () { switchDataDropDownItemActive(item); scheduleId = null; demoDataId = item; refreshSchedule(); }); }); // load first data set demoDataId = data[0]; switchDataDropDownItemActive(demoDataId); refreshSchedule(); }).fail(function (xhr, ajaxOptions, thrownError) { // disable this page as there is no data let $demo = $("#demo"); $demo.empty(); $demo.html("

No test data available

") }); } function switchDataDropDownItemActive(newItem) { activeCssClass = "active"; $("#testDataButton > a." + activeCssClass).removeClass(activeCssClass); $("#" + newItem + "TestData").addClass(activeCssClass); } function refreshSchedule() { let path = "/schedules/" + scheduleId; if (scheduleId === null) { if (demoDataId === null) { alert("Please select a test data set."); return; } path = "/demo-data/" + demoDataId; } $.getJSON(path, function (schedule) { loadedSchedule = schedule; renderSchedule(schedule); }) .fail(function (xhr, ajaxOptions, thrownError) { showError("Getting the schedule has failed.", xhr); refreshSolvingButtons(false); }); } function renderSchedule(schedule) { refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING"); $("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score)); const unassignedJobs = $("#unassignedJobs"); unassignedJobs.children().remove(); var unassignedJobsCount = 0; byCrewGroupData.clear(); byJobGroupData.clear(); byCrewItemData.clear(); byJobItemData.clear(); $.each(schedule.crews, (index, crew) => { byCrewGroupData.add({id: crew.id, content: crew.name}); }); $.each(schedule.jobs, (index, job) => { const jobGroupElement = $(`
`) .append($(`
`).text(job.name)) .append($(`

`).text(`${job.durationInDays} workdays`)); byJobGroupData.add({ id: job.id, content: jobGroupElement.html() }); byJobItemData.add({ id: job.id + "_readyToIdealEnd", group: job.id, start: job.minStartDate, end: job.idealEndDate, type: "background", style: `background-color: ${COLOR_IDEAL_BG}` }); byJobItemData.add({ id: job.id + "_idealEndToDue", group: job.id, start: job.idealEndDate, end: job.maxEndDate, type: "background", style: `background-color: ${COLOR_ACCEPTABLE_BG}` }); if (job.crew == null || job.startDate == null) { unassignedJobsCount++; const unassignedJobElement = $(`

`) .append($(`
`).text(job.name)) .append($(`

`).text(`${job.durationInDays} workdays`)) .append($(`

`).text(`Start: ${job.minStartDate}`)) .append($(`

`).text(`End: ${job.maxEndDate}`)); const byJobJobElement = $(`

`) .append($(`
`).text(`Unassigned`)); $.each(job.tags, (index, tag) => { const color = pickColor(tag); unassignedJobElement.append($(``).text(tag)); byJobJobElement.append($(``).text(tag)); }); const card = $(`
`).append(unassignedJobElement); card.click(() => showJobDetails(job.id)); unassignedJobs.append($(`
`).append(card)); byJobItemData.add({ id: job.id, group: job.id, content: byJobJobElement.html(), start: job.minStartDate, end: JSJoda.LocalDate.parse(job.minStartDate).plusDays(job.durationInDays).toString(), style: `background-color: ${COLOR_OVERDUE_BG}` }); } else { const beforeReady = JSJoda.LocalDate.parse(job.startDate).isBefore(JSJoda.LocalDate.parse(job.minStartDate)); const afterDue = JSJoda.LocalDate.parse(job.endDate).isAfter(JSJoda.LocalDate.parse(job.maxEndDate)); const byCrewJobElement = $(`
`) .append($(`
`).text(job.name)) .append($(`

`).text(`${job.durationInDays} workdays`)); const byJobJobElement = $(`

`) .append($(`
`).text(job.crew.name)); if (beforeReady) { byCrewJobElement.append($(`

`).text(`Before ready (too early)`)); byJobJobElement.append($(`

`).text(`Before ready (too early)`)); } if (afterDue) { byCrewJobElement.append($(`

`).text(`After due (too late)`)); byJobJobElement.append($(`

`).text(`After due (too late)`)); } $.each(job.tags, (index, tag) => { const color = pickColor(tag); byCrewJobElement.append($(``).text(tag)); byJobJobElement.append($(``).text(tag)); }); byCrewItemData.add({ id: job.id, group: job.crew.id, content: byCrewJobElement.html(), start: job.startDate, end: job.endDate }); byJobItemData.add({ id: job.id, group: job.id, content: byJobJobElement.html(), start: job.startDate, end: job.endDate, crewId: job.crew.id }); } }); if (unassignedJobsCount === 0) { unassignedJobs.append($(`

`).text(`There are no unassigned jobs.`)); } byCrewTimeline.setWindow(schedule.workCalendar.fromDate, schedule.workCalendar.toDate); byJobTimeline.setWindow(schedule.workCalendar.fromDate, schedule.workCalendar.toDate); // Render crew table and clear any previous highlighting renderCrewTable(schedule); highlightedCrewId = null; } function solve() { $.post("/schedules", JSON.stringify(loadedSchedule), function (data) { scheduleId = data; refreshSolvingButtons(true); }).fail(function (xhr, ajaxOptions, thrownError) { showError("Start solving failed.", xhr); refreshSolvingButtons(false); }, "text"); } function analyze() { analyzeScore(loadedSchedule, "/schedules/analyze"); } function refreshSolvingButtons(solving) { if (solving) { $("#solveButton").hide(); $("#stopSolvingButton").show(); $("#solvingSpinner").addClass("active"); if (autoRefreshIntervalId == null) { autoRefreshIntervalId = setInterval(refreshSchedule, 2000); } } else { $("#solveButton").show(); $("#stopSolvingButton").hide(); $("#solvingSpinner").removeClass("active"); if (autoRefreshIntervalId != null) { clearInterval(autoRefreshIntervalId); autoRefreshIntervalId = null; } } } function stopSolving() { $.delete("/schedules/" + scheduleId, function () { refreshSolvingButtons(false); refreshSchedule(); }).fail(function (xhr, ajaxOptions, thrownError) { showError("Stop solving failed.", xhr); }); } function copyTextToClipboard(id) { var text = document.getElementById(id).innerText; navigator.clipboard.writeText(text); } function replaceQuickstartSolverForgeAutoHeaderFooter() { const solverforgeHeader = $("header#solverforge-auto-header"); if (solverforgeHeader != null) { solverforgeHeader.css("background-color", "#ffffff"); solverforgeHeader.append( $(`

`), ); } const solverforgeFooter = $("footer#solverforge-auto-footer"); if (solverforgeFooter != null) { solverforgeFooter.append( $(``), ); } } // **************************************************************************** // Job Details Modal // **************************************************************************** function showJobDetails(jobId) { const job = loadedSchedule.jobs.find(j => j.id === jobId); if (!job) return; $("#jobDetailsName").text(job.name); $("#jobDetailsCrew").html(job.crew ? `${job.crew.name}` : 'Unassigned'); $("#jobDetailsDuration").text(`${job.durationInDays} workdays`); $("#jobDetailsMinStart").text(job.minStartDate); $("#jobDetailsMaxEnd").text(job.maxEndDate); $("#jobDetailsIdealEnd").text(job.idealEndDate); if (job.startDate && job.endDate) { $("#jobDetailsScheduled").text(`${job.startDate} to ${job.endDate}`); } else { $("#jobDetailsScheduled").html('Not scheduled'); } // Tags const tagsContainer = $("#jobDetailsTags"); tagsContainer.empty(); if (job.tags && job.tags.length > 0) { job.tags.forEach(tag => { const color = pickColor(tag); tagsContainer.append($(``).text(tag)); }); } else { tagsContainer.html('None'); } // Status alerts const statusDiv = $("#jobDetailsStatus"); statusDiv.hide().removeClass("alert-danger alert-warning alert-success"); if (!job.crew || !job.startDate) { statusDiv.addClass("alert-danger").text("This job is not assigned to any crew.").show(); } else { const beforeReady = JSJoda.LocalDate.parse(job.startDate).isBefore(JSJoda.LocalDate.parse(job.minStartDate)); const afterDue = JSJoda.LocalDate.parse(job.endDate).isAfter(JSJoda.LocalDate.parse(job.maxEndDate)); const afterIdeal = JSJoda.LocalDate.parse(job.endDate).isAfter(JSJoda.LocalDate.parse(job.idealEndDate)); if (beforeReady) { statusDiv.addClass("alert-warning").text("Scheduled before ready date (too early).").show(); } else if (afterDue) { statusDiv.addClass("alert-danger").text("Scheduled after due date (too late).").show(); } else if (afterIdeal) { statusDiv.addClass("alert-warning").text("Ends after ideal date.").show(); } else { statusDiv.addClass("alert-success").text("Scheduled within ideal window.").show(); } } new bootstrap.Modal("#jobDetailsModal").show(); } // **************************************************************************** // Crew Details Modal // **************************************************************************** function showCrewDetails(crewId) { const crew = loadedSchedule.crews.find(c => c.id === crewId); if (!crew) return; const crewJobs = loadedSchedule.jobs.filter(j => j.crew && j.crew.id === crewId); const totalWorkdays = crewJobs.reduce((sum, j) => sum + j.durationInDays, 0); $("#crewDetailsName").text(crew.name); $("#crewDetailsJobCount").text(crewJobs.length); $("#crewDetailsWorkdays").text(totalWorkdays); // Job list const jobList = $("#crewDetailsJobList"); jobList.empty(); if (crewJobs.length === 0) { jobList.append('
No jobs assigned
'); } else { crewJobs.forEach(job => { const afterDue = job.endDate && JSJoda.LocalDate.parse(job.endDate).isAfter(JSJoda.LocalDate.parse(job.maxEndDate)); const statusIcon = afterDue ? '' : ''; const item = $(`
${statusIcon} ${job.name} ${job.durationInDays} days
${job.startDate || 'Not scheduled'}
`); item.click((e) => { e.preventDefault(); bootstrap.Modal.getInstance(document.getElementById('crewDetailsModal')).hide(); setTimeout(() => showJobDetails(job.id), 300); }); jobList.append(item); }); } new bootstrap.Modal("#crewDetailsModal").show(); } // **************************************************************************** // Crew Highlighting // **************************************************************************** function renderCrewTable(schedule) { const crewTableBody = $("#crewTableBody"); crewTableBody.empty(); schedule.crews.forEach(crew => { const crewJobs = schedule.jobs.filter(j => j.crew && j.crew.id === crew.id); const totalWorkdays = crewJobs.reduce((sum, j) => sum + j.durationInDays, 0); const color = pickColor("crew" + crew.id); const row = $(`
${crew.name} ${crewJobs.length} ${totalWorkdays} `); // Click row to highlight row.click((e) => { if (!$(e.target).closest('.crew-info-btn').length) { toggleCrewHighlight(crew.id); } }); // Click info button to show details row.find('.crew-info-btn').click((e) => { e.stopPropagation(); showCrewDetails(crew.id); }); crewTableBody.append(row); }); } function toggleCrewHighlight(crewId) { if (highlightedCrewId === crewId) { clearCrewHighlight(); } else { highlightCrew(crewId); } } function clearCrewHighlight() { highlightedCrewId = null; $(".crew-row").removeClass("table-active"); // Reset timeline item opacity in both views byCrewItemData.forEach(item => { if (item.originalStyle !== undefined) { byCrewItemData.update({ id: item.id, style: item.originalStyle }); } }); byJobItemData.forEach(item => { if (item.originalStyle !== undefined) { byJobItemData.update({ id: item.id, style: item.originalStyle }); } }); } function highlightCrew(crewId) { highlightedCrewId = crewId; // Highlight table row $(".crew-row").removeClass("table-active"); $(`[data-crew-id="${crewId}"]`).addClass("table-active"); // Dim non-highlighted timeline items in "By crew" view byCrewItemData.forEach(item => { if (item.originalStyle === undefined) { item.originalStyle = item.style || ""; } if (item.group !== crewId) { byCrewItemData.update({ id: item.id, style: item.originalStyle + "; opacity: 0.25;" }); } else { byCrewItemData.update({ id: item.id, style: item.originalStyle }); } }); // Dim non-highlighted timeline items in "By job" view byJobItemData.forEach(item => { if (item.originalStyle === undefined) { item.originalStyle = item.style || ""; } // Check if this job belongs to the highlighted crew if (item.crewId !== crewId && item.type !== "background") { byJobItemData.update({ id: item.id, style: item.originalStyle + "; opacity: 0.25;" }); } else if (item.type !== "background") { byJobItemData.update({ id: item.id, style: item.originalStyle }); } }); }