Compare commits

...

2 Commits

Author SHA1 Message Date
79c0d8d276 upload.html au top 2025-11-01 12:31:11 +01:00
af13ae9f1e Supprimer target/ du dépôt Git 2025-11-01 12:30:35 +01:00
31 changed files with 1429 additions and 1342 deletions

View File

@@ -3,149 +3,16 @@
{
"name": "Aurélie Antoine",
"skills": ["INFIRMIER", "PRELEVEMENT"],
"unavailableDates": ["2024-12-25", "2024-12-26", "2024-12-31"],
"undesiredDates": ["2024-12-24", "2024-12-30"],
"unavailableDates": ["2024-12-25", "2024-12-26"],
"undesiredDates": ["2024-12-24", "2024-12-31"],
"desiredDates": ["2024-12-20", "2024-12-21"]
},
{
"name": "Cathy Coucou",
"skills": ["MEDECIN", "SUPERVISION"],
"unavailableDates": ["2024-12-30", "2024-12-31"],
"undesiredDates": ["2024-12-29", "2024-12-28"],
"unavailableDates": ["2024-12-30"],
"undesiredDates": ["2024-12-29"],
"desiredDates": ["2024-12-22", "2024-12-23"]
},
{
"name": "Sophie Bernard-Dupont",
"skills": ["INFIRMIER", "ACCUEIL"],
"unavailableDates": ["2024-12-27", "2024-12-28"],
"undesiredDates": ["2024-12-25", "2024-12-26"],
"desiredDates": ["2024-12-24", "2024-12-29"]
},
{
"name": "Jean Leroy",
"skills": ["TRANSPORT", "PRELEVEMENT"],
"unavailableDates": ["2024-12-24", "2024-12-25"],
"undesiredDates": ["2024-12-23", "2024-12-22"],
"desiredDates": ["2024-12-27", "2024-12-30"]
},
{
"name": "Anne Moreau",
"skills": ["MEDECIN", "INFIRMIER"],
"unavailableDates": ["2024-12-26", "2024-12-27"],
"undesiredDates": ["2024-12-31", "2024-12-20"],
"desiredDates": ["2024-12-21", "2024-12-29"]
},
{
"name": "Luc Petit",
"skills": ["ACCUEIL", "TRANSPORT"],
"unavailableDates": ["2024-12-20", "2024-12-21"],
"undesiredDates": ["2024-12-22", "2024-12-23"],
"desiredDates": ["2024-12-25", "2024-12-26"]
},
{
"name": "Marie Dubois",
"skills": ["INFIRMIER", "ACCUEIL"],
"unavailableDates": ["2024-12-27", "2024-12-28"],
"undesiredDates": ["2024-12-29", "2024-12-30"],
"desiredDates": ["2024-12-22", "2024-12-23"]
},
{
"name": "Pierre Martin",
"skills": ["MEDECIN", "SUPERVISION"],
"unavailableDates": ["2024-12-20", "2024-12-21"],
"undesiredDates": ["2024-12-22", "2024-12-23"],
"desiredDates": ["2024-12-29", "2024-12-30"]
},
{
"name": "Claire Dupuis",
"skills": ["INFIRMIER", "PRELEVEMENT"],
"unavailableDates": ["2024-12-25", "2024-12-26"],
"undesiredDates": ["2024-12-27", "2024-12-28"],
"desiredDates": ["2024-12-20", "2024-12-21"]
},
{
"name": "Thomas Lambert",
"skills": ["TRANSPORT", "ACCUEIL"],
"unavailableDates": ["2024-12-30", "2024-12-31"],
"undesiredDates": ["2024-12-24", "2024-12-25"],
"desiredDates": ["2024-12-22", "2024-12-23"]
},
{
"name": "Julie Lefèvre",
"skills": ["INFIRMIER", "MEDECIN"],
"unavailableDates": ["2024-12-22", "2024-12-23"],
"undesiredDates": ["2024-12-24", "2024-12-25"],
"desiredDates": ["2024-12-27", "2024-12-28"]
},
{
"name": "Nicolas Bernard",
"skills": ["PRELEVEMENT", "SUPERVISION"],
"unavailableDates": ["2024-12-29", "2024-12-30"],
"undesiredDates": ["2024-12-20", "2024-12-21"],
"desiredDates": ["2024-12-25", "2024-12-26"]
},
{
"name": "Isabelle Moreau",
"skills": ["ACCUEIL", "INFIRMIER"],
"unavailableDates": ["2024-12-24", "2024-12-25"],
"undesiredDates": ["2024-12-26", "2024-12-27"],
"desiredDates": ["2024-12-22", "2024-12-23"]
},
{
"name": "François Dubois",
"skills": ["MEDECIN", "TRANSPORT"],
"unavailableDates": ["2024-12-27", "2024-12-28"],
"undesiredDates": ["2024-12-29", "2024-12-30"],
"desiredDates": ["2024-12-20", "2024-12-21"]
},
{
"name": "Élodie Martin",
"skills": ["INFIRMIER", "ACCUEIL"],
"unavailableDates": ["2024-12-31"],
"undesiredDates": ["2024-12-25", "2024-12-26"],
"desiredDates": ["2024-12-27", "2024-12-28"]
},
{
"name": "Guillaume Lefèvre",
"skills": ["PRELEVEMENT", "TRANSPORT"],
"unavailableDates": ["2024-12-20", "2024-12-21"],
"undesiredDates": ["2024-12-22", "2024-12-23"],
"desiredDates": ["2024-12-29", "2024-12-30"]
},
{
"name": "Caroline Lambert",
"skills": ["MEDECIN", "SUPERVISION"],
"unavailableDates": ["2024-12-25", "2024-12-26"],
"undesiredDates": ["2024-12-27", "2024-12-28"],
"desiredDates": ["2024-12-20", "2024-12-21"]
},
{
"name": "Olivier Bernard",
"skills": ["INFIRMIER", "PRELEVEMENT"],
"unavailableDates": ["2024-12-22", "2024-12-23"],
"undesiredDates": ["2024-12-24", "2024-12-25"],
"desiredDates": ["2024-12-29", "2024-12-30"]
},
{
"name": "Sandrine Moreau",
"skills": ["ACCUEIL", "TRANSPORT"],
"unavailableDates": ["2024-12-27", "2024-12-28"],
"undesiredDates": ["2024-12-29", "2024-12-30"],
"desiredDates": ["2024-12-20", "2024-12-21"]
},
{
"name": "David Lefèvre",
"skills": ["MEDECIN", "INFIRMIER"],
"unavailableDates": ["2024-12-30", "2024-12-31"],
"undesiredDates": ["2024-12-24", "2024-12-25"],
"desiredDates": ["2024-12-22", "2024-12-23"]
},
{
"name": "Céline Martin",
"skills": ["SUPERVISION", "ACCUEIL"],
"unavailableDates": ["2024-12-24", "2024-12-25"],
"undesiredDates": ["2024-12-26", "2024-12-27"],
"desiredDates": ["2024-12-29", "2024-12-30"]
}
],
"collectes": [
@@ -154,114 +21,9 @@
"start": "2024-12-20T08:00:00",
"end": "2024-12-20T16:00:00",
"location": "Centre de collecte - Toulouse",
"requiredSkills": {
"INFIRMIER": 3,
"MEDECIN": 1,
"ACCUEIL": 1,
"TRANSPORT": 1
}
},
{
"id": "collecte_blagnac_20241220",
"start": "2024-12-20T08:00:00",
"end": "2024-12-20T16:00:00",
"location": "Centre de collecte - Blagnac",
"requiredSkills": {
"INFIRMIER": 2,
"MEDECIN": 1,
"ACCUEIL": 1
}
},
{
"id": "collecte_purpan_20241221",
"start": "2024-12-21T08:00:00",
"end": "2024-12-21T16:00:00",
"location": "Hôpital Purpan - Toulouse",
"requiredSkills": {
"INFIRMIER": 3,
"MEDECIN": 1,
"ACCUEIL": 1,
"TRANSPORT": 1
}
},
{
"id": "collecte_rangueil_20241221",
"start": "2024-12-21T08:00:00",
"end": "2024-12-21T16:00:00",
"location": "Hôpital Rangueil - Toulouse",
"requiredSkills": {
"INFIRMIER": 2,
"MEDECIN": 1,
"PRELEVEMENT": 1
}
},
{
"id": "collecte_toulouse_20241222",
"start": "2024-12-22T08:00:00",
"end": "2024-12-22T16:00:00",
"location": "Centre de collecte - Toulouse",
"requiredSkills": {
"INFIRMIER": 3,
"MEDECIN": 1,
"ACCUEIL": 1,
"TRANSPORT": 1
}
},
{
"id": "collecte_blagnac_20241222",
"start": "2024-12-22T08:00:00",
"end": "2024-12-22T16:00:00",
"location": "Centre de collecte - Blagnac",
"requiredSkills": {
"INFIRMIER": 2,
"MEDECIN": 1,
"ACCUEIL": 1
}
},
{
"id": "collecte_purpan_20241223",
"start": "2024-12-23T08:00:00",
"end": "2024-12-23T16:00:00",
"location": "Hôpital Purpan - Toulouse",
"requiredSkills": {
"INFIRMIER": 3,
"MEDECIN": 1,
"ACCUEIL": 1,
"TRANSPORT": 1
}
},
{
"id": "collecte_rangueil_20241223",
"start": "2024-12-23T08:00:00",
"end": "2024-12-23T16:00:00",
"location": "Hôpital Rangueil - Toulouse",
"requiredSkills": {
"INFIRMIER": 2,
"MEDECIN": 1,
"PRELEVEMENT": 1
}
},
{
"id": "collecte_toulouse_20241224",
"start": "2024-12-24T08:00:00",
"end": "2024-12-24T16:00:00",
"location": "Centre de collecte - Toulouse",
"requiredSkills": {
"INFIRMIER": 3,
"MEDECIN": 1,
"ACCUEIL": 1,
"TRANSPORT": 1
}
},
{
"id": "collecte_blagnac_20241224",
"start": "2024-12-24T08:00:00",
"end": "2024-12-24T16:00:00",
"location": "Centre de collecte - Blagnac",
"requiredSkills": {
"INFIRMIER": 2,
"MEDECIN": 1,
"ACCUEIL": 1
"MEDECIN": 1
}
}
]

