| let autoRefreshIntervalId = null; |
| const zoomMin = 2 * 1000 * 60 * 60 * 24 |
| const zoomMax = 4 * 7 * 1000 * 60 * 60 * 24 |
|
|
| const UNAVAILABLE_COLOR = '#ef2929' |
| const UNDESIRED_COLOR = '#f57900' |
| const DESIRED_COLOR = '#73d216' |
|
|
| let demoDataId = null; |
| let scheduleId = null; |
| let loadedSchedule = null; |
|
|
| const byEmployeePanel = document.getElementById("byEmployeePanel"); |
| const byEmployeeTimelineOptions = { |
| timeAxis: {scale: "hour", step: 6}, |
| orientation: {axis: "top"}, |
| stack: false, |
| xss: {disabled: true}, |
| zoomMin: zoomMin, |
| zoomMax: zoomMax, |
| }; |
| let byEmployeeGroupDataSet = new vis.DataSet(); |
| let byEmployeeItemDataSet = new vis.DataSet(); |
| let byEmployeeTimeline = new vis.Timeline(byEmployeePanel, byEmployeeItemDataSet, byEmployeeGroupDataSet, byEmployeeTimelineOptions); |
|
|
| const byLocationPanel = document.getElementById("byLocationPanel"); |
| const byLocationTimelineOptions = { |
| timeAxis: {scale: "hour", step: 6}, |
| orientation: {axis: "top"}, |
| xss: {disabled: true}, |
| zoomMin: zoomMin, |
| zoomMax: zoomMax, |
| }; |
| let byLocationGroupDataSet = new vis.DataSet(); |
| let byLocationItemDataSet = new vis.DataSet(); |
| let byLocationTimeline = new vis.Timeline(byLocationPanel, byLocationItemDataSet, byLocationGroupDataSet, byLocationTimelineOptions); |
|
|
| let windowStart = JSJoda.LocalDate.now().toString(); |
| let windowEnd = JSJoda.LocalDate.parse(windowStart).plusDays(7).toString(); |
|
|
| $(document).ready(function () { |
| let initialized = false; |
|
|
| function safeInitialize() { |
| if (!initialized) { |
| initialized = true; |
| initializeApp(); |
| } |
| } |
|
|
| |
| $(window).on('load', safeInitialize); |
|
|
| |
| setTimeout(safeInitialize, 100); |
| }); |
|
|
| function initializeApp() { |
| replaceQuickstartSolverForgeAutoHeaderFooter(); |
|
|
| $("#solveButton").click(function () { |
| solve(); |
| }); |
| $("#stopSolvingButton").click(function () { |
| stopSolving(); |
| }); |
| $("#analyzeButton").click(function () { |
| analyze(); |
| }); |
| |
| $("#byEmployeeTab").on('shown.bs.tab', function (event) { |
| byEmployeeTimeline.redraw(); |
| }) |
| $("#byLocationTab").on('shown.bs.tab', function (event) { |
| byLocationTimeline.redraw(); |
| }) |
|
|
| setupAjax(); |
| fetchDemoData(); |
| } |
|
|
| function setupAjax() { |
| $.ajaxSetup({ |
| headers: { |
| 'Content-Type': 'application/json', |
| 'Accept': 'application/json,text/plain', |
| } |
| }); |
| |
| 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($('<a id="' + item + 'TestData" class="dropdown-item" href="#">' + item + '</a>')); |
| $("#" + item + "TestData").click(function () { |
| switchDataDropDownItemActive(item); |
| scheduleId = null; |
| demoDataId = item; |
|
|
| refreshSchedule(); |
| }); |
| }); |
| demoDataId = data[0]; |
| switchDataDropDownItemActive(demoDataId); |
| refreshSchedule(); |
| }).fail(function (xhr, ajaxOptions, thrownError) { |
| |
| let $demo = $("#demo"); |
| $demo.empty(); |
| $demo.html("<h1><p align=\"center\">No test data available</p></h1>") |
| }); |
| } |
|
|
| function switchDataDropDownItemActive(newItem) { |
| activeCssClass = "active"; |
| $("#testDataButton > a." + activeCssClass).removeClass(activeCssClass); |
| $("#" + newItem + "TestData").addClass(activeCssClass); |
| } |
|
|
| function getShiftColor(shift, employee) { |
| const shiftStart = JSJoda.LocalDateTime.parse(shift.start); |
| const shiftStartDateString = shiftStart.toLocalDate().toString(); |
| const shiftEnd = JSJoda.LocalDateTime.parse(shift.end); |
| const shiftEndDateString = shiftEnd.toLocalDate().toString(); |
| if (employee.unavailableDates.includes(shiftStartDateString) || |
| |
| (shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) && |
| employee.unavailableDates.includes(shiftEndDateString))) { |
| return UNAVAILABLE_COLOR |
| } else if (employee.undesiredDates.includes(shiftStartDateString) || |
| |
| (shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) && |
| employee.undesiredDates.includes(shiftEndDateString))) { |
| return UNDESIRED_COLOR |
| } else if (employee.desiredDates.includes(shiftStartDateString) || |
| |
| (shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) && |
| employee.desiredDates.includes(shiftEndDateString))) { |
| return DESIRED_COLOR |
| } else { |
| return " #729fcf"; |
| } |
| } |
|
|
| 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) { |
| console.log('Rendering schedule:', schedule); |
| |
| if (!schedule) { |
| console.error('No schedule data provided to renderSchedule'); |
| return; |
| } |
| |
| refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING"); |
| $("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score)); |
|
|
| const unassignedShifts = $("#unassignedShifts"); |
| const groups = []; |
|
|
| |
| if (!schedule.shifts || !Array.isArray(schedule.shifts) || schedule.shifts.length === 0) { |
| console.warn('No shifts data available in schedule'); |
| return; |
| } |
|
|
| |
| const scheduleStart = schedule.shifts.map(shift => JSJoda.LocalDateTime.parse(shift.start).toLocalDate()).sort()[0].toString(); |
| const scheduleEnd = JSJoda.LocalDate.parse(scheduleStart).plusDays(7).toString(); |
|
|
| windowStart = scheduleStart; |
| windowEnd = scheduleEnd; |
|
|
| unassignedShifts.children().remove(); |
| let unassignedShiftsCount = 0; |
| byEmployeeGroupDataSet.clear(); |
| byLocationGroupDataSet.clear(); |
|
|
| byEmployeeItemDataSet.clear(); |
| byLocationItemDataSet.clear(); |
|
|
| |
| if (!schedule.employees || !Array.isArray(schedule.employees)) { |
| console.warn('No employees data available in schedule'); |
| return; |
| } |
|
|
| schedule.employees.forEach((employee, index) => { |
| const employeeGroupElement = $('<div class="card-body p-2"/>') |
| .append($(`<h5 class="card-title mb-2"/>)`) |
| .append(employee.name)) |
| .append($('<div/>') |
| .append($(employee.skills.map(skill => `<span class="badge me-1 mt-1" style="background-color:#d3d7cf">${skill}</span>`).join('')))); |
| byEmployeeGroupDataSet.add({id: employee.name, content: employeeGroupElement.html()}); |
|
|
| employee.unavailableDates.forEach((rawDate, dateIndex) => { |
| const date = JSJoda.LocalDate.parse(rawDate) |
| const start = date.atStartOfDay().toString(); |
| const end = date.plusDays(1).atStartOfDay().toString(); |
| const byEmployeeShiftElement = $(`<div/>`) |
| .append($(`<h5 class="card-title mb-1"/>`).text("Unavailable")); |
| byEmployeeItemDataSet.add({ |
| id: "employee-" + index + "-unavailability-" + dateIndex, group: employee.name, |
| content: byEmployeeShiftElement.html(), |
| start: start, end: end, |
| type: "background", |
| style: "opacity: 0.5; background-color: " + UNAVAILABLE_COLOR, |
| }); |
| }); |
| employee.undesiredDates.forEach((rawDate, dateIndex) => { |
| const date = JSJoda.LocalDate.parse(rawDate) |
| const start = date.atStartOfDay().toString(); |
| const end = date.plusDays(1).atStartOfDay().toString(); |
| const byEmployeeShiftElement = $(`<div/>`) |
| .append($(`<h5 class="card-title mb-1"/>`).text("Undesired")); |
| byEmployeeItemDataSet.add({ |
| id: "employee-" + index + "-undesired-" + dateIndex, group: employee.name, |
| content: byEmployeeShiftElement.html(), |
| start: start, end: end, |
| type: "background", |
| style: "opacity: 0.5; background-color: " + UNDESIRED_COLOR, |
| }); |
| }); |
| employee.desiredDates.forEach((rawDate, dateIndex) => { |
| const date = JSJoda.LocalDate.parse(rawDate) |
| const start = date.atStartOfDay().toString(); |
| const end = date.plusDays(1).atStartOfDay().toString(); |
| const byEmployeeShiftElement = $(`<div/>`) |
| .append($(`<h5 class="card-title mb-1"/>`).text("Desired")); |
| byEmployeeItemDataSet.add({ |
| id: "employee-" + index + "-desired-" + dateIndex, group: employee.name, |
| content: byEmployeeShiftElement.html(), |
| start: start, end: end, |
| type: "background", |
| style: "opacity: 0.5; background-color: " + DESIRED_COLOR, |
| }); |
| }); |
| }); |
|
|
| schedule.shifts.forEach((shift, index) => { |
| if (groups.indexOf(shift.location) === -1) { |
| groups.push(shift.location); |
| byLocationGroupDataSet.add({ |
| id: shift.location, |
| content: shift.location, |
| }); |
| } |
|
|
| if (shift.employee == null) { |
| unassignedShiftsCount++; |
|
|
| const byLocationShiftElement = $('<div class="card-body p-2"/>') |
| .append($(`<h5 class="card-title mb-2"/>)`) |
| .append("Unassigned")) |
| .append($('<div/>') |
| .append($(`<span class="badge me-1 mt-1" style="background-color:#d3d7cf">${shift.requiredSkill}</span>`))); |
|
|
| byLocationItemDataSet.add({ |
| id: 'shift-' + index, group: shift.location, |
| content: byLocationShiftElement.html(), |
| start: shift.start, end: shift.end, |
| style: "background-color: #EF292999" |
| }); |
| } else { |
| const skillColor = (shift.employee.skills.indexOf(shift.requiredSkill) === -1 ? '#ef2929' : '#8ae234'); |
| const byEmployeeShiftElement = $('<div class="card-body p-2"/>') |
| .append($(`<h5 class="card-title mb-2"/>)`) |
| .append(shift.location)) |
| .append($('<div/>') |
| .append($(`<span class="badge me-1 mt-1" style="background-color:${skillColor}">${shift.requiredSkill}</span>`))); |
| const byLocationShiftElement = $('<div class="card-body p-2"/>') |
| .append($(`<h5 class="card-title mb-2"/>)`) |
| .append(shift.employee.name)) |
| .append($('<div/>') |
| .append($(`<span class="badge me-1 mt-1" style="background-color:${skillColor}">${shift.requiredSkill}</span>`))); |
|
|
| const shiftColor = getShiftColor(shift, shift.employee); |
| byEmployeeItemDataSet.add({ |
| id: 'shift-' + index, group: shift.employee.name, |
| content: byEmployeeShiftElement.html(), |
| start: shift.start, end: shift.end, |
| style: "background-color: " + shiftColor |
| }); |
| byLocationItemDataSet.add({ |
| id: 'shift-' + index, group: shift.location, |
| content: byLocationShiftElement.html(), |
| start: shift.start, end: shift.end, |
| style: "background-color: " + shiftColor |
| }); |
| } |
| }); |
|
|
|
|
| if (unassignedShiftsCount === 0) { |
| unassignedShifts.append($(`<p/>`).text(`There are no unassigned shifts.`)); |
| } else { |
| unassignedShifts.append($(`<p/>`).text(`There are ${unassignedShiftsCount} unassigned shifts.`)); |
| } |
| byEmployeeTimeline.setWindow(scheduleStart, scheduleEnd); |
| byLocationTimeline.setWindow(scheduleStart, scheduleEnd); |
| } |
|
|
| function solve() { |
| if (!loadedSchedule) { |
| showError("No schedule data loaded. Please wait for the data to load or refresh the page."); |
| return; |
| } |
| |
| console.log('Sending schedule data for solving:', loadedSchedule); |
| $.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() { |
| new bootstrap.Modal("#scoreAnalysisModal").show() |
| const scoreAnalysisModalContent = $("#scoreAnalysisModalContent"); |
| scoreAnalysisModalContent.children().remove(); |
| if (loadedSchedule.score == null) { |
| scoreAnalysisModalContent.text("No score to analyze yet, please first press the 'solve' button."); |
| } else { |
| $('#scoreAnalysisScoreLabel').text(`(${loadedSchedule.score})`); |
| $.put("/schedules/analyze", JSON.stringify(loadedSchedule), function (scoreAnalysis) { |
| let constraints = scoreAnalysis.constraints; |
| constraints.sort((a, b) => { |
| let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score); |
| if (aComponents.hard < 0 && bComponents.hard > 0) return -1; |
| if (aComponents.hard > 0 && bComponents.soft < 0) return 1; |
| if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) { |
| return -1; |
| } else { |
| if (aComponents.medium < 0 && bComponents.medium > 0) return -1; |
| if (aComponents.medium > 0 && bComponents.medium < 0) return 1; |
| if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) { |
| return -1; |
| } else { |
| if (aComponents.soft < 0 && bComponents.soft > 0) return -1; |
| if (aComponents.soft > 0 && bComponents.soft < 0) return 1; |
|
|
| return Math.abs(bComponents.soft) - Math.abs(aComponents.soft); |
| } |
| } |
| }); |
| constraints.map((e) => { |
| let components = getScoreComponents(e.weight); |
| e.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft'); |
| e.weight = components[e.type]; |
| let scores = getScoreComponents(e.score); |
| e.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft); |
| }); |
| scoreAnalysis.constraints = constraints; |
|
|
| scoreAnalysisModalContent.children().remove(); |
| scoreAnalysisModalContent.text(""); |
|
|
| const analysisTable = $(`<table class="table"/>`).css({textAlign: 'center'}); |
| const analysisTHead = $(`<thead/>`).append($(`<tr/>`) |
| .append($(`<th></th>`)) |
| .append($(`<th>Constraint</th>`).css({textAlign: 'left'})) |
| .append($(`<th>Type</th>`)) |
| .append($(`<th># Matches</th>`)) |
| .append($(`<th>Weight</th>`)) |
| .append($(`<th>Score</th>`)) |
| .append($(`<th></th>`))); |
| analysisTable.append(analysisTHead); |
| const analysisTBody = $(`<tbody/>`) |
| $.each(scoreAnalysis.constraints, (index, constraintAnalysis) => { |
| let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '<span class="fas fa-exclamation-triangle" style="color: red"></span>' : ''; |
| if (!icon) icon = constraintAnalysis.matches.length == 0 ? '<span class="fas fa-check-circle" style="color: green"></span>' : ''; |
|
|
| let row = $(`<tr/>`); |
| row.append($(`<td/>`).html(icon)) |
| .append($(`<td/>`).text(constraintAnalysis.name).css({textAlign: 'left'})) |
| .append($(`<td/>`).text(constraintAnalysis.type)) |
| .append($(`<td/>`).html(`<b>${constraintAnalysis.matches.length}</b>`)) |
| .append($(`<td/>`).text(constraintAnalysis.weight)) |
| .append($(`<td/>`).text(constraintAnalysis.implicitScore)); |
| analysisTBody.append(row); |
| row.append($(`<td/>`)); |
| }); |
| analysisTable.append(analysisTBody); |
| scoreAnalysisModalContent.append(analysisTable); |
| }).fail(function (xhr, ajaxOptions, thrownError) { |
| showError("Analyze failed.", xhr); |
| }, "text"); |
| } |
| } |
|
|
| function getScoreComponents(score) { |
| let components = {hard: 0, medium: 0, soft: 0}; |
|
|
| $.each([...score.matchAll(/(-?\d*(\.\d+)?)(hard|medium|soft)/g)], (i, parts) => { |
| components[parts[3]] = parseFloat(parts[1], 10); |
| }); |
|
|
| return components; |
| } |
|
|
| 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 replaceQuickstartSolverForgeAutoHeaderFooter() { |
| const solverforgeHeader = $("header#solverforge-auto-header"); |
| if (solverforgeHeader != null) { |
| solverforgeHeader.css("background-color", "#ffffff"); |
| solverforgeHeader.append( |
| $(`<div class="container-fluid"> |
| <nav class="navbar sticky-top navbar-expand-lg shadow-sm mb-3" style="background-color: #ffffff;"> |
| <a class="navbar-brand" href="https://www.solverforge.org"> |
| <img src="/webjars/solverforge/img/solverforge-horizontal.svg" alt="SolverForge logo" width="400"> |
| </a> |
| <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> |
| <span class="navbar-toggler-icon"></span> |
| </button> |
| <div class="collapse navbar-collapse" id="navbarNav"> |
| <ul class="nav nav-pills"> |
| <li class="nav-item active" id="navUIItem"> |
| <button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button" style="color: #1f2937;">Demo UI</button> |
| </li> |
| <li class="nav-item" id="navRestItem"> |
| <button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button" style="color: #1f2937;">Guide</button> |
| </li> |
| <li class="nav-item" id="navOpenApiItem"> |
| <button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button" style="color: #1f2937;">REST API</button> |
| </li> |
| </ul> |
| </div> |
| <div class="ms-auto"> |
| <div class="dropdown"> |
| <button class="btn dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" style="background-color: #10b981; color: #ffffff; border-color: #10b981;"> |
| Data |
| </button> |
| <div id="testDataButton" class="dropdown-menu" aria-labelledby="dropdownMenuButton"></div> |
| </div> |
| </div> |
| </nav> |
| </div>`)); |
| } |
|
|
| const solverforgeFooter = $("footer#solverforge-auto-footer"); |
| if (solverforgeFooter != null) { |
| solverforgeFooter.append( |
| $(`<footer class="bg-black text-white-50"> |
| <div class="container"> |
| <div class="hstack gap-3 p-4"> |
| <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div> |
| <div class="vr"></div> |
| <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div> |
| <div class="vr"></div> |
| <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div> |
| <div class="vr"></div> |
| <div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div> |
| </div> |
| </div> |
| </footer>`)); |
| } |
| } |
|
|