Compare commits
2 Commits
94ae364aad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 79c0d8d276 | |||
| af13ae9f1e |
248
chat3.json
248
chat3.json
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
170
newUI.html
170
newUI.html
@@ -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];
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
509
src/main/resources/META-INF/resources/upload-simple.html
Normal file
509
src/main/resources/META-INF/resources/upload-simple.html
Normal 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>
|
||||
@@ -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) {
|
||||
@@ -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) {
|
||||
|
||||
528
src/main/resources/META-INF/resources/upload.html.foiré
Normal file
528
src/main/resources/META-INF/resources/upload.html.foiré
Normal 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
@@ -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>`));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
Binary file not shown.
Reference in New Issue
Block a user