| | 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>`)); |
| | } |
| | } |
| |
|