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