This commit is contained in:
Christophe 2025-09-21 10:36:20 +02:00
parent a9bc1e47db
commit 9be454ca81
4 changed files with 606 additions and 1 deletions

605
newUI.html Normal file
View File

@ -0,0 +1,605 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
<meta content="width=device-width, initial-scale=1" name="viewport">
<title>Employee scheduling - Timefold Solver on Quarkus</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/styles/vis-timeline-graph2d.min.css" integrity="sha256-svzNasPg1yR5gvEaRei2jg+n4Pc3sVyMUWeS6xRAh6U=" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
.vis-time-axis .vis-grid.vis-saturday,
.vis-time-axis .vis-grid.vis-sunday {
background: #D3D7CFFF;
}
.file-upload-area {
border: 2px dashed #ccc;
border-radius: 10px;
padding: 20px;
text-align: center;
margin: 20px 0;
cursor: pointer;
transition: border-color 0.3s ease;
}
.file-upload-area:hover {
border-color: #007bff;
}
.file-upload-area.dragover {
border-color: #007bff;
background-color: #f8f9fa;
}
</style>
</head>
<body>
<div class="container-fluid">
<div class="sticky-top d-flex justify-content-center align-items-center" aria-live="polite" aria-atomic="true">
<div id="notificationPanel" style="position: absolute; top: .5rem;"></div>
</div>
<h1 class="mt-4">Employee scheduling solver</h1>
<p>Upload your JSON file and generate the optimal schedule for your employees.</p>
<!-- File Upload Section -->
<div class="card mb-4">
<div class="card-header">
<h5>1. Load your data</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="file-upload-area" id="fileUploadArea">
<i class="fas fa-upload fa-3x text-muted mb-3"></i>
<p class="mb-2">Drag & drop your JSON file here or click to select</p>
<input type="file" id="fileInput" accept=".json" style="display: none;">
<button class="btn btn-outline-primary" onclick="document.getElementById('fileInput').click()">
Choose File
</button>
</div>
<div id="fileInfo" class="mt-2" style="display: none;">
<small class="text-success">
<i class="fas fa-check-circle"></i>
<span id="fileName"></span> loaded successfully
</small>
</div>
</div>
<div class="col-md-6">
<h6>Or use demo data:</h6>
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="testDataButton" data-bs-toggle="dropdown">
Select demo data
</button>
<div class="dropdown-menu" id="testDataMenu">
<!-- Filled by JavaScript -->
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Control Section -->
<div class="card mb-4">
<div class="card-header">
<h5>2. Solve the problem</h5>
</div>
<div class="card-body">
<button id="solveButton" type="button" class="btn btn-success me-2" disabled>
<span class="fas fa-play"></span> Solve
</button>
<button id="stopSolvingButton" type="button" class="btn btn-danger me-2" style="display: none;">
<span class="fas fa-stop"></span> Stop solving
</button>
<button id="downloadButton" type="button" class="btn btn-info me-2" disabled>
<span class="fas fa-download"></span> Download Solution
</button>
<span id="unassignedShifts" class="ms-2 align-middle fw-bold"></span>
<span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
<button id="analyzeButton" type="button" class="ms-2 btn btn-secondary" disabled>
<span class="fas fa-question"></span> Analyze
</button>
</div>
</div>
<!-- Visualization Section -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">3. View results</h5>
<ul class="nav nav-pills" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="byLocationTab" data-bs-toggle="tab"
data-bs-target="#byLocationPanel" type="button" role="tab">
By location
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="byEmployeeTab" data-bs-toggle="tab"
data-bs-target="#byEmployeePanel" type="button" role="tab">
By employee
</button>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content">
<div class="tab-pane fade show active" id="byLocationPanel" role="tabpanel">
<div id="byLocationVisualization" style="height: 400px;"></div>
</div>
<div class="tab-pane fade" id="byEmployeePanel" role="tabpanel">
<div id="byEmployeeVisualization" style="height: 400px;"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Score Analysis Modal -->
<div class="modal fade" id="scoreAnalysisModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Score analysis <span id="scoreAnalysisScoreLabel"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="scoreAnalysisModalContent">
<!-- Filled by JavaScript -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@js-joda/core@5.4.2/dist/js-joda.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"></script>
<script>
// Global variables
let loadedSchedule = null;
let scheduleId = null;
let autoRefreshIntervalId = null;
// Timefold server URL - modify this to match your server
const TIMEFOLD_SERVER = 'http://10.0.100.13:8080';
// Timeline configuration
const timelineOptions = {
timeAxis: {scale: "hour", step: 6},
orientation: {axis: "top"},
stack: false,
xss: {disabled: true}
};
let byEmployeeGroupDataSet = new vis.DataSet();
let byEmployeeItemDataSet = new vis.DataSet();
let byLocationGroupDataSet = new vis.DataSet();
let byLocationItemDataSet = new vis.DataSet();
let byEmployeeTimeline = new vis.Timeline(
document.getElementById('byEmployeeVisualization'),
byEmployeeItemDataSet,
byEmployeeGroupDataSet,
timelineOptions
);
let byLocationTimeline = new vis.Timeline(
document.getElementById('byLocationVisualization'),
byLocationItemDataSet,
byLocationGroupDataSet,
timelineOptions
);
// Colors for visualization
const COLORS = {
UNAVAILABLE: '#ef2929',
UNDESIRED: '#f57900',
DESIRED: '#73d216',
DEFAULT: '#729fcf',
UNASSIGNED: '#EF292999',
SKILL_MATCH: '#8ae234',
SKILL_MISMATCH: '#ef2929'
};
$(document).ready(function() {
setupEventListeners();
setupAjax();
loadDemoData();
});
function setupEventListeners() {
// File upload
const fileInput = document.getElementById('fileInput');
const fileUploadArea = document.getElementById('fileUploadArea');
fileInput.addEventListener('change', handleFileSelect);
fileUploadArea.addEventListener('click', () => fileInput.click());
fileUploadArea.addEventListener('dragover', handleDragOver);
fileUploadArea.addEventListener('drop', handleDrop);
// Buttons
$('#solveButton').click(solve);
$('#stopSolvingButton').click(stopSolving);
$('#analyzeButton').click(analyze);
$('#downloadButton').click(downloadSolution);
// Tabs
$('#byEmployeeTab').on('shown.bs.tab', () => byEmployeeTimeline.redraw());
$('#byLocationTab').on('shown.bs.tab', () => byLocationTimeline.redraw());
}
function setupAjax() {
$.ajaxSetup({
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json,text/plain'
}
});
}
function handleFileSelect(event) {
const file = event.target.files[0];
if (file) {
readJsonFile(file);
}
}
function handleDragOver(event) {
event.preventDefault();
event.currentTarget.classList.add('dragover');
}
function handleDrop(event) {
event.preventDefault();
event.currentTarget.classList.remove('dragover');
const file = event.dataTransfer.files[0];
if (file && file.type === 'application/json') {
readJsonFile(file);
}
}
function readJsonFile(file) {
const reader = new FileReader();
reader.onload = function(e) {
try {
const jsonData = JSON.parse(e.target.result);
loadedSchedule = jsonData;
// Show file info
$('#fileName').text(file.name);
$('#fileInfo').show();
// Enable solve button
$('#solveButton').prop('disabled', false);
// Render the loaded data
renderSchedule(jsonData);
showNotification('File loaded successfully!', 'success');
} catch (error) {
showNotification('Error parsing JSON file: ' + error.message, 'danger');
}
};
reader.readAsText(file);
}
function loadDemoData() {
// Load demo data options from server
$.get(TIMEFOLD_SERVER + '/demo-data')
.done(function(data) {
data.forEach(item => {
$('#testDataMenu').append(
`<a class="dropdown-item" href="#" onclick="loadDemoDataSet('${item}')">${item}</a>`
);
});
})
.fail(function() {
console.log('Demo data not available');
});
}
function loadDemoDataSet(dataSet) {
$.get(TIMEFOLD_SERVER + '/demo-data/' + dataSet)
.done(function(data) {
loadedSchedule = data;
renderSchedule(data);
$('#solveButton').prop('disabled', false);
showNotification(`Demo data ${dataSet} loaded!`, 'success');
})
.fail(function() {
showNotification('Failed to load demo data', 'danger');
});
}
function solve() {
if (!loadedSchedule) {
showNotification('Please load data first', 'warning');
return;
}
$.post(TIMEFOLD_SERVER + '/schedules', JSON.stringify(loadedSchedule))
.done(function(data) {
scheduleId = data;
refreshSolvingButtons(true);
showNotification('Solving started...', 'info');
})
.fail(function(xhr) {
showNotification('Failed to start solving: ' + xhr.responseText, 'danger');
});
}
function stopSolving() {
if (scheduleId) {
$.ajax({
url: TIMEFOLD_SERVER + '/schedules/' + scheduleId,
type: 'DELETE'
}).done(function() {
refreshSolvingButtons(false);
refreshSchedule();
showNotification('Solving stopped', 'info');
});
}
}
function refreshSchedule() {
if (scheduleId) {
$.get(TIMEFOLD_SERVER + '/schedules/' + scheduleId)
.done(function(schedule) {
loadedSchedule = schedule;
renderSchedule(schedule);
$('#downloadButton').prop('disabled', false);
})
.fail(function() {
refreshSolvingButtons(false);
});
}
}
function refreshSolvingButtons(solving) {
if (solving) {
$('#solveButton').hide();
$('#stopSolvingButton').show();
if (autoRefreshIntervalId == null) {
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
}
} else {
$('#solveButton').show();
$('#stopSolvingButton').hide();
if (autoRefreshIntervalId != null) {
clearInterval(autoRefreshIntervalId);
autoRefreshIntervalId = null;
}
}
}
function renderSchedule(schedule) {
// Clear existing data
byEmployeeGroupDataSet.clear();
byEmployeeItemDataSet.clear();
byLocationGroupDataSet.clear();
byLocationItemDataSet.clear();
// Update score
$('#score').text('Score: ' + (schedule.score || '?'));
$('#analyzeButton').prop('disabled', !schedule.score);
// Count unassigned shifts
let unassignedCount = 0;
schedule.shifts.forEach(shift => {
if (!shift.employee) unassignedCount++;
});
$('#unassignedShifts').text(
unassignedCount === 0
? 'No unassigned shifts'
: `${unassignedCount} unassigned shifts`
);
// Render employees
schedule.employees.forEach((employee, index) => {
// Add employee group
const skillsBadges = employee.skills.map(skill =>
`<span class="badge bg-secondary me-1">${skill}</span>`
).join('');
byEmployeeGroupDataSet.add({
id: employee.name,
content: `<div><strong>${employee.name}</strong><br/>${skillsBadges}</div>`
});
// Add availability indicators
['unavailableDates', 'undesiredDates', 'desiredDates'].forEach((dateType, typeIndex) => {
const color = [COLORS.UNAVAILABLE, COLORS.UNDESIRED, COLORS.DESIRED][typeIndex];
const label = ['Unavailable', 'Undesired', 'Desired'][typeIndex];
employee[dateType]?.forEach((date, dateIndex) => {
const startDate = new Date(date + 'T00:00:00');
const endDate = new Date(date + 'T23:59:59');
byEmployeeItemDataSet.add({
id: `${employee.name}-${dateType}-${dateIndex}`,
group: employee.name,
content: label,
start: startDate,
end: endDate,
type: 'background',
style: `opacity: 0.5; background-color: ${color}`
});
});
});
});
// Render locations and shifts
const locations = [...new Set(schedule.shifts.map(shift => shift.location))];
locations.forEach(location => {
byLocationGroupDataSet.add({
id: location,
content: location
});
});
// Render shifts
schedule.shifts.forEach((shift, index) => {
const startTime = new Date(shift.start);
const endTime = new Date(shift.end);
if (shift.employee) {
// Assigned shift
const hasRequiredSkill = shift.employee.skills.includes(shift.requiredSkill);
const skillColor = hasRequiredSkill ? COLORS.SKILL_MATCH : COLORS.SKILL_MISMATCH;
const shiftColor = getShiftColor(shift, shift.employee);
// Add to employee timeline
byEmployeeItemDataSet.add({
id: `shift-${index}-emp`,
group: shift.employee.name,
content: `<div><strong>${shift.location}</strong><br/>
<span class="badge" style="background-color:${skillColor}">${shift.requiredSkill}</span></div>`,
start: startTime,
end: endTime,
style: `background-color: ${shiftColor}`
});
// Add to location timeline
byLocationItemDataSet.add({
id: `shift-${index}-loc`,
group: shift.location,
content: `<div><strong>${shift.employee.name}</strong><br/>
<span class="badge" style="background-color:${skillColor}">${shift.requiredSkill}</span></div>`,
start: startTime,
end: endTime,
style: `background-color: ${shiftColor}`
});
} else {
// Unassigned shift
byLocationItemDataSet.add({
id: `shift-${index}-unassigned`,
group: shift.location,
content: `<div><strong>Unassigned</strong><br/>
<span class="badge bg-secondary">${shift.requiredSkill}</span></div>`,
start: startTime,
end: endTime,
style: `background-color: ${COLORS.UNASSIGNED}`
});
}
});
// Set timeline window
if (schedule.shifts.length > 0) {
const dates = schedule.shifts.map(shift => new Date(shift.start));
const minDate = new Date(Math.min(...dates));
const maxDate = new Date(Math.max(...dates));
maxDate.setDate(maxDate.getDate() + 1);
byEmployeeTimeline.setWindow(minDate, maxDate);
byLocationTimeline.setWindow(minDate, maxDate);
}
}
function getShiftColor(shift, employee) {
const shiftDate = new Date(shift.start).toISOString().split('T')[0];
if (employee.unavailableDates?.includes(shiftDate)) {
return COLORS.UNAVAILABLE;
} else if (employee.undesiredDates?.includes(shiftDate)) {
return COLORS.UNDESIRED;
} else if (employee.desiredDates?.includes(shiftDate)) {
return COLORS.DESIRED;
}
return COLORS.DEFAULT;
}
function analyze() {
if (!loadedSchedule?.score) {
showNotification('No score to analyze', 'warning');
return;
}
$.ajax({
url: TIMEFOLD_SERVER + '/schedules/analyze',
type: 'PUT',
data: JSON.stringify(loadedSchedule)
}).done(function(analysis) {
displayScoreAnalysis(analysis);
}).fail(function() {
showNotification('Analysis failed', 'danger');
});
}
function displayScoreAnalysis(analysis) {
$('#scoreAnalysisScoreLabel').text(`(${loadedSchedule.score})`);
const content = $('#scoreAnalysisModalContent');
content.empty();
const table = $(`
<table class="table table-sm">
<thead>
<tr>
<th></th>
<th>Constraint</th>
<th>Type</th>
<th>Matches</th>
<th>Score</th>
</tr>
</thead>
<tbody></tbody>
</table>
`);
analysis.constraints.forEach(constraint => {
const icon = constraint.score.includes('hard') && constraint.score.includes('-')
? '<i class="fas fa-exclamation-triangle text-danger"></i>'
: '<i class="fas fa-check-circle text-success"></i>';
const row = $(`
<tr>
<td>${icon}</td>
<td>${constraint.name}</td>
<td>${constraint.score.includes('hard') ? 'Hard' : 'Soft'}</td>
<td>${constraint.matches?.length || 0}</td>
<td>${constraint.score}</td>
</tr>
`);
table.find('tbody').append(row);
});
content.append(table);
new bootstrap.Modal('#scoreAnalysisModal').show();
}
function downloadSolution() {
if (loadedSchedule) {
const dataStr = JSON.stringify(loadedSchedule, null, 2);
const dataBlob = new Blob([dataStr], {type: 'application/json'});
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = 'timefold_solution.json';
link.click();
URL.revokeObjectURL(url);
showNotification('Solution downloaded', 'success');
}
}
function showNotification(message, type) {
const alertDiv = $(`
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`);
$('#notificationPanel').append(alertDiv);
setTimeout(() => {
alertDiv.alert('close');
}, 5000);
}
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.