upload.html au top
This commit is contained in:
parent
af13ae9f1e
commit
79c0d8d276
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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
168
newUI.html
168
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 => {
|
||||
// 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: 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}`
|
||||
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>
|
||||
705
src/main/resources/META-INF/resources/upload-vieux.html
Normal file
705
src/main/resources/META-INF/resources/upload-vieux.html
Normal file
@ -0,0 +1,705 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport">
|
||||
<title>Employee scheduling - Timefold Solver on Quarkus</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/styles/vis-timeline-graph2d.min.css" integrity="sha256-svzNasPg1yR5gvEaRei2jg+n4Pc3sVyMUWeS6xRAh6U=" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<style>
|
||||
.vis-time-axis .vis-grid.vis-saturday,
|
||||
.vis-time-axis .vis-grid.vis-sunday {
|
||||
background: #D3D7CFFF;
|
||||
}
|
||||
.file-upload-area {
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
.file-upload-area:hover {
|
||||
border-color: #007bff;
|
||||
}
|
||||
.file-upload-area.dragover {
|
||||
border-color: #007bff;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid">
|
||||
<div class="sticky-top d-flex justify-content-center align-items-center" aria-live="polite" aria-atomic="true">
|
||||
<div id="notificationPanel" style="position: absolute; top: .5rem;"></div>
|
||||
</div>
|
||||
|
||||
<h1 class="mt-4">Employee scheduling solver</h1>
|
||||
<p>Upload your JSON file and generate the optimal schedule for your employees.</p>
|
||||
|
||||
<!-- File Upload Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5>1. Load your data</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="file-upload-area" id="fileUploadArea">
|
||||
<i class="fas fa-upload fa-3x text-muted mb-3"></i>
|
||||
<p class="mb-2">Drag & drop your JSON file here or click to select</p>
|
||||
<input type="file" id="fileInput" accept=".json" style="display: none;">
|
||||
<button class="btn btn-outline-primary" onclick="document.getElementById('fileInput').click()">
|
||||
Choose File
|
||||
</button>
|
||||
</div>
|
||||
<div id="fileInfo" class="mt-2" style="display: none;">
|
||||
<small class="text-success">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<span id="fileName"></span> loaded successfully
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Or use demo data:</h6>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="testDataButton" data-bs-toggle="dropdown">
|
||||
Select demo data
|
||||
</button>
|
||||
<div class="dropdown-menu" id="testDataMenu">
|
||||
<!-- Filled by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5>2. Solve the problem</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<button id="solveButton" type="button" class="btn btn-success me-2" disabled>
|
||||
<span class="fas fa-play"></span> Solve
|
||||
</button>
|
||||
<button id="stopSolvingButton" type="button" class="btn btn-danger me-2" style="display: none;">
|
||||
<span class="fas fa-stop"></span> Stop solving
|
||||
</button>
|
||||
<button id="downloadButton" type="button" class="btn btn-info me-2" disabled>
|
||||
<span class="fas fa-download"></span> Download Solution
|
||||
</button>
|
||||
<span id="unassignedShifts" class="ms-2 align-middle fw-bold"></span>
|
||||
<span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
|
||||
<button id="analyzeButton" type="button" class="ms-2 btn btn-secondary" disabled>
|
||||
<span class="fas fa-question"></span> Analyze
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visualization Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">3. View results</h5>
|
||||
<ul class="nav nav-pills" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="byLocationTab" data-bs-toggle="tab"
|
||||
data-bs-target="#byLocationPanel" type="button" role="tab">
|
||||
By collecte
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="byEmployeeTab" data-bs-toggle="tab"
|
||||
data-bs-target="#byEmployeePanel" type="button" role="tab">
|
||||
By employee
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="byLocationPanel" role="tabpanel">
|
||||
<div id="byLocationVisualization" style="height: 400px;"></div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="byEmployeePanel" role="tabpanel">
|
||||
<div id="byEmployeeVisualization" style="height: 400px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score Analysis Modal -->
|
||||
<div class="modal fade" id="scoreAnalysisModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Score analysis <span id="scoreAnalysisScoreLabel"></span></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="scoreAnalysisModalContent">
|
||||
<!-- Filled by JavaScript -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@js-joda/core@5.4.2/dist/js-joda.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Global variables
|
||||
let loadedSchedule = null;
|
||||
let scheduleId = null;
|
||||
let autoRefreshIntervalId = null;
|
||||
|
||||
// Timefold server URL - modify this to match your server
|
||||
// Option 1: Direct (nécessite CORS activé sur le serveur)
|
||||
const TIMEFOLD_SERVER = 'http://10.0.100.13:8080';
|
||||
|
||||
// Option 2: Si vous servez depuis le serveur Timefold même
|
||||
// const TIMEFOLD_SERVER = window.location.origin;
|
||||
|
||||
// Option 3: Via proxy local (si vous utilisez un proxy)
|
||||
// const TIMEFOLD_SERVER = 'http://localhost:3000/api';
|
||||
|
||||
// Timeline configuration
|
||||
const timelineOptions = {
|
||||
timeAxis: {scale: "hour", step: 6},
|
||||
orientation: {axis: "top"},
|
||||
stack: false,
|
||||
xss: {disabled: true}
|
||||
};
|
||||
|
||||
let byEmployeeGroupDataSet = new vis.DataSet();
|
||||
let byEmployeeItemDataSet = new vis.DataSet();
|
||||
let byLocationGroupDataSet = new vis.DataSet();
|
||||
let byLocationItemDataSet = new vis.DataSet();
|
||||
|
||||
let byEmployeeTimeline = new vis.Timeline(
|
||||
document.getElementById('byEmployeeVisualization'),
|
||||
byEmployeeItemDataSet,
|
||||
byEmployeeGroupDataSet,
|
||||
timelineOptions
|
||||
);
|
||||
|
||||
let byLocationTimeline = new vis.Timeline(
|
||||
document.getElementById('byLocationVisualization'),
|
||||
byLocationItemDataSet,
|
||||
byLocationGroupDataSet,
|
||||
timelineOptions
|
||||
);
|
||||
|
||||
// Colors for visualization
|
||||
const COLORS = {
|
||||
UNAVAILABLE: '#ef2929',
|
||||
UNDESIRED: '#f57900',
|
||||
DESIRED: '#73d216',
|
||||
DEFAULT: '#729fcf',
|
||||
UNASSIGNED: '#EF292999',
|
||||
SKILL_MATCH: '#8ae234',
|
||||
SKILL_MISMATCH: '#ef2929'
|
||||
};
|
||||
|
||||
$(document).ready(function() {
|
||||
setupEventListeners();
|
||||
setupAjax();
|
||||
loadDemoData();
|
||||
});
|
||||
|
||||
function 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');
|
||||
const fileUploadArea = document.getElementById('fileUploadArea');
|
||||
|
||||
fileInput.addEventListener('change', handleFileSelect);
|
||||
fileUploadArea.addEventListener('click', () => fileInput.click());
|
||||
fileUploadArea.addEventListener('dragover', handleDragOver);
|
||||
fileUploadArea.addEventListener('drop', handleDrop);
|
||||
|
||||
// Buttons
|
||||
$('#solveButton').click(solve);
|
||||
$('#stopSolvingButton').click(stopSolving);
|
||||
$('#analyzeButton').click(analyze);
|
||||
$('#downloadButton').click(downloadSolution);
|
||||
|
||||
// Tabs
|
||||
$('#byEmployeeTab').on('shown.bs.tab', () => byEmployeeTimeline.redraw());
|
||||
$('#byLocationTab').on('shown.bs.tab', () => byLocationTimeline.redraw());
|
||||
}
|
||||
|
||||
function setupAjax() {
|
||||
$.ajaxSetup({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json,text/plain'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleFileSelect(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
readJsonFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.add('dragover');
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.remove('dragover');
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file && file.type === 'application/json') {
|
||||
readJsonFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonFile(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
try {
|
||||
const rawText = e.target.result;
|
||||
|
||||
// Debug: montrer les premiers caractères
|
||||
console.log('Premiers caractères du fichier:', rawText.substring(0, 50));
|
||||
console.log('Code du premier caractère:', rawText.charCodeAt(0));
|
||||
|
||||
// Nettoyer le texte (supprimer BOM et espaces invisibles)
|
||||
const cleanText = rawText.trim().replace(/^\uFEFF/, '');
|
||||
|
||||
const jsonData = JSON.parse(cleanText);
|
||||
loadedSchedule = jsonData;
|
||||
|
||||
// Show file info
|
||||
$('#fileName').text(file.name);
|
||||
$('#fileInfo').show();
|
||||
|
||||
// Enable solve button
|
||||
$('#solveButton').prop('disabled', false);
|
||||
|
||||
// Render the loaded data
|
||||
renderSchedule(jsonData);
|
||||
|
||||
showNotification('File loaded successfully!', 'success');
|
||||
} catch (error) {
|
||||
console.error('Erreur détaillée:', error);
|
||||
console.error('Contenu du fichier:', e.target.result.substring(0, 200));
|
||||
showNotification(`Error parsing JSON file: ${error.message}`, 'danger');
|
||||
|
||||
// Afficher une aide pour résoudre le problème
|
||||
const helpMessage = `
|
||||
<div class="alert alert-warning mt-3">
|
||||
<h6>Conseils pour résoudre le problème:</h6>
|
||||
<ul class="mb-0">
|
||||
<li>Vérifiez que le fichier commence bien par { et se termine par }</li>
|
||||
<li>Sauvegardez le fichier en UTF-8 sans BOM</li>
|
||||
<li>Vérifiez qu'il n'y a pas de virgules en trop</li>
|
||||
<li>Testez votre JSON sur <a href="https://jsonlint.com" target="_blank">JSONLint</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
$('#fileInfo').html(helpMessage).show();
|
||||
}
|
||||
};
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
}
|
||||
|
||||
function loadDemoData() {
|
||||
// Load demo data options from server
|
||||
$.get(TIMEFOLD_SERVER + '/demo-data')
|
||||
.done(function(data) {
|
||||
data.forEach(item => {
|
||||
$('#testDataMenu').append(
|
||||
`<a class="dropdown-item" href="#" onclick="loadDemoDataSet('${item}')">${item}</a>`
|
||||
);
|
||||
});
|
||||
})
|
||||
.fail(function() {
|
||||
console.log('Demo data not available');
|
||||
});
|
||||
}
|
||||
|
||||
function loadDemoDataSet(dataSet) {
|
||||
$.get(TIMEFOLD_SERVER + '/demo-data/' + dataSet)
|
||||
.done(function(data) {
|
||||
loadedSchedule = data;
|
||||
renderSchedule(data);
|
||||
$('#solveButton').prop('disabled', false);
|
||||
showNotification(`Demo data ${dataSet} loaded!`, 'success');
|
||||
})
|
||||
.fail(function() {
|
||||
showNotification('Failed to load demo data', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function solve() {
|
||||
if (!loadedSchedule) {
|
||||
showNotification('Please load data first', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Sending data to solve:', loadedSchedule);
|
||||
|
||||
$.post({
|
||||
url: TIMEFOLD_SERVER + '/schedules',
|
||||
data: JSON.stringify(loadedSchedule),
|
||||
contentType: 'application/json',
|
||||
dataType: 'text' // Attendre du texte (l'ID du job)
|
||||
})
|
||||
.done(function(data, textStatus, xhr) {
|
||||
console.log('Solve response:', data);
|
||||
console.log('Response status:', xhr.status);
|
||||
console.log('Response headers:', xhr.getAllResponseHeaders());
|
||||
|
||||
if (data && data.trim()) {
|
||||
scheduleId = data.trim();
|
||||
refreshSolvingButtons(true);
|
||||
showNotification('Solving started...', 'info');
|
||||
} else {
|
||||
showNotification('Server returned empty response', 'danger');
|
||||
}
|
||||
})
|
||||
.fail(function(xhr, textStatus, errorThrown) {
|
||||
console.error('Solve failed:', {
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
responseText: xhr.responseText,
|
||||
textStatus: textStatus,
|
||||
errorThrown: errorThrown
|
||||
});
|
||||
|
||||
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 if (xhr.responseText) {
|
||||
errorMsg += xhr.responseText;
|
||||
} 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;
|
||||
renderSchedule(schedule);
|
||||
$('#downloadButton').prop('disabled', false);
|
||||
})
|
||||
.fail(function() {
|
||||
refreshSolvingButtons(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSolvingButtons(solving) {
|
||||
if (solving) {
|
||||
$('#solveButton').hide();
|
||||
$('#stopSolvingButton').show();
|
||||
if (autoRefreshIntervalId == null) {
|
||||
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
|
||||
}
|
||||
} else {
|
||||
$('#solveButton').show();
|
||||
$('#stopSolvingButton').hide();
|
||||
if (autoRefreshIntervalId != null) {
|
||||
clearInterval(autoRefreshIntervalId);
|
||||
autoRefreshIntervalId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderSchedule(schedule) {
|
||||
// Efface les anciennes données
|
||||
byEmployeeGroupDataSet.clear();
|
||||
byEmployeeItemDataSet.clear();
|
||||
byLocationGroupDataSet.clear();
|
||||
byLocationItemDataSet.clear();
|
||||
|
||||
// Met à jour le score
|
||||
$('#score').text('Score: ' + (schedule.score || '?'));
|
||||
$('#analyzeButton').prop('disabled', !schedule.score);
|
||||
|
||||
// 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`
|
||||
);
|
||||
|
||||
// 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>`
|
||||
});
|
||||
|
||||
// Ajoute les disponibilités
|
||||
['unavailableDates', 'undesiredDates', 'desiredDates'].forEach((dateType, typeIndex) => {
|
||||
const color = [COLORS.UNAVAILABLE, COLORS.UNDESIRED, COLORS.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: ['Unavailable', 'Undesired', 'Desired'][typeIndex],
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
type: 'background',
|
||||
style: `opacity: 0.3; background-color: ${color}`
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Crée d'abord la liste des lieux uniques
|
||||
const locations = [...new Set(schedule.shifts.map(shift => shift.location))];
|
||||
|
||||
// Crée les groupes par lieu avec sous-groupes pour les compétences
|
||||
const locationGroups = [];
|
||||
const skillSubGroups = [];
|
||||
|
||||
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) {
|
||||
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 analyze() {
|
||||
if (!loadedSchedule?.score) {
|
||||
showNotification('No score to analyze', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: TIMEFOLD_SERVER + '/schedules/analyze',
|
||||
type: 'PUT',
|
||||
data: JSON.stringify(loadedSchedule)
|
||||
}).done(function(analysis) {
|
||||
displayScoreAnalysis(analysis);
|
||||
}).fail(function() {
|
||||
showNotification('Analysis failed', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function displayScoreAnalysis(analysis) {
|
||||
$('#scoreAnalysisScoreLabel').text(`(${loadedSchedule.score})`);
|
||||
|
||||
const content = $('#scoreAnalysisModalContent');
|
||||
content.empty();
|
||||
|
||||
const table = $(`
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Constraint</th>
|
||||
<th>Type</th>
|
||||
<th>Matches</th>
|
||||
<th>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
`);
|
||||
|
||||
analysis.constraints.forEach(constraint => {
|
||||
const icon = constraint.score.includes('hard') && constraint.score.includes('-')
|
||||
? '<i class="fas fa-exclamation-triangle text-danger"></i>'
|
||||
: '<i class="fas fa-check-circle text-success"></i>';
|
||||
|
||||
const row = $(`
|
||||
<tr>
|
||||
<td>${icon}</td>
|
||||
<td>${constraint.name}</td>
|
||||
<td>${constraint.score.includes('hard') ? 'Hard' : 'Soft'}</td>
|
||||
<td>${constraint.matches?.length || 0}</td>
|
||||
<td>${constraint.score}</td>
|
||||
</tr>
|
||||
`);
|
||||
|
||||
table.find('tbody').append(row);
|
||||
});
|
||||
|
||||
content.append(table);
|
||||
new bootstrap.Modal('#scoreAnalysisModal').show();
|
||||
}
|
||||
|
||||
function downloadSolution() {
|
||||
if (loadedSchedule) {
|
||||
const dataStr = JSON.stringify(loadedSchedule, null, 2);
|
||||
const dataBlob = new Blob([dataStr], {type: 'application/json'});
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'timefold_solution.json';
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification('Solution downloaded', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function showNotification(message, type) {
|
||||
const alertDiv = $(`
|
||||
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$('#notificationPanel').append(alertDiv);
|
||||
|
||||
setTimeout(() => {
|
||||
alertDiv.alert('close');
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -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';
|
||||
|
||||
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
|
||||
// === PARTIE 2: Locations ===
|
||||
// Pré-calcule toutes les données nécessaires
|
||||
const locations = [...new Set(schedule.shifts.map(shift => shift.location))];
|
||||
locations.forEach(location => {
|
||||
byLocationGroupDataSet.add({
|
||||
id: location,
|
||||
content: location
|
||||
});
|
||||
});
|
||||
}
|
||||
const locationSkillsMap = new Map();
|
||||
|
||||
// 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: []
|
||||
});
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
// Render 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);
|
||||
|
||||
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>
|
||||
Loading…
x
Reference in New Issue
Block a user