View File

@@ -168,7 +168,7 @@
const timelineOptions = {
timeAxis: {scale: "hour", step: 6},
orientation: {axis: "top"},
stack: false,
stack: true,
xss: {disabled: true}
};
@@ -372,131 +372,155 @@
}
}
}
function getColorForSkill(skill) {
const skillColors = {
"INFIRMIER": "#FF69B4", // Rose
"MEDECIN": "#4169E1", // Bleu
"PRELEVEMENT": "#FF8C00", // Orange
"ACCUEIL": "#228B22", // Vert
"TRANSPORT": "#8B008B", // Violet
"SUPERVISION": "#FFD700" // Or
};
return skillColors[skill] || COLORS.DEFAULT;
}
function renderSchedule(schedule) {
// Clear existing data
// Efface les anciennes données
byEmployeeGroupDataSet.clear();
byEmployeeItemDataSet.clear();
byLocationGroupDataSet.clear();
byLocationItemDataSet.clear();
// Update score
// Met à jour le score
$('#score').text('Score: ' + (schedule.score || '?'));
$('#analyzeButton').prop('disabled', !schedule.score);
// Count unassigned shifts
// Compte les shifts non assignés
let unassignedCount = 0;
schedule.shifts.forEach(shift => {
if (!shift.employee) unassignedCount++;
});
$('#unassignedShifts').text(
unassignedCount === 0
? 'No unassigned shifts'
: `${unassignedCount} unassigned shifts`
unassignedCount === 0 ? 'No unassigned shifts' : `${unassignedCount} unassigned shifts`
);
// Render employees
schedule.employees.forEach((employee, index) => {
// Add employee group
// Rend les employés
schedule.employees.forEach(employee => {
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
// Ajoute les disponibilités
['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,
content: ['Unavailable', 'Undesired', 'Desired'][typeIndex],
start: startDate,
end: endDate,
type: 'background',
style: `opacity: 0.5; background-color: ${color}`
style: `opacity: 0.3; 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}`
// Crée les groupes par (lieu + compétence)
const locationSkillGroups = {};
schedule.shifts.forEach(shift => {
const groupId = `${shift.location}_${shift.requiredSkill}`;
if (!locationSkillGroups[groupId]) {
locationSkillGroups[groupId] = true;
byLocationGroupDataSet.add({
id: groupId,
content: `<div>
<strong>${shift.location}</strong><br/>
<span class="badge" style="background-color: ${getColorForSkill(shift.requiredSkill)}">
${shift.requiredSkill}
</span>
</div>`
});
}
});
// Set timeline window
// Ajoute les shifts aux groupes
schedule.shifts.forEach((shift, index) => {
const startTime = new Date(shift.start);
const endTime = new Date(shift.end);
const groupId = `${shift.location}_${shift.requiredSkill}`;
const skillColor = getColorForSkill(shift.requiredSkill);
// Ajoute au timeline "By Employee"
if (shift.employee) {
const hasRequiredSkill = shift.employee.skills.includes(shift.requiredSkill);
const shiftColor = hasRequiredSkill ? COLORS.SKILL_MATCH : COLORS.SKILL_MISMATCH;
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><br/>
${startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} -
${endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</div>`,
start: startTime,
end: endTime,
style: `background-color: ${shiftColor}; border: 1px solid #555; color: #fff;`
});
}
// Ajoute au timeline "By Location"
if (shift.employee) {
const hasRequiredSkill = shift.employee.skills.includes(shift.requiredSkill);
const shiftColor = hasRequiredSkill ? skillColor : COLORS.SKILL_MISMATCH;
byLocationItemDataSet.add({
id: `shift-${index}-loc`,
group: groupId,
content: `<div>
<strong>${shift.employee.name}</strong><br/>
${startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} -
${endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</div>`,
start: startTime,
end: endTime,
style: `background-color: ${shiftColor}; border: 1px solid #555; color: #fff;`
});
} else {
// Shift non assigné
byLocationItemDataSet.add({
id: `shift-${index}-unassigned`,
group: groupId,
content: `<div><strong>Non assigné</strong></div>`,
start: startTime,
end: endTime,
style: `background-color: ${COLORS.UNASSIGNED}; border: 1px solid #555; color: #000;`
});
}
});
// Ajuste la fenêtre de la timeline
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];

View File

@@ -4,11 +4,13 @@ import static ai.timefold.solver.core.api.score.stream.Joiners.equal;
import static ai.timefold.solver.core.api.score.stream.Joiners.lessThanOrEqual;
import static ai.timefold.solver.core.api.score.stream.Joiners.overlapping;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.function.Function;
import ai.timefold.solver.core.api.score.buildin.hardsoftbigdecimal.HardSoftBigDecimalScore;
import ai.timefold.solver.core.api.score.buildin.hardsoftlong.HardSoftLongScore;
import ai.timefold.solver.core.api.score.stream.Constraint;
import ai.timefold.solver.core.api.score.stream.ConstraintCollectors;
import ai.timefold.solver.core.api.score.stream.ConstraintFactory;
@@ -38,9 +40,10 @@ public class EmployeeSchedulingConstraintProvider implements ConstraintProvider
// Hard constraints
requiredSkill(constraintFactory),
noOverlappingShifts(constraintFactory),
atLeast10HoursBetweenTwoShifts(constraintFactory),
atLeast11HoursBetweenTwoShifts(constraintFactory),
oneShiftPerDay(constraintFactory),
unavailableEmployee(constraintFactory),
dailyWorkDuration(constraintFactory),
// Soft constraints
undesiredDayForEmployee(constraintFactory),
desiredDayForEmployee(constraintFactory),
@@ -48,6 +51,7 @@ public class EmployeeSchedulingConstraintProvider implements ConstraintProvider
};
}
// Vérifie que l'employé affecté à un shift possède la compétence requise.
private Constraint requiredSkill(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Shift.class)
.filter(shift -> shift.getEmployee() != null &&
@@ -56,6 +60,7 @@ public class EmployeeSchedulingConstraintProvider implements ConstraintProvider
.asConstraint("Missing required skill");
}
// Empêche qu'un employé ait deux shifts qui se chevauchent.
private Constraint noOverlappingShifts(ConstraintFactory constraintFactory) {
return constraintFactory.forEachUniquePair(Shift.class,
equal(Shift::getEmployee),
@@ -64,19 +69,20 @@ public class EmployeeSchedulingConstraintProvider implements ConstraintProvider
.asConstraint("Overlapping shift");
}
Constraint atLeast10HoursBetweenTwoShifts(ConstraintFactory constraintFactory) {
// Garantit un repos minimal de 11 heures entre deux shifts consécutifs pour un employé.
Constraint atLeast11HoursBetweenTwoShifts(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Shift.class)
.join(Shift.class, equal(Shift::getEmployee), lessThanOrEqual(Shift::getEnd, Shift::getStart))
.filter((firstShift,
secondShift) -> Duration.between(firstShift.getEnd(), secondShift.getStart()).toHours() < 10)
.penalize(HardSoftBigDecimalScore.ONE_HARD,
.filter((firstShift, secondShift) -> Duration.between(firstShift.getEnd(), secondShift.getStart()).toHours() < 11)
.penalizeBigDecimal(HardSoftBigDecimalScore.ONE_HARD,
(firstShift, secondShift) -> {
int breakLength = (int) Duration.between(firstShift.getEnd(), secondShift.getStart()).toMinutes();
return (10 * 60) - breakLength;
long breakLengthInMinutes = Duration.between(firstShift.getEnd(), secondShift.getStart()).toMinutes();
return new BigDecimal((11 * 60) - breakLengthInMinutes);
})
.asConstraint("At least 10 hours between 2 shifts");
.asConstraint("At least 11 hours between 2 shifts");
}
// Un employé ne peut avoir qu'un seul shift par jour
Constraint oneShiftPerDay(ConstraintFactory constraintFactory) {
return constraintFactory.forEachUniquePair(Shift.class, equal(Shift::getEmployee),
equal(shift -> shift.getStart().toLocalDate()))
@@ -84,6 +90,7 @@ public class EmployeeSchedulingConstraintProvider implements ConstraintProvider
.asConstraint("Max one shift per day");
}
// Un employé ne peut pas être affecté à un shift pendant ses dates d'indisponibilité.
Constraint unavailableEmployee(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Shift.class)
.join(Employee.class, equal(Shift::getEmployee, Function.identity()))
@@ -93,6 +100,30 @@ public class EmployeeSchedulingConstraintProvider implements ConstraintProvider
.asConstraint("Unavailable employee");
}
// Spécifie qu'un employé doit travailler entre 4 et 10 heures par jour.
private Constraint dailyWorkDuration(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Shift.class)
.groupBy(Shift::getEmployee,
shift -> shift.getStart().toLocalDate(),
ConstraintCollectors.sumDuration(shift -> Duration.between(shift.getStart(), shift.getEnd())))
.penalizeBigDecimal(HardSoftBigDecimalScore.ONE_HARD,
(employee, date, totalDuration) -> {
// Convertir la durée totale en BigDecimal pour le calcul
BigDecimal totalMinutes = new BigDecimal(totalDuration.toMinutes());
BigDecimal minMinutes = new BigDecimal(4 * 60);
BigDecimal maxMinutes = new BigDecimal(10 * 60);
if (totalMinutes.compareTo(minMinutes) < 0) {
return minMinutes.subtract(totalMinutes);
}
if (totalMinutes.compareTo(maxMinutes) > 0) {
return totalMinutes.subtract(maxMinutes);
}
return BigDecimal.ZERO;
})
.asConstraint("Daily work duration between 4 and 10 hours");
}
// Pénalise les shifts qui tombent sur des jours non souhaités par l'employé.
Constraint undesiredDayForEmployee(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Shift.class)
.join(Employee.class, equal(Shift::getEmployee, Function.identity()))
@@ -102,6 +133,7 @@ public class EmployeeSchedulingConstraintProvider implements ConstraintProvider
.asConstraint("Undesired day for employee");
}
// Récompense les shifts qui tombent sur des jours souhaités par l'employé.
Constraint desiredDayForEmployee(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Shift.class)
.join(Employee.class, equal(Shift::getEmployee, Function.identity()))
@@ -111,6 +143,7 @@ public class EmployeeSchedulingConstraintProvider implements ConstraintProvider
.asConstraint("Desired day for employee");
}
// Équilibre la répartition des shifts entre les employés pour éviter que certains en aient trop et d'autres trop peu.
Constraint balanceEmployeeShiftAssignments(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Shift.class)
.groupBy(Shift::getEmployee, ConstraintCollectors.count())

