Compare commits
5 Commits
a9bc1e47db
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 79c0d8d276 | |||
| af13ae9f1e | |||
| 94ae364aad | |||
| 9fab9f83ef | |||
| 9be454ca81 |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
932
claude2.json
Normal file
932
claude2.json
Normal file
@@ -0,0 +1,932 @@
|
||||
{
|
||||
"employees": [
|
||||
{
|
||||
"name": "Dr. Marie Dupont",
|
||||
"skills": ["MEDECIN", "SUPERVISION"],
|
||||
"unavailableDates": ["2024-12-25", "2024-12-26"],
|
||||
"undesiredDates": ["2024-12-24", "2024-12-31"],
|
||||
"desiredDates": ["2024-12-20", "2024-12-23"]
|
||||
},
|
||||
{
|
||||
"name": "Dr. Pierre Moreau",
|
||||
"skills": ["MEDECIN"],
|
||||
"unavailableDates": ["2024-12-22"],
|
||||
"undesiredDates": ["2024-12-29"],
|
||||
"desiredDates": ["2024-12-21", "2024-12-27"]
|
||||
},
|
||||
{
|
||||
"name": "Dr. Claire Rousseau",
|
||||
"skills": ["MEDECIN", "SUPERVISION"],
|
||||
"unavailableDates": [],
|
||||
"undesiredDates": ["2024-12-25"],
|
||||
"desiredDates": ["2024-12-24", "2024-12-28"]
|
||||
},
|
||||
{
|
||||
"name": "Inf. Sophie Bernard",
|
||||
"skills": ["INFIRMIER", "PRELEVEMENT"],
|
||||
"unavailableDates": ["2024-12-25"],
|
||||
"undesiredDates": ["2024-12-31"],
|
||||
"desiredDates": ["2024-12-20", "2024-12-21"]
|
||||
},
|
||||
{
|
||||
"name": "Inf. Jean Leroy",
|
||||
"skills": ["INFIRMIER", "ACCUEIL"],
|
||||
"unavailableDates": ["2024-12-24", "2024-12-25"],
|
||||
"undesiredDates": [],
|
||||
"desiredDates": ["2024-12-27", "2024-12-30"]
|
||||
},
|
||||
{
|
||||
"name": "Inf. Anne Moreau",
|
||||
"skills": ["INFIRMIER", "PRELEVEMENT"],
|
||||
"unavailableDates": ["2024-12-26"],
|
||||
"undesiredDates": ["2024-12-23"],
|
||||
"desiredDates": ["2024-12-21", "2024-12-29"]
|
||||
},
|
||||
{
|
||||
"name": "Inf. Luc Petit",
|
||||
"skills": ["INFIRMIER"],
|
||||
"unavailableDates": [],
|
||||
"undesiredDates": ["2024-12-20"],
|
||||
"desiredDates": ["2024-12-22", "2024-12-28"]
|
||||
},
|
||||
{
|
||||
"name": "Inf. Julie Martin",
|
||||
"skills": ["INFIRMIER", "ACCUEIL"],
|
||||
"unavailableDates": ["2024-12-30"],
|
||||
"undesiredDates": ["2024-12-25"],
|
||||
"desiredDates": ["2024-12-23", "2024-12-27"]
|
||||
},
|
||||
{
|
||||
"name": "Inf. Thomas Dubois",
|
||||
"skills": ["INFIRMIER", "PRELEVEMENT"],
|
||||
"unavailableDates": [],
|
||||
"undesiredDates": ["2024-12-22"],
|
||||
"desiredDates": ["2024-12-24", "2024-12-26"]
|
||||
},
|
||||
{
|
||||
"name": "Chauffeur Michel Blanc",
|
||||
"skills": ["CHAUFFEUR", "TRANSPORT"],
|
||||
"unavailableDates": ["2024-12-25"],
|
||||
"undesiredDates": ["2024-12-24"],
|
||||
"desiredDates": ["2024-12-20", "2024-12-27"]
|
||||
},
|
||||
{
|
||||
"name": "Chauffeur Paul Girard",
|
||||
"skills": ["CHAUFFEUR", "LOGISTIQUE"],
|
||||
"unavailableDates": ["2024-12-26"],
|
||||
"undesiredDates": [],
|
||||
"desiredDates": ["2024-12-22", "2024-12-29"]
|
||||
},
|
||||
{
|
||||
"name": "Chauffeur Marc Vincent",
|
||||
"skills": ["CHAUFFEUR"],
|
||||
"unavailableDates": ["2024-12-31"],
|
||||
"undesiredDates": ["2024-12-23"],
|
||||
"desiredDates": ["2024-12-21", "2024-12-28"]
|
||||
},
|
||||
{
|
||||
"name": "Acc. Sylvie Roux",
|
||||
"skills": ["ACCUEIL", "SECRETARIAT"],
|
||||
"unavailableDates": ["2024-12-25", "2024-12-26"],
|
||||
"undesiredDates": ["2024-12-24"],
|
||||
"desiredDates": ["2024-12-20", "2024-12-30"]
|
||||
},
|
||||
{
|
||||
"name": "Acc. Nathalie Fabre",
|
||||
"skills": ["ACCUEIL"],
|
||||
"unavailableDates": ["2024-12-22"],
|
||||
"undesiredDates": ["2024-12-29"],
|
||||
"desiredDates": ["2024-12-21", "2024-12-27"]
|
||||
},
|
||||
{
|
||||
"name": "Acc. Patricia Leclerc",
|
||||
"skills": ["ACCUEIL", "INFORMATIQUE"],
|
||||
"unavailableDates": [],
|
||||
"undesiredDates": ["2024-12-25", "2024-12-31"],
|
||||
"desiredDates": ["2024-12-23", "2024-12-28"]
|
||||
}
|
||||
],
|
||||
"collectes": [
|
||||
{
|
||||
"id": "collecte_toulouse_centre_20241220",
|
||||
"start": "2024-12-20T08:00:00",
|
||||
"end": "2024-12-20T17:00:00",
|
||||
"location": "Centre de collecte - Toulouse Centre",
|
||||
"requiredSkills": {
|
||||
"INFIRMIER": 2,
|
||||
"MEDECIN": 1,
|
||||
"CHAUFFEUR": 1,
|
||||
"ACCUEIL": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "collecte_blagnac_20241221",
|
||||
"start": "2024-12-21T09:00:00",
|
||||
"end": "2024-12-21T16:00:00",
|
||||
"location": "Centre de collecte - Blagnac",
|
||||
"requiredSkills": {
|
||||
"INFIRMIER": 2,
|
||||
"MEDECIN": 1,
|
||||
"CHAUFFEUR": 1,
|
||||
"ACCUEIL": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "collecte_colomiers_20241222",
|
||||
"start": "2024-12-22T08:30:00",
|
||||
"end": "2024-12-22T17:30:00",
|
||||
"location": "Centre de collecte - Colomiers",
|
||||
"requiredSkills": {
|
||||
"INFIRMIER": 3,
|
||||
"MEDECIN": 1,
|
||||
"CHAUFFEUR": 1,
|
||||
"ACCUEIL": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "collecte_mobile_muret_20241223",
|
||||
"start": "2024-12-23T07:30:00",
|
||||
"end": "2024-12-23T18:30:00",
|
||||
"location": "Collecte mobile - Muret",
|
||||
"requiredSkills": {
|
||||
"INFIRMIER": 2,
|
||||
"MEDECIN": 1,
|
||||
"CHAUFFEUR": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "collecte_tournefeuille_20241224",
|
||||
"start": "2024-12-24T08:00:00",
|
||||
"end": "2024-12-24T14:00:00",
|
||||
"location": "Centre de collecte - Tournefeuille",
|
||||
"requiredSkills": {
|
||||
"INFIRMIER": 1,
|
||||
"MEDECIN": 1,
|
||||
"CHAUFFEUR": 1,
|
||||
"ACCUEIL": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "collecte_urgence_purpan_20241227",
|
||||
"start": "2024-12-27T06:00:00",
|
||||
"end": "2024-12-27T20:00:00",
|
||||
"location": "Hôpital Purpan - Urgence",
|
||||
"requiredSkills": {
|
||||
"INFIRMIER": 4,
|
||||
"MEDECIN": 2,
|
||||
"CHAUFFEUR": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "collecte_leguevin_20241228",
|
||||
"start": "2024-12-28T09:00:00",
|
||||
"end": "2024-12-28T16:00:00",
|
||||
"location": "Centre de collecte - Léguevin",
|
||||
"requiredSkills": {
|
||||
"INFIRMIER": 2,
|
||||
"MEDECIN": 1,
|
||||
"CHAUFFEUR": 1,
|
||||
"ACCUEIL": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "collecte_weekend_rangueil_20241229",
|
||||
"start": "2024-12-29T10:00:00",
|
||||
"end": "2024-12-29T18:00:00",
|
||||
"location": "Hôpital Rangueil - Weekend",
|
||||
"requiredSkills": {
|
||||
"INFIRMIER": 3,
|
||||
"MEDECIN": 1,
|
||||
"CHAUFFEUR": 1,
|
||||
"ACCUEIL": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "collecte_mobile_saint_gaudens_20241230",
|
||||
"start": "2024-12-30T08:00:00",
|
||||
"end": "2024-12-30T19:00:00",
|
||||
"location": "Collecte mobile - Saint-Gaudens",
|
||||
"requiredSkills": {
|
||||
"INFIRMIER": 3,
|
||||
"MEDECIN": 1,
|
||||
"CHAUFFEUR": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "collecte_reveillon_toulouse_20241231",
|
||||
"start": "2024-12-31T14:00:00",
|
||||
"end": "2024-12-31T22:00:00",
|
||||
"location": "Centre de collecte - Toulouse Réveillon",
|
||||
"requiredSkills": {
|
||||
"INFIRMIER": 2,
|
||||
"MEDECIN": 1,
|
||||
"CHAUFFEUR": 1,
|
||||
"ACCUEIL": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"shifts": [
|
||||
{
|
||||
"id": "shift_toulouse_centre_20241220_chauffeur",
|
||||
"start": "2024-12-20T07:00:00",
|
||||
"end": "2024-12-20T18:00:00",
|
||||
"location": "Centre de collecte - Toulouse Centre",
|
||||
"requiredSkill": "CHAUFFEUR",
|
||||
"collecte": {
|
||||
"id": "collecte_toulouse_centre_20241220",
|
||||
"start": "2024-12-20T08:00:00",
|
||||
"end": "2024-12-20T17:00:00",
|
||||
"location": "Centre de collecte - Toulouse Centre"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_toulouse_centre_20241220_accueil",
|
||||
"start": "2024-12-20T08:30:00",
|
||||
"end": "2024-12-20T16:30:00",
|
||||
"location": "Centre de collecte - Toulouse Centre",
|
||||
"requiredSkill": "ACCUEIL",
|
||||
"collecte": {
|
||||
"id": "collecte_toulouse_centre_20241220",
|
||||
"start": "2024-12-20T08:00:00",
|
||||
"end": "2024-12-20T17:00:00",
|
||||
"location": "Centre de collecte - Toulouse Centre"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_toulouse_centre_20241220_medecin",
|
||||
"start": "2024-12-20T08:45:00",
|
||||
"end": "2024-12-20T16:45:00",
|
||||
"location": "Centre de collecte - Toulouse Centre",
|
||||
"requiredSkill": "MEDECIN",
|
||||
"collecte": {
|
||||
"id": "collecte_toulouse_centre_20241220",
|
||||
"start": "2024-12-20T08:00:00",
|
||||
"end": "2024-12-20T17:00:00",
|
||||
"location": "Centre de collecte - Toulouse Centre"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_toulouse_centre_20241220_infirmier_1",
|
||||
"start": "2024-12-20T09:00:00",
|
||||
"end": "2024-12-20T17:00:00",
|
||||
"location": "Centre de collecte - Toulouse Centre",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_toulouse_centre_20241220",
|
||||
"start": "2024-12-20T08:00:00",
|
||||
"end": "2024-12-20T17:00:00",
|
||||
"location": "Centre de collecte - Toulouse Centre"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_toulouse_centre_20241220_infirmier_2",
|
||||
"start": "2024-12-20T09:00:00",
|
||||
"end": "2024-12-20T17:00:00",
|
||||
"location": "Centre de collecte - Toulouse Centre",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_toulouse_centre_20241220",
|
||||
"start": "2024-12-20T08:00:00",
|
||||
"end": "2024-12-20T17:00:00",
|
||||
"location": "Centre de collecte - Toulouse Centre"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_blagnac_20241221_chauffeur",
|
||||
"start": "2024-12-21T08:00:00",
|
||||
"end": "2024-12-21T17:00:00",
|
||||
"location": "Centre de collecte - Blagnac",
|
||||
"requiredSkill": "CHAUFFEUR",
|
||||
"collecte": {
|
||||
"id": "collecte_blagnac_20241221",
|
||||
"start": "2024-12-21T09:00:00",
|
||||
"end": "2024-12-21T16:00:00",
|
||||
"location": "Centre de collecte - Blagnac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_blagnac_20241221_accueil",
|
||||
"start": "2024-12-21T09:30:00",
|
||||
"end": "2024-12-21T15:30:00",
|
||||
"location": "Centre de collecte - Blagnac",
|
||||
"requiredSkill": "ACCUEIL",
|
||||
"collecte": {
|
||||
"id": "collecte_blagnac_20241221",
|
||||
"start": "2024-12-21T09:00:00",
|
||||
"end": "2024-12-21T16:00:00",
|
||||
"location": "Centre de collecte - Blagnac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_blagnac_20241221_medecin",
|
||||
"start": "2024-12-21T09:15:00",
|
||||
"end": "2024-12-21T15:45:00",
|
||||
"location": "Centre de collecte - Blagnac",
|
||||
"requiredSkill": "MEDECIN",
|
||||
"collecte": {
|
||||
"id": "collecte_blagnac_20241221",
|
||||
"start": "2024-12-21T09:00:00",
|
||||
"end": "2024-12-21T16:00:00",
|
||||
"location": "Centre de collecte - Blagnac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_blagnac_20241221_infirmier_1",
|
||||
"start": "2024-12-21T09:00:00",
|
||||
"end": "2024-12-21T16:00:00",
|
||||
"location": "Centre de collecte - Blagnac",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_blagnac_20241221",
|
||||
"start": "2024-12-21T09:00:00",
|
||||
"end": "2024-12-21T16:00:00",
|
||||
"location": "Centre de collecte - Blagnac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_blagnac_20241221_infirmier_2",
|
||||
"start": "2024-12-21T09:00:00",
|
||||
"end": "2024-12-21T16:00:00",
|
||||
"location": "Centre de collecte - Blagnac",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_blagnac_20241221",
|
||||
"start": "2024-12-21T09:00:00",
|
||||
"end": "2024-12-21T16:00:00",
|
||||
"location": "Centre de collecte - Blagnac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_colomiers_20241222_chauffeur",
|
||||
"start": "2024-12-22T07:30:00",
|
||||
"end": "2024-12-22T18:30:00",
|
||||
"location": "Centre de collecte - Colomiers",
|
||||
"requiredSkill": "CHAUFFEUR",
|
||||
"collecte": {
|
||||
"id": "collecte_colomiers_20241222",
|
||||
"start": "2024-12-22T08:30:00",
|
||||
"end": "2024-12-22T17:30:00",
|
||||
"location": "Centre de collecte - Colomiers"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_colomiers_20241222_accueil",
|
||||
"start": "2024-12-22T08:00:00",
|
||||
"end": "2024-12-22T18:00:00",
|
||||
"location": "Centre de collecte - Colomiers",
|
||||
"requiredSkill": "ACCUEIL",
|
||||
"collecte": {
|
||||
"id": "collecte_colomiers_20241222",
|
||||
"start": "2024-12-22T08:30:00",
|
||||
"end": "2024-12-22T17:30:00",
|
||||
"location": "Centre de collecte - Colomiers"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_colomiers_20241222_medecin",
|
||||
"start": "2024-12-22T08:45:00",
|
||||
"end": "2024-12-22T17:15:00",
|
||||
"location": "Centre de collecte - Colomiers",
|
||||
"requiredSkill": "MEDECIN",
|
||||
"collecte": {
|
||||
"id": "collecte_colomiers_20241222",
|
||||
"start": "2024-12-22T08:30:00",
|
||||
"end": "2024-12-22T17:30:00",
|
||||
"location": "Centre de collecte - Colomiers"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_colomiers_20241222_infirmier_1",
|
||||
"start": "2024-12-22T09:00:00",
|
||||
"end": "2024-12-22T17:30:00",
|
||||
"location": "Centre de collecte - Colomiers",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_colomiers_20241222",
|
||||
"start": "2024-12-22T08:30:00",
|
||||
"end": "2024-12-22T17:30:00",
|
||||
"location": "Centre de collecte - Colomiers"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_colomiers_20241222_infirmier_2",
|
||||
"start": "2024-12-22T09:00:00",
|
||||
"end": "2024-12-22T17:30:00",
|
||||
"location": "Centre de collecte - Colomiers",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_colomiers_20241222",
|
||||
"start": "2024-12-22T08:30:00",
|
||||
"end": "2024-12-22T17:30:00",
|
||||
"location": "Centre de collecte - Colomiers"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_colomiers_20241222_infirmier_3",
|
||||
"start": "2024-12-22T09:00:00",
|
||||
"end": "2024-12-22T17:30:00",
|
||||
"location": "Centre de collecte - Colomiers",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_colomiers_20241222",
|
||||
"start": "2024-12-22T08:30:00",
|
||||
"end": "2024-12-22T17:30:00",
|
||||
"location": "Centre de collecte - Colomiers"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_mobile_muret_20241223_chauffeur_1",
|
||||
"start": "2024-12-23T07:00:00",
|
||||
"end": "2024-12-23T19:00:00",
|
||||
"location": "Collecte mobile - Muret",
|
||||
"requiredSkill": "CHAUFFEUR",
|
||||
"collecte": {
|
||||
"id": "collecte_mobile_muret_20241223",
|
||||
"start": "2024-12-23T07:30:00",
|
||||
"end": "2024-12-23T18:30:00",
|
||||
"location": "Collecte mobile - Muret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_mobile_muret_20241223_chauffeur_2",
|
||||
"start": "2024-12-23T07:00:00",
|
||||
"end": "2024-12-23T19:00:00",
|
||||
"location": "Collecte mobile - Muret",
|
||||
"requiredSkill": "CHAUFFEUR",
|
||||
"collecte": {
|
||||
"id": "collecte_mobile_muret_20241223",
|
||||
"start": "2024-12-23T07:30:00",
|
||||
"end": "2024-12-23T18:30:00",
|
||||
"location": "Collecte mobile - Muret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_mobile_muret_20241223_medecin",
|
||||
"start": "2024-12-23T08:00:00",
|
||||
"end": "2024-12-23T18:00:00",
|
||||
"location": "Collecte mobile - Muret",
|
||||
"requiredSkill": "MEDECIN",
|
||||
"collecte": {
|
||||
"id": "collecte_mobile_muret_20241223",
|
||||
"start": "2024-12-23T07:30:00",
|
||||
"end": "2024-12-23T18:30:00",
|
||||
"location": "Collecte mobile - Muret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_mobile_muret_20241223_infirmier_1",
|
||||
"start": "2024-12-23T08:30:00",
|
||||
"end": "2024-12-23T18:30:00",
|
||||
"location": "Collecte mobile - Muret",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_mobile_muret_20241223",
|
||||
"start": "2024-12-23T07:30:00",
|
||||
"end": "2024-12-23T18:30:00",
|
||||
"location": "Collecte mobile - Muret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_mobile_muret_20241223_infirmier_2",
|
||||
"start": "2024-12-23T08:30:00",
|
||||
"end": "2024-12-23T18:30:00",
|
||||
"location": "Collecte mobile - Muret",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_mobile_muret_20241223",
|
||||
"start": "2024-12-23T07:30:00",
|
||||
"end": "2024-12-23T18:30:00",
|
||||
"location": "Collecte mobile - Muret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_tournefeuille_20241224_chauffeur",
|
||||
"start": "2024-12-24T07:30:00",
|
||||
"end": "2024-12-24T15:00:00",
|
||||
"location": "Centre de collecte - Tournefeuille",
|
||||
"requiredSkill": "CHAUFFEUR",
|
||||
"collecte": {
|
||||
"id": "collecte_tournefeuille_20241224",
|
||||
"start": "2024-12-24T08:00:00",
|
||||
"end": "2024-12-24T14:00:00",
|
||||
"location": "Centre de collecte - Tournefeuille"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_tournefeuille_20241224_accueil",
|
||||
"start": "2024-12-24T08:00:00",
|
||||
"end": "2024-12-24T14:00:00",
|
||||
"location": "Centre de collecte - Tournefeuille",
|
||||
"requiredSkill": "ACCUEIL",
|
||||
"collecte": {
|
||||
"id": "collecte_tournefeuille_20241224",
|
||||
"start": "2024-12-24T08:00:00",
|
||||
"end": "2024-12-24T14:00:00",
|
||||
"location": "Centre de collecte - Tournefeuille"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_tournefeuille_20241224_medecin",
|
||||
"start": "2024-12-24T08:15:00",
|
||||
"end": "2024-12-24T13:45:00",
|
||||
"location": "Centre de collecte - Tournefeuille",
|
||||
"requiredSkill": "MEDECIN",
|
||||
"collecte": {
|
||||
"id": "collecte_tournefeuille_20241224",
|
||||
"start": "2024-12-24T08:00:00",
|
||||
"end": "2024-12-24T14:00:00",
|
||||
"location": "Centre de collecte - Tournefeuille"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_tournefeuille_20241224_infirmier",
|
||||
"start": "2024-12-24T08:30:00",
|
||||
"end": "2024-12-24T14:00:00",
|
||||
"location": "Centre de collecte - Tournefeuille",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_tournefeuille_20241224",
|
||||
"start": "2024-12-24T08:00:00",
|
||||
"end": "2024-12-24T14:00:00",
|
||||
"location": "Centre de collecte - Tournefeuille"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_urgence_purpan_20241227_chauffeur",
|
||||
"start": "2024-12-27T05:30:00",
|
||||
"end": "2024-12-27T20:30:00",
|
||||
"location": "Hôpital Purpan - Urgence",
|
||||
"requiredSkill": "CHAUFFEUR",
|
||||
"collecte": {
|
||||
"id": "collecte_urgence_purpan_20241227",
|
||||
"start": "2024-12-27T06:00:00",
|
||||
"end": "2024-12-27T20:00:00",
|
||||
"location": "Hôpital Purpan - Urgence"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_urgence_purpan_20241227_infirmier_3",
|
||||
"start": "2024-12-27T09:00:00",
|
||||
"end": "2024-12-27T16:00:00",
|
||||
"location": "Hôpital Purpan - Urgence",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_urgence_purpan_20241227",
|
||||
"start": "2024-12-27T06:00:00",
|
||||
"end": "2024-12-27T20:00:00",
|
||||
"location": "Hôpital Purpan - Urgence"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_urgence_purpan_20241227_infirmier_4",
|
||||
"start": "2024-12-27T15:00:00",
|
||||
"end": "2024-12-27T20:00:00",
|
||||
"location": "Hôpital Purpan - Urgence",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_urgence_purpan_20241227",
|
||||
"start": "2024-12-27T06:00:00",
|
||||
"end": "2024-12-27T20:00:00",
|
||||
"location": "Hôpital Purpan - Urgence"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_leguevin_20241228_chauffeur",
|
||||
"start": "2024-12-28T08:00:00",
|
||||
"end": "2024-12-28T17:00:00",
|
||||
"location": "Centre de collecte - Léguevin",
|
||||
"requiredSkill": "CHAUFFEUR",
|
||||
"collecte": {
|
||||
"id": "collecte_leguevin_20241228",
|
||||
"start": "2024-12-28T09:00:00",
|
||||
"end": "2024-12-28T16:00:00",
|
||||
"location": "Centre de collecte - Léguevin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_leguevin_20241228_accueil",
|
||||
"start": "2024-12-28T09:30:00",
|
||||
"end": "2024-12-28T15:30:00",
|
||||
"location": "Centre de collecte - Léguevin",
|
||||
"requiredSkill": "ACCUEIL",
|
||||
"collecte": {
|
||||
"id": "collecte_leguevin_20241228",
|
||||
"start": "2024-12-28T09:00:00",
|
||||
"end": "2024-12-28T16:00:00",
|
||||
"location": "Centre de collecte - Léguevin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_leguevin_20241228_medecin",
|
||||
"start": "2024-12-28T09:15:00",
|
||||
"end": "2024-12-28T15:45:00",
|
||||
"location": "Centre de collecte - Léguevin",
|
||||
"requiredSkill": "MEDECIN",
|
||||
"collecte": {
|
||||
"id": "collecte_leguevin_20241228",
|
||||
"start": "2024-12-28T09:00:00",
|
||||
"end": "2024-12-28T16:00:00",
|
||||
"location": "Centre de collecte - Léguevin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_leguevin_20241228_infirmier_1",
|
||||
"start": "2024-12-28T09:00:00",
|
||||
"end": "2024-12-28T16:00:00",
|
||||
"location": "Centre de collecte - Léguevin",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_leguevin_20241228",
|
||||
"start": "2024-12-28T09:00:00",
|
||||
"end": "2024-12-28T16:00:00",
|
||||
"location": "Centre de collecte - Léguevin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_leguevin_20241228_infirmier_2",
|
||||
"start": "2024-12-28T09:00:00",
|
||||
"end": "2024-12-28T16:00:00",
|
||||
"location": "Centre de collecte - Léguevin",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_leguevin_20241228",
|
||||
"start": "2024-12-28T09:00:00",
|
||||
"end": "2024-12-28T16:00:00",
|
||||
"location": "Centre de collecte - Léguevin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_weekend_rangueil_20241229_chauffeur",
|
||||
"start": "2024-12-29T09:00:00",
|
||||
"end": "2024-12-29T19:00:00",
|
||||
"location": "Hôpital Rangueil - Weekend",
|
||||
"requiredSkill": "CHAUFFEUR",
|
||||
"collecte": {
|
||||
"id": "collecte_weekend_rangueil_20241229",
|
||||
"start": "2024-12-29T10:00:00",
|
||||
"end": "2024-12-29T18:00:00",
|
||||
"location": "Hôpital Rangueil - Weekend"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_weekend_rangueil_20241229_accueil",
|
||||
"start": "2024-12-29T10:30:00",
|
||||
"end": "2024-12-29T17:30:00",
|
||||
"location": "Hôpital Rangueil - Weekend",
|
||||
"requiredSkill": "ACCUEIL",
|
||||
"collecte": {
|
||||
"id": "collecte_weekend_rangueil_20241229",
|
||||
"start": "2024-12-29T10:00:00",
|
||||
"end": "2024-12-29T18:00:00",
|
||||
"location": "Hôpital Rangueil - Weekend"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_weekend_rangueil_20241229_medecin",
|
||||
"start": "2024-12-29T10:15:00",
|
||||
"end": "2024-12-29T17:45:00",
|
||||
"location": "Hôpital Rangueil - Weekend",
|
||||
"requiredSkill": "MEDECIN",
|
||||
"collecte": {
|
||||
"id": "collecte_weekend_rangueil_20241229",
|
||||
"start": "2024-12-29T10:00:00",
|
||||
"end": "2024-12-29T18:00:00",
|
||||
"location": "Hôpital Rangueil - Weekend"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_weekend_rangueil_20241229_infirmier_1",
|
||||
"start": "2024-12-29T10:00:00",
|
||||
"end": "2024-12-29T18:00:00",
|
||||
"location": "Hôpital Rangueil - Weekend",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_weekend_rangueil_20241229",
|
||||
"start": "2024-12-29T10:00:00",
|
||||
"end": "2024-12-29T18:00:00",
|
||||
"location": "Hôpital Rangueil - Weekend"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_weekend_rangueil_20241229_infirmier_2",
|
||||
"start": "2024-12-29T10:00:00",
|
||||
"end": "2024-12-29T18:00:00",
|
||||
"location": "Hôpital Rangueil - Weekend",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_weekend_rangueil_20241229",
|
||||
"start": "2024-12-29T10:00:00",
|
||||
"end": "2024-12-29T18:00:00",
|
||||
"location": "Hôpital Rangueil - Weekend"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_weekend_rangueil_20241229_infirmier_3",
|
||||
"start": "2024-12-29T10:00:00",
|
||||
"end": "2024-12-29T18:00:00",
|
||||
"location": "Hôpital Rangueil - Weekend",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_weekend_rangueil_20241229",
|
||||
"start": "2024-12-29T10:00:00",
|
||||
"end": "2024-12-29T18:00:00",
|
||||
"location": "Hôpital Rangueil - Weekend"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_mobile_saint_gaudens_20241230_chauffeur_1",
|
||||
"start": "2024-12-30T07:00:00",
|
||||
"end": "2024-12-30T20:00:00",
|
||||
"location": "Collecte mobile - Saint-Gaudens",
|
||||
"requiredSkill": "CHAUFFEUR",
|
||||
"collecte": {
|
||||
"id": "collecte_mobile_saint_gaudens_20241230",
|
||||
"start": "2024-12-30T08:00:00",
|
||||
"end": "2024-12-30T19:00:00",
|
||||
"location": "Collecte mobile - Saint-Gaudens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_mobile_saint_gaudens_20241230_chauffeur_2",
|
||||
"start": "2024-12-30T07:00:00",
|
||||
"end": "2024-12-30T20:00:00",
|
||||
"location": "Collecte mobile - Saint-Gaudens",
|
||||
"requiredSkill": "CHAUFFEUR",
|
||||
"collecte": {
|
||||
"id": "collecte_mobile_saint_gaudens_20241230",
|
||||
"start": "2024-12-30T08:00:00",
|
||||
"end": "2024-12-30T19:00:00",
|
||||
"location": "Collecte mobile - Saint-Gaudens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_mobile_saint_gaudens_20241230_medecin",
|
||||
"start": "2024-12-30T08:30:00",
|
||||
"end": "2024-12-30T18:30:00",
|
||||
"location": "Collecte mobile - Saint-Gaudens",
|
||||
"requiredSkill": "MEDECIN",
|
||||
"collecte": {
|
||||
"id": "collecte_mobile_saint_gaudens_20241230",
|
||||
"start": "2024-12-30T08:00:00",
|
||||
"end": "2024-12-30T19:00:00",
|
||||
"location": "Collecte mobile - Saint-Gaudens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_mobile_saint_gaudens_20241230_infirmier_1",
|
||||
"start": "2024-12-30T08:00:00",
|
||||
"end": "2024-12-30T19:00:00",
|
||||
"location": "Collecte mobile - Saint-Gaudens",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_mobile_saint_gaudens_20241230",
|
||||
"start": "2024-12-30T08:00:00",
|
||||
"end": "2024-12-30T19:00:00",
|
||||
"location": "Collecte mobile - Saint-Gaudens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_mobile_saint_gaudens_20241230_infirmier_2",
|
||||
"start": "2024-12-30T08:00:00",
|
||||
"end": "2024-12-30T19:00:00",
|
||||
"location": "Collecte mobile - Saint-Gaudens",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_mobile_saint_gaudens_20241230",
|
||||
"start": "2024-12-30T08:00:00",
|
||||
"end": "2024-12-30T19:00:00",
|
||||
"location": "Collecte mobile - Saint-Gaudens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_mobile_saint_gaudens_20241230_infirmier_3",
|
||||
"start": "2024-12-30T08:00:00",
|
||||
"end": "2024-12-30T19:00:00",
|
||||
"location": "Collecte mobile - Saint-Gaudens",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_mobile_saint_gaudens_20241230",
|
||||
"start": "2024-12-30T08:00:00",
|
||||
"end": "2024-12-30T19:00:00",
|
||||
"location": "Collecte mobile - Saint-Gaudens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_reveillon_toulouse_20241231_chauffeur",
|
||||
"start": "2024-12-31T13:30:00",
|
||||
"end": "2024-12-31T22:30:00",
|
||||
"location": "Centre de collecte - Toulouse Réveillon",
|
||||
"requiredSkill": "CHAUFFEUR",
|
||||
"collecte": {
|
||||
"id": "collecte_reveillon_toulouse_20241231",
|
||||
"start": "2024-12-31T14:00:00",
|
||||
"end": "2024-12-31T22:00:00",
|
||||
"location": "Centre de collecte - Toulouse Réveillon"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_reveillon_toulouse_20241231_accueil",
|
||||
"start": "2024-12-31T14:30:00",
|
||||
"end": "2024-12-31T21:30:00",
|
||||
"location": "Centre de collecte - Toulouse Réveillon",
|
||||
"requiredSkill": "ACCUEIL",
|
||||
"collecte": {
|
||||
"id": "collecte_reveillon_toulouse_20241231",
|
||||
"start": "2024-12-31T14:00:00",
|
||||
"end": "2024-12-31T22:00:00",
|
||||
"location": "Centre de collecte - Toulouse Réveillon"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_reveillon_toulouse_20241231_medecin",
|
||||
"start": "2024-12-31T14:15:00",
|
||||
"end": "2024-12-31T21:45:00",
|
||||
"location": "Centre de collecte - Toulouse Réveillon",
|
||||
"requiredSkill": "MEDECIN",
|
||||
"collecte": {
|
||||
"id": "collecte_reveillon_toulouse_20241231",
|
||||
"start": "2024-12-31T14:00:00",
|
||||
"end": "2024-12-31T22:00:00",
|
||||
"location": "Centre de collecte - Toulouse Réveillon"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_reveillon_toulouse_20241231_infirmier_1",
|
||||
"start": "2024-12-31T14:00:00",
|
||||
"end": "2024-12-31T22:00:00",
|
||||
"location": "Centre de collecte - Toulouse Réveillon",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_reveillon_toulouse_20241231",
|
||||
"start": "2024-12-31T14:00:00",
|
||||
"end": "2024-12-31T22:00:00",
|
||||
"location": "Centre de collecte - Toulouse Réveillon"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_reveillon_toulouse_20241231_infirmier_2",
|
||||
"start": "2024-12-31T14:00:00",
|
||||
"end": "2024-12-31T22:00:00",
|
||||
"location": "Centre de collecte - Toulouse Réveillon",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_reveillon_toulouse_20241231",
|
||||
"start": "2024-12-31T14:00:00",
|
||||
"end": "2024-12-31T22:00:00",
|
||||
"location": "Centre de collecte - Toulouse Réveillon"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_urgence_purpan_20241227_medecin_1",
|
||||
"start": "2024-12-27T06:00:00",
|
||||
"end": "2024-12-27T14:00:00",
|
||||
"location": "Hôpital Purpan - Urgence",
|
||||
"requiredSkill": "MEDECIN",
|
||||
"collecte": {
|
||||
"id": "collecte_urgence_purpan_20241227",
|
||||
"start": "2024-12-27T06:00:00",
|
||||
"end": "2024-12-27T20:00:00",
|
||||
"location": "Hôpital Purpan - Urgence"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_urgence_purpan_20241227_medecin_2",
|
||||
"start": "2024-12-27T12:00:00",
|
||||
"end": "2024-12-27T20:00:00",
|
||||
"location": "Hôpital Purpan - Urgence",
|
||||
"requiredSkill": "MEDECIN",
|
||||
"collecte": {
|
||||
"id": "collecte_urgence_purpan_20241227",
|
||||
"start": "2024-12-27T06:00:00",
|
||||
"end": "2024-12-27T20:00:00",
|
||||
"location": "Hôpital Purpan - Urgence"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_urgence_purpan_20241227_infirmier_1",
|
||||
"start": "2024-12-27T06:30:00",
|
||||
"end": "2024-12-27T13:30:00",
|
||||
"location": "Hôpital Purpan - Urgence",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_urgence_purpan_20241227",
|
||||
"start": "2024-12-27T06:00:00",
|
||||
"end": "2024-12-27T20:00:00",
|
||||
"location": "Hôpital Purpan - Urgence"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shift_urgence_purpan_20241227_infirmier_2",
|
||||
"start": "2024-12-27T13:00:00",
|
||||
"end": "2024-12-27T20:00:00",
|
||||
"location": "Hôpital Purpan - Urgence",
|
||||
"requiredSkill": "INFIRMIER",
|
||||
"collecte": {
|
||||
"id": "collecte_urgence_purpan_20241227",
|
||||
"start": "2024-12-27T06:00:00",
|
||||
"end": "2024-12-27T20:00:00",
|
||||
"location": "Hôpital Purpan - Urgence"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -162,20 +162,13 @@
|
||||
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: Via proxy local (démarrez le serveur proxy sur port 3000)
|
||||
// const TIMEFOLD_SERVER = 'http://localhost:3000/api';
|
||||
|
||||
// Option 3: Si vous servez depuis le serveur Timefold
|
||||
const TIMEFOLD_SERVER = window.location.origin;
|
||||
const TIMEFOLD_SERVER = 'http://10.0.100.13:8080';
|
||||
|
||||
// Timeline configuration
|
||||
const timelineOptions = {
|
||||
timeAxis: {scale: "hour", step: 6},
|
||||
orientation: {axis: "top"},
|
||||
stack: false,
|
||||
stack: true,
|
||||
xss: {disabled: true}
|
||||
};
|
||||
|
||||
@@ -379,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>
|
||||
@@ -108,7 +108,7 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="byLocationTab" data-bs-toggle="tab"
|
||||
data-bs-target="#byLocationPanel" type="button" role="tab">
|
||||
By location
|
||||
By collecte
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
@@ -163,14 +163,14 @@
|
||||
|
||||
// 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';
|
||||
const TIMEFOLD_SERVER = 'http://10.0.100.13:8080';
|
||||
|
||||
// Option 2: Via proxy local (démarrez le serveur proxy sur port 3000)
|
||||
// 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';
|
||||
|
||||
// Option 3: Si vous servez depuis le serveur Timefold
|
||||
const TIMEFOLD_SERVER = window.location.origin;
|
||||
|
||||
// Timeline configuration
|
||||
const timelineOptions = {
|
||||
timeAxis: {scale: "hour", step: 6},
|
||||
@@ -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');
|
||||
@@ -270,7 +282,16 @@
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
try {
|
||||
const jsonData = JSON.parse(e.target.result);
|
||||
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
|
||||
@@ -285,10 +306,26 @@
|
||||
|
||||
showNotification('File loaded successfully!', 'success');
|
||||
} catch (error) {
|
||||
showNotification('Error parsing JSON file: ' + error.message, 'danger');
|
||||
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);
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
}
|
||||
|
||||
function loadDemoData() {
|
||||
@@ -325,14 +362,48 @@
|
||||
return;
|
||||
}
|
||||
|
||||
$.post(TIMEFOLD_SERVER + '/schedules', JSON.stringify(loadedSchedule))
|
||||
.done(function(data) {
|
||||
scheduleId = data;
|
||||
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) {
|
||||
showNotification('Failed to start solving: ' + xhr.responseText, '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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -381,141 +452,163 @@
|
||||
}
|
||||
|
||||
function renderSchedule(schedule) {
|
||||
// Clear existing data
|
||||
// Efface les anciennes données
|
||||
byEmployeeGroupDataSet.clear();
|
||||
byEmployeeItemDataSet.clear();
|
||||
byLocationGroupDataSet.clear();
|
||||
byLocationItemDataSet.clear();
|
||||
|
||||
// Update score
|
||||
// Met à jour le score
|
||||
$('#score').text('Score: ' + (schedule.score || '?'));
|
||||
$('#analyzeButton').prop('disabled', !schedule.score);
|
||||
|
||||
// Count unassigned shifts
|
||||
// Compte les shifts non assignés
|
||||
let unassignedCount = 0;
|
||||
schedule.shifts.forEach(shift => {
|
||||
if (!shift.employee) unassignedCount++;
|
||||
});
|
||||
|
||||
$('#unassignedShifts').text(
|
||||
unassignedCount === 0
|
||||
? 'No unassigned shifts'
|
||||
: `${unassignedCount} unassigned shifts`
|
||||
unassignedCount === 0 ? 'No unassigned shifts' : `${unassignedCount} unassigned shifts`
|
||||
);
|
||||
|
||||
// Render employees
|
||||
schedule.employees.forEach((employee, index) => {
|
||||
// Add employee group
|
||||
// Rend les employés
|
||||
schedule.employees.forEach(employee => {
|
||||
const skillsBadges = employee.skills.map(skill =>
|
||||
`<span class="badge bg-secondary me-1">${skill}</span>`
|
||||
).join('');
|
||||
|
||||
byEmployeeGroupDataSet.add({
|
||||
id: employee.name,
|
||||
content: `<div><strong>${employee.name}</strong><br/>${skillsBadges}</div>`
|
||||
});
|
||||
|
||||
// Add availability indicators
|
||||
// Ajoute les disponibilités
|
||||
['unavailableDates', 'undesiredDates', 'desiredDates'].forEach((dateType, typeIndex) => {
|
||||
const color = [COLORS.UNAVAILABLE, COLORS.UNDESIRED, COLORS.DESIRED][typeIndex];
|
||||
const label = ['Unavailable', 'Undesired', 'Desired'][typeIndex];
|
||||
|
||||
employee[dateType]?.forEach((date, dateIndex) => {
|
||||
const startDate = new Date(date + 'T00:00:00');
|
||||
const endDate = new Date(date + 'T23:59:59');
|
||||
|
||||
byEmployeeItemDataSet.add({
|
||||
id: `${employee.name}-${dateType}-${dateIndex}`,
|
||||
group: employee.name,
|
||||
content: label,
|
||||
content: ['Unavailable', 'Undesired', 'Desired'][typeIndex],
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
type: 'background',
|
||||
style: `opacity: 0.5; background-color: ${color}`
|
||||
style: `opacity: 0.3; background-color: ${color}`
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Render locations and shifts
|
||||
// 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 => {
|
||||
byLocationGroupDataSet.add({
|
||||
id: location,
|
||||
content: 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);
|
||||
});
|
||||
});
|
||||
|
||||
// Render shifts
|
||||
// Ajoute tous les groupes au dataset
|
||||
locationGroups.forEach(group => byLocationGroupDataSet.add(group));
|
||||
skillSubGroups.forEach(group => byLocationGroupDataSet.add(group));
|
||||
|
||||
// Ajoute les shifts
|
||||
schedule.shifts.forEach((shift, index) => {
|
||||
const startTime = new Date(shift.start);
|
||||
const endTime = new Date(shift.end);
|
||||
const groupId = `${shift.location}_${shift.requiredSkill}`;
|
||||
const skillColor = getColorForSkill(shift.requiredSkill);
|
||||
|
||||
// Ajoute au timeline "By Employee"
|
||||
if (shift.employee) {
|
||||
// Assigned shift
|
||||
const hasRequiredSkill = shift.employee.skills.includes(shift.requiredSkill);
|
||||
const skillColor = hasRequiredSkill ? COLORS.SKILL_MATCH : COLORS.SKILL_MISMATCH;
|
||||
const shiftColor = getShiftColor(shift, shift.employee);
|
||||
|
||||
// Add to employee timeline
|
||||
const shiftColor = hasRequiredSkill ? COLORS.SKILL_MATCH : COLORS.SKILL_MISMATCH;
|
||||
byEmployeeItemDataSet.add({
|
||||
id: `shift-${index}-emp`,
|
||||
group: shift.employee.name,
|
||||
content: `<div><strong>${shift.location}</strong><br/>
|
||||
<span class="badge" style="background-color:${skillColor}">${shift.requiredSkill}</span></div>`,
|
||||
content: `<div>
|
||||
<strong>${shift.location}</strong><br/>
|
||||
<span class="badge" style="background-color:${skillColor}">${shift.requiredSkill}</span><br/>
|
||||
${startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} -
|
||||
${endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||
</div>`,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
style: `background-color: ${shiftColor}`
|
||||
style: `background-color: ${shiftColor}; border: 1px solid #555; color: #fff;`
|
||||
});
|
||||
}
|
||||
|
||||
// Add to location timeline
|
||||
// Ajoute au timeline "By Location"
|
||||
if (shift.employee) {
|
||||
const hasRequiredSkill = shift.employee.skills.includes(shift.requiredSkill);
|
||||
const shiftColor = hasRequiredSkill ? skillColor : COLORS.SKILL_MISMATCH;
|
||||
byLocationItemDataSet.add({
|
||||
id: `shift-${index}-loc`,
|
||||
group: shift.location,
|
||||
content: `<div><strong>${shift.employee.name}</strong><br/>
|
||||
<span class="badge" style="background-color:${skillColor}">${shift.requiredSkill}</span></div>`,
|
||||
group: groupId,
|
||||
content: `<div>
|
||||
<strong>${shift.employee.name}</strong><br/>
|
||||
${startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} -
|
||||
${endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||
</div>`,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
style: `background-color: ${shiftColor}`
|
||||
style: `background-color: ${shiftColor}; border: 1px solid #555; color: #fff;`
|
||||
});
|
||||
} else {
|
||||
// Unassigned shift
|
||||
// Shift non assigné
|
||||
byLocationItemDataSet.add({
|
||||
id: `shift-${index}-unassigned`,
|
||||
group: shift.location,
|
||||
content: `<div><strong>Unassigned</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;`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Set timeline window
|
||||
// Ajuste la fenêtre de la timeline
|
||||
if (schedule.shifts.length > 0) {
|
||||
const dates = schedule.shifts.map(shift => new Date(shift.start));
|
||||
const minDate = new Date(Math.min(...dates));
|
||||
const maxDate = new Date(Math.max(...dates));
|
||||
maxDate.setDate(maxDate.getDate() + 1);
|
||||
|
||||
byEmployeeTimeline.setWindow(minDate, maxDate);
|
||||
byLocationTimeline.setWindow(minDate, maxDate);
|
||||
}
|
||||
}
|
||||
|
||||
function getShiftColor(shift, employee) {
|
||||
const shiftDate = new Date(shift.start).toISOString().split('T')[0];
|
||||
|
||||
if (employee.unavailableDates?.includes(shiftDate)) {
|
||||
return COLORS.UNAVAILABLE;
|
||||
} else if (employee.undesiredDates?.includes(shiftDate)) {
|
||||
return COLORS.UNDESIRED;
|
||||
} else if (employee.desiredDates?.includes(shiftDate)) {
|
||||
return COLORS.DESIRED;
|
||||
}
|
||||
return COLORS.DEFAULT;
|
||||
}
|
||||
|
||||
function analyze() {
|
||||
if (!loadedSchedule?.score) {
|
||||
744
src/main/resources/META-INF/resources/upload.html
Normal file
744
src/main/resources/META-INF/resources/upload.html
Normal file
@@ -0,0 +1,744 @@
|
||||
<!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);
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSolvingButtons(solving) {
|
||||
if (solving) {
|
||||
$('#solveButton').hide();
|
||||
$('#stopSolvingButton').show();
|
||||
if (autoRefreshIntervalId == null) {
|
||||
autoRefreshIntervalId = setInterval(refreshSchedule, 5000);
|
||||
}
|
||||
} else {
|
||||
$('#solveButton').show();
|
||||
$('#stopSolvingButton').hide();
|
||||
if (autoRefreshIntervalId != null) {
|
||||
clearInterval(autoRefreshIntervalId);
|
||||
autoRefreshIntervalId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderSchedule(schedule) {
|
||||
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();
|
||||
|
||||
// Met à jour le score
|
||||
$('#score').text('Score: ' + (schedule.score || '?'));
|
||||
$('#analyzeButton').prop('disabled', !schedule.score);
|
||||
|
||||
// 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`
|
||||
);
|
||||
|
||||
// Prépare tous les groupes et items en mémoire
|
||||
const employeeGroups = [];
|
||||
const employeeItems = [];
|
||||
const locationGroupsData = [];
|
||||
const locationItems = [];
|
||||
|
||||
// === PARTIE 1: Employés ===
|
||||
schedule.employees.forEach(employee => {
|
||||
employeeGroups.push({
|
||||
id: employee.name,
|
||||
content: `<div><strong>${employee.name}</strong></div>`
|
||||
});
|
||||
|
||||
// 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 }
|
||||
];
|
||||
|
||||
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');
|
||||
employeeItems.push({
|
||||
id: `${employee.name}-${key}-${dateIndex}`,
|
||||
group: employee.name,
|
||||
content: label,
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
type: 'background',
|
||||
style: `opacity: 0.3; background-color: ${color}`
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// === PARTIE 2: Locations ===
|
||||
// Pré-calcule toutes les données nécessaires
|
||||
const locations = [...new Set(schedule.shifts.map(shift => shift.location))];
|
||||
const locationSkillsMap = new Map();
|
||||
|
||||
// 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));
|
||||
});
|
||||
|
||||
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) {
|
||||
const hasRequiredSkill = employeeSkillsMap.get(shift.employee.name)?.has(shift.requiredSkill);
|
||||
const shiftColor = hasRequiredSkill ? COLORS.SKILL_MATCH : COLORS.SKILL_MISMATCH;
|
||||
|
||||
// Timeline "By Employee"
|
||||
employeeItems.push({
|
||||
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/>
|
||||
${timeStr}
|
||||
</div>`,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
style: `background-color: ${shiftColor}; border: 1px solid #555; color: #fff;`
|
||||
});
|
||||
|
||||
// Timeline "By Location"
|
||||
const locationShiftColor = hasRequiredSkill ? skillColor : COLORS.SKILL_MISMATCH;
|
||||
locationItems.push({
|
||||
id: `shift-${index}-loc`,
|
||||
group: groupId,
|
||||
content: `<div>
|
||||
<strong>${shift.employee.name}</strong><br/>
|
||||
${timeStr}
|
||||
</div>`,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
style: `background-color: ${locationShiftColor}; border: 1px solid #555; color: #fff;`
|
||||
});
|
||||
} else {
|
||||
// Shift non assigné
|
||||
locationItems.push({
|
||||
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;`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// === 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`);
|
||||
|
||||
// Utilise add() avec un tableau pour tout ajouter d'un coup
|
||||
byEmployeeGroupDataSet.add(employeeGroups);
|
||||
byEmployeeItemDataSet.add(employeeItems);
|
||||
byLocationGroupDataSet.add(locationGroupsData);
|
||||
byLocationItemDataSet.add(locationItems);
|
||||
|
||||
// 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 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>
|
||||
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>
|
||||
@@ -44,3 +44,8 @@ quarkus.swagger-ui.always-include=true
|
||||
|
||||
quarkus.log.category."ai.timefold.solver".level=DEBUG
|
||||
quarkus.log.category."org.acme.employeescheduling".level=DEBUG
|
||||
|
||||
# quarkus.http.cors=true
|
||||
# quarkus.http.cors.origins=*
|
||||
# quarkus.http.cors.headers=*
|
||||
# quarkus.http.cors.methods=*
|
||||
File diff suppressed because one or more lines are too long
@@ -1,505 +0,0 @@
|
||||
let autoRefreshIntervalId = null;
|
||||
const zoomMin = 2 * 1000 * 60 * 60 * 24 // 2 day in milliseconds
|
||||
const zoomMax = 4 * 7 * 1000 * 60 * 60 * 24 // 4 weeks in milliseconds
|
||||
|
||||
const UNAVAILABLE_COLOR = '#ef2929' // Tango Scarlet Red
|
||||
const UNDESIRED_COLOR = '#f57900' // Tango Orange
|
||||
const DESIRED_COLOR = '#73d216' // Tango Chameleon
|
||||
|
||||
let demoDataId = null;
|
||||
let scheduleId = null;
|
||||
let loadedSchedule = null;
|
||||
|
||||
const byEmployeePanel = document.getElementById("byEmployeePanel");
|
||||
const byEmployeeTimelineOptions = {
|
||||
timeAxis: {scale: "hour", step: 6},
|
||||
orientation: {axis: "top"},
|
||||
stack: false,
|
||||
xss: {disabled: true}, // Items are XSS safe through JQuery
|
||||
zoomMin: zoomMin,
|
||||
zoomMax: zoomMax,
|
||||
};
|
||||
let byEmployeeGroupDataSet = new vis.DataSet();
|
||||
let byEmployeeItemDataSet = new vis.DataSet();
|
||||
let byEmployeeTimeline = new vis.Timeline(byEmployeePanel, byEmployeeItemDataSet, byEmployeeGroupDataSet, byEmployeeTimelineOptions);
|
||||
|
||||
const byLocationPanel = document.getElementById("byLocationPanel");
|
||||
const byLocationTimelineOptions = {
|
||||
timeAxis: {scale: "hour", step: 6},
|
||||
orientation: {axis: "top"},
|
||||
xss: {disabled: true}, // Items are XSS safe through JQuery
|
||||
zoomMin: zoomMin,
|
||||
zoomMax: zoomMax,
|
||||
};
|
||||
let byLocationGroupDataSet = new vis.DataSet();
|
||||
let byLocationItemDataSet = new vis.DataSet();
|
||||
let byLocationTimeline = new vis.Timeline(byLocationPanel, byLocationItemDataSet, byLocationGroupDataSet, byLocationTimelineOptions);
|
||||
|
||||
let windowStart = JSJoda.LocalDate.now().toString();
|
||||
let windowEnd = JSJoda.LocalDate.parse(windowStart).plusDays(7).toString();
|
||||
|
||||
$(document).ready(function () {
|
||||
replaceQuickstartTimefoldAutoHeaderFooter();
|
||||
|
||||
$("#solveButton").click(function () {
|
||||
solve();
|
||||
});
|
||||
$("#stopSolvingButton").click(function () {
|
||||
stopSolving();
|
||||
});
|
||||
$("#analyzeButton").click(function () {
|
||||
analyze();
|
||||
});
|
||||
// HACK to allow vis-timeline to work within Bootstrap tabs
|
||||
$("#byEmployeeTab").on('shown.bs.tab', function (event) {
|
||||
byEmployeeTimeline.redraw();
|
||||
})
|
||||
$("#byLocationTab").on('shown.bs.tab', function (event) {
|
||||
byLocationTimeline.redraw();
|
||||
})
|
||||
|
||||
setupAjax();
|
||||
fetchDemoData();
|
||||
$("#loadScheduleBtn").click(function () {
|
||||
const inputId = $("#scheduleIdInput").val().trim();
|
||||
if (inputId.length === 0) {
|
||||
alert("Veuillez entrer un UUID de schedule.");
|
||||
return;
|
||||
}
|
||||
scheduleId = inputId;
|
||||
refreshSchedule();
|
||||
});
|
||||
});
|
||||
|
||||
function setupAjax() {
|
||||
$.ajaxSetup({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json,text/plain', // plain text is required by solve() returning UUID of the solver job
|
||||
}
|
||||
});
|
||||
// Extend jQuery to support $.put() and $.delete()
|
||||
jQuery.each(["put", "delete"], function (i, method) {
|
||||
jQuery[method] = function (url, data, callback, type) {
|
||||
if (jQuery.isFunction(data)) {
|
||||
type = type || callback;
|
||||
callback = data;
|
||||
data = undefined;
|
||||
}
|
||||
return jQuery.ajax({
|
||||
url: url,
|
||||
type: method,
|
||||
dataType: type,
|
||||
data: data,
|
||||
success: callback
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function fetchDemoData() {
|
||||
$.get("/demo-data", function (data) {
|
||||
data.forEach(item => {
|
||||
$("#testDataButton").append($('<a id="' + item + 'TestData" class="dropdown-item" href="#">' + item + '</a>'));
|
||||
$("#" + item + "TestData").click(function () {
|
||||
switchDataDropDownItemActive(item);
|
||||
scheduleId = null;
|
||||
demoDataId = item;
|
||||
|
||||
refreshSchedule();
|
||||
});
|
||||
});
|
||||
demoDataId = data[0];
|
||||
switchDataDropDownItemActive(demoDataId);
|
||||
refreshSchedule();
|
||||
}).fail(function (xhr, ajaxOptions, thrownError) {
|
||||
// disable this page as there is no data
|
||||
let $demo = $("#demo");
|
||||
$demo.empty();
|
||||
$demo.html("<h1><p align=\"center\">No test data available</p></h1>")
|
||||
});
|
||||
}
|
||||
|
||||
function switchDataDropDownItemActive(newItem) {
|
||||
activeCssClass = "active";
|
||||
$("#testDataButton > a." + activeCssClass).removeClass(activeCssClass);
|
||||
$("#" + newItem + "TestData").addClass(activeCssClass);
|
||||
}
|
||||
|
||||
function getShiftColor(shift, employee) {
|
||||
const shiftStart = JSJoda.LocalDateTime.parse(shift.start);
|
||||
const shiftStartDateString = shiftStart.toLocalDate().toString();
|
||||
const shiftEnd = JSJoda.LocalDateTime.parse(shift.end);
|
||||
const shiftEndDateString = shiftEnd.toLocalDate().toString();
|
||||
if (employee.unavailableDates.includes(shiftStartDateString) ||
|
||||
// The contains() check is ignored for a shift end at midnight (00:00:00).
|
||||
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
|
||||
employee.unavailableDates.includes(shiftEndDateString))) {
|
||||
return UNAVAILABLE_COLOR
|
||||
} else if (employee.undesiredDates.includes(shiftStartDateString) ||
|
||||
// The contains() check is ignored for a shift end at midnight (00:00:00).
|
||||
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
|
||||
employee.undesiredDates.includes(shiftEndDateString))) {
|
||||
return UNDESIRED_COLOR
|
||||
} else if (employee.desiredDates.includes(shiftStartDateString) ||
|
||||
// The contains() check is ignored for a shift end at midnight (00:00:00).
|
||||
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
|
||||
employee.desiredDates.includes(shiftEndDateString))) {
|
||||
return DESIRED_COLOR
|
||||
} else {
|
||||
return " #729fcf"; // Tango Sky Blue
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSchedule() {
|
||||
let path = "/schedules/" + scheduleId;
|
||||
if (scheduleId === null) {
|
||||
if (demoDataId === null) {
|
||||
alert("Please select a test data set.");
|
||||
return;
|
||||
}
|
||||
|
||||
path = "/demo-data/" + demoDataId;
|
||||
}
|
||||
$.getJSON(path, function (schedule) {
|
||||
loadedSchedule = schedule;
|
||||
renderSchedule(schedule);
|
||||
})
|
||||
.fail(function (xhr, ajaxOptions, thrownError) {
|
||||
showError("Getting the schedule has failed.", xhr);
|
||||
refreshSolvingButtons(false);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSchedule(schedule) {
|
||||
refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING");
|
||||
$("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score));
|
||||
|
||||
const unassignedShifts = $("#unassignedShifts");
|
||||
const groups = [];
|
||||
|
||||
// Show only first 7 days of draft
|
||||
const scheduleStart = schedule.shifts.map(shift => JSJoda.LocalDateTime.parse(shift.start).toLocalDate()).sort()[0].toString();
|
||||
const scheduleEnd = JSJoda.LocalDate.parse(scheduleStart).plusDays(7).toString();
|
||||
|
||||
windowStart = scheduleStart;
|
||||
windowEnd = scheduleEnd;
|
||||
|
||||
unassignedShifts.children().remove();
|
||||
let unassignedShiftsCount = 0;
|
||||
byEmployeeGroupDataSet.clear();
|
||||
byLocationGroupDataSet.clear();
|
||||
|
||||
byEmployeeItemDataSet.clear();
|
||||
byLocationItemDataSet.clear();
|
||||
|
||||
|
||||
schedule.employees.forEach((employee, index) => {
|
||||
const employeeGroupElement = $('<div class="card-body p-2"/>')
|
||||
.append($(`<h5 class="card-title mb-2"/>)`)
|
||||
.append(employee.name))
|
||||
.append($('<div/>')
|
||||
.append($(employee.skills.map(skill => `<span class="badge me-1 mt-1" style="background-color:#d3d7cf">${skill}</span>`).join(''))));
|
||||
byEmployeeGroupDataSet.add({id: employee.name, content: employeeGroupElement.html()});
|
||||
|
||||
employee.unavailableDates.forEach((rawDate, dateIndex) => {
|
||||
const date = JSJoda.LocalDate.parse(rawDate)
|
||||
const start = date.atStartOfDay().toString();
|
||||
const end = date.plusDays(1).atStartOfDay().toString();
|
||||
const byEmployeeShiftElement = $(`<div/>`)
|
||||
.append($(`<h5 class="card-title mb-1"/>`).text("Unavailable"));
|
||||
byEmployeeItemDataSet.add({
|
||||
id: "employee-" + index + "-unavailability-" + dateIndex, group: employee.name,
|
||||
content: byEmployeeShiftElement.html(),
|
||||
start: start, end: end,
|
||||
type: "background",
|
||||
style: "opacity: 0.5; background-color: " + UNAVAILABLE_COLOR,
|
||||
});
|
||||
});
|
||||
employee.undesiredDates.forEach((rawDate, dateIndex) => {
|
||||
const date = JSJoda.LocalDate.parse(rawDate)
|
||||
const start = date.atStartOfDay().toString();
|
||||
const end = date.plusDays(1).atStartOfDay().toString();
|
||||
const byEmployeeShiftElement = $(`<div/>`)
|
||||
.append($(`<h5 class="card-title mb-1"/>`).text("Undesired"));
|
||||
byEmployeeItemDataSet.add({
|
||||
id: "employee-" + index + "-undesired-" + dateIndex, group: employee.name,
|
||||
content: byEmployeeShiftElement.html(),
|
||||
start: start, end: end,
|
||||
type: "background",
|
||||
style: "opacity: 0.5; background-color: " + UNDESIRED_COLOR,
|
||||
});
|
||||
});
|
||||
employee.desiredDates.forEach((rawDate, dateIndex) => {
|
||||
const date = JSJoda.LocalDate.parse(rawDate)
|
||||
const start = date.atStartOfDay().toString();
|
||||
const end = date.plusDays(1).atStartOfDay().toString();
|
||||
const byEmployeeShiftElement = $(`<div/>`)
|
||||
.append($(`<h5 class="card-title mb-1"/>`).text("Desired"));
|
||||
byEmployeeItemDataSet.add({
|
||||
id: "employee-" + index + "-desired-" + dateIndex, group: employee.name,
|
||||
content: byEmployeeShiftElement.html(),
|
||||
start: start, end: end,
|
||||
type: "background",
|
||||
style: "opacity: 0.5; background-color: " + DESIRED_COLOR,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
schedule.shifts.forEach((shift, index) => {
|
||||
if (groups.indexOf(shift.location) === -1) {
|
||||
groups.push(shift.location);
|
||||
byLocationGroupDataSet.add({
|
||||
id: shift.location,
|
||||
content: shift.location,
|
||||
});
|
||||
}
|
||||
|
||||
if (shift.employee == null) {
|
||||
unassignedShiftsCount++;
|
||||
|
||||
const byLocationShiftElement = $('<div class="card-body p-2"/>')
|
||||
.append($(`<h5 class="card-title mb-2"/>)`)
|
||||
.append("Unassigned"))
|
||||
.append($('<div/>')
|
||||
.append($(`<span class="badge me-1 mt-1" style="background-color:#d3d7cf">${shift.requiredSkill}</span>`)));
|
||||
|
||||
byLocationItemDataSet.add({
|
||||
id: 'shift-' + index, group: shift.location,
|
||||
content: byLocationShiftElement.html(),
|
||||
start: shift.start, end: shift.end,
|
||||
style: "background-color: #EF292999"
|
||||
});
|
||||
} else {
|
||||
const skillColor = (shift.employee.skills.indexOf(shift.requiredSkill) === -1 ? '#ef2929' : '#8ae234');
|
||||
const byEmployeeShiftElement = $('<div class="card-body p-2"/>')
|
||||
.append($(`<h5 class="card-title mb-2"/>)`)
|
||||
.append(shift.location))
|
||||
.append($('<div/>')
|
||||
.append($(`<span class="badge me-1 mt-1" style="background-color:${skillColor}">${shift.requiredSkill}</span>`)));
|
||||
const byLocationShiftElement = $('<div class="card-body p-2"/>')
|
||||
.append($(`<h5 class="card-title mb-2"/>)`)
|
||||
.append(shift.employee.name))
|
||||
.append($('<div/>')
|
||||
.append($(`<span class="badge me-1 mt-1" style="background-color:${skillColor}">${shift.requiredSkill}</span>`)));
|
||||
|
||||
const shiftColor = getShiftColor(shift, shift.employee);
|
||||
byEmployeeItemDataSet.add({
|
||||
id: 'shift-' + index, group: shift.employee.name,
|
||||
content: byEmployeeShiftElement.html(),
|
||||
start: shift.start, end: shift.end,
|
||||
style: "background-color: " + shiftColor
|
||||
});
|
||||
byLocationItemDataSet.add({
|
||||
id: 'shift-' + index, group: shift.location,
|
||||
content: byLocationShiftElement.html(),
|
||||
start: shift.start, end: shift.end,
|
||||
style: "background-color: " + shiftColor
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (unassignedShiftsCount === 0) {
|
||||
unassignedShifts.append($(`<p/>`).text(`There are no unassigned shifts.`));
|
||||
} else {
|
||||
unassignedShifts.append($(`<p/>`).text(`There are ${unassignedShiftsCount} unassigned shifts.`));
|
||||
}
|
||||
byEmployeeTimeline.setWindow(scheduleStart, scheduleEnd);
|
||||
byLocationTimeline.setWindow(scheduleStart, scheduleEnd);
|
||||
}
|
||||
|
||||
function solve() {
|
||||
$.post("/schedules", JSON.stringify(loadedSchedule), function (data) {
|
||||
scheduleId = data;
|
||||
refreshSolvingButtons(true);
|
||||
}).fail(function (xhr, ajaxOptions, thrownError) {
|
||||
showError("Start solving failed.", xhr);
|
||||
refreshSolvingButtons(false);
|
||||
},
|
||||
"text");
|
||||
}
|
||||
|
||||
function analyze() {
|
||||
new bootstrap.Modal("#scoreAnalysisModal").show()
|
||||
const scoreAnalysisModalContent = $("#scoreAnalysisModalContent");
|
||||
scoreAnalysisModalContent.children().remove();
|
||||
if (loadedSchedule.score == null) {
|
||||
scoreAnalysisModalContent.text("No score to analyze yet, please first press the 'solve' button.");
|
||||
} else {
|
||||
$('#scoreAnalysisScoreLabel').text(`(${loadedSchedule.score})`);
|
||||
$.put("/schedules/analyze", JSON.stringify(loadedSchedule), function (scoreAnalysis) {
|
||||
let constraints = scoreAnalysis.constraints;
|
||||
constraints.sort((a, b) => {
|
||||
let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score);
|
||||
if (aComponents.hard < 0 && bComponents.hard > 0) return -1;
|
||||
if (aComponents.hard > 0 && bComponents.soft < 0) return 1;
|
||||
if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) {
|
||||
return -1;
|
||||
} else {
|
||||
if (aComponents.medium < 0 && bComponents.medium > 0) return -1;
|
||||
if (aComponents.medium > 0 && bComponents.medium < 0) return 1;
|
||||
if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) {
|
||||
return -1;
|
||||
} else {
|
||||
if (aComponents.soft < 0 && bComponents.soft > 0) return -1;
|
||||
if (aComponents.soft > 0 && bComponents.soft < 0) return 1;
|
||||
|
||||
return Math.abs(bComponents.soft) - Math.abs(aComponents.soft);
|
||||
}
|
||||
}
|
||||
});
|
||||
constraints.map((e) => {
|
||||
let components = getScoreComponents(e.weight);
|
||||
e.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft');
|
||||
e.weight = components[e.type];
|
||||
let scores = getScoreComponents(e.score);
|
||||
e.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft);
|
||||
});
|
||||
scoreAnalysis.constraints = constraints;
|
||||
|
||||
scoreAnalysisModalContent.children().remove();
|
||||
scoreAnalysisModalContent.text("");
|
||||
|
||||
const analysisTable = $(`<table class="table"/>`).css({textAlign: 'center'});
|
||||
const analysisTHead = $(`<thead/>`).append($(`<tr/>`)
|
||||
.append($(`<th></th>`))
|
||||
.append($(`<th>Constraint</th>`).css({textAlign: 'left'}))
|
||||
.append($(`<th>Type</th>`))
|
||||
.append($(`<th># Matches</th>`))
|
||||
.append($(`<th>Weight</th>`))
|
||||
.append($(`<th>Score</th>`))
|
||||
.append($(`<th></th>`)));
|
||||
analysisTable.append(analysisTHead);
|
||||
const analysisTBody = $(`<tbody/>`)
|
||||
$.each(scoreAnalysis.constraints, (index, constraintAnalysis) => {
|
||||
let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '<span class="fas fa-exclamation-triangle" style="color: red"></span>' : '';
|
||||
if (!icon) icon = constraintAnalysis.matches.length == 0 ? '<span class="fas fa-check-circle" style="color: green"></span>' : '';
|
||||
|
||||
let row = $(`<tr/>`);
|
||||
row.append($(`<td/>`).html(icon))
|
||||
.append($(`<td/>`).text(constraintAnalysis.name).css({textAlign: 'left'}))
|
||||
.append($(`<td/>`).text(constraintAnalysis.type))
|
||||
.append($(`<td/>`).html(`<b>${constraintAnalysis.matches.length}</b>`))
|
||||
.append($(`<td/>`).text(constraintAnalysis.weight))
|
||||
.append($(`<td/>`).text(constraintAnalysis.implicitScore));
|
||||
analysisTBody.append(row);
|
||||
row.append($(`<td/>`));
|
||||
});
|
||||
analysisTable.append(analysisTBody);
|
||||
scoreAnalysisModalContent.append(analysisTable);
|
||||
}).fail(function (xhr, ajaxOptions, thrownError) {
|
||||
showError("Analyze failed.", xhr);
|
||||
}, "text");
|
||||
}
|
||||
}
|
||||
|
||||
function getScoreComponents(score) {
|
||||
let components = {hard: 0, medium: 0, soft: 0};
|
||||
|
||||
$.each([...score.matchAll(/(-?\d*(\.\d+)?)(hard|medium|soft)/g)], (i, parts) => {
|
||||
components[parts[3]] = parseFloat(parts[1], 10);
|
||||
});
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
function refreshSolvingButtons(solving) {
|
||||
if (solving) {
|
||||
$("#solveButton").hide();
|
||||
$("#stopSolvingButton").show();
|
||||
if (autoRefreshIntervalId == null) {
|
||||
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
|
||||
}
|
||||
} else {
|
||||
$("#solveButton").show();
|
||||
$("#stopSolvingButton").hide();
|
||||
if (autoRefreshIntervalId != null) {
|
||||
clearInterval(autoRefreshIntervalId);
|
||||
autoRefreshIntervalId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSolvingButtons(solving) {
|
||||
if (solving) {
|
||||
$("#solveButton").hide();
|
||||
$("#stopSolvingButton").show();
|
||||
if (autoRefreshIntervalId == null) {
|
||||
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
|
||||
}
|
||||
} else {
|
||||
$("#solveButton").show();
|
||||
$("#stopSolvingButton").hide();
|
||||
if (autoRefreshIntervalId != null) {
|
||||
clearInterval(autoRefreshIntervalId);
|
||||
autoRefreshIntervalId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopSolving() {
|
||||
$.delete(`/schedules/${scheduleId}`, function () {
|
||||
refreshSolvingButtons(false);
|
||||
refreshSchedule();
|
||||
}).fail(function (xhr, ajaxOptions, thrownError) {
|
||||
showError("Stop solving failed.", xhr);
|
||||
});
|
||||
}
|
||||
|
||||
function replaceQuickstartTimefoldAutoHeaderFooter() {
|
||||
const timefoldHeader = $("header#timefold-auto-header");
|
||||
if (timefoldHeader != null) {
|
||||
timefoldHeader.addClass("bg-black")
|
||||
timefoldHeader.append(
|
||||
$(`<div class="container-fluid">
|
||||
<nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
|
||||
<a class="navbar-brand" href="https://timefold.ai">
|
||||
<img src="/webjars/timefold/img/timefold-logo-horizontal-negative.svg" alt="Timefold logo" width="200">
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="nav nav-pills">
|
||||
<li class="nav-item active" id="navUIItem">
|
||||
<button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button">Demo UI</button>
|
||||
</li>
|
||||
<li class="nav-item" id="navRestItem">
|
||||
<button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button">Guide</button>
|
||||
</li>
|
||||
<li class="nav-item" id="navOpenApiItem">
|
||||
<button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button">REST API</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="ms-auto">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
Data
|
||||
</button>
|
||||
<div id="testDataButton" class="dropdown-menu" aria-labelledby="dropdownMenuButton"></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>`));
|
||||
}
|
||||
|
||||
const timefoldFooter = $("footer#timefold-auto-footer");
|
||||
if (timefoldFooter != null) {
|
||||
timefoldFooter.append(
|
||||
$(`<footer class="bg-black text-white-50">
|
||||
<div class="container">
|
||||
<div class="hstack gap-3 p-4">
|
||||
<div class="ms-auto"><a class="text-white" href="https://timefold.ai">Timefold</a></div>
|
||||
<div class="vr"></div>
|
||||
<div><a class="text-white" href="https://timefold.ai/docs">Documentation</a></div>
|
||||
<div class="vr"></div>
|
||||
<div><a class="text-white" href="https://github.com/TimefoldAI/timefold-quickstarts">Code</a></div>
|
||||
<div class="vr"></div>
|
||||
<div class="me-auto"><a class="text-white" href="https://timefold.ai/product/support/">Support</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>`));
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport">
|
||||
<title>Employee scheduling - Timefold Solver on Quarkus</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/styles/vis-timeline-graph2d.min.css"
|
||||
integrity="sha256-svzNasPg1yR5gvEaRei2jg+n4Pc3sVyMUWeS6xRAh6U=" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.min.css"/>
|
||||
<link rel="stylesheet" href="/webjars/font-awesome/css/all.css"/>
|
||||
<link rel="stylesheet" href="/webjars/timefold/css/timefold-webui.css"/>
|
||||
<style>
|
||||
.vis-time-axis .vis-grid.vis-saturday,
|
||||
.vis-time-axis .vis-grid.vis-sunday {
|
||||
background: #D3D7CFFF;
|
||||
}
|
||||
</style>
|
||||
<link rel="icon" href="/webjars/timefold/img/timefold-favicon.svg" type="image/svg+xml">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header id="timefold-auto-header">
|
||||
<!-- Filled in by app.js -->
|
||||
</header>
|
||||
<div style="margin: 1em 0;">
|
||||
<label for="scheduleIdInput">Charger un schedule par ID :</label>
|
||||
<input type="text" id="scheduleIdInput" placeholder="UUID du schedule" style="width: 350px;" />
|
||||
<button id="loadScheduleBtn">Charger</button>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<div id="demo" class="tab-pane fade show active container-fluid">
|
||||
<div class="sticky-top d-flex justify-content-center align-items-center" aria-live="polite"
|
||||
aria-atomic="true">
|
||||
<div id="notificationPanel" style="position: absolute; top: .5rem;"></div>
|
||||
</div>
|
||||
<h1>Employee scheduling solver</h1>
|
||||
<p>Generate the optimal schedule for your employees.</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<button id="solveButton" type="button" class="btn btn-success">
|
||||
<span class="fas fa-play"></span> Solve
|
||||
</button>
|
||||
<button id="stopSolvingButton" type="button" class="btn btn-danger">
|
||||
<span class="fas fa-stop"></span> Stop solving
|
||||
</button>
|
||||
<span id="unassignedShifts" class="ms-2 align-middle fw-bold"></span>
|
||||
<span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
|
||||
<button id="analyzeButton" type="button" class="ms-2 btn btn-secondary">
|
||||
<span class="fas fa-question"></span>
|
||||
</button>
|
||||
|
||||
<div class="float-end">
|
||||
<ul class="nav nav-pills" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="byLocationTab" data-bs-toggle="tab"
|
||||
data-bs-target="#byLocationPanel" type="button" role="tab"
|
||||
aria-controls="byLocationPanel" aria-selected="true">By location
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="byEmployeeTab" data-bs-toggle="tab"
|
||||
data-bs-target="#byEmployeePanel" type="button" role="tab"
|
||||
aria-controls="byEmployeePanel" aria-selected="false">By employee
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 tab-content">
|
||||
<div class="tab-pane fade show active" id="byLocationPanel" role="tabpanel"
|
||||
aria-labelledby="byLocationTab">
|
||||
<div id="locationVisualization"></div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="byEmployeePanel" role="tabpanel" aria-labelledby="byEmployeeTab">
|
||||
<div id="employeeVisualization"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="rest" class="tab-pane fade container-fluid">
|
||||
<h1>REST API Guide</h1>
|
||||
|
||||
<h2>Employee Scheduling solver integration via cURL</h2>
|
||||
|
||||
<h3>1. Download demo data</h3>
|
||||
<pre>
|
||||
<button class="btn btn-outline-dark btn-sm float-end"
|
||||
onclick="copyTextToClipboard('curl1')">Copy</button>
|
||||
<code id="curl1">curl -X GET -H 'Accept:application/json' http://localhost:8080/demo-data/SMALL -o sample.json</code>
|
||||
</pre>
|
||||
|
||||
<h3>2. Post the sample data for solving</h3>
|
||||
<p>The POST operation returns a <code>jobId</code> that should be used in subsequent commands.</p>
|
||||
<pre>
|
||||
<button class="btn btn-outline-dark btn-sm float-end"
|
||||
onclick="copyTextToClipboard('curl2')">Copy</button>
|
||||
<code id="curl2">curl -X POST -H 'Content-Type:application/json' http://localhost:8080/schedules -d@sample.json</code>
|
||||
</pre>
|
||||
|
||||
<h3>3. Get the current status and score</h3>
|
||||
<pre>
|
||||
<button class="btn btn-outline-dark btn-sm float-end"
|
||||
onclick="copyTextToClipboard('curl3')">Copy</button>
|
||||
<code id="curl3">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}/status</code>
|
||||
</pre>
|
||||
|
||||
<h3>4. Get the complete solution</h3>
|
||||
<pre>
|
||||
<button class="btn btn-outline-dark btn-sm float-end"
|
||||
onclick="copyTextToClipboard('curl4')">Copy</button>
|
||||
<code id="curl4">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}</code>
|
||||
</pre>
|
||||
|
||||
<h3>5. Fetch the analysis of the solution</h3>
|
||||
<pre>
|
||||
<button class="btn btn-outline-dark btn-sm float-end"
|
||||
onclick="copyTextToClipboard('curl5')">Copy</button>
|
||||
<code id="curl5">curl -X PUT -H 'Content-Type:application/json' http://localhost:8080/schedules/analyze -d@solution.json</code>
|
||||
</pre>
|
||||
|
||||
<h3>5. Terminate solving early</h3>
|
||||
<pre>
|
||||
<button class="btn btn-outline-dark btn-sm float-end"
|
||||
onclick="copyTextToClipboard('curl5')">Copy</button>
|
||||
<code id="curl6">curl -X DELETE -H 'Accept:application/json' http://localhost:8080/schedules/{id}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div id="openapi" class="tab-pane fade container-fluid">
|
||||
<h1>REST API Reference</h1>
|
||||
<div class="ratio ratio-1x1">
|
||||
<!-- "scrolling" attribute is obsolete, but e.g. Chrome does not support "overflow:hidden" -->
|
||||
<iframe src="/q/swagger-ui" style="overflow:hidden;" scrolling="no"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer id="timefold-auto-footer"></footer>
|
||||
<div class="modal fadebd-example-modal-lg" id="scoreAnalysisModal" tabindex="-1"
|
||||
aria-labelledby="scoreAnalysisModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="scoreAnalysisModalLabel">Score analysis <span
|
||||
id="scoreAnalysisScoreLabel"></span></h1>
|
||||
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="scoreAnalysisModalContent">
|
||||
<!-- Filled in by app.js -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/webjars/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/webjars/jquery/jquery.min.js"></script>
|
||||
<script src="/webjars/js-joda/dist/js-joda.min.js"></script>
|
||||
<script src="/webjars/timefold/js/timefold-webui.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"
|
||||
integrity="sha256-Jy2+UO7rZ2Dgik50z3XrrNpnc5+2PAx9MhL2CicodME=" crossorigin="anonymous"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,46 +0,0 @@
|
||||
########################
|
||||
# Timefold Solver properties
|
||||
########################
|
||||
|
||||
# The solver runs for 30 seconds. To run for 5 minutes use "5m" and for 2 hours use "2h".
|
||||
quarkus.timefold.solver.termination.spent-limit=30s
|
||||
|
||||
# To change how many solvers to run in parallel
|
||||
# timefold.solver-manager.parallel-solver-count=4
|
||||
|
||||
# Temporary comment this out to detect bugs in your code (lowers performance)
|
||||
# quarkus.timefold.solver.environment-mode=FULL_ASSERT
|
||||
|
||||
# Temporary comment this out to return a feasible solution as soon as possible
|
||||
# quarkus.timefold.solver.termination.best-score-limit=0hard/*soft
|
||||
|
||||
# To see what Timefold is doing, turn on DEBUG or TRACE logging.
|
||||
quarkus.log.category."ai.timefold.solver".level=INFO
|
||||
%test.quarkus.log.category."ai.timefold.solver".level=INFO
|
||||
%prod.quarkus.log.category."ai.timefold.solver".level=INFO
|
||||
|
||||
# XML file for power tweaking, defaults to solverConfig.xml (directly under src/main/resources)
|
||||
# quarkus.timefold.solver-config-xml=org/.../maintenanceScheduleSolverConfig.xml
|
||||
|
||||
########################
|
||||
# Timefold Solver Enterprise properties
|
||||
########################
|
||||
|
||||
# To run increase CPU cores usage per solver
|
||||
%enterprise.quarkus.timefold.solver.move-thread-count=AUTO
|
||||
|
||||
########################
|
||||
# Native build properties
|
||||
########################
|
||||
|
||||
# Enable Swagger UI also in the native mode
|
||||
quarkus.swagger-ui.always-include=true
|
||||
|
||||
########################
|
||||
# Test overrides
|
||||
########################
|
||||
|
||||
%test.quarkus.timefold.solver.termination.spent-limit=10s
|
||||
|
||||
quarkus.log.category."ai.timefold.solver".level=DEBUG
|
||||
quarkus.log.category."org.acme.employeescheduling".level=DEBUG
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,16 +0,0 @@
|
||||
org/acme/employeescheduling/domain/EmployeeSchedule.class
|
||||
org/acme/employeescheduling/rest/DemoDataGenerator$CountDistribution.class
|
||||
org/acme/employeescheduling/rest/DemoDataGenerator$DemoDataParameters.class
|
||||
org/acme/employeescheduling/rest/exception/EmployeeScheduleSolverException.class
|
||||
org/acme/employeescheduling/domain/Collecte.class
|
||||
org/acme/employeescheduling/rest/EmployeeScheduleDemoResource.class
|
||||
org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProvider.class
|
||||
org/acme/employeescheduling/rest/exception/EmployeeScheduleSolverExceptionMapper.class
|
||||
org/acme/employeescheduling/rest/EmployeeScheduleResource$Job.class
|
||||
org/acme/employeescheduling/rest/EmployeeScheduleResource.class
|
||||
org/acme/employeescheduling/rest/DemoDataGenerator.class
|
||||
org/acme/employeescheduling/domain/Shift.class
|
||||
org/acme/employeescheduling/domain/Employee.class
|
||||
org/acme/employeescheduling/rest/EmployeeScheduleResource$ScheduleStatus.class
|
||||
org/acme/employeescheduling/rest/exception/ErrorInfo.class
|
||||
org/acme/employeescheduling/rest/DemoDataGenerator$DemoData.class
|
||||
@@ -1,11 +0,0 @@
|
||||
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/domain/Collecte.java
|
||||
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/domain/Employee.java
|
||||
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/domain/EmployeeSchedule.java
|
||||
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/domain/Shift.java
|
||||
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/DemoDataGenerator.java
|
||||
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/EmployeeScheduleDemoResource.java
|
||||
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/EmployeeScheduleResource.java
|
||||
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/exception/EmployeeScheduleSolverException.java
|
||||
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/exception/EmployeeScheduleSolverExceptionMapper.java
|
||||
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/exception/ErrorInfo.java
|
||||
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProvider.java
|
||||
Reference in New Issue
Block a user