View File

@@ -0,0 +1,509 @@
<!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 (Optimized)</title>
<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>
.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;
}
.loading-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 9999;
justify-content: center;
align-items: center;
}
.loading-overlay.active {
display: flex;
}
.stats-card {
border-left: 4px solid #007bff;
}
</style>
</head>
<body>
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner-border text-light" style="width: 3rem; height: 3rem;" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<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 <small class="text-muted">(Optimized - No Visualization)</small></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"></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>
<button id="analyzeButton" type="button" class="ms-2 btn btn-secondary" disabled>
<span class="fas fa-question"></span> Analyze
</button>
</div>
</div>
<!-- Stats Section -->
<div class="card mb-4 stats-card">
<div class="card-header">
<h5>3. Results Summary</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="card bg-light">
<div class="card-body text-center">
<h6 class="card-subtitle mb-2 text-muted">Score</h6>
<h3 id="score" class="mb-0">?</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-light">
<div class="card-body text-center">
<h6 class="card-subtitle mb-2 text-muted">Total Shifts</h6>
<h3 id="totalShifts" class="mb-0">0</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-light">
<div class="card-body text-center">
<h6 class="card-subtitle mb-2 text-muted">Assigned Shifts</h6>
<h3 id="assignedShifts" class="mb-0 text-success">0</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-light">
<div class="card-body text-center">
<h6 class="card-subtitle mb-2 text-muted">Unassigned Shifts</h6>
<h3 id="unassignedShifts" class="mb-0 text-danger">0</h3>
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-12">
<div class="card bg-light">
<div class="card-body">
<h6 class="card-subtitle mb-2 text-muted">Additional Info</h6>
<div id="additionalInfo">
<p class="mb-1"><strong>Employees:</strong> <span id="employeeCount">0</span></p>
<p class="mb-1"><strong>Locations:</strong> <span id="locationCount">0</span></p>
<p class="mb-0"><strong>Date Range:</strong> <span id="dateRange">-</span></p>
</div>
</div>
</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"></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>
let loadedSchedule = null;
let scheduleId = null;
let autoRefreshIntervalId = null;
const TIMEFOLD_SERVER = 'http://10.0.100.13:8080';
$(document).ready(function() {
setupEventListeners();
setupAjax();
loadDemoData();
});
function showLoading(show) {
$('#loadingOverlay').toggleClass('active', show);
}
function setupEventListeners() {
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);
$('#solveButton').click(solve);
$('#stopSolvingButton').click(stopSolving);
$('#analyzeButton').click(analyze);
$('#downloadButton').click(downloadSolution);
}
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) {
showLoading(true);
const reader = new FileReader();
reader.onload = function(e) {
try {
const cleanText = e.target.result.trim().replace(/^\uFEFF/, '');
const jsonData = JSON.parse(cleanText);
loadedSchedule = jsonData;
$('#fileName').text(file.name);
$('#fileInfo').show();
$('#solveButton').prop('disabled', false);
setTimeout(() => {
updateStats(jsonData);
showLoading(false);
showNotification('File loaded successfully!', 'success');
}, 100);
} catch (error) {
showLoading(false);
console.error('Error:', error);
showNotification(`Error parsing JSON: ${error.message}`, 'danger');
}
};
reader.readAsText(file, 'UTF-8');
}
function loadDemoData() {
$.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');
});
}
window.loadDemoDataSet = function(dataSet) {
showLoading(true);
$.get(TIMEFOLD_SERVER + '/demo-data/' + dataSet)
.done(function(data) {
loadedSchedule = data;
setTimeout(() => {
updateStats(data);
showLoading(false);
$('#solveButton').prop('disabled', false);
showNotification(`Demo data ${dataSet} loaded!`, 'success');
}, 100);
})
.fail(function() {
showLoading(false);
showNotification('Failed to load demo data', 'danger');
});
};
function solve() {
if (!loadedSchedule) {
showNotification('Please load data first', 'warning');
return;
}
$.post({
url: TIMEFOLD_SERVER + '/schedules',
data: JSON.stringify(loadedSchedule),
contentType: 'application/json',
dataType: 'text'
})
.done(function(data) {
if (data && data.trim()) {
scheduleId = data.trim();
refreshSolvingButtons(true);
showNotification('Solving started...', 'info');
} else {
showNotification('Server returned empty response', 'danger');
}
})
.fail(function(xhr) {
let errorMsg = 'Failed to start solving: ';
if (xhr.status === 0) {
errorMsg += 'Cannot connect to server. Check CORS configuration.';
} else if (xhr.status === 404) {
errorMsg += 'Endpoint not found. Check server URL.';
} else {
errorMsg += `${xhr.status} ${xhr.statusText}`;
}
showNotification(errorMsg, '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;
updateStats(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 updateStats(schedule) {
// Score
$('#score').text(schedule.score || '?');
$('#analyzeButton').prop('disabled', !schedule.score);
// Shifts count
const totalShifts = schedule.shifts.length;
const assignedShifts = schedule.shifts.filter(s => s.employee).length;
const unassignedShifts = totalShifts - assignedShifts;
$('#totalShifts').text(totalShifts);
$('#assignedShifts').text(assignedShifts);
$('#unassignedShifts').text(unassignedShifts);
// Additional info
$('#employeeCount').text(schedule.employees?.length || 0);
const locations = [...new Set(schedule.shifts.map(s => s.location))];
$('#locationCount').text(locations.length);
// Date range
if (schedule.shifts.length > 0) {
const dates = schedule.shifts.map(s => new Date(s.start));
const minDate = new Date(Math.min(...dates));
const maxDate = new Date(Math.max(...dates));
$('#dateRange').text(
`${minDate.toLocaleDateString()} - ${maxDate.toLocaleDateString()}`
);
} else {
$('#dateRange').text('-');
}
}
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>';
table.find('tbody').append(`
<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>
`);
});
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>

View File

@@ -215,6 +215,18 @@
loadDemoData();
});
function getColorForSkill(skill) {
const skillColors = {
"INFIRMIER": "#FF69B4", // Rose
"MEDECIN": "#4169E1", // Bleu
"PRELEVEMENT": "#FF8C00", // Orange
"ACCUEIL": "#228B22", // Vert
"TRANSPORT": "#8B008B", // Violet
"SUPERVISION": "#FFD700" // Or
};
return skillColors[skill] || COLORS.DEFAULT;
}
function setupEventListeners() {
// File upload
const fileInput = document.getElementById('fileInput');
@@ -440,204 +452,163 @@
}
function renderSchedule(schedule) {
// Clear existing data
// Efface les anciennes données
byEmployeeGroupDataSet.clear();
byEmployeeItemDataSet.clear();
byLocationGroupDataSet.clear();
byLocationItemDataSet.clear();
// Update score
// Met à jour le score
$('#score').text('Score: ' + (schedule.score || '?'));
$('#analyzeButton').prop('disabled', !schedule.score);
// Count unassigned shifts
// Compte les shifts non assignés
let unassignedCount = 0;
schedule.shifts.forEach(shift => {
if (!shift.employee) unassignedCount++;
});
$('#unassignedShifts').text(
unassignedCount === 0
? 'No unassigned shifts'
: `${unassignedCount} unassigned shifts`
unassignedCount === 0 ? 'No unassigned shifts' : `${unassignedCount} unassigned shifts`
);
// Render employees
schedule.employees.forEach((employee, index) => {
// Add employee group
// Rend les employés
schedule.employees.forEach(employee => {
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
// Ajoute les disponibilités
['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,
content: ['Unavailable', 'Undesired', 'Desired'][typeIndex],
start: startDate,
end: endDate,
type: 'background',
style: `opacity: 0.5; background-color: ${color}`
style: `opacity: 0.3; background-color: ${color}`
});
});
});
});
// Render collectes/shifts as groups in location view
if (schedule.collectes && schedule.shifts) {
// Create individual groups for each shift within collectes
schedule.shifts.forEach((shift, index) => {
const collecte = schedule.collectes.find(c => c.id === shift.collecte?.id);
const collecteInfo = collecte ?
`Collecte: ${collecte.id} - Requis: ${Object.entries(collecte.requiredSkills || {}).map(([skill, count]) => `${count} ${skill}`).join(', ')}` :
'Collecte inconnue';
// Crée d'abord la liste des lieux uniques
const locations = [...new Set(schedule.shifts.map(shift => shift.location))];
byLocationGroupDataSet.add({
id: `shift-group-${index}`,
content: `<div><strong>${shift.location}</strong><br/>
<small>${collecteInfo}</small><br/>
<small class="text-primary">Shift ${shift.requiredSkill}</small></div>`
});
});
} else if (schedule.shifts) {
// Fallback for old format without collectes
const locations = [...new Set(schedule.shifts.map(shift => shift.location))];
locations.forEach(location => {
byLocationGroupDataSet.add({
id: location,
content: location
});
});
}
// Crée les groupes par lieu avec sous-groupes pour les compétences
const locationGroups = [];
const skillSubGroups = [];
// Render shifts
locations.forEach(location => {
// Ajoute le groupe principal pour le lieu
const locationGroupId = `loc-${location}`;
locationGroups.push({
id: locationGroupId,
content: `<div><strong>${location}</strong></div>`,
nestedGroups: []
});
// Trouve toutes les compétences requises pour ce lieu
const skillsForLocation = [...new Set(
schedule.shifts
.filter(shift => shift.location === location)
.map(shift => shift.requiredSkill)
)];
// Crée les sous-groupes pour chaque compétence
skillsForLocation.forEach(skill => {
const subGroupId = `${location}_${skill}`;
skillSubGroups.push({
id: subGroupId,
content: `<span class="badge" style="background-color: ${getColorForSkill(skill)}">
${skill}
</span>`,
nestedGroups: []
});
// Ajoute ce sous-groupe au groupe parent
locationGroups.find(g => g.id === locationGroupId).nestedGroups.push(subGroupId);
});
});
// Ajoute tous les groupes au dataset
locationGroups.forEach(group => byLocationGroupDataSet.add(group));
skillSubGroups.forEach(group => byLocationGroupDataSet.add(group));
// Ajoute les shifts
schedule.shifts.forEach((shift, index) => {
const startTime = new Date(shift.start);
const endTime = new Date(shift.end);
const groupId = `${shift.location}_${shift.requiredSkill}`;
const skillColor = getColorForSkill(shift.requiredSkill);
// Ajoute au timeline "By Employee"
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
const shiftColor = hasRequiredSkill ? COLORS.SKILL_MATCH : COLORS.SKILL_MISMATCH;
byEmployeeItemDataSet.add({
id: `shift-${index}-emp`,
group: shift.employee.name,
content: `<div><strong>${shift.collecte?.id || shift.location}</strong><br/>
<span class="badge" style="background-color:${skillColor}">${shift.requiredSkill}</span></div>`,
content: `<div>
<strong>${shift.location}</strong><br/>
<span class="badge" style="background-color:${skillColor}">${shift.requiredSkill}</span><br/>
${startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} -
${endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</div>`,
start: startTime,
end: endTime,
style: `background-color: ${shiftColor}`
style: `background-color: ${shiftColor}; border: 1px solid #555; color: #fff;`
});
}
// Add to shift-specific timeline (each shift has its own group)
// Ajoute au timeline "By Location"
if (shift.employee) {
const hasRequiredSkill = shift.employee.skills.includes(shift.requiredSkill);
const shiftColor = hasRequiredSkill ? skillColor : COLORS.SKILL_MISMATCH;
byLocationItemDataSet.add({
id: `shift-${index}-loc`,
group: `shift-group-${index}`,
content: `<div><strong>${shift.employee.name}</strong><br/>
<span class="badge" style="background-color:${skillColor}">${shift.requiredSkill}</span></div>`,
group: groupId,
content: `<div>
<strong>${shift.employee.name}</strong><br/>
${startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} -
${endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</div>`,
start: startTime,
end: endTime,
style: `background-color: ${shiftColor}`
style: `background-color: ${shiftColor}; border: 1px solid #555; color: #fff;`
});
} else {
// Unassigned shift
// Shift non assigné
byLocationItemDataSet.add({
id: `shift-${index}-unassigned`,
group: `shift-group-${index}`,
content: `<div><strong>Non assigné</strong><br/>
<span class="badge bg-secondary">${shift.requiredSkill}</span></div>`,
group: groupId,
content: `<div><strong>Non assigné</strong></div>`,
start: startTime,
end: endTime,
style: `background-color: ${COLORS.UNASSIGNED}`
style: `background-color: ${COLORS.UNASSIGNED}; border: 1px solid #555; color: #000;`
});
}
});
// Fallback for old format without collectes
if (!schedule.collectes && schedule.shifts) {
// Override groups for old format - use locations instead
byLocationGroupDataSet.clear();
const locations = [...new Set(schedule.shifts.map(shift => shift.location))];
locations.forEach(location => {
byLocationGroupDataSet.add({
id: location,
content: location
});
});
// Re-render shifts with location groups for old format
schedule.shifts.forEach((shift, index) => {
const startTime = new Date(shift.start);
const endTime = new Date(shift.end);
if (shift.employee) {
const hasRequiredSkill = shift.employee.skills.includes(shift.requiredSkill);
const skillColor = hasRequiredSkill ? COLORS.SKILL_MATCH : COLORS.SKILL_MISMATCH;
const shiftColor = getShiftColor(shift, shift.employee);
byLocationItemDataSet.add({
id: `shift-${index}-loc-old`,
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 {
byLocationItemDataSet.add({
id: `shift-${index}-unassigned-old`,
group: shift.location,
content: `<div><strong>Non assigné</strong><br/>
<span class="badge bg-secondary">${shift.requiredSkill}</span></div>`,
start: startTime,
end: endTime,
style: `background-color: ${COLORS.UNASSIGNED}`
});
}
});
}
// Set timeline window
// Ajuste la fenêtre de la timeline
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) {

View File

@@ -215,6 +215,18 @@
loadDemoData();
});
function getColorForSkill(skill) {
const skillColors = {
"INFIRMIER": "#FF69B4", // Rose
"MEDECIN": "#4169E1", // Bleu
"PRELEVEMENT": "#FF8C00", // Orange
"ACCUEIL": "#228B22", // Vert
"TRANSPORT": "#8B008B", // Violet
"SUPERVISION": "#FFD700" // Or
};
return skillColors[skill] || COLORS.DEFAULT;
}
function setupEventListeners() {
// File upload
const fileInput = document.getElementById('fileInput');
@@ -415,6 +427,12 @@
loadedSchedule = schedule;
renderSchedule(schedule);
$('#downloadButton').prop('disabled', false);
// Vérifier le statut et arrêter l'auto-refresh si terminé
if (schedule.solverStatus === 'NOT_SOLVING') {
console.log('Solving terminé, arrêt de l\'auto-refresh');
refreshSolvingButtons(false);
}
})
.fail(function() {
refreshSolvingButtons(false);
@@ -427,7 +445,7 @@
$('#solveButton').hide();
$('#stopSolvingButton').show();
if (autoRefreshIntervalId == null) {
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
autoRefreshIntervalId = setInterval(refreshSchedule, 5000);
}
} else {
$('#solveButton').show();
@@ -440,204 +458,196 @@
}
function renderSchedule(schedule) {
// Clear existing data
console.time('renderSchedule'); // Pour mesurer le temps
// Sauvegarde les positions de scroll actuelles
const employeeScroll = document.querySelector('#byEmployeeVisualization .vis-center')?.scrollTop || 0;
const locationScroll = document.querySelector('#byLocationVisualization .vis-center')?.scrollTop || 0;
// Désactive les mises à jour automatiques pendant la construction
byEmployeeGroupDataSet.clear();
byEmployeeItemDataSet.clear();
byLocationGroupDataSet.clear();
byLocationItemDataSet.clear();
// Update score
// Met à jour le 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++;
});
// Compte les shifts non assignés (optimisé avec filter)
const unassignedCount = schedule.shifts.filter(shift => !shift.employee).length;
$('#unassignedShifts').text(
unassignedCount === 0
? 'No unassigned shifts'
: `${unassignedCount} unassigned shifts`
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('');
// Prépare tous les groupes et items en mémoire
const employeeGroups = [];
const employeeItems = [];
const locationGroupsData = [];
const locationItems = [];
byEmployeeGroupDataSet.add({
// === PARTIE 1: Employés ===
schedule.employees.forEach(employee => {
employeeGroups.push({
id: employee.name,
content: `<div><strong>${employee.name}</strong><br/>${skillsBadges}</div>`
content: `<div><strong>${employee.name}</strong></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];
// Ajoute toutes les disponibilités d'un coup
const dateTypes = [
{ key: 'unavailableDates', label: 'Unavailable', color: COLORS.UNAVAILABLE },
{ key: 'undesiredDates', label: 'Undesired', color: COLORS.UNDESIRED },
{ key: 'desiredDates', label: 'Desired', color: COLORS.DESIRED }
];
employee[dateType]?.forEach((date, dateIndex) => {
dateTypes.forEach(({ key, label, color }) => {
employee[key]?.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}`,
employeeItems.push({
id: `${employee.name}-${key}-${dateIndex}`,
group: employee.name,
content: label,
start: startDate,
end: endDate,
type: 'background',
style: `opacity: 0.5; background-color: ${color}`
style: `opacity: 0.3; background-color: ${color}`
});
});
});
});
// Render collectes/shifts as groups in location view
if (schedule.collectes && schedule.shifts) {
// Create individual groups for each shift within collectes
schedule.shifts.forEach((shift, index) => {
const collecte = schedule.collectes.find(c => c.id === shift.collecte?.id);
const collecteInfo = collecte ?
`Collecte: ${collecte.id} - Requis: ${Object.entries(collecte.requiredSkills || {}).map(([skill, count]) => `${count} ${skill}`).join(', ')}` :
'Collecte inconnue';
// === PARTIE 2: Locations ===
// Pré-calcule toutes les données nécessaires
const locations = [...new Set(schedule.shifts.map(shift => shift.location))];
const locationSkillsMap = new Map();
byLocationGroupDataSet.add({
id: `shift-group-${index}`,
content: `<div><strong>${shift.location}</strong><br/>
<small>${collecteInfo}</small><br/>
<small class="text-primary">Shift ${shift.requiredSkill}</small></div>`
// Construit la map location -> skills en une seule passe
schedule.shifts.forEach(shift => {
if (!locationSkillsMap.has(shift.location)) {
locationSkillsMap.set(shift.location, new Set());
}
locationSkillsMap.get(shift.location).add(shift.requiredSkill);
});
// Crée tous les groupes
locations.forEach(location => {
const locationGroupId = `loc-${location}`;
const nestedGroups = [];
const skills = Array.from(locationSkillsMap.get(location));
skills.forEach(skill => {
const subGroupId = `${location}_${skill}`;
nestedGroups.push(subGroupId);
locationGroupsData.push({
id: subGroupId,
content: `<span class="badge" style="background-color: ${getColorForSkill(skill)}">
${skill}
</span>`,
nestedGroups: []
});
});
} else if (schedule.shifts) {
// Fallback for old format without collectes
const locations = [...new Set(schedule.shifts.map(shift => shift.location))];
locations.forEach(location => {
byLocationGroupDataSet.add({
id: location,
content: location
});
});
}
// Render shifts
locationGroupsData.push({
id: locationGroupId,
content: `<div><strong>${location}</strong></div>`,
nestedGroups: nestedGroups
});
});
// === PARTIE 3: Shifts ===
// Crée une map des compétences des employés pour un accès rapide
const employeeSkillsMap = new Map();
schedule.employees.forEach(emp => {
employeeSkillsMap.set(emp.name, new Set(emp.skills));
});
schedule.shifts.forEach((shift, index) => {
const startTime = new Date(shift.start);
const endTime = new Date(shift.end);
const groupId = `${shift.location}_${shift.requiredSkill}`;
const skillColor = getColorForSkill(shift.requiredSkill);
const timeStr = `${startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} - ${endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}`;
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);
const hasRequiredSkill = employeeSkillsMap.get(shift.employee.name)?.has(shift.requiredSkill);
const shiftColor = hasRequiredSkill ? COLORS.SKILL_MATCH : COLORS.SKILL_MISMATCH;
// Add to employee timeline
byEmployeeItemDataSet.add({
// Timeline "By Employee"
employeeItems.push({
id: `shift-${index}-emp`,
group: shift.employee.name,
content: `<div><strong>${shift.collecte?.id || shift.location}</strong><br/>
<span class="badge" style="background-color:${skillColor}">${shift.requiredSkill}</span></div>`,
content: `<div>
<strong>${shift.location}</strong><br/>
<span class="badge" style="background-color:${skillColor}">${shift.requiredSkill}</span><br/>
${timeStr}
</div>`,
start: startTime,
end: endTime,
style: `background-color: ${shiftColor}`
style: `background-color: ${shiftColor}; border: 1px solid #555; color: #fff;`
});
// Add to shift-specific timeline (each shift has its own group)
byLocationItemDataSet.add({
// Timeline "By Location"
const locationShiftColor = hasRequiredSkill ? skillColor : COLORS.SKILL_MISMATCH;
locationItems.push({
id: `shift-${index}-loc`,
group: `shift-group-${index}`,
content: `<div><strong>${shift.employee.name}</strong><br/>
<span class="badge" style="background-color:${skillColor}">${shift.requiredSkill}</span></div>`,
group: groupId,
content: `<div>
<strong>${shift.employee.name}</strong><br/>
${timeStr}
</div>`,
start: startTime,
end: endTime,
style: `background-color: ${shiftColor}`
style: `background-color: ${locationShiftColor}; border: 1px solid #555; color: #fff;`
});
} else {
// Unassigned shift
byLocationItemDataSet.add({
// Shift non assigné
locationItems.push({
id: `shift-${index}-unassigned`,
group: `shift-group-${index}`,
content: `<div><strong>Non assigné</strong><br/>
<span class="badge bg-secondary">${shift.requiredSkill}</span></div>`,
group: groupId,
content: `<div><strong>Non assigné</strong></div>`,
start: startTime,
end: endTime,
style: `background-color: ${COLORS.UNASSIGNED}`
style: `background-color: ${COLORS.UNASSIGNED}; border: 1px solid #555; color: #000;`
});
}
});
// Fallback for old format without collectes
if (!schedule.collectes && schedule.shifts) {
// Override groups for old format - use locations instead
byLocationGroupDataSet.clear();
const locations = [...new Set(schedule.shifts.map(shift => shift.location))];
locations.forEach(location => {
byLocationGroupDataSet.add({
id: location,
content: location
});
});
// === PARTIE 4: Ajoute tout en une seule opération ===
console.log(`Adding ${employeeGroups.length} employee groups`);
console.log(`Adding ${employeeItems.length} employee items`);
console.log(`Adding ${locationGroupsData.length} location groups`);
console.log(`Adding ${locationItems.length} location items`);
// Re-render shifts with location groups for old format
schedule.shifts.forEach((shift, index) => {
const startTime = new Date(shift.start);
const endTime = new Date(shift.end);
// Utilise add() avec un tableau pour tout ajouter d'un coup
byEmployeeGroupDataSet.add(employeeGroups);
byEmployeeItemDataSet.add(employeeItems);
byLocationGroupDataSet.add(locationGroupsData);
byLocationItemDataSet.add(locationItems);
if (shift.employee) {
const hasRequiredSkill = shift.employee.skills.includes(shift.requiredSkill);
const skillColor = hasRequiredSkill ? COLORS.SKILL_MATCH : COLORS.SKILL_MISMATCH;
const shiftColor = getShiftColor(shift, shift.employee);
byLocationItemDataSet.add({
id: `shift-${index}-loc-old`,
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 {
byLocationItemDataSet.add({
id: `shift-${index}-unassigned-old`,
group: shift.location,
content: `<div><strong>Non assigné</strong><br/>
<span class="badge bg-secondary">${shift.requiredSkill}</span></div>`,
start: startTime,
end: endTime,
style: `background-color: ${COLORS.UNASSIGNED}`
});
}
});
}
// Set timeline window
// Ajuste la fenêtre de la timeline
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);
}
// Restaure les positions de scroll après un court délai
setTimeout(() => {
const employeeScrollContainer = document.querySelector('#byEmployeeVisualization .vis-center');
const locationScrollContainer = document.querySelector('#byLocationVisualization .vis-center');
if (employeeScrollContainer) employeeScrollContainer.scrollTop = employeeScroll;
if (locationScrollContainer) locationScrollContainer.scrollTop = locationScroll;
}, 50);
console.timeEnd('renderSchedule');
}
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) {

View File

@@ -0,0 +1,528 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Employee scheduling - Timefold Solver (Optimized)</title>
<!-- External styles -->
<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;
}
.file-upload-area input[type="file"] {
display: none;
}
.loading-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 9999;
justify-content: center;
align-items: center;
}
.loading-overlay.active {
display: flex;
}
</style>
</head>
<body>
<!-- Loading Overlay -->
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner-border text-light" style="width: 3rem; height: 3rem;" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<div class="container-fluid">
<!-- Notifications -->
<div class="sticky-top d-flex justify-content-center align-items-center" aria-live="polite" aria-atomic="true">
<div id="notificationPanel" role="status" style="position: absolute; top: .5rem;"></div>
</div>
<h1 class="mt-4">Employee scheduling solver <small class="text-muted">(Optimized)</small></h1>
<p>Upload your JSON file and generate the optimal schedule for your employees.</p>
<!-- 1. Load data -->
<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">
<button class="btn btn-outline-primary" id="chooseFileBtn">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"></div>
</div>
</div>
</div>
</div>
</div>
<!-- 2. Solve -->
<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>
<!-- 3. Results -->
<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">
<button class="nav-link active" id="byLocationTab" data-bs-toggle="tab" data-bs-target="#byLocationPanel" type="button">
By location
</button>
</li>
<li class="nav-item">
<button class="nav-link" id="byEmployeeTab" data-bs-toggle="tab" data-bs-target="#byEmployeePanel" type="button">
By employee
</button>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content">
<div class="tab-pane fade show active" id="byLocationPanel">
<div id="byLocationVisualization" style="height: 400px;"></div>
</div>
<div class="tab-pane fade" id="byEmployeePanel">
<div id="byEmployeeVisualization" style="height: 400px;"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal: Score Analysis -->
<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"></div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<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/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"></script>
<script>
// ========= CONSTANTS =========
const TIMEFOLD_SERVER = new URLSearchParams(window.location.search).get('server') ||
(window.location.origin.includes('localhost') ? 'http://localhost:8080' : 'http://10.0.100.13:8080');
const COLORS = {
UNAVAILABLE: '#ef2929',
UNDESIRED: '#f57900',
DESIRED: '#73d216',
DEFAULT: '#729fcf',
UNASSIGNED: '#EF292999',
SKILL_MATCH: '#8ae234',
SKILL_MISMATCH: '#ef2929'
};
const SKILL_COLORS = {
"INFIRMIER": "#FF69B4",
"MEDECIN": "#4169E1",
"PRELEVEMENT": "#FF8C00",
"ACCUEIL": "#228B22",
"TRANSPORT": "#8B008B",
"SUPERVISION": "#FFD700"
};
const TIMELINE_OPTIONS = {
timeAxis: { scale: "hour", step: 6 },
orientation: { axis: "top" },
stack: false,
xss: { disabled: true },
maxHeight: 800,
verticalScroll: true,
horizontalScroll: true,
redraw: false,
zoomKey: 'ctrlKey'
};
// ========= GLOBALS =========
let loadedSchedule = null;
let scheduleId = null;
let autoRefreshIntervalId = null;
const byEmployeeGroupDataSet = new vis.DataSet();
const byEmployeeItemDataSet = new vis.DataSet();
const byLocationGroupDataSet = new vis.DataSet();
const byLocationItemDataSet = new vis.DataSet();
const byEmployeeTimeline = new vis.Timeline(
document.getElementById('byEmployeeVisualization'),
byEmployeeItemDataSet,
byEmployeeGroupDataSet,
TIMELINE_OPTIONS
);
const byLocationTimeline = new vis.Timeline(
document.getElementById('byLocationVisualization'),
byLocationItemDataSet,
byLocationGroupDataSet,
TIMELINE_OPTIONS
);
// ========= INIT =========
$(document).ready(() => {
setupEventListeners();
setupAjax();
loadDemoData();
});
// ========= CORE FUNCTIONS =========
const setupEventListeners = () => {
const fileInput = $('#fileInput')[0];
const fileUploadArea = $('#fileUploadArea')[0];
$('#chooseFileBtn').click(() => fileInput.click());
fileInput.addEventListener('change', handleFileSelect);
fileUploadArea.addEventListener('dragover', handleDragOver);
fileUploadArea.addEventListener('drop', handleDrop);
$('#solveButton').click(solve);
$('#stopSolvingButton').click(stopSolving);
$('#analyzeButton').click(analyze);
$('#downloadButton').click(downloadSolution);
$('#byEmployeeTab').on('shown.bs.tab', () => byEmployeeTimeline.redraw());
$('#byLocationTab').on('shown.bs.tab', () => byLocationTimeline.redraw());
};
const setupAjax = () => {
$.ajaxSetup({
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json,text/plain'
}
});
};
const showLoading = (show) => $('#loadingOverlay').toggleClass('active', show);
const getColorForSkill = (skill) => SKILL_COLORS[skill] || COLORS.DEFAULT;
// ========= FILE HANDLING =========
const handleFileSelect = (event) => {
const file = event.target.files[0];
if (file) readJsonFile(file);
};
const handleDragOver = (event) => {
event.preventDefault();
event.currentTarget.classList.add('dragover');
};
const handleDrop = (event) => {
event.preventDefault();
event.currentTarget.classList.remove('dragover');
const file = event.dataTransfer.files[0];
if (file?.type === 'application/json') readJsonFile(file);
};
const readJsonFile = (file) => {
showLoading(true);
const reader = new FileReader();
reader.onload = (e) => {
try {
const cleanText = e.target.result.trim().replace(/^\uFEFF/, '');
const jsonData = JSON.parse(cleanText);
loadedSchedule = jsonData;
$('#fileName').text(file.name);
$('#fileInfo').show();
$('#solveButton').prop('disabled', false);
(window.requestIdleCallback || setTimeout)(() => {
renderSchedule(jsonData);
showLoading(false);
showNotification('File loaded successfully!', 'success');
});
} catch (error) {
showLoading(false);
showNotification(`Error parsing JSON: ${error.message}`, 'danger', true);
}
};
reader.readAsText(file, 'UTF-8');
};
// ========= DEMO DATA =========
const loadDemoData = () => {
$.get(`${TIMEFOLD_SERVER}/demo-data`)
.done((data) => {
data.forEach(item =>
$('#testDataMenu').append(`<a class="dropdown-item" href="#" onclick="loadDemoDataSet('${item}')">${item}</a>`)
);
})
.fail(() => console.log('Demo data not available'));
};
window.loadDemoDataSet = (dataSet) => {
showLoading(true);
$.get(`${TIMEFOLD_SERVER}/demo-data/${dataSet}`)
.done((data) => {
loadedSchedule = data;
(window.requestIdleCallback || setTimeout)(() => {
renderSchedule(data);
showLoading(false);
$('#solveButton').prop('disabled', false);
showNotification(`Demo data ${dataSet} loaded!`, 'success');
});
})
.fail(() => {
showLoading(false);
showNotification('Failed to load demo data', 'danger', true);
});
};
// ========= SOLVER CONTROL =========
const solve = () => {
if (!loadedSchedule) return showNotification('Please load data first', 'warning');
$('#solveButton').html('<span class="spinner-border spinner-border-sm"></span> Solving...');
$.post({
url: `${TIMEFOLD_SERVER}/schedules`,
data: JSON.stringify(loadedSchedule),
contentType: 'application/json',
dataType: 'text'
})
.done((data) => {
if (data?.trim()) {
scheduleId = data.trim();
refreshSolvingButtons(true);
showNotification('Solving started...', 'info');
} else showNotification('Server returned empty response', 'danger', true);
})
.fail((xhr) => {
let errorMsg = 'Failed to start solving: ';
if (xhr.status === 0) errorMsg += 'Cannot connect to server. Check CORS configuration.';
else if (xhr.status === 404) errorMsg += 'Endpoint not found. Check server URL.';
else errorMsg += `${xhr.status} ${xhr.statusText}`;
showNotification(errorMsg, 'danger', true);
})
.always(() => $('#solveButton').html('<span class="fas fa-play"></span> Solve'));
};
const stopSolving = () => {
if (!scheduleId) return;
$.ajax({ url: `${TIMEFOLD_SERVER}/schedules/${scheduleId}`, type: 'DELETE' })
.done(() => {
refreshSolvingButtons(false);
refreshSchedule();
showNotification('Solving stopped', 'info');
});
};
const refreshSchedule = () => {
if (!scheduleId) return;
return $.get(`${TIMEFOLD_SERVER}/schedules/${scheduleId}`)
.done((schedule) => {
loadedSchedule = schedule;
renderSchedule(schedule);
$('#downloadButton').prop('disabled', false);
})
.fail(() => refreshSolvingButtons(false));
};
const refreshSolvingButtons = (solving) => {
if (solving) {
$('#solveButton').hide();
$('#stopSolvingButton').show();
if (!autoRefreshIntervalId) startPolling();
} else {
$('#solveButton').show();
$('#stopSolvingButton').hide();
if (autoRefreshIntervalId) {
clearTimeout(autoRefreshIntervalId);
autoRefreshIntervalId = null;
}
}
};
const startPolling = () => {
const loop = () => {
refreshSchedule().finally(() => {
if (autoRefreshIntervalId) autoRefreshIntervalId = setTimeout(loop, 2000);
});
};
autoRefreshIntervalId = setTimeout(loop, 2000);
};
// ========= RENDERING =========
const renderSchedule = (schedule) => {
byEmployeeGroupDataSet.clear();
byEmployeeItemDataSet.clear();
byLocationGroupDataSet.clear();
byLocationItemDataSet.clear();
$('#score').text('Score: ' + (schedule.score || '?'));
$('#analyzeButton').prop('disabled', !schedule.score);
const unassignedCount = schedule.shifts.filter(s => !s.employee).length;
$('#unassignedShifts').text(unassignedCount === 0 ? 'No unassigned shifts' : `${unassignedCount} unassigned shifts`);
// EMPLOYEES
const employeeGroups = [], employeeItems = [];
schedule.employees.forEach(employee => {
const skillsBadges = employee.skills.map(skill =>
`<span class="badge bg-secondary me-1">${skill}</span>`
).join('');
employeeGroups.push({ id: employee.name, content: `<div><strong>${employee.name}</strong><br/>${skillsBadges}</div>` });
['unavailableDates', 'undesiredDates', 'desiredDates'].forEach((dateType, i) => {
const color = [COLORS.UNAVAILABLE, COLORS.UNDESIRED, COLORS.DESIRED][i];
const label = ['Unavailable', 'Undesired', 'Desired'][i];
employee[dateType]?.forEach((date, idx) => {
employeeItems.push({
id: `${employee.name}-${dateType}-${idx}`,
group: employee.name,
content: label,
start: new Date(date + 'T00:00:00'),
end: new Date(date + 'T23:59:59'),
style: `background-color: ${color}`
});
});
});
});
byEmployeeGroupDataSet.add(employeeGroups);
byEmployeeItemDataSet.add(employeeItems);
// LOCATIONS
const locationGroups = [], locationItems = [];
schedule.locations.forEach(loc => {
locationGroups.push({ id: loc.name, content: loc.name });
loc.requiredSkillList.forEach(skill => {
const shifts = schedule.shifts.filter(s => s.requiredSkill === skill && s.location?.name === loc.name);
shifts.forEach((shift, idx) => {
const color = shift.employee ? getColorForSkill(skill) : COLORS.UNASSIGNED;
locationItems.push({
id: `${loc.name}-${skill}-${idx}`,
group: loc.name,
content: `<div>${skill} - ${shift.employee?.name || '<em>Unassigned</em>'}</div>`,
start: new Date(shift.startDateTime),
end: new Date(shift.endDateTime),
style: `background-color: ${color}`
});
});
});
});
byLocationGroupDataSet.add(locationGroups);
byLocationItemDataSet.add(locationItems);
byEmployeeTimeline.fit();
byLocationTimeline.fit();
};
// ========= ANALYSIS =========
const analyze = () => {
if (!scheduleId) return;
$.get(`${TIMEFOLD_SERVER}/schedules/${scheduleId}/analysis`)
.done((analysis) => {
$('#scoreAnalysisScoreLabel').text(`(Score: ${analysis.score})`);
$('#scoreAnalysisModalContent').html(`<pre>${JSON.stringify(analysis.constraintMatches, null, 2)}</pre>`);
new bootstrap.Modal(document.getElementById('scoreAnalysisModal')).show();
});
};
// ========= UTILITIES =========
const downloadSolution = () => {
if (!loadedSchedule) return;
const blob = new Blob([JSON.stringify(loadedSchedule, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "schedule_solution.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const showNotification = (message, type, persistent = false) => {
const alert = $(`
<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(alert);
if (!persistent) setTimeout(() => alert.alert('close'), 5000);
};
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -1,505 +0,0 @@
let autoRefreshIntervalId = null;
const zoomMin = 2 * 1000 * 60 * 60 * 24 // 2 day in milliseconds
const zoomMax = 4 * 7 * 1000 * 60 * 60 * 24 // 4 weeks in milliseconds
const UNAVAILABLE_COLOR = '#ef2929' // Tango Scarlet Red
const UNDESIRED_COLOR = '#f57900' // Tango Orange
const DESIRED_COLOR = '#73d216' // Tango Chameleon
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}, // Items are XSS safe through JQuery
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}, // Items are XSS safe through JQuery
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 () {
replaceQuickstartTimefoldAutoHeaderFooter();
$("#solveButton").click(function () {
solve();
});
$("#stopSolvingButton").click(function () {
stopSolving();
});
$("#analyzeButton").click(function () {
analyze();
});
// HACK to allow vis-timeline to work within Bootstrap tabs
$("#byEmployeeTab").on('shown.bs.tab', function (event) {
byEmployeeTimeline.redraw();
})
$("#byLocationTab").on('shown.bs.tab', function (event) {
byLocationTimeline.redraw();
})
setupAjax();
fetchDemoData();
$("#loadScheduleBtn").click(function () {
const inputId = $("#scheduleIdInput").val().trim();
if (inputId.length === 0) {
alert("Veuillez entrer un UUID de schedule.");
return;
}
scheduleId = inputId;
refreshSchedule();
});
});
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($('<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) {
// disable this page as there is no data
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) ||
// The contains() check is ignored for a shift end at midnight (00:00:00).
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
employee.unavailableDates.includes(shiftEndDateString))) {
return UNAVAILABLE_COLOR
} else if (employee.undesiredDates.includes(shiftStartDateString) ||
// The contains() check is ignored for a shift end at midnight (00:00:00).
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
employee.undesiredDates.includes(shiftEndDateString))) {
return UNDESIRED_COLOR
} else if (employee.desiredDates.includes(shiftStartDateString) ||
// The contains() check is ignored for a shift end at midnight (00:00:00).
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
employee.desiredDates.includes(shiftEndDateString))) {
return DESIRED_COLOR
} else {
return " #729fcf"; // Tango Sky Blue
}
}
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 unassignedShifts = $("#unassignedShifts");
const groups = [];
// Show only first 7 days of draft
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();
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() {
$.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();
if (autoRefreshIntervalId == null) {
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
}
} else {
$("#solveButton").show();
$("#stopSolvingButton").hide();
if (autoRefreshIntervalId != null) {
clearInterval(autoRefreshIntervalId);
autoRefreshIntervalId = null;
}
}
}
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 stopSolving() {
$.delete(`/schedules/${scheduleId}`, function () {
refreshSolvingButtons(false);
refreshSchedule();
}).fail(function (xhr, ajaxOptions, thrownError) {
showError("Stop solving failed.", xhr);
});
}
function replaceQuickstartTimefoldAutoHeaderFooter() {
const timefoldHeader = $("header#timefold-auto-header");
if (timefoldHeader != null) {
timefoldHeader.addClass("bg-black")
timefoldHeader.append(
$(`<div class="container-fluid">
<nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
<a class="navbar-brand" href="https://timefold.ai">
<img src="/webjars/timefold/img/timefold-logo-horizontal-negative.svg" alt="Timefold logo" width="200">
</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">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">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">REST API</button>
</li>
</ul>
</div>
<div class="ms-auto">
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Data
</button>
<div id="testDataButton" class="dropdown-menu" aria-labelledby="dropdownMenuButton"></div>
</div>
</div>
</nav>
</div>`));
}
const timefoldFooter = $("footer#timefold-auto-footer");
if (timefoldFooter != null) {
timefoldFooter.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://timefold.ai">Timefold</a></div>
<div class="vr"></div>
<div><a class="text-white" href="https://timefold.ai/docs">Documentation</a></div>
<div class="vr"></div>
<div><a class="text-white" href="https://github.com/TimefoldAI/timefold-quickstarts">Code</a></div>
<div class="vr"></div>
<div class="me-auto"><a class="text-white" href="https://timefold.ai/product/support/">Support</a></div>
</div>
</div>
</footer>`));
}
}

View File

@@ -1,166 +0,0 @@
<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="/webjars/bootstrap/css/bootstrap.min.css"/>
<link rel="stylesheet" href="/webjars/font-awesome/css/all.css"/>
<link rel="stylesheet" href="/webjars/timefold/css/timefold-webui.css"/>
<style>
.vis-time-axis .vis-grid.vis-saturday,
.vis-time-axis .vis-grid.vis-sunday {
background: #D3D7CFFF;
}
</style>
<link rel="icon" href="/webjars/timefold/img/timefold-favicon.svg" type="image/svg+xml">
</head>
<body>
<header id="timefold-auto-header">
<!-- Filled in by app.js -->
</header>
<div style="margin: 1em 0;">
<label for="scheduleIdInput">Charger un schedule par ID :</label>
<input type="text" id="scheduleIdInput" placeholder="UUID du schedule" style="width: 350px;" />
<button id="loadScheduleBtn">Charger</button>
</div>
<div class="tab-content">
<div id="demo" class="tab-pane fade show active 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>Employee scheduling solver</h1>
<p>Generate the optimal schedule for your employees.</p>
<div class="mb-4">
<button id="solveButton" type="button" class="btn btn-success">
<span class="fas fa-play"></span> Solve
</button>
<button id="stopSolvingButton" type="button" class="btn btn-danger">
<span class="fas fa-stop"></span> Stop solving
</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">
<span class="fas fa-question"></span>
</button>
<div class="float-end">
<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"
aria-controls="byLocationPanel" aria-selected="true">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"
aria-controls="byEmployeePanel" aria-selected="false">By employee
</button>
</li>
</ul>
</div>
</div>
<div class="mb-4 tab-content">
<div class="tab-pane fade show active" id="byLocationPanel" role="tabpanel"
aria-labelledby="byLocationTab">
<div id="locationVisualization"></div>
</div>
<div class="tab-pane fade" id="byEmployeePanel" role="tabpanel" aria-labelledby="byEmployeeTab">
<div id="employeeVisualization"></div>
</div>
</div>
</div>
<div id="rest" class="tab-pane fade container-fluid">
<h1>REST API Guide</h1>
<h2>Employee Scheduling solver integration via cURL</h2>
<h3>1. Download demo data</h3>
<pre>
<button class="btn btn-outline-dark btn-sm float-end"
onclick="copyTextToClipboard('curl1')">Copy</button>
<code id="curl1">curl -X GET -H 'Accept:application/json' http://localhost:8080/demo-data/SMALL -o sample.json</code>
</pre>
<h3>2. Post the sample data for solving</h3>
<p>The POST operation returns a <code>jobId</code> that should be used in subsequent commands.</p>
<pre>
<button class="btn btn-outline-dark btn-sm float-end"
onclick="copyTextToClipboard('curl2')">Copy</button>
<code id="curl2">curl -X POST -H 'Content-Type:application/json' http://localhost:8080/schedules -d@sample.json</code>
</pre>
<h3>3. Get the current status and score</h3>
<pre>
<button class="btn btn-outline-dark btn-sm float-end"
onclick="copyTextToClipboard('curl3')">Copy</button>
<code id="curl3">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}/status</code>
</pre>
<h3>4. Get the complete solution</h3>
<pre>
<button class="btn btn-outline-dark btn-sm float-end"
onclick="copyTextToClipboard('curl4')">Copy</button>
<code id="curl4">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}</code>
</pre>
<h3>5. Fetch the analysis of the solution</h3>
<pre>
<button class="btn btn-outline-dark btn-sm float-end"
onclick="copyTextToClipboard('curl5')">Copy</button>
<code id="curl5">curl -X PUT -H 'Content-Type:application/json' http://localhost:8080/schedules/analyze -d@solution.json</code>
</pre>
<h3>5. Terminate solving early</h3>
<pre>
<button class="btn btn-outline-dark btn-sm float-end"
onclick="copyTextToClipboard('curl5')">Copy</button>
<code id="curl6">curl -X DELETE -H 'Accept:application/json' http://localhost:8080/schedules/{id}</code>
</pre>
</div>
<div id="openapi" class="tab-pane fade container-fluid">
<h1>REST API Reference</h1>
<div class="ratio ratio-1x1">
<!-- "scrolling" attribute is obsolete, but e.g. Chrome does not support "overflow:hidden" -->
<iframe src="/q/swagger-ui" style="overflow:hidden;" scrolling="no"></iframe>
</div>
</div>
</div>
<footer id="timefold-auto-footer"></footer>
<div class="modal fadebd-example-modal-lg" id="scoreAnalysisModal" tabindex="-1"
aria-labelledby="scoreAnalysisModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="scoreAnalysisModalLabel">Score analysis <span
id="scoreAnalysisScoreLabel"></span></h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="scoreAnalysisModalContent">
<!-- Filled in by app.js -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script src="/webjars/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/webjars/jquery/jquery.min.js"></script>
<script src="/webjars/js-joda/dist/js-joda.min.js"></script>
<script src="/webjars/timefold/js/timefold-webui.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"
integrity="sha256-Jy2+UO7rZ2Dgik50z3XrrNpnc5+2PAx9MhL2CicodME=" crossorigin="anonymous"></script>
<script src="/app.js"></script>
</body>
</html>

View File

@@ -1,51 +0,0 @@
########################
# Timefold Solver properties
########################
# The solver runs for 30 seconds. To run for 5 minutes use "5m" and for 2 hours use "2h".
quarkus.timefold.solver.termination.spent-limit=30s
# To change how many solvers to run in parallel
# timefold.solver-manager.parallel-solver-count=4
# Temporary comment this out to detect bugs in your code (lowers performance)
# quarkus.timefold.solver.environment-mode=FULL_ASSERT
# Temporary comment this out to return a feasible solution as soon as possible
# quarkus.timefold.solver.termination.best-score-limit=0hard/*soft
# To see what Timefold is doing, turn on DEBUG or TRACE logging.
quarkus.log.category."ai.timefold.solver".level=INFO
%test.quarkus.log.category."ai.timefold.solver".level=INFO
%prod.quarkus.log.category."ai.timefold.solver".level=INFO
# XML file for power tweaking, defaults to solverConfig.xml (directly under src/main/resources)
# quarkus.timefold.solver-config-xml=org/.../maintenanceScheduleSolverConfig.xml
########################
# Timefold Solver Enterprise properties
########################
# To run increase CPU cores usage per solver
%enterprise.quarkus.timefold.solver.move-thread-count=AUTO
########################
# Native build properties
########################
# Enable Swagger UI also in the native mode
quarkus.swagger-ui.always-include=true
########################
# Test overrides
########################
%test.quarkus.timefold.solver.termination.spent-limit=10s
quarkus.log.category."ai.timefold.solver".level=DEBUG
quarkus.log.category."org.acme.employeescheduling".level=DEBUG
# quarkus.http.cors=true
# quarkus.http.cors.origins=*
# quarkus.http.cors.headers=*
# quarkus.http.cors.methods=*

Binary file not shown.

View File

@@ -1,16 +0,0 @@
org/acme/employeescheduling/domain/EmployeeSchedule.class
org/acme/employeescheduling/rest/DemoDataGenerator$CountDistribution.class
org/acme/employeescheduling/rest/DemoDataGenerator$DemoDataParameters.class
org/acme/employeescheduling/rest/exception/EmployeeScheduleSolverException.class
org/acme/employeescheduling/domain/Collecte.class
org/acme/employeescheduling/rest/EmployeeScheduleDemoResource.class
org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProvider.class
org/acme/employeescheduling/rest/exception/EmployeeScheduleSolverExceptionMapper.class
org/acme/employeescheduling/rest/EmployeeScheduleResource$Job.class
org/acme/employeescheduling/rest/EmployeeScheduleResource.class
org/acme/employeescheduling/rest/DemoDataGenerator.class
org/acme/employeescheduling/domain/Shift.class
org/acme/employeescheduling/domain/Employee.class
org/acme/employeescheduling/rest/EmployeeScheduleResource$ScheduleStatus.class
org/acme/employeescheduling/rest/exception/ErrorInfo.class
org/acme/employeescheduling/rest/DemoDataGenerator$DemoData.class

View File

@@ -1,11 +0,0 @@
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/domain/Collecte.java
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/domain/Employee.java
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/domain/EmployeeSchedule.java
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/domain/Shift.java
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/DemoDataGenerator.java
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/EmployeeScheduleDemoResource.java
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/EmployeeScheduleResource.java
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/exception/EmployeeScheduleSolverException.java
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/exception/EmployeeScheduleSolverExceptionMapper.java
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/exception/ErrorInfo.java
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProvider.java