From 71285e4ef8b7793f5f34745e2326353698c6a5ab Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 23 Jul 2026 11:03:24 -0400 Subject: [PATCH] =?UTF-8?q?feat(dispatch):=20lot=20d'am=C3=A9liorations=20?= =?UTF-8?q?Planification=20(doc=20=C2=AB=20Remarques=20=C2=BB=20+=20captur?= =?UTF-8?q?es)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug Gigafibre.ca — corrigés/ajoutés : - P1 réassignation « Non assignés » (le menu placeholder n'exige plus l'union des compétences → assignable à la main, ⚠ par job) - P2 tech en congé/vacances exclu du pool de dispatch (garde suggestUnavailable + pastille congé) - P3 cadence MASQUÉE par défaut (tous 100 %), révélée seulement si nettement plus lent/rapide - P5 l'auto-save (draft) ne supprime plus un quart PUBLIÉ (cause du « publié puis à republier ») - P6 récap jobs simplifié (type·ville·rue·AM/PM·#) dans la modale Techniciens disponibles - P8 plages perso Jour/Soir PAR TECH (presets personnalisables, ex. 7:30–17:30) - A1 re-clic sur un tech (q-menu no-parent-event) · A4 type de job au survol · A5 « Vider » visible - A3 modem/ONU/remplacement → réparation (skill-resolver = source unique ; copie legacy périmée supprimée) - A6 clip colonne Ressource + label de rôle (barre de défilement horizontale proportionnelle) - A7 techs par compétence (survol) · A8 « Donner à » trié par compétences en commun Note : ces fichiers portaient déjà des changements déployés-non-commités ; ce commit les RÉCONCILIE avec le lot ci-dessus (co-édition — cf. reference_coedit_uncommitted_audit). Co-Authored-By: Claude Opus 4.8 --- .../src/components/planif/PlanifCellMenu.vue | 230 +++-- .../components/planif/SkillCadenceTable.vue | 32 +- .../components/planif/TechScheduleDialog.vue | 18 +- apps/ops/src/pages/PlanificationPage.vue | 804 +++++++++++++++--- .../targo-hub/lib/legacy-dispatch-sync.js | 56 +- services/targo-hub/lib/roster.js | 299 ++++++- services/targo-hub/lib/skill-resolver.js | 144 +++- 7 files changed, 1314 insertions(+), 269 deletions(-) diff --git a/apps/ops/src/components/planif/PlanifCellMenu.vue b/apps/ops/src/components/planif/PlanifCellMenu.vue index ee7d698..af31714 100644 --- a/apps/ops/src/components/planif/PlanifCellMenu.vue +++ b/apps/ops/src/components/planif/PlanifCellMenu.vue @@ -1,85 +1,197 @@ + + diff --git a/apps/ops/src/components/planif/SkillCadenceTable.vue b/apps/ops/src/components/planif/SkillCadenceTable.vue index c9cb14a..5f67a73 100644 --- a/apps/ops/src/components/planif/SkillCadenceTable.vue +++ b/apps/ops/src/components/planif/SkillCadenceTable.vue @@ -8,7 +8,7 @@ * « Équipe — compétences, cadence & coût » → même gabarit partout. Opère sur l'objet tech LIVE * (skills[]/skill_levels{}/skill_eff{}/efficiency) ; la persistance reste au parent via les events. */ -import { reactive } from 'vue' +import { reactive, ref, computed, watch } from 'vue' import TagEditor from 'src/components/shared/TagEditor.vue' import HelpHint from 'src/components/shared/HelpHint.vue' @@ -29,6 +29,19 @@ const levelOf = (sk) => (props.tech.skill_levels && props.tech.skill_levels[sk]) const cadenceOf = (sk) => { const e = props.tech.skill_eff && props.tech.skill_eff[sk]; return (e != null && e !== '') ? pctOf(e) : '' } // '' = hérite du global const globalPct = () => pctOf(props.tech.efficiency) // défaut efficiency=1 → 100 % +// P3 — la CADENCE (vitesse) est MASQUÉE par défaut : tous les techs sont à 100 % (neutre pour le dispatch). On ne la +// révèle que si CE tech a déjà une cadence personnalisée, ou si l'utilisateur clique « Ajuster la vitesse » (cas d'un +// tech nettement plus lent/rapide). Aucune donnée n'est modifiée — 100 % = facteur 1 = le dispatch reste « au Score ». +const showCadence = ref(false) +const hasCustomCadence = computed(() => { + const eff = props.tech.skill_eff || {} + const anySkill = (props.tech.skills || []).some(sk => { const e = eff[sk]; return e != null && e !== '' && Number(e) !== 1 }) + const g = Number(props.tech.efficiency) + return anySkill || (Number.isFinite(g) && g !== 0 && g !== 1) +}) +const cadenceVisible = computed(() => showCadence.value || hasCustomCadence.value) +watch(() => props.tech && props.tech.id, () => { showCadence.value = false }) // réinitialise le révélateur quand on change de tech + // Tampons de saisie (n'écrit qu'au blur/enter, comme l'original) — locaux au composant. const buf = reactive({}) // { sk: pct } const gBuf = reactive({ v: undefined }) @@ -50,7 +63,7 @@ function commitGlobal () { if (gBuf.v === undefined) return; const v = gBuf.v; g
Compétence
Score
-
Cadence
+
Cadence
@@ -72,15 +85,20 @@ function commitGlobal () { if (gBuf.v === undefined) return; const v = gBuf.v; g
-
+
-
Score ★ = maîtrise · Cadence : 100 % = normale · 200 % = deux fois plus de jobs · < 100 % = plus lent · vide = hérite du global · × sur le chip = retirer.
+
Score ★ = maîtrise (priorité au dispatch) · × sur le chip = retirer.
+ +
- -
+ +
Cadence globale (défaut 100 %)
@@ -94,4 +112,6 @@ function commitGlobal () { if (gBuf.v === undefined) return; const v = gBuf.v; g .skill-chip { font-size: 10px; line-height: 15px; height: 15px; padding: 0 5px; border-radius: 8px; color: #fff; font-weight: 600; white-space: nowrap; flex-shrink: 0; display: inline-flex; align-items: center; gap: 2px; } .skill-rank { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: #e2e8f0; color: #64748b; font-size: 9px; font-weight: 800; margin-right: 5px; flex-shrink: 0; } .skill-rank-1 { background: #fef3c7; color: #b45309; box-shadow: 0 0 0 1.5px rgba(250,204,21,0.7); } +.cad-toggle { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 600; color: #4338ca; cursor: pointer; user-select: none; } +.cad-toggle:hover { text-decoration: underline; } diff --git a/apps/ops/src/components/planif/TechScheduleDialog.vue b/apps/ops/src/components/planif/TechScheduleDialog.vue index 3bcc832..25fc258 100644 --- a/apps/ops/src/components/planif/TechScheduleDialog.vue +++ b/apps/ops/src/components/planif/TechScheduleDialog.vue @@ -81,9 +81,10 @@ + :shifts="menuShifts" :is-garde="menuIsGarde" :is-absent="menuIsAbsent" :show-copy="false" :functions="functions" + :day-preset="dayPreset" :eve-preset="evePreset" + @set-windows="onMenuSetWindows" @toggle-garde="onMenuGarde" @toggle-absent="onMenuAbsent" + @clear="onMenuClear" @set-preset="e => emit('set-preset', e)" />
{{ b.label }} @@ -120,8 +121,11 @@ const props = defineProps({ pendingPaused: { type: Boolean, default: false }, // pause indéfinie EN ATTENTE (staged par le parent, publish-required) gardeMap: { type: Object, default: () => ({}) }, // garde EFFECTIVE du parent (gardeEffective) : clé tech|iso → shift refreshKey: { type: Number, default: 0 }, // ↑ par le parent après une écriture de quart → recharge le mois + functions: { type: Array, default: () => [] }, // catalogue de rôles → sélecteur de rôle par quart dans le menu de cellule + dayPreset: { type: Object, default: () => ({ start: 8, end: 16 }) }, // P8 — préréglage Jour PAR TECH (heures décimales) + evePreset: { type: Object, default: () => ({ start: 16, end: 20 }) }, // P8 — préréglage Soir PAR TECH }) -const emit = defineEmits(['update:modelValue', 'edit-schedule', 'changed', 'stage-abs', 'stage-pause', 'set-shift', 'remove-shift', 'clear-shifts', 'toggle-garde']) +const emit = defineEmits(['update:modelValue', 'edit-schedule', 'changed', 'stage-abs', 'stage-pause', 'set-shift', 'set-windows', 'remove-shift', 'clear-shifts', 'toggle-garde', 'set-preset']) const $q = useQuasar() const err = (e) => $q.notify({ type: 'negative', message: '' + (e.message || e) }) @@ -245,7 +249,7 @@ async function loadCal () { const abd = {} for (const a of ((asgRes && asgRes.assignments) || [])) { if (a.tech !== props.tech.id && a.tech !== props.tech.name) continue - ;(abd[a.date] || (abd[a.date] = [])).push({ name: a.name, shift: a.shift, shift_name: a.shift_name || a.shift, hours: a.hours }) + ;(abd[a.date] || (abd[a.date] = [])).push({ name: a.name, shift: a.shift, shift_name: a.shift_name || a.shift, hours: a.hours, function: a.function || '' }) } asgByDate.value = abd } catch (e) { err(e) } finally { calLoading.value = false } @@ -271,10 +275,10 @@ function endDrag () { } // ── Menu de cellule (clic simple) — émet des intentions au parent (single source of logic). ── function openCellMenu (iso) { cellMenu.iso = iso; cellMenu.show = true } -function onMenuWindow (r) { if (props.tech && r) emit('set-shift', { techId: props.tech.id, iso: cellMenu.iso, min: r.min, max: r.max }); cellMenu.show = false } +// Rangées heure→heure du menu partagé → réconcilie plusieurs quarts pour ce jour (le menu reste ouvert pour enchaîner). +function onMenuSetWindows (list) { if (props.tech) emit('set-windows', { techId: props.tech.id, iso: cellMenu.iso, windows: list || [] }) } function onMenuGarde () { if (props.tech) emit('toggle-garde', { techId: props.tech.id, iso: cellMenu.iso }); cellMenu.show = false } function onMenuAbsent () { if (props.tech) emit('stage-abs', { techId: props.tech.id, changes: [{ iso: cellMenu.iso, type: menuIsAbsent.value ? '' : 'Congé' }] }); cellMenu.show = false } -function onMenuRemoveShift (a) { if (a && a.name) emit('remove-shift', { name: a.name }); cellMenu.show = false } function onMenuClear () { if (props.tech) emit('clear-shifts', { techId: props.tech.id, iso: cellMenu.iso, names: (asgByDate.value[cellMenu.iso] || []).map(a => a.name) }); cellMenu.show = false } // ── Archivage (réversible) ─────────────────────────────────────────────────── diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index b1c9e0a..8f6eefb 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -86,7 +86,10 @@ 🗓  Horaires & personnel Générer l'horaire…solveur auto (semaine) ou modèles de quarts (lot) + Corriger les chevauchementsquarts qui se chevauchent ou tombent sur un congé — 1 par 1 Congés / absences + Ajustement temporaireRetirer/ajouter une compétence sur une plage (ex. mal de dos → pas d'install cette semaine) + Modèles de rôleEnsembles de compétences réutilisables à poser sur un quart (ex. Support, Vente) Synchroniser les techniciens @@ -163,13 +166,19 @@
- Compétences : + Compétences :Par défaut, ses compétences de base. Si un rôle est posé sur son quart (ex. Support), ce jour-là il fait les compétences de ce rôle à la place. Un ajustement temporaire (menu Outils) retire/ajoute une compétence sur une plage — ex. mal de dos → pas d'installation cette semaine. Un modèle de rôle (menu Outils) = un ensemble de compétences réutilisable qu'on pose sur un quart en un clic. {{ sk }}
+ +
+ + Couverture terrain — {{ coverageGaps.length }} jour(s) sans quart : + {{ covLabel(g) }} +
@@ -218,7 +227,7 @@
Charge — {{ selDayLabel }}{{ mobileTechBusy.length }} occupé(s)
{{ cellOfficeN(t.id, kanbanDay.iso) }} ticket(s) sans déplacement (admin / support / NP) — hors charge dispatch.
• {{ j.subject }} · {{ j.dept }}

clic = liste · ouvrir un ticket ↗
{{ cellOfficeN(t.id, kanbanDay.iso) }} ticket(s) sans déplacement · {{ t.name }}{{ j.subject }}{{ j.dept }} · #{{ j.id }}
@@ -553,13 +586,18 @@ - + + + + + + @@ -585,7 +623,7 @@ - {{ tg.count }} technicien(s) · clic = renommer + {{ tg.count }} technicien(s) · clic = renommer
Techniciens — {{ tg.label }}
• {{ n }}
Supprimer partout @@ -644,17 +682,40 @@
-
Règles actives
+
Files de garde · partagées · clic sur un nom = contacter / position
- - - {{ r.dept }} · {{ shiftName(r.shift) }} · WE : {{ shiftName(r.shiftWeekend) }} - {{ gardeDowLabel(r) }} · dès {{ (r.anchor || '').slice(5) }} · {{ gardeSeqLabel(r) }} - - + +
+
+
🛡️ {{ r.dept }} · {{ shiftName(r.shift) }} · WE : {{ shiftName(r.shiftWeekend) }}
+
{{ gardeDowLabel(r) }} · dès {{ (r.anchor || '').slice(5) }}
+
Modifier (ordre, techs, période) - +
+ +
+ Rotation : + + {{ si + 1 }} + {{ techShort(s.tech) }}×{{ s.weeks }} + + + {{ techFullName(s.tech) }} · {{ si + 1 }}ᵉ / {{ ruleSteps(r).length }} · 🟢 sur appel + Appeler{{ techContact(s.tech).phone }} + Texto (SMS) + Courriel{{ techContact(s.tech).email }} + Aucune coordonnée + + Mettre sur appel aujourd'hui + + + aucun tech +
+
+ 🟢 sur appel : {{ techFullName(queueCurrentTech(r)) }} + · ↑ prochain : {{ techFullName(queueNextTech(r)) }} +
@@ -666,7 +727,7 @@
1 · Quand & quel quart
-
+
@@ -717,7 +778,7 @@
La grille montre la garde en direct (calque). « Publier » la matérialise sur l'horizon (remplacement propre) pour dispatch & les techs. Vacances ⇒ substitut auto.
- + Horizon à matérialiser en une publication. « 1 an » = couverture continue toute l'année (à re-publier ~annuellement — pas de cron).
@@ -726,7 +787,10 @@ - + +
🏷 {{ skillDialog.name }}
@@ -736,6 +800,7 @@ @set-cadence="e => setSkillEffPct(skillDialog, e.sk, e.pct)" @create-tag="onCreateRosterTag" @update-tag="onUpdateRosterTag" />
Enregistré automatiquement — pas de bouton à cliquer.
+
Retirer/ajouter une compétence sur une PLAGE (ex. mal de dos → pas d'installation cette semaine) sans toucher ses compétences de base. Le tech reste dispo pour ses autres tâches.
🗓 Horaire
@@ -833,7 +898,7 @@ - {{ tg.count }} technicien(s) · clic = renommer + {{ tg.count }} technicien(s) · clic = renommer
Techniciens — {{ tg.label }}
• {{ n }}
Supprimer partout @@ -946,17 +1011,36 @@ — enregistre une sélection (ex. Installateurs, Épisseurs)
-
+
- {{ initials(t.name) }} + {{ t.name }} Rapide (efficacité {{ t.efficiency }}) Plus lent (efficacité {{ t.efficiency }}) - Aucun quart publié cette période + En congé / absent sur la période visée — non dispatchable (décoché par défaut) + Aucun quart publié cette période Réglages du tech — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée
+ + +
+ + + + + + + + + + + + +
TypeVille / secteurRueAM/PM#
{{ j.required_skill || j.service_type || '—' }}{{ jobCity(j) || '—' }}{{ j.address || j.service_location || j.location_label || '—' }}{{ jobTimeOf(j.name).ampm === 'am' ? 'AM' : 'PM' }}{{ j.legacy_ticket_id || j.lid || '' }}
Aucun job à répartir dans le périmètre.
+
+
+ +
+ +
+ + Deux quarts se chevauchent ce jour — supprimez le doublon avec ✕. +
+ +
+ Rôle du quart {{ asgShiftLabel(a) }} — compétences effectives du tech pour ce quart (vide = compétences de base) + {{ asgShiftLabel(a) }} + + Pré-remplir avec un rôle, puis ajuster les chips à la main + + Pré-remplir avec… + {{ f.name }}{{ (f.skills || []).join(' · ') }} + Aucun modèle — créez-en via Outils → Fonctions + + Effacer (compétences de base) + + + Supprimer ce quart (retire le doublon / chevauchement) +
+
+ + Rôle + publier le quart pour poser un rôle +
+
+ +
+ + Ajustement temporaire : {{ adjLabel(activeAdjustment(dayEditor.tech.id, dayEditor.day.iso)) }} + +
@@ -1635,7 +1761,7 @@ 🏁 Départ (domicile plus proche)Le tech quitte {{ dayOrigin.kind === 'home' ? 'son domicile' : 'le dépôt' }}{{ dayOrigin.address ? ' (' + dayOrigin.address + ')' : '' }} à l'heure du shift.
@@ -1650,16 +1776,17 @@ Glisser pour réordonner (souris). Sur mobile : ▲▼ ou 📌 figer. Ticket F (legacy) — lecture seule. Position dans la tournée à titre indicatif (non réécrite dans F). {{ i + 1 }} - -
{{ j.detail }} -
{{ j.subject }} F
+ Type : {{ j.skill || j.service_type || j.job_type || j.legacy_dept || j.dept || '—' }} +
{{ j.detail }} +
{{ j.subject }} F Ouvrir le détail du ticket
{{ fmtHM(packedDay[i].startMin) }}–{{ fmtHM(packedDay[i].endMin) }} · 🔒 RDV fixe
· ⚠ sans coords — 📍 situer · sans coords (absent de la carte)
Ouvrir le ticket F (lecture seule)
-
-
Chargement du fil…
- -
{{ j.detail || 'Aucun détail importé pour ce ticket.' }} · fil indisponible
-
- glisser (souris) · ▲▼ (mobile) = ordre · figer 1er/dernier · 🔒 RDV fixe · total {{ dayTotalH() }}h + glisser (souris) · ▲▼ (mobile) = ordre · figer 1er/dernier · 🔒 RDV fixe · total {{ dayTotalH() }}hOrdre par défaut : priorité du ticket, puis heure (les RDV à heure fixe 🔒 gardent leur place). Ce n'est PAS un tri par distance : pour ordonner selon le trajet le plus court, clique « Optimiser depuis le départ ». Ensuite tu peux glisser/▲▼ à la main et figer un job en 1er/dernier. Faire correspondre l'adresse de chaque job sans coordonnées à une adresse existante (base RQA) → ajoute le GPS. Adresses ambiguës = à situer à la main. Réordonne par plus proche voisin depuis {{ dayOrigin ? (dayOrigin.kind === 'home' ? 'le domicile du tech' : 'le dépôt') : 'le 1er arrêt' }} → la 1re job devient la plus proche. Puis « Enregistrer » (les tickets F restent indicatifs). @@ -1750,9 +1867,114 @@ - + @set-shift="onTechSchedSetShift" @set-windows="onTechSchedSetWindows" @remove-shift="onTechSchedRemoveShift" @clear-shifts="onTechSchedClearShifts" @toggle-garde="onTechSchedToggleGarde" @set-preset="p => onSetPreset(techSchedTech && techSchedTech.id, p)" /> + + + + + + +
Rôle du quart — {{ roleDialog.tech.name }} · {{ roleDialog.day.dow }} {{ roleDialog.day.dnum }}
+ +
+ +
Compétences effectives du tech pour chaque quart (vide = compétences de base). « Modèle » pré-remplit depuis un rôle, puis on ajuste les chips.
+
+ Deux quarts se chevauchent ce jour — supprimez le doublon avec ✕. +
+
+ + {{ asgShiftLabel(a) }} + + Pré-remplir avec un rôle, puis ajuster les chips + + Pré-remplir avec… + {{ f.name }}{{ (f.skills || []).join(' · ') }} + Aucun modèle — créez-en via Outils → Fonctions + + Effacer (compétences de base) + + + Supprimer ce quart (retire le doublon / chevauchement) +
+
Aucun quart régulier ce jour — ajoutez-en un depuis le menu de la cellule.
+
+ +
+
+ + + + + + +
Garde — {{ gardeDayDlg.dow }} {{ gardeDayDlg.dnum }}
+ +
+ +
Qui est sur appel ce jour. La rotation remplit par défaut ; ici on surcharge juste ce jour.
+
De garde ce jour
+
+ + {{ (techByIdMap[tid] && techByIdMap[tid].name) || tid }} + Retirer de la garde ce jour + +
+
Personne de garde ce jour.
+ + + +
+ + Annuler les surcharges de ce jour → revenir à la rotation + + +
Publier la garde (Rotation → « Publier la garde ») pour matérialiser sur l'horizon.
+
+
+ + + + + + +
Corriger les chevauchements
+ + + +
+ +
+
Aucun chevauchement sur l'horizon.
+ +
+
+
@@ -1782,6 +2004,7 @@ import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } fr import { onBeforeRouteLeave, useRouter } from 'vue-router' import { useQuasar } from 'quasar' import * as roster from 'src/api/roster' +import { deleteJob as apiDeleteJob } from 'src/api/dispatch' // suppression d'un blocage (job interne) — voir deleteBlockJob import { usePermissions } from 'src/composables/usePermissions' import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement import { useSSE, sendSmsViaHub } from 'src/composables/useSSE' @@ -1797,12 +2020,16 @@ import AssignmentField from 'src/components/shared/AssignmentField.vue' // Modules détail on-site/dispatch PARTAGÉS (mêmes composants que IssueDetail) — le volet job les consomme désormais. import JobMapModule from 'src/components/shared/detail-sections/modules/JobMapModule.vue' import GeofenceTimeline from 'src/components/shared/detail-sections/modules/GeofenceTimeline.vue' +import JobMediaModule from 'src/components/shared/detail-sections/modules/JobMediaModule.vue' import DurationField from 'src/components/shared/detail-sections/modules/DurationField.vue' import RequiredSkillsField from 'src/components/shared/detail-sections/modules/RequiredSkillsField.vue' import JobTeamField from 'src/components/shared/detail-sections/modules/JobTeamField.vue' import JobThread from 'src/components/shared/detail-sections/modules/JobThread.vue' import JobReplyBox from 'src/components/shared/detail-sections/modules/JobReplyBox.vue' +import GpsDevicesDialog from 'src/components/shared/GpsDevicesDialog.vue' // vue « appareil GPS → tech » (assignation inverse) import LeaveDialog from 'src/components/planif/LeaveDialog.vue' // Congés & disponibilités (extrait — décomposition #4) +import SkillExceptionDialog from 'src/components/planif/SkillExceptionDialog.vue' // Compétences temporaires (restrictions ponctuelles par tech) +import FunctionCatalogDialog from 'src/components/planif/FunctionCatalogDialog.vue' // Catalogue de fonctions (skillsets nommés portés par un quart) import TechSyncDialog from 'src/components/planif/TechSyncDialog.vue' // Synchroniser les techniciens (extrait — décomposition #4) import ShiftTypesDialog from 'src/components/planif/ShiftTypesDialog.vue' // Types de shift (extrait — décomposition #4) import WeekTemplatesDialog from 'src/components/planif/WeekTemplatesDialog.vue' // Modèles de semaine (ex-sous-menu Outils → dialogue, règle « pas de menu-dans-menu ») @@ -1944,6 +2171,20 @@ const hiddenTechs = ref([]); const showHidden = ref(false) function isHidden (id) { return hiddenTechs.value.includes(id) } function toggleHidden (id) { const i = hiddenTechs.value.indexOf(id); if (i >= 0) hiddenTechs.value.splice(i, 1); else hiddenTechs.value.push(id); localStorage.setItem('roster-hidden-techs-v1', JSON.stringify(hiddenTechs.value)) } const hiddenCount = computed(() => techs.value.filter(t => isHidden(t.id)).length) +// P8 — préréglages Jour/Soir PAR TECH (heures décimales), persistés localStorage. Défauts sobres 8-16 / 16-20 ; +// personnalisés depuis le menu de cellule (ex. Philip 7:30→17:30) → réutilisés à chaque clic « Jour »/« Soir ». +const LS_SHIFT_PRESETS = 'roster-shift-presets-v1' +const techShiftPresets = ref({}) // { [techId]: { day:{start,end}, eve:{start,end} } } +function presetDayFor (techId) { const p = techId && techShiftPresets.value[techId]; return (p && p.day) || { start: 8, end: 16 } } +function presetEveFor (techId) { const p = techId && techShiftPresets.value[techId]; return (p && p.eve) || { start: 16, end: 20 } } +function onSetPreset (techId, { kind, start, end } = {}) { + if (!techId || start == null || end == null) return + const cur = { ...(techShiftPresets.value[techId] || {}) } + cur[kind === 'eve' ? 'eve' : 'day'] = { start, end } + techShiftPresets.value = { ...techShiftPresets.value, [techId]: cur } + try { localStorage.setItem(LS_SHIFT_PRESETS, JSON.stringify(techShiftPresets.value)) } catch (e) {} + $q.notify({ type: 'positive', icon: 'schedule', message: 'Préréglage ' + (kind === 'eve' ? 'Soir' : 'Jour') + ' enregistré pour ce tech', timeout: 1600 }) +} const showShiftEditor = ref(false) // v-model de (editTpls/newTpl/fonctions déplacés dans le composant) const showWeekTemplates = ref(false) // v-model de — l'état weekTemplates + mutations restent ICI (localStorage) const showTeamEditor = ref(false); const editTechs = ref([]); const teamCost = reactive({}) @@ -1991,8 +2232,21 @@ async function saveNotifyContact (m) { } catch (e) { err(e) } finally { m.saving = false } } const showLeave = ref(false) // ouverture de (état leaveRows/leaveFilter/newLeave + fonctions déplacés dans le composant) +const showSkillExc = ref(false); const skillExcTech = ref('') // : compétences temporaires (restrictions ponctuelles) +function openSkillExc (techId) { skillExcTech.value = techId || ''; showSkillExc.value = true } +const showFunctionCatalog = ref(false) // : catalogue de fonctions (skillsets nommés portés par un quart) +const gpsDevicesDialog = ref(false) // vue « appareil GPS → tech » (assignation inverse depuis les appareils Traccar) // numToTime : heure décimale → HH:MM (partagé — aussi utilisé par le glisser-créer de quart). newTpl/newTplRange déplacés dans ShiftTypesDialog. function numToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') } +// Normalise une saisie d'heure souple → « HH:MM » 24 h (jamais AM/PM). « 8 »→08:00 · « 830 »→08:30 · « 16h »→16:00. +function norm24 (s) { + s = String(s || '').trim().toLowerCase().replace(/h/g, ':').replace(/[^\d:]/g, ''); if (!s) return '' + let h, m + if (s.includes(':')) { const p = s.split(':'); h = parseInt(p[0] || '0', 10); m = parseInt(p[1] || '0', 10) } else if (s.length <= 2) { h = parseInt(s, 10); m = 0 } else { h = parseInt(s.slice(0, s.length - 2), 10); m = parseInt(s.slice(-2), 10) } + if (!Number.isFinite(h)) return '' + h = Math.min(23, Math.max(0, h)); m = Math.min(59, Math.max(0, Number.isFinite(m) ? m : 0)) + return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') +} const LS_HOL = 'roster-holidays-v1'; const LS_TPL = 'roster-week-templates-v1'; const LS_GARDE = 'roster-garde-rules-v1'; const LS_GARDE_MANUAL = 'roster-garde-manual-v1' @@ -2158,6 +2412,18 @@ async function unassignOne (b) { const jn = b && (b.name || b.job); if (!jn || b.assist || b.legacy) return // Kanban=name, grille=job ; legacy/assist non désassignables try { await roster.unassignJobRoster(jn); await reloadOccupancy(); await reloadPool(); $q.notify({ type: 'info', message: (b.subject || b.skill || 'Job') + ' renvoyé au pool', timeout: 2200 }) } catch (e) { err(e) } } +// Un bloc « Bloquer du temps » (formation/réunion…) = Dispatch Job job_type Interne/Réservation → SUPPRIMABLE (pas juste +// désassignable). Distingue le blocage d'un vrai job client, qu'on ne propose PAS de supprimer d'un clic. +function isBlockingJob (b) { const jt = String(b && b.job_type || ''); return (jt === 'Interne' || jt === 'Réservation') && !b.legacy && !b.assist } +async function deleteBlockJob (b) { + const jn = b && (b.name || b.job); if (!jn || !isBlockingJob(b)) return + try { + await apiDeleteJob(jn) + if (dayEditor.list && dayEditor.list.length) dayEditor.list = dayEditor.list.filter(x => (x.name || x.job) !== jn) // MAJ immédiate de l'éditeur de jour + await reloadOccupancy() + $q.notify({ type: 'positive', icon: 'delete', message: 'Blocage retiré · ' + (b.subject || 'temps bloqué') + ' — tech redevient dispatchable', timeout: 2600 }) + } catch (e) { err(e) } +} // Ligne-curseur « maintenant » (Eastern Time, America/Toronto), rafraîchie chaque minute — style Gaiia. const nowTick = ref(0); let _nowTimer = null const nowET = computed(() => { @@ -2266,7 +2532,16 @@ async function jdSetJobSkills (list) { jobDetail.skills = arr; jobDetail.skill = arr[0] || '' if (jobDetail.name) { try { await roster.setJobSkills(jobDetail.name, arr); if (typeof reloadPool === 'function') reloadPool() } catch (e) { err(e) } } } -const jdAllTechOpts = computed(() => (techs.value || []).map(t => ({ label: t.name + ((t.skills || []).length ? ' · ' + t.skills.slice(0, 3).join(', ') : ''), value: t.id }))) +// Liste « Donner à » triée par COMPÉTENCES EN COMMUN avec la job (le plus de recoupements d'abord) → les techs déjà +// capables ressortent en haut. À match égal, tri alphabétique. (Le champ ne sert qu'à attribuer la compétence manquante, +// mais on veut voir en premier ceux qui font déjà ce type de job.) +const jdAllTechOpts = computed(() => { + const req = (jobDetail.skills && jobDetail.skills.length) ? jobDetail.skills : (jobDetail.skill ? [jobDetail.skill] : []) + return (techs.value || []) + .map(t => { const sk = (t.skills || []); const match = req.filter(s => sk.includes(s)).length; return { match, label: t.name + (sk.length ? ' · ' + sk.slice(0, 3).join(', ') : ''), value: t.id } }) + .sort((a, b) => (b.match - a.match) || a.label.localeCompare(b.label)) + .map(({ label, value }) => ({ label, value })) +}) async function jdGiveSkillToTech () { // attribue au tech TOUTES les compétences requises du job qu'il n'a pas encore const req = (jobDetail.skills && jobDetail.skills.length) ? jobDetail.skills : (jobDetail.skill ? [jobDetail.skill] : []) const tid = jdSkillTech.value; if (!req.length || !tid) return @@ -2405,6 +2680,8 @@ function openAbsDialog () { absDialog.techId = menu.tech.id; absDialog.techName = menu.tech.name absDialog.from = menu.day.iso; absDialog.to = menu.day.iso; absDialog.open = true; menu.show = false } +// P4 — « Ajuster les compétences » depuis le menu de cellule : rôle du quart (éditeur de jour) OU ajustement temporaire (plage). +function roleFromMenu () { if (!menu.tech || !menu.day) return; menu.show = false; openRoleDialog(menu.tech, menu.day) } // → modale d'édition des compétences (chips) par quart function absWeek () { const m = mondayISO(absDialog.from || todayISO()); absDialog.from = m; absDialog.to = addDaysISO(m, 6) } function absDays () { const out = []; let d = absDialog.from; if (!d || !absDialog.to || absDialog.to < d) return d ? [d] : out; let g = 0; while (d <= absDialog.to && g++ < 400) { out.push(d); d = addDaysISO(d, 1) }; return out } function applyAbs (remove) { @@ -2552,6 +2829,19 @@ const gardeEffective = computed(() => { function onGarde (techId, iso) { return !!gardeEffective.value[techId + '|' + iso] } // Nb de techs de garde par jour (garde EFFECTIVE) → ligne de pied cohérente avec la grille const gardeCountByDate = computed(() => { const m = {}; for (const k in gardeEffective.value) { const iso = k.split('|')[1]; m[iso] = (m[iso] || 0) + 1 } return m }) +// ── Ligne GARDE (couche flottante) : qui est de garde par jour + surcharge par jour ────────────────────────────── +const gardeByDay = computed(() => { const m = {}; for (const k in gardeEffective.value) { const [tid, iso] = k.split('|'); (m[iso] = m[iso] || []).push(tid) } return m }) +function gardeTechsForDay (iso) { return gardeByDay.value[iso] || [] } +const techByIdMap = computed(() => { const m = {}; for (const t of (techs.value || [])) m[t.id] = t; return m }) +function techShort (techId) { const t = techByIdMap.value[techId]; const n = (t && t.name) || techId; return n.split(' ')[0] } +function gardeColor (techId) { const t = techByIdMap.value[techId]; return hashColor((t && t.name) || techId) } +// Surcharge de garde d'un JOUR (override manuel, comme la touche « G ») : ajouter / retirer un tech, ou rétablir la rotation. +const gardeDayDlg = reactive({ open: false, iso: '', dow: '', dnum: '' }) +function openGardeDay (d) { gardeDayDlg.iso = d.iso; gardeDayDlg.dow = d.dow; gardeDayDlg.dnum = d.dnum; gardeDayDlg.open = true } +function addGardeForDay (iso, techId) { if (!techId) return; if (!templates.value.some(t => t.on_call)) { $q.notify({ type: 'warning', message: 'Aucun modèle de garde (🛡️) — créez-en un dans « Types de shift »' }); return } const m = { ...manualGarde.value }; setGardeCell(m, techId + '|' + iso, true); manualGarde.value = m; saveManualGarde() } +function removeGardeDayTech (iso, techId) { const m = { ...manualGarde.value }; setGardeCell(m, techId + '|' + iso, false); manualGarde.value = m; saveManualGarde() } +function resetGardeDay (iso) { const m = { ...manualGarde.value }; let n = 0; for (const k in m) { if (k.endsWith('|' + iso)) { delete m[k]; n++ } } if (n) { manualGarde.value = m; saveManualGarde() } } +const gardeDayAddOptions = computed(() => { const on = new Set(gardeTechsForDay(gardeDayDlg.iso)); return (techs.value || []).filter(t => !on.has(t.id)).map(t => ({ label: t.name, value: t.id })) }) // chip() (pastille couleur du type de shift) déplacé dans ShiftTypesDialog — seul consommateur. // techs visibles (recherche + groupe + tri) @@ -2655,6 +2945,18 @@ async function onTechSchedSetShift ({ techId, iso, min, max } = {}) { } catch (e) { err(e) } techSchedRefresh.value++; await loadWeek() } +// Calendrier par tech : réconcilie plusieurs quarts NON chevauchants pour un jour (persiste par date, garde préservée). +async function onTechSchedSetWindows ({ techId, iso, windows } = {}) { + if (!techId || !iso) return + const t = (techs.value || []).find(x => x.id === techId) + try { + const existing = (await roster.listAssignments(iso, 1)).assignments || [] + for (const a of existing) { const tp = tplByName.value[a.shift]; if (a.tech === techId && a.date === iso && tp && !tp.on_call) { try { await roster.deleteAssignment(a.name) } catch (e) {} } } + for (const w of (windows || [])) { if (w && w.max > w.min) { const tpl = await ensureWindowTpl(w.min, w.max); if (!tpl) continue; const r = await roster.createShift({ tech: techId, tech_name: t ? t.name : '', date: iso, shift: tpl.name, zone: tpl.zone || '', hours: tpl.hours || calcHours(numToTime(w.min), numToTime(w.max)) }); const nm = r && (r.name || (r.data && r.data.name)); if (w.function && nm) { try { await roster.setAssignmentFunction(nm, w.function) } catch (e) {} } } } + $q.notify({ type: 'positive', message: `${(windows || []).length} quart(s) · ${iso}`, timeout: 1600 }) + } catch (e) { err(e) } + techSchedRefresh.value++; await loadWeek() +} async function onTechSchedRemoveShift ({ name } = {}) { if (!name) return; try { await roster.deleteAssignment(name); $q.notify({ type: 'info', message: 'Quart retiré', timeout: 1500 }) } catch (e) { err(e) } techSchedRefresh.value++; await loadWeek() } async function onTechSchedClearShifts ({ names } = {}) { if (!names || !names.length) return; for (const n of names) { try { await roster.deleteAssignment(n) } catch (e) {} } $q.notify({ type: 'info', message: 'Quarts retirés', timeout: 1500 }); techSchedRefresh.value++; await loadWeek() } function onTechSchedToggleGarde ({ techId, iso } = {}) { if (techId && iso) toggleGardeCells([techId + '|' + iso]) } // garde = manualGarde (localStorage), clé tech|iso — cross-fenêtre OK @@ -2707,22 +3009,49 @@ async function onTechSchedChanged (ev) { // ── Génération de quarts HEBDO par tech (modèles + N semaines) — écrit DIRECTEMENT les Shift Assignment (Publié). ── const schedGenOpen = ref(false); const schedGenTechs = ref([]); const schedGenChooser = ref(false) // chooser : solveur auto (doGenerate) vs modèles de quarts (openSchedGenBulk) — 1 entrée « Générer l'horaire… » -function openSchedGen (t) { schedGenTechs.value = [{ id: t.id, name: t.name }]; skillMenuShown.value = false; schedGenOpen.value = true } // 1 tech +function openSchedGen (t) { schedGenTechs.value = [{ id: t.id, name: t.name, skills: t.skills || [] }]; skillMenuShown.value = false; schedGenOpen.value = true } // 1 tech (skills → affinage par chips) function openSchedGenBulk () { schedGenTechs.value = (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name, skills: t.skills || [] })); schedGenOpen.value = true } // LOT : tous les techs visibles (+ compétences pour filtrer) const _hmNum = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 } async function onScheduleApply ({ schedule, weeks, techIds, mode }) { const targets = (schedGenTechs.value || []).filter(t => (techIds || []).includes(t.id)); if (!targets.length) return // ── RÉCURRENT : le patron devient la SOURCE (weekly_schedule) → matérialisation auto (fériés/vacances sautés, manuels préservés) ── if (mode === 'recurring') { - const patt = {}; for (const d of schedule) patt[d.dow] = d.on ? { start: d.start, end: d.end } : null + const patt = {}; for (const d of schedule) patt[d.dow] = (d.on && d.slots && d.slots.length) ? d.slots.map(sl => ({ start: sl.start, end: sl.end, function: sl.function || '' })) : null // MULTI-SLOT : tableau de quarts/jour let okT = 0, failT = 0 for (const t of targets) { try { const r = await roster.setWeeklySchedule(t.id, patt); if (r && r.ok) okT++; else { failT++; err(new Error('weekly-schedule ' + t.name + ': ' + ((r && r.error) || 'échec'))) } } catch (e) { failT++; err(e) } } - let mat = null, matErr = ''; try { mat = await roster.materializeShifts({ weeks }) } catch (e) { matErr = (e && e.message) || 'erreur'; err(e) } + // 1 tech : détecte les jours à quart MANUEL (conflit) → propose Conserver / Remplacer. En LOT : on garde le comportement + // sûr (roles_to_manual) — remplacer en masse des quarts manuels sur plusieurs techs serait trop destructeur. + let replaceManual = false + if (targets.length === 1) { + let conflicts = 0 + try { const d = await roster.materializeShifts({ weeks, dry_run: true, tech: targets[0].id }); conflicts = (d && d.kept_manual) || 0 } catch (e) {} + if (conflicts > 0) { + const choice = await new Promise(resolve => { + $q.dialog({ + title: 'Quarts déjà posés à la main', + message: `${conflicts} jour(s) de l'horizon ont déjà un quart créé manuellement pour ${targets[0].name}. Que faire avec l'horaire récurrent ?`, + options: { type: 'radio', model: 'keep', items: [{ label: 'Conserver le quart manuel — appliquer seulement le rôle du récurrent', value: 'keep' }, { label: 'Remplacer par l\'horaire récurrent (supprime le quart manuel, garde conservée)', value: 'replace' }] }, + cancel: { label: 'Annuler', flat: true }, persistent: true, ok: { label: 'Appliquer', unelevated: true, color: 'primary' }, + }).onOk(v => resolve(v)).onCancel(() => resolve(null)) + }) + if (choice === null) { $q.notify({ type: 'info', message: 'Horaire récurrent enregistré — matérialisation annulée', timeout: 3000 }); return } + replaceManual = choice === 'replace' + } + } + // roles_to_manual : le rôle du patron s'applique aux quarts existants ; replace_manual (si choisi) : le patron remplace les quarts manuels. + let mat = null, matErr = '' + try { + if (replaceManual) { // SCOPÉ par tech (ne touche QUE les techs édités) ; on agrège les compteurs + const acc = { created: 0, updated: 0, deleted: 0, create_fail: 0, skipped_holiday: 0 } + for (const t of targets) { const r = await roster.materializeShifts({ weeks, tech: t.id, roles_to_manual: true, replace_manual: true }); if (r) { acc.created += r.created || 0; acc.updated += r.updated || 0; acc.deleted += r.deleted || 0; acc.create_fail += r.create_fail || 0; acc.skipped_holiday += r.skipped_holiday || 0 } } + mat = acc + } else { mat = await roster.materializeShifts({ weeks, roles_to_manual: true }) } + } catch (e) { matErr = (e && e.message) || 'erreur'; err(e) } try { await loadWeek() } catch (e) {} // #4c : on NE SWALLOW PLUS — les échecs (compte tech introuvable, création de quart refusée) remontent à l'écran. const parts = [okT + ' tech'] if (failT) parts.push('⚠ ' + failT + ' non enregistré(s)') - if (mat) { parts.push(mat.created + ' quart(s)/' + weeks + ' sem.'); if (mat.create_fail) parts.push('⚠ ' + mat.create_fail + ' échec(s) de création'); if (mat.skipped_holiday) parts.push(mat.skipped_holiday + ' férié(s) sauté(s)') } else if (matErr) parts.push('⚠ matérialisation : ' + matErr) + if (mat) { parts.push(mat.created + ' quart(s)/' + weeks + ' sem.'); if (mat.updated) parts.push(mat.updated + ' rôle(s) appliqué(s)'); if (mat.deleted) parts.push(mat.deleted + ' manuel(s) remplacé(s)'); if (mat.create_fail) parts.push('⚠ ' + mat.create_fail + ' échec(s) de création'); if (mat.skipped_holiday) parts.push(mat.skipped_holiday + ' férié(s) sauté(s)') } else if (matErr) parts.push('⚠ matérialisation : ' + matErr) $q.notify({ type: (failT || (mat && mat.create_fail) || matErr) ? 'warning' : 'positive', icon: 'event_repeat', message: 'Horaire récurrent · ' + parts.join(' · '), timeout: 5500 }) schedGenTechs.value = [] return @@ -2733,13 +3062,15 @@ async function onScheduleApply ({ schedule, weeks, techIds, mode }) { for (const t of targets) { for (let w = 0; w < weeks; w++) { for (let i = 0; i < 7; i++) { - const day = schedule[i]; if (!day || !day.on) continue - const sh = _hmNum(day.start), eh = _hmNum(day.end); if (!(eh > sh)) { failed++; continue } - const key = sh + '-' + eh; let tpl = tplByKey[key] - if (!tpl) { tpl = await ensureWindowTpl(sh, eh); tplByKey[key] = tpl } - if (!tpl) { failed++; continue } + const day = schedule[i]; if (!day || !day.on || !day.slots) continue const iso = addDaysISO(base, w * 7 + i) - try { const r = await roster.createShift({ tech: t.id, tech_name: t.name, date: iso, shift: tpl.name, hours: tpl.hours || calcHours(day.start, day.end) }); if (r && (r.ok || r.existed)) created++; else failed++ } catch (e) { failed++ } + for (const sl of day.slots) { // MULTI-SLOT : 1 quart par créneau du jour + const sh = _hmNum(sl.start), eh = _hmNum(sl.end); if (!(eh > sh)) { failed++; continue } + const key = sh + '-' + eh; let tpl = tplByKey[key] + if (!tpl) { tpl = await ensureWindowTpl(sh, eh); tplByKey[key] = tpl } + if (!tpl) { failed++; continue } + try { const r = await roster.createShift({ tech: t.id, tech_name: t.name, date: iso, shift: tpl.name, hours: tpl.hours || calcHours(sl.start, sl.end) }); if (r && (r.ok || r.existed)) created++; else failed++ } catch (e) { failed++ } + } } } } @@ -3479,6 +3810,76 @@ function gotoDispatch (t, dateIso) { // Garde le contexte de la grille derrière. Timeline + réordonnancement DRAG-DROP + retrait d'un job. const dayEditor = reactive({ open: false, tech: null, day: null, list: [], saving: false, dragIdx: null, travelMap: {}, routeReady: false }) const dayShift = ref({ min: 8, max: 16 }) // quart de l'éditeur de jour (q-range, révélé par « Modifier ») +// ── Rôle d'un quart = ENSEMBLE de compétences EXPLICITE (chips curés) ; une fonction du catalogue est un GABARIT +// qui pré-remplit ces chips, puis on retire/ajoute à la main (ex. Technicien → inst/répar/tv/tél, on retire inst). +// L'ensemble curé remplace les compétences de base ce quart-là (skill-resolver.effectiveSkills). Endpoint /roster/assignment/:name/function. +const shiftFunctions = ref([]) +function loadShiftFunctions () { roster.listFunctions().then(r => { shiftFunctions.value = (r && r.functions) || [] }).catch(() => {}) } +function funcName (id) { const f = (shiftFunctions.value || []).find(x => x.id === id || x.name === id); return f ? f.name : id } +// Valeur stockée sur un quart → liste de compétences. Rétro-compat : un NOM de fonction est étendu à ses compétences. +function expandFnVal (val) { const f = (shiftFunctions.value || []).find(x => x.id === val || x.name === val); return f ? f.skills.slice() : String(val || '').split(',').map(s => s.trim()).filter(Boolean) } +// Quarts RÉGULIERS d'un tech/jour (garde exclue) — cibles du rôle. Inclut les quarts NON publiés (Proposé, sans name) : +// le rôle se pose localement puis voyage au brouillon/publication (hub stampe function). +function regShiftsOf (techId, iso) { return cellsOf(techId, iso).filter(a => !(tplByName.value[a.shift] && tplByName.value[a.shift].on_call)) } +function dayFuncAsgs () { return (dayEditor.tech && dayEditor.day) ? regShiftsOf(dayEditor.tech.id, dayEditor.day.iso) : [] } +// Deux quarts réguliers qui se chevauchent (souvent 1 manuel + 1 du patron sur la même fenêtre) → doublon à nettoyer. +function overlapOf (list) { + const ws = list.map(a => { const t = tplByName.value[a.shift]; return t ? { s: hToNum(t.start_time), e: hToNum(t.end_time) } : null }).filter(w => w && w.s != null && w.e != null).sort((x, y) => x.s - y.s) + for (let i = 1; i < ws.length; i++) if (ws[i].s < ws[i - 1].e - 1e-6) return true + return false +} +function dayShiftOverlap () { return overlapOf(dayFuncAsgs()) } +// ── Modale FOCALISÉE « Rôle du quart » (chips) — ouverte depuis le menu de cellule (edit-role). Réutilise regShiftsOf/asgSkills/setAsgSkills/deleteDayAsg. ── +const roleDialog = reactive({ open: false, tech: null, day: null }) +function openRoleDialog (t, d) { roleDialog.tech = t; roleDialog.day = d; roleDialog.open = true; loadShiftFunctions() } +function roleAsgs () { return (roleDialog.tech && roleDialog.day) ? regShiftsOf(roleDialog.tech.id, roleDialog.day.iso) : [] } +// ── OUTIL « Corriger les chevauchements » : liste les conflits de l'horizon (hub) et les résout 1 par 1 (garder/supprimer). ── +// Compteur du badge = SCAN HUB (autoritaire, tout l'horizon) — PAS l'état local (qui peut être périmé après une +// suppression hors-SPA → badge « 14 » alors que l'outil montre 0). Rafraîchi au montage, après résolution, après publication. +const overlapCount = ref(0) +async function refreshOverlapCount () { try { const r = await roster.getShiftOverlaps(6); overlapCount.value = (r && r.count) || 0 } catch (e) { /* best-effort */ } } +const overlapsDlg = reactive({ open: false, loading: false, conflicts: [], busy: '' }) +async function openOverlaps () { overlapsDlg.open = true; await loadOverlaps() } +async function loadOverlaps () { overlapsDlg.loading = true; try { const r = await roster.getShiftOverlaps(6); overlapsDlg.conflicts = (r && r.conflicts) || []; overlapCount.value = overlapsDlg.conflicts.length } catch (e) { err(e) } finally { overlapsDlg.loading = false } } +function ovDayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam'][d.getDay()] + ' ' + iso.slice(8) + '/' + iso.slice(5, 7) } +async function _ovDel (names) { for (const nm of names) { if (!nm) continue; try { await roster.deleteAssignment(nm) } catch (e) {} } } +async function _ovAfter () { overlapsDlg.busy = ''; await loadOverlaps(); try { await loadWeek() } catch (e) {} } +async function ovKeep (c, keepName) { overlapsDlg.busy = c.tech + '|' + c.date; await _ovDel(c.shifts.filter(s => s.name !== keepName).map(s => s.name)); await _ovAfter() } // garder 1, supprimer les autres +async function ovDeleteOne (c, name) { overlapsDlg.busy = c.tech + '|' + c.date; await _ovDel([name]); await _ovAfter() } +async function ovDeleteAll (c) { overlapsDlg.busy = c.tech + '|' + c.date; await _ovDel(c.shifts.map(s => s.name)); await _ovAfter() } // quart(s) sur congé → congé prioritaire +// Supprimer UN quart précis (par identité d'objet → ne touche pas un doublon de même fenêtre). Publié → supprime aussi côté serveur. +async function deleteDayAsg (a) { + if (!a) return + pushHistory() + assignments.value = assignments.value.filter(x => x !== a) + if (a.name) { try { await roster.deleteAssignment(a.name) } catch (e) { err(e) } } + scheduleDraftSave() + $q.notify({ type: 'positive', icon: 'delete', message: 'Quart retiré · ' + asgShiftLabel(a), timeout: 1800 }) +} +// MULTI-SLOT : le rôle se pose PAR QUART (un jour peut avoir plusieurs Shift Assignment — ex. jour 8-16 + soir 16-21). +function asgSkills (a) { return a ? expandFnVal(a.function) : [] } // chips curés d'UN quart +function asgShiftLabel (a) { const t = a && tplByName.value[a.shift]; if (!t) return 'Quart'; const s = (t.start_time || '').slice(0, 5); const e = (t.end_time || '').slice(0, 5); return (s && e) ? (s + '–' + e) : (t.template_name || 'Quart') } +async function setAsgSkills (a, list) { + if (!a) return + const arr = [...new Set((Array.isArray(list) ? list : String(list || '').split(',')).map(x => (typeof x === 'string' ? x : (x && x.tag) || '')).map(s => String(s).trim()).filter(Boolean))] + const csv = arr.join(',') + a.function = csv // objet réel d'assignments.value → reflété en UI + envoyé au brouillon/publication + try { + if (a.name) await roster.setAssignmentFunction(a.name, csv) // quart publié → persiste tout de suite côté hub + else scheduleDraftSave() // quart Proposé (pas encore de name) → persiste via le brouillon (le hub stampe `function`) + $q.notify({ type: 'positive', message: arr.length ? (asgShiftLabel(a) + ' · rôle : ' + arr.join(' · ')) : (asgShiftLabel(a) + ' · rôle retiré'), timeout: 1800 }) + } catch (e) { err(e) } +} +// Compétences posées sur le quart d'une cellule (grille) → petit libellé discret. +// Quarts RÉGULIERS de la cellule, un par un (fenêtre + rôle) → bandes SÉPARÉES dans la grille (ex. 12–16 install · 16–20 support). +function cellRegShifts (techId, iso) { + return cellsOf(techId, iso) + .map(a => ({ a, tp: tplByName.value[a.shift] })) + .filter(x => x.tp && !x.tp.on_call) + .map(x => ({ name: x.a.name || '', s: hToNum(x.tp.start_time), e: hToNum(x.tp.end_time), role: expandFnVal(x.a.function) })) + .filter(x => x.s != null && x.e != null && x.e > x.s) + .sort((p, q) => p.s - q.s) +} const dayShiftEdit = ref(false) // false = affichage statique ; true = curseur d'édition révélé // Jobs LEGACY (osTicket F) datés ce jour, PAS encore importés en Dispatch Job → comptés dans l'occupation mais absents // de cellJobs (roster). On les liste en LECTURE SEULE dans l'éditeur de jour (sinon « Aucun job » alors que 88% occupé). @@ -3494,13 +3895,14 @@ function openDayEditor (t, d) { // RDV confirmé (ou heure légacy précise) = heure FIXE → verrouillé ; sinon flexible (replanifiable par la tournée). // Roster (Dispatch Jobs, ÉDITABLES) + jobs LEGACY (osTicket F, géocodés, LECTURE SEULE) dans la MÊME liste → pins sur la // carte + tournée optimisable + legs de transport, via la logique existante. Le legacy ne se sauvegarde pas (F autoritaire). - const rosterItems = cellJobs(t.id, d.iso).map(j => ({ ...j, locked: j.booking_status === 'Confirmé', showDetail: false })) - const legacyItems = dayLegacyJobs.value.map(j => ({ name: 'LEG:' + j.id, legacy: true, readonly: true, legacy_id: j.id, lid: j.id, subject: j.subject, dept: j.dept, skill: j.skill, dur: Number(j.est_h) || 1, lat: j.lat, lon: j.lon, address: j.address || '', customer: j.customer || '', locked: false, showDetail: false })) + const rosterItems = cellJobs(t.id, d.iso).map(j => ({ ...j, locked: j.booking_status === 'Confirmé' })) + const legacyItems = dayLegacyJobs.value.map(j => ({ name: 'LEG:' + j.id, legacy: true, readonly: true, legacy_id: j.id, lid: j.id, subject: j.subject, dept: j.dept, skill: j.skill, dur: Number(j.est_h) || 1, lat: j.lat, lon: j.lon, address: j.address || '', customer: j.customer || '', locked: false })) dayEditor.list = [...rosterItems, ...legacyItems] dayOriginChoice.value = null // départ = auto (politique) à chaque ouverture dayEditor.dragIdx = null; dayEditor.travelMap = {}; dayEditor.routeReady = false; dayEditor.open = true; dayShowTrack.value = false loadDayRoute() // charge la matrice de temps routiers RÉELS (Mapbox) → packedDay les utilise dès l'arrivée (réactif) loadTraccarDevices() // liste des appareils GPS (pour associer le tech, inline) — chargée une fois + loadShiftFunctions() // catalogue de fonctions (pour le sélecteur « Rôle » du quart) } // Quart MODIFIABLE depuis l'éditeur de jour : applique un gabarit d'heures (réutilise ensureWindowTpl + setCellReplace, // comme la saisie rapide « 8-17 ») sur le tech/jour courant. Édition LOCALE → « Publier » pour enregistrer. @@ -3677,10 +4079,10 @@ const dayMapEl = ref(null) let _dayMap = null; let _dayMapRO = null; let _dirTimer = null; let _dayStopMarkers = [] function ensureMapbox () { return Promise.resolve(installMaplibre()) } // MapLibre bundlé + protocole pmtiles:// (alias window.mapboxgl pour compat) const _esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])) -function dayStops () { // arrêts géolocalisés, dans l'ordre de tournée (packedDay) + infos pour le popup +function dayStops () { // arrêts géolocalisés, dans l'ordre de tournée (packedDay) + infos pour le popup ; job = objet source (clic pin → volet détail partagé) return packedDay.value.filter(hasLL).map((j, i) => ({ lon: +j.lon, lat: +j.lat, label: String(i + 1), color: j.skill ? getTagColor(j.skill) : '#1976d2', skill: j.skill || '', - subject: j.subject || '', customer: j.customer || '', address: j.address || '', time: fmtHM(j.startMin) + '–' + fmtHM(j.endMin), + subject: j.subject || '', customer: j.customer || '', address: j.address || '', time: fmtHM(j.startMin) + '–' + fmtHM(j.endMin), name: j.name || '', job: j, })) } // Segments de DÉPLACEMENT (pointillés) = l'espace entre 2 jobs dans la barre timeline. @@ -3694,7 +4096,10 @@ async function loadThread (j) { try { const r = await roster.ticketThread(id); j._thread = { loading: false, messages: r.messages || [], count: r.count || 0, status: r.status || '' } } catch (e) { j._thread = { loading: false, error: true, messages: [] } } } -function toggleJobDetail (j) { j.showDetail = !j.showDetail; if (j.showDetail) loadThread(j); focusDayJob(j) } // tournée : clic = déplie détails + fil + centre carte +// Clic sur une job (liste OU pin carte) = comportement UNIQUE et cohérent : centre la carte sur l'arrêt PUIS ouvre le +// volet détail PARTAGÉ (JobMapModule + fil + équipe + média). On ne déplie plus de fil inline dans l'éditeur de jour : +// le volet détail est la source unique (réutilisation, « n'afficher que le nécessaire »). +function openDayJob (j) { focusDayJob(j); openJobDetail(j, dayEditor.tech) } function toggleAssignThread (j) { j._showThread = !j._showThread; if (j._showThread) loadThread(j) } // panneau d'assignation : icône fil // Fermer un ticket inutile dans le legacy (depuis le panneau) → sort du dispatch (Completed). Acteur = utilisateur Authentik. function closeLegacyTicket (j) { @@ -3742,15 +4147,7 @@ async function initDayMap () { _dayMap.addSource('day-stops', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) _dayMap.addLayer({ id: 'day-stops-c', type: 'circle', source: 'day-stops', paint: { 'circle-radius': ['case', ['>', ['get', 'count'], 1], 12, 9], 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' } }) // groupe même adresse = un peu plus gros ; rayon réduit pour moins de chevauchement _dayMap.addLayer({ id: 'day-stops-l', type: 'symbol', source: 'day-stops', layout: { 'text-field': ['get', 'label'], 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-size': 12, 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } }) - // Clic sur un pin → popup avec les détails du job ; curseur main au survol. - _dayMap.on('mouseenter', 'day-stops-c', () => { _dayMap.getCanvas().style.cursor = 'pointer' }) - _dayMap.on('mouseleave', 'day-stops-c', () => { _dayMap.getCanvas().style.cursor = '' }) - _dayMap.on('click', 'day-stops-c', (e) => { - const f = e.features[0]; const p = f.properties - new window.mapboxgl.Popup({ offset: 14 }).setLngLat(f.geometry.coordinates) - .setHTML(`
${p.popup || ('' + _esc(p.label) + '')}${p.address ? '
📍 ' + _esc(p.address) + '' : ''}
`) - .addTo(_dayMap) - }) + // (Les arrêts sont des marqueurs DOM custom — voir addPin dans refreshDayMap ; les couches circle/symbol restent vides.) refreshDayMap() }) } @@ -3773,15 +4170,29 @@ function refreshDayMap () { ? (() => { const [def, vb = '0 0 24 24'] = String(icon).split('|'); const paths = def.split('&&').map(p => { const [d, style = ''] = p.split('@@'); return `` }).join(''); return `${paths}` })() : `${icon}` const pinHtml = (color, icon, num, items, round) => `${seg(items || [])}
${iconMarkup(icon)}${num ? `${num}` : ''}
` - const addPin = (lon, lat, html, popup, addr) => { + // Clic pin : MÊME cohérence que la liste. 1 job à l'adresse → ouvre directement le volet détail (openDayJob) ; plusieurs + // jobs → petit choix, chaque ligne ouvre son volet détail. Pas de jobs (pin de départ) → popup info comme avant. + const addPin = (lon, lat, html, popup, addr, items) => { const el = document.createElement('div'); el.style.cssText = 'display:flex;flex-direction:column;align-items:center;cursor:pointer;line-height:0'; el.innerHTML = html - el.addEventListener('click', (ev) => { ev.stopPropagation(); new window.mapboxgl.Popup({ offset: 16, maxWidth: '260px' }).setLngLat([lon, lat]).setHTML(`
${popup}${addr ? '
📍 ' + _esc(addr) + '' : ''}
`).addTo(_dayMap) }) + el.addEventListener('click', (ev) => { + ev.stopPropagation() + const js = (items || []).filter(i => i && i.job) + if (js.length === 1) { openDayJob(js[0].job); return } + if (js.length > 1) { + const rows = js.map((i, k) => `
${_esc(i.label)}.${_esc(i.subject)}${_esc(i.time || '')}
`).join('') + const pop = new window.mapboxgl.Popup({ offset: 16, maxWidth: '260px' }).setLngLat([lon, lat]) + .setHTML(`
${js.length} jobs${addr ? ' · ' + _esc(addr) : ''}
${rows}
`).addTo(_dayMap) + setTimeout(() => { const pe = pop.getElement(); if (pe) pe.querySelectorAll('.de-pop-row').forEach(r => r.addEventListener('click', () => { openDayJob(js[+r.dataset.k].job); pop.remove() })) }, 0) + return + } + new window.mapboxgl.Popup({ offset: 16, maxWidth: '260px' }).setLngLat([lon, lat]).setHTML(`
${popup}${addr ? '
📍 ' + _esc(addr) + '' : ''}
`).addTo(_dayMap) + }) _dayStopMarkers.push(new window.mapboxgl.Marker({ element: el, anchor: 'bottom' }).setLngLat([lon, lat]).addTo(_dayMap)) } for (const k of gord) { const g = grp[k]; const f = g.items[0] const popup = g.items.map(i => `${_esc(i.label)}. ${_esc(i.subject)}${i.time ? ' · ' + _esc(i.time) : ''}${i.customer ? ' · ' + _esc(i.customer) : ''}`).join('
') - addPin(g.lon, g.lat, pinHtml(f.color, skillIcon(f.skill), f.label, g.items, false), popup, f.address) // n° = job principale ; barre = N jobs à l'adresse + addPin(g.lon, g.lat, pinHtml(f.color, skillIcon(f.skill), f.label, g.items, false), popup, f.address, g.items) // n° = job principale ; barre = N jobs à l'adresse ; clic → volet détail } if (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) { // pin de départ (dépôt/domicile) : icône maison/entrepôt const oc = origin.kind === 'home' ? '#00897b' : '#37474f' @@ -3921,7 +4332,10 @@ function onUpdateRosterTag ({ name, color }) { const ct = customTags.value.find( const showTagManager = ref(false) const managedTags = computed(() => { const labels = new Set([...techs.value.flatMap(t => t.skills || []), ...customTags.value.map(c => c.label)]) - return [...labels].filter(Boolean).sort((a, b) => a.localeCompare(b)).map(l => ({ label: l, color: getTagColor(l), count: techs.value.filter(t => (t.skills || []).includes(l)).length })) + return [...labels].filter(Boolean).sort((a, b) => a.localeCompare(b)).map(l => { + const names = techs.value.filter(t => (t.skills || []).includes(l)).map(t => t.name) + return { label: l, color: getTagColor(l), count: names.length, techNames: names } // techNames → aperçu « qui a cette compétence » + }) }) async function renameTagGlobal (oldL, newL) { // remplace le label sur TOUS les techs (skills + niveaux + eff) + catalogue newL = String(newL || '').trim(); if (!newL || newL === oldL) return @@ -3993,7 +4407,16 @@ function techScore (t) { const v = priorityScores.value[t.id]; return v == null function techRank (t) { if (!skillFilter.value.length) return null; const i = visibleTechs.value.findIndex(x => x.id === t.id); return i >= 0 ? i + 1 : null } const visibleTechs = computed(() => { const q = (search.value || '').trim().toLowerCase() // clearable (X) met search=null → null-safe (sinon le computed plante et le champ « ne se vide pas ») - const list = techs.value.filter(t => (showHidden.value || !isHidden(t.id)) && (!groupFilter.value || t.group === groupFilter.value) && techHasSkill(t) && (!q || (t.name || '').toLowerCase().includes(q) || (t.group || '').toLowerCase().includes(q))) + // Recherche active = « où est X ? » sur TOUT l'effectif : on IGNORE les filtres compétence/groupe ET on révèle les + // ressources masquées, en ne gardant que la correspondance texte (nom ou groupe). Les filtres restreignent déjà à peu de + // ressources (facile à repérer à l'œil) → les cumuler avec la recherche masquerait justement ce qu'on cherche. + // Tri équipe/nom (prévisible sur un petit set) ; les masqués qui ressortent restent grisés + œil pour réafficher. + if (q) { + return techs.value + .filter(t => (t.name || '').toLowerCase().includes(q) || (t.group || '').toLowerCase().includes(q)) + .sort((a, b) => (a.group || '~').localeCompare(b.group || '~') || (a.name || '').localeCompare(b.name || '')) + } + const list = techs.value.filter(t => (showHidden.value || !isHidden(t.id)) && (!groupFilter.value || t.group === groupFilter.value) && techHasSkill(t)) // Filtre par compétence actif → on TRIE par priorité (meilleur score d'abord) ; sinon ordre équipe/nom. if (skillFilter.value.length) return list.slice().sort((a, b) => techScore(a) - techScore(b) || (a.name || '').localeCompare(b.name || '')) return list.slice().sort((a, b) => (a.group || '~').localeCompare(b.group || '~') || (a.name || '').localeCompare(b.name || '')) @@ -4126,7 +4549,7 @@ function patternWorksDay (techId, iso) { const t = (techs.value || []).find(x => x.id === techId); const sched = t && t.weekly_schedule if (!sched || typeof sched !== 'object') return null const day = sched[['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'][new Date(iso + 'T12:00:00Z').getUTCDay()]] - return !!(day && day.start && day.end) + return Array.isArray(day) ? day.some(s => s && s.start && s.end) : !!(day && day.start && day.end) // MULTI-SLOT : jour = tableau de quarts, ou objet simple (rétro-compat) } // Bascule absence d'1 jour sur des cases (clic + « A » ou menu). Toutes absentes → retire ; sinon marque. LOCAL → à Publier. function toggleAbsentCells (targets) { @@ -4234,9 +4657,9 @@ const occCells = computed(() => { const dur1 = (j) => Number(j.dur) || (j.est_min ? j.est_min / 60 : 1) const ordered = (jobs || []).slice().sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99))) const locked = [] - for (const j of ordered) { if (j.cancelled) continue; if ((j.booking_status === 'Confirmé' || j.assist) && j.start_h != null) { const d = dur1(j); locked.push({ s: j.start_h, e: j.start_h + d, skill: j.skill, job: j.name, assist: !!j.assist, done: !!j.done, subject: j.subject, legacy_id: j.legacy_id, legacy_dept: j.legacy_dept }) } } + for (const j of ordered) { if (j.cancelled) continue; if ((j.booking_status === 'Confirmé' || j.assist) && j.start_h != null) { const d = dur1(j); locked.push({ s: j.start_h, e: j.start_h + d, skill: j.skill, job: j.name, assist: !!j.assist, done: !!j.done, subject: j.subject, job_type: j.job_type || '', legacy_id: j.legacy_id, legacy_dept: j.legacy_dept }) } } const flow = [] // jobs « à planifier » (heure ignorée) + legacy résiduel, dans l'ordre de tournée. (Annulés exclus du timeline : bruit.) - for (const j of ordered) { if (j.cancelled) continue; if ((j.booking_status === 'Confirmé' && j.start_h != null) || j.assist) continue; const d = dur1(j); if (d > 0) flow.push({ dur: d, skill: j.skill, job: j.name, subject: j.subject, done: !!j.done, legacy_id: j.legacy_id, legacy_dept: j.legacy_dept }) } + for (const j of ordered) { if (j.cancelled) continue; if ((j.booking_status === 'Confirmé' && j.start_h != null) || j.assist) continue; const d = dur1(j); if (d > 0) flow.push({ dur: d, skill: j.skill, job: j.name, subject: j.subject, job_type: j.job_type || '', done: !!j.done, legacy_id: j.legacy_id, legacy_dept: j.legacy_dept }) } const lg = showLegacyLoad.value ? legacyLoad.value[techId + '|' + iso] : null // Seuls les jobs TERRAIN (j.field) deviennent des blocs horaires + comptent dans la charge. L'admin/bureau (officeN) est listé en pastille, pas en bloc → pas de fausse surcharge. if (lg) { for (const j of (lg.jobs || [])) { if (!j.field) continue; const d = Number(j.est_h) || 0; if (d > 0) flow.push({ dur: d, legacy: true, subject: j.subject, dept: j.dept, skill: j.skill, lid: j.id }) }; usedH += (lg.h || 0) } @@ -4246,7 +4669,7 @@ const occCells = computed(() => { for (const f of flow) { const w = f.dur * scale let guard = 0; while (guard++ < 80) { const c = locked.find(b => cursor < b.e - 0.001 && (cursor + w) > b.s + 0.001); if (c) cursor = c.e; else break } // saute par-dessus un RDV qu'il chevaucherait - synth.push({ s: cursor, e: cursor + w, skill: f.skill, legacy: f.legacy, subject: f.subject, dept: f.dept, lid: f.lid, job: f.job, done: f.done, legacy_id: f.legacy_id, legacy_dept: f.legacy_dept }); cursor += w + synth.push({ s: cursor, e: cursor + w, skill: f.skill, legacy: f.legacy, subject: f.subject, dept: f.dept, lid: f.lid, job: f.job, job_type: f.job_type || '', done: f.done, legacy_id: f.legacy_id, legacy_dept: f.legacy_dept }); cursor += w } const blocks = [...locked, ...synth] usedH = Math.round(usedH * 10) / 10 @@ -4782,7 +5205,9 @@ function openSuggest () { suggestDlg.fromSel = selectedNames.value.length > 0 // Étape « disponibilité » d'abord : défaut = techs AVEC un quart sur un des jours de la fenêtre (= disponibles) ; sinon tous les visibles. const win = suggestWindow.value // fenêtre = chips de date cochés, sinon le jour du sélecteur - const techs = visibleTechs.value || [] + // P2 — on EXCLUT d'emblée les techs en pause ou absents/en congé sur toute la fenêtre (ex. « en vacances » alors qu'un + // quart traîne à l'horaire) : ils ne doivent pas être pré-sélectionnés ni recevoir de jobs. + const techs = (visibleTechs.value || []).filter(t => !suggestUnavailable(t)) const withShift = techs.filter(t => win.some(iso => hasShiftDay(t.id, iso))) const availPool = withShift.length ? withShift : techs // Pré-sélection par COMPÉTENCE : parmi les dispos, ceux dont les compétences couvrent au moins un skill requis @@ -4827,6 +5252,7 @@ const suggestJobs = () => { return jobs } const suggestJobCount = computed(() => suggestJobs().length) +const suggestJobList = computed(() => suggestJobs()) // P6 — récap simplifié (évite d'appeler suggestJobs() plusieurs fois dans le template) // Y a-t-il un filtre restreignant le dispatch ? (lasso ou chips de TYPE — les chips de date ne comptent plus : le jour vient de selDay) const suggestFiltered = computed(() => !!(selectedNames.value.length || floatingPool.skillFilter.value.length)) // Libellé du périmètre : « sélectionnés » (lasso) · « des types cochés » (chips de compétence). @@ -4840,10 +5266,19 @@ const suggestWindowLabel = computed(() => { if ((floatingPool.dateFilter.value || []).length) return w.map(windowDayLabel).join(', ') return w.length === 1 ? windowDayLabel(w[0]) : (windowDayLabel(w[0]) + ' + ' + windowDayLabel(w[w.length - 1])) }) +// P2 — un tech est INDISPONIBLE pour ce dispatch s'il est en pause indéfinie, OU absent/en congé sur TOUS les jours de +// la fenêtre visée (vacances). On ne le pré-sélectionne pas, on le grise + on le signale (pastille congé) dans la grille. +// (Absent seulement CERTAINS jours de la fenêtre → reste dispo : la capacité par jour est gérée en aval.) +function suggestUnavailable (t) { + if (!t) return false + if (isPaused(t)) return true + const win = suggestWindow.value || [] + return win.length > 0 && win.every(iso => isAbsent(t.id, iso)) +} function suggestSelectTechs (which) { // 'all' | 'shift' | 'none' const techs = visibleTechs.value || []; const sel = {} - if (which === 'all') techs.forEach(t => { sel[t.id] = true }) - else if (which === 'shift') techs.forEach(t => { if ((dayList.value || []).some(d => hasShiftDay(t.id, d.iso))) sel[t.id] = true }) + if (which === 'all') techs.forEach(t => { if (!suggestUnavailable(t)) sel[t.id] = true }) + else if (which === 'shift') techs.forEach(t => { if (!suggestUnavailable(t) && (dayList.value || []).some(d => hasShiftDay(t.id, d.iso))) sel[t.id] = true }) suggestDlg.techSel = sel } // « Groupe de techs par compétence » : sélectionner d'un coup tous les techs d'une compétence (ex : tous les monteurs). @@ -4852,10 +5287,10 @@ const suggestSkillGroups = computed(() => { for (const t of (visibleTechs.value || [])) for (const s of (t.skills || [])) m[s] = (m[s] || 0) + 1 return Object.entries(m).map(([skill, n]) => ({ skill, n })).sort((a, b) => b.n - a.n) }) -function suggestSkillOn (skill) { const techs = (visibleTechs.value || []).filter(t => (t.skills || []).includes(skill)); return !!techs.length && techs.every(t => suggestDlg.techSel[t.id]) } -function suggestSelectSkill (skill) { // (dé)sélectionne TOUT le groupe de techs ayant cette compétence +function suggestSkillOn (skill) { const techs = (visibleTechs.value || []).filter(t => (t.skills || []).includes(skill) && !suggestUnavailable(t)); return !!techs.length && techs.every(t => suggestDlg.techSel[t.id]) } +function suggestSelectSkill (skill) { // (dé)sélectionne TOUT le groupe de techs ayant cette compétence (hors indisponibles) const sel = { ...suggestDlg.techSel } - const techs = (visibleTechs.value || []).filter(t => (t.skills || []).includes(skill)) + const techs = (visibleTechs.value || []).filter(t => (t.skills || []).includes(skill) && !suggestUnavailable(t)) const allOn = techs.length && techs.every(t => sel[t.id]) techs.forEach(t => { if (allOn) delete sel[t.id]; else sel[t.id] = true }) suggestDlg.techSel = sel @@ -4864,9 +5299,9 @@ function suggestSelectSkill (skill) { // (dé)sélectionne TOUT le groupe de tec // utile quand la compétence déduite du job est imprécise. Persisté serveur (useUserPrefs, suit l'usager). ── const { prefs: techGroupPrefs, save: saveTechGroupPrefs } = useUserPrefs('techGroups', { groups: [] }) const techGroups = computed(() => techGroupPrefs.value.groups || []) -function applyTechGroup (g) { // remplace la sélection par les techs du groupe (présents/visibles seulement) +function applyTechGroup (g) { // remplace la sélection par les techs du groupe (présents/visibles ET disponibles seulement) const ids = new Set(g.ids || []); const sel = {} - for (const t of (visibleTechs.value || [])) if (ids.has(t.id)) sel[t.id] = true + for (const t of (visibleTechs.value || [])) if (ids.has(t.id) && !suggestUnavailable(t)) sel[t.id] = true suggestDlg.techSel = sel } function saveTechGroup () { @@ -5237,7 +5672,7 @@ const allDayRoutes = computed(() => { // TOUTES les tournées du jour (couleurs const dayRoutes = computed(() => allDayRoutes.value.filter(r => !hiddenRouteTechs.value.has(r.id))) // ce que la carte affiche // ── Positions GPS LIVE des techs (onglet Tournées) : polling doux (25 s) tant que l'onglet est ouvert ; couleur = celle de la tournée du tech ── -const livePosRaw = ref([]) // [{ techId, techName, lat, lon, time, speed }] +const livePosRaw = ref([]) // [{ techId, techName, deviceName, name, lat, lon, time, speed }] — techId null = véhicule sans tech assigné const showLivePos = ref(true) // afficher/masquer les positions GPS live sur la carte des tournées let _livePosTimer = null async function refreshLivePositions () { try { const r = await roster.techPositions(); livePosRaw.value = (r && r.positions) || [] } catch (e) { /* non bloquant */ } } @@ -5248,8 +5683,9 @@ const dayLivePositions = computed(() => { const colorByTech = {}; for (const r of (allDayRoutes.value || [])) colorByTech[r.id] = r.color const visIds = new Set((visibleTechs.value || []).map(t => t.id)) // filtre de compétence : seules les positions des techs capables (+ non masqués) sur la carte return (livePosRaw.value || []) - .filter(p => visIds.has(p.techId) && !hiddenRouteTechs.value.has(p.techId)) - .map(p => ({ techId: p.techId, name: p.techName, lat: p.lat, lon: p.lon, time: p.time, speed: p.speed, color: colorByTech[p.techId] || '#455a64' })) + // véhicule SANS tech assigné → toujours affiché (gris, initiales du device) ; véhicule d'un tech → filtré par visibilité/masquage + .filter(p => !p.techId || (visIds.has(p.techId) && !hiddenRouteTechs.value.has(p.techId))) + .map(p => ({ techId: p.techId, techName: p.techName, deviceName: p.deviceName, name: p.techName || p.deviceName, lat: p.lat, lon: p.lon, time: p.time, speed: p.speed, color: p.techId ? (colorByTech[p.techId] || '#455a64') : '#94a3b8' })) }) watch(boardView, (v) => { if (v === 'routes') startLivePositions(); else stopLivePositions() }, { immediate: true }) watch(routesDay, () => { for (const k in dayRouteMetrics) delete dayRouteMetrics[k]; hiddenRouteTechs.value = new Set() }) @@ -5679,6 +6115,14 @@ async function saveCost (t) { try { await roster.setTechCost(t.id, { salary: t.s function calcHours (st, et) { if (!st || !et) return 0; const [h1, m1] = st.split(':').map(Number); const [h2, m2] = et.split(':').map(Number); let mins = (h2 * 60 + m2) - (h1 * 60 + m1); if (mins < 0) mins += 1440; return Math.round(mins / 60 * 100) / 100 } // Types de shift → extrait dans components/planif/ShiftTypesDialog.vue (état + fonctions déplacés ; @changed → refreshTemplates). function snapshotServer (list) { serverSet.value = new Set(list.map(a => a.tech + '|' + a.date + '|' + a.shift)) } +const coverageGaps = ref([]) // jours de semaine sans aucun tech terrain de quart (bandeau de couverture) +const COV_DOW = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam'] +function covLabel (g) { const p = String(g && g.date || '').split('-'); return (COV_DOW[g && g.dow] || '') + ' ' + (p[2] || '') + '/' + (p[1] || '') } +// Ajustements temporaires (par tech) — chargés pour toute la fenêtre → affichés sur la grille + éditeur de jour (P2). +const skillExcByTech = ref({}) +async function loadAdjustments () { try { const e = await roster.getAllSkillExceptions(); skillExcByTech.value = (e && e.all) || {} } catch (e) { skillExcByTech.value = {} } } +function activeAdjustment (techId, iso) { const list = skillExcByTech.value[techId]; if (!Array.isArray(list)) return null; return list.find(e => iso >= e.from && iso <= e.to) || null } +function adjLabel (e) { if (!e) return ''; return (e.mode === 'add' ? '+ ' : '− ') + (e.skills || []).join(', ') + (e.reason ? ' (' + e.reason + ')' : '') } async function loadWeek () { loading.value = true try { @@ -5692,6 +6136,8 @@ async function loadStats () { try { const s = await roster.getStats(start.value, days.value); dailyStats.value = s.stats || [] } catch (e) { /* non bloquant */ } try { const o = await roster.getOccupancy(start.value, $q.screen.lt.md ? MOBILE_STRIP_DAYS : days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ } try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) { /* non bloquant */ } + try { const c = await roster.getFieldCoverage(start.value, days.value); coverageGaps.value = (c && c.gaps) || [] } catch (e) { coverageGaps.value = [] } + loadAdjustments() // ajustements temporaires (par tech) → grille + éditeur de jour loadLegacyWindow() // charge legacy datée de la fenêtre (durées estimées appliquées aux timelines) — non bloquant } // Refresh CIBLÉ + rapide de l'occupation (recharge les blocs/jobs des cellules → timeline à jour) après une assignation, @@ -5790,7 +6236,7 @@ async function doWeekStatus (mode) { } // demande -function loadLS () { try { holidays.value = JSON.parse(localStorage.getItem(LS_HOL) || '[]') } catch { holidays.value = [] } try { weekTemplates.value = JSON.parse(localStorage.getItem(LS_TPL) || '[]') } catch { weekTemplates.value = [] } try { gardeRules.value = JSON.parse(localStorage.getItem(LS_GARDE) || '[]') } catch { gardeRules.value = [] } try { manualGarde.value = JSON.parse(localStorage.getItem(LS_GARDE_MANUAL) || '{}') } catch { manualGarde.value = {} } try { customTags.value = JSON.parse(localStorage.getItem('roster-skill-tags-v1') || '[]') } catch { customTags.value = [] } try { hiddenTechs.value = JSON.parse(localStorage.getItem('roster-hidden-techs-v1') || '[]') } catch { hiddenTechs.value = [] } try { pendingAbs.value = JSON.parse(localStorage.getItem(LS_PENDING_ABS) || '{}') } catch { pendingAbs.value = {} } try { pendingPause.value = JSON.parse(localStorage.getItem(LS_PENDING_PAUSE) || '{}') } catch { pendingPause.value = {} } } +function loadLS () { try { holidays.value = JSON.parse(localStorage.getItem(LS_HOL) || '[]') } catch { holidays.value = [] } try { weekTemplates.value = JSON.parse(localStorage.getItem(LS_TPL) || '[]') } catch { weekTemplates.value = [] } try { gardeRules.value = JSON.parse(localStorage.getItem(LS_GARDE) || '[]') } catch { gardeRules.value = [] } try { manualGarde.value = JSON.parse(localStorage.getItem(LS_GARDE_MANUAL) || '{}') } catch { manualGarde.value = {} } try { customTags.value = JSON.parse(localStorage.getItem('roster-skill-tags-v1') || '[]') } catch { customTags.value = [] } try { hiddenTechs.value = JSON.parse(localStorage.getItem('roster-hidden-techs-v1') || '[]') } catch { hiddenTechs.value = [] } try { pendingAbs.value = JSON.parse(localStorage.getItem(LS_PENDING_ABS) || '{}') } catch { pendingAbs.value = {} } try { pendingPause.value = JSON.parse(localStorage.getItem(LS_PENDING_PAUSE) || '{}') } catch { pendingPause.value = {} } try { techShiftPresets.value = JSON.parse(localStorage.getItem(LS_SHIFT_PRESETS) || '{}') || {} } catch { techShiftPresets.value = {} } } // ── Rotation de garde par département (récurrence + rotation) ──────────────── const GARDE_EPOCH = '2026-01-05' // lundi de référence pour l'index de semaine @@ -5840,10 +6286,25 @@ const gardePreview = computed(() => { } return out }) -function saveGarde () { localStorage.setItem(LS_GARDE, JSON.stringify(gardeRules.value)) } +// Persistance PARTAGÉE (hub) + cache localStorage local. Le hub est autoritaire ; le cache permet un paint instantané. +function saveGarde () { + localStorage.setItem(LS_GARDE, JSON.stringify(gardeRules.value)) + roster.replaceGardeQueues(gardeRules.value).catch(e => $q.notify({ type: 'negative', message: 'Sauvegarde des files de garde échouée (partage) : ' + (e.message || e) })) +} +// Charge les files depuis le hub (partagées). Migration one-shot : si le hub est vide mais le localStorage a des règles, on les y pousse. +async function loadGardeQueues () { + try { + const r = await roster.listGardeQueues(); const remote = (r && r.queues) || [] + if (remote.length) { gardeRules.value = remote; localStorage.setItem(LS_GARDE, JSON.stringify(remote)); return } + if (gardeRules.value.length) { // migration : les règles locales deviennent partagées + await roster.replaceGardeQueues(gardeRules.value) + $q.notify({ type: 'info', message: gardeRules.value.length + ' file(s) de garde migrée(s) vers le partage serveur', timeout: 4000 }) + } + } catch (e) { /* hub indispo → on garde le cache localStorage déjà chargé par loadLS */ } +} function addGardeRule () { if (!newGardeRule.shift || !newGardeRule.steps.length || !newGardeRule.weekdays.length) { $q.notify({ type: 'warning', message: 'Shift, jours et au moins un tech requis' }); return } - const rule = { id: editingGardeId.value || Date.now(), dept: newGardeRule.dept || '—', shift: newGardeRule.shift, shiftWeekend: newGardeRule.shiftWeekend || '', weekdays: [...newGardeRule.weekdays], anchor: newGardeRule.anchor || mondayISO(start.value), steps: newGardeRule.steps.map(s => ({ tech: s.tech, weeks: Number(s.weeks) || 1 })) } + const rule = { id: editingGardeId.value || ('q' + Date.now()), dept: newGardeRule.dept || '—', shift: newGardeRule.shift, shiftWeekend: newGardeRule.shiftWeekend || '', weekdays: [...newGardeRule.weekdays], anchor: newGardeRule.anchor || mondayISO(start.value), steps: newGardeRule.steps.map(s => ({ tech: s.tech, weeks: Number(s.weeks) || 1 })) } if (editingGardeId.value) gardeRules.value = gardeRules.value.map(r => r.id === editingGardeId.value ? rule : r) else gardeRules.value = [...gardeRules.value, rule] saveGarde(); editingGardeId.value = null; newGardeRule.steps = []; newGardeRule.weekdays = [] @@ -5852,14 +6313,18 @@ function addGardeRule () { function removeGardeRule (i) { gardeRules.value = gardeRules.value.filter((_, j) => j !== i); saveGarde(); if (editingGardeId.value && !gardeRules.value.some(r => r.id === editingGardeId.value)) editingGardeId.value = null } function gardeDowLabel (r) { return (r.weekdays || []).map(w => (GARDE_DOW.find(x => x.v === w) || {}).l).join('') } function gardeSeqLabel (r) { return ruleSteps(r).map(s => ((techs.value.find(t => t.id === s.tech) || {}).name || s.tech) + (s.weeks > 1 ? ' ×' + s.weeks : '')).join(' → ') } +// ── Roster d'une file : qui est sur appel maintenant / prochain + contact (clic sur un nom) ────────── +function techFullName (techId) { const t = techByIdMap.value[techId]; return (t && t.name) || techId || '—' } +function techContact (techId) { const t = techByIdMap.value[techId] || {}; return { id: techId, name: t.name || techId, phone: (t.phone || '').trim(), email: (t.email || '').trim() } } +function queueCurrentTech (r) { return rotationTech(r, todayISO()) } // tech sur appel AUJOURD'HUI (rotation ancrée + saut d'absent) +function queueNextTech (r) { const cur = queueCurrentTech(r); for (let k = 1; k <= 12; k++) { const id = rotationTech(r, addDaysISO(mondayISO(todayISO()), k * 7)); if (id && id !== cur) return id } return null } // prochain tech DIFFÉRENT dans la rotation +function makeGardeNowFor (techId) { if (!techId) return; addGardeForDay(todayISO(), techId); $q.notify({ type: 'positive', message: techFullName(techId) + ' — sur appel aujourd\'hui', timeout: 2500 }) } // Génère les gardes de la semaine affichée selon les règles (rotation par département) const gardeHorizon = ref(8) // nb de semaines à matérialiser (évènement récurrent) // Génère la garde sur un HORIZON (plusieurs semaines) et l'écrit directement (publié) → navigable semaine par semaine. -async function applyGardeRules () { - if (!gardeRules.value.length && !Object.keys(manualGarde.value).length) { $q.notify({ type: 'info', message: 'Aucune règle — ajoute-en une' }); return } - if (dirty.value && !window.confirm('Les modifications non publiées de la grille seront rechargées. Continuer ?')) return - const weeks = gardeHorizon.value || 8; const wk0 = mondayISO(start.value) - // Construit la même garde EFFECTIVE que l'affichage, mais sur tout l'horizon : rotation par règles… +// Construit la garde EFFECTIVE (rotation par règles + overrides manuels « G ») sur [wk0, wk0+weeks·7) → assignations +// à matérialiser. SOURCE UNIQUE partagée par « Publier » et l'auto-prolongation (même calcul que l'affichage). +function buildGardeMaterialization (wk0, weeks) { const horizon = new Set(); for (let i = 0; i < weeks * 7; i++) horizon.add(addDaysISO(wk0, i)) const map = {} for (const iso of horizon) { @@ -5872,11 +6337,42 @@ async function applyGardeRules () { map[id + '|' + iso] = sh } } - // … + overrides MANUELS « G » dans l'horizon (déplacements faits à la main) → publiés aussi. for (const key in manualGarde.value) { const iso = key.split('|')[1]; if (!horizon.has(iso)) continue; const v = manualGarde.value[key]; if (v === 'off') delete map[key]; else if (v === 'on') { const sh = gardeShiftForDay(iso); if (sh) map[key] = sh } } const list = [] for (const key in map) { const [id, iso] = key.split('|'); const sh = map[key]; const tpl = tplByName.value[sh]; const t = techs.value.find(x => x.id === id); list.push({ tech: id, tech_name: t ? t.name : id, date: iso, shift: sh, hours: (tpl && tpl.hours) || 8, zone: (tpl && tpl.zone) || '' }) } const shifts = [...new Set([...gardeRules.value.flatMap(r => [r.shift, r.shiftWeekend].filter(Boolean)), ...Object.values(map)])] + return { list, shifts, map } +} + +// AUTO-PROLONGATION de la garde : au chargement, si l'horizon matérialisé descend sous MIN semaines, on prolonge +// SEULEMENT la queue manquante (au-delà du dernier jour publié) jusqu'à TARGET semaines. Réutilise le calcul exact +// de « Publier » → aucune divergence. NE ré-écrit PAS l'existant (start = semaine APRÈS le dernier jour publié). +// Déclencheur = ouverture de la page (Planif ouverte ~quotidiennement) — pas un cron serveur (rotation = côté client). +const AUTOROLL_MIN_WEEKS = 6; const AUTOROLL_TARGET_WEEKS = 26; let _gardeRolled = false +async function autoRollGarde () { + if (_gardeRolled || !gardeRules.value.length || dirty.value) return // pas de files, ou édits en attente → on ne touche pas + if (!templates.value.some(t => t.on_call)) return + try { + const st = await roster.getGardeStatus(); const furthest = st && st.furthest + const today = todayISO(); const minEnd = addDaysISO(today, AUTOROLL_MIN_WEEKS * 7) + if (furthest && furthest >= minEnd) { _gardeRolled = true; return } // horizon déjà sain → rien à faire + const gapStart = mondayISO(furthest && furthest >= today ? addDaysISO(furthest, 7) : today) // démarre APRÈS le dernier publié + const targetEnd = addDaysISO(mondayISO(today), AUTOROLL_TARGET_WEEKS * 7) + const weeks = Math.ceil((d2ms(targetEnd) - d2ms(gapStart)) / (7 * 86400000)) + if (weeks < 1) { _gardeRolled = true; return } + const { list, shifts } = buildGardeMaterialization(gapStart, weeks) + if (!list.length) { _gardeRolled = true; return } + await roster.applyGardeHorizon(gapStart, weeks, list, shifts) + _gardeRolled = true + $q.notify({ type: 'info', message: `Garde prolongée automatiquement (~${AUTOROLL_TARGET_WEEKS} sem. couvertes)`, timeout: 3500 }) + } catch (e) { /* best-effort : si le hub répond mal, on laisse la garde telle quelle */ } +} + +async function applyGardeRules () { + if (!gardeRules.value.length && !Object.keys(manualGarde.value).length) { $q.notify({ type: 'info', message: 'Aucune règle — ajoute-en une' }); return } + if (dirty.value && !window.confirm('Les modifications non publiées de la grille seront rechargées. Continuer ?')) return + const weeks = gardeHorizon.value || 8; const wk0 = mondayISO(start.value) + const { list, shifts } = buildGardeMaterialization(wk0, weeks) try { const r = await roster.applyGardeHorizon(wk0, weeks, list, shifts) showGarde.value = false; await loadWeek() @@ -5931,7 +6427,7 @@ function applyTemplate (tm) { // édition + sélection const menu = reactive({ show: false, target: null, tech: null, day: null, x: 0, y: 0 }) const menuAnchorEl = ref(null) // ancre 1px positionnée au CURSEUR → le menu s'ouvre là où on clique -const menuRange = ref({ min: 8, max: 16 }); const quickEntry = ref('') +const quickEntry = ref('') function rect (sti, sdi, eti, edi) { const t0 = Math.min(sti, eti), t1 = Math.max(sti, eti), d0 = Math.min(sdi, edi), d1 = Math.max(sdi, edi) const out = [] @@ -5977,7 +6473,7 @@ async function autosaveDraft () { try { await roster.publishWeek(start.value, days.value, assignments.value, false, 'draft') } catch (e) { /* non bloquant : réessaie au prochain edit */ } finally { autosaving.value = false } } function cellSnap (techId, iso) { return JSON.parse(JSON.stringify(cellsOf(techId, iso))) } // état AVANT d'une cellule → payload de revert du journal -function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a => a.shift === tpl.name)) return; const before = cellSnap(techId, iso); assignments.value = [...assignments.value, { tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }]; logChange('Quart ajouté · ' + techName + ' · ' + iso, { tech: techId, date: iso, before }); scheduleDraftSave() } +function addShift (techId, techName, iso, tpl, fn) { if (cellsOf(techId, iso).some(a => a.shift === tpl.name)) return; const before = cellSnap(techId, iso); assignments.value = [...assignments.value, { tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color, function: fn || '' }]; logChange('Quart ajouté · ' + techName + ' · ' + iso, { tech: techId, date: iso, before }); scheduleDraftSave() } function setCellReplace (techId, techName, iso, tpl) { const before = cellSnap(techId, iso); const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept; logChange('Quart défini · ' + techName + ' · ' + iso, { tech: techId, date: iso, before }); scheduleDraftSave() } function removeShift (techId, iso, shift) { assignments.value = assignments.value.filter(a => !(a.tech === techId && a.date === iso && a.shift === shift)) } function clearLocal (techId, iso) { const before = cellSnap(techId, iso); assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)); logChange('Quart retiré · ' + iso, { tech: techId, date: iso, before }); scheduleDraftSave() } @@ -5985,22 +6481,18 @@ function clearLocal (techId, iso) { const before = cellSnap(techId, iso); assign function openShiftMenu (t, d, ev, ti, di) { selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d if (ev && ev.clientX != null) { menu.x = ev.clientX; menu.y = ev.clientY; menu.target = menuAnchorEl.value } else { menu.target = ev && ev.currentTarget } // ancre au curseur si dispo - const wr = winOf(t.id, d.iso, false); quickEntry.value = '' - menuRange.value = wr ? { min: wr.s, max: wr.e } : { min: 8, max: 16 } menu.show = true } -// Y a-t-il une bande timeline cliquable sur cette case ? (shift régulier OU garde, et pas absent/pause) -// Clic gauche sur le FOND de cellule (zone sans quart ni job) → menu d'horaire (POSER / modifier un quart). -// Le clic sur la BANDE de quart/garde ou un BLOC de job ouvre la tournée (openDayFromCell, via @click.stop). +// La cellule a-t-elle du TRAVAIL à voir (jobs terrain ou tickets sans déplacement) ? → sinon le menu d'horaire suffit. +function cellHasWork (techId, iso) { const o = cellOcc(techId, iso); return !!(o && (((o.blocks || []).length) || o.officeN)) } +// Clic sur une cellule : tournée (liste + carte) SEULEMENT s'il y a des jobs ; sinon (jour vide OU quart sans job) → +// menu d'horaire directement (éditer/ajouter/scinder les quarts). Le clic sur un BLOC de job ouvre toujours la tournée. function onCellClick (t, d, ev, ti, di) { if (justDragged.value) { justDragged.value = false; return } activeCell.value = { id: t.id, name: t.name, iso: d.iso } // mémorise la case pour Cmd+C/V if (ev.shiftKey && anchor.value) { selectBlock(ti, di); return } if (ev.ctrlKey || ev.metaKey) { const k = t.id + '|' + d.iso; selection.value = selSet.value.has(k) ? selection.value.filter(x => x !== k) : [...selection.value, k]; anchor.value = { ti, di }; return } - // Clic = LISTE DE TÂCHES du tech (tournée du jour) dès que la cellule a du contenu (quart, jobs terrain, ou tickets sans déplacement). - // Cellule vide = menu d'horaire (on garde la pose de quart au clic). La pose en lot reste : glisser-sélectionner + saisie rapide / clic droit. - const occ = cellOcc(t.id, d.iso) - if (hasReg(t.id, d.iso) || onGarde(t.id, d.iso) || (occ && (occ.noShift || occ.officeN))) openDayFromCell(t, d) + if (cellHasWork(t.id, d.iso)) openDayFromCell(t, d) else openShiftMenu(t, d, ev, ti, di) } // Clic sur la BANDE de quart/garde ou un BLOC de job → ouvre la tournée du jour. Guardé contre les fins de drag. @@ -6015,12 +6507,25 @@ function selectBlock (ti, di) { const a = anchor.value; const t0 = Math.min(a.ti function maybeSelectCol (di) { const ks = visibleTechs.value.map(t => t.id + '|' + dayList.value[di].iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] } function maybeSelectRow (ti) { const ks = dayList.value.map(d => visibleTechs.value[ti].id + '|' + d.iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] } function isRowSelected (ti) { const t = visibleTechs.value[ti]; if (!t || !dayList.value.length) return false; return dayList.value.every(d => selSet.value.has(t.id + '|' + d.iso)) } // rangée entière sélectionnée ? -const menuCellShifts = computed(() => (menu.tech && menu.day) ? cellsOf(menu.tech.id, menu.day.iso) : []) +// Quarts de la cellule ENRICHIS (start/end/on_call depuis le modèle) → le menu affiche des rangées heure→heure. +const menuCellShifts = computed(() => { + if (!menu.tech || !menu.day) return [] + return cellsOf(menu.tech.id, menu.day.iso).map(a => { const t = tplByName.value[a.shift] || {}; return { ...a, on_call: !!t.on_call, start: t.start_time ? t.start_time.slice(0, 5) : '', end: t.end_time ? t.end_time.slice(0, 5) : '', function: a.function || '' } }) +}) +// Réconcilie les quarts RÉGULIERS de la cellule vers l'ensemble de fenêtres voulu (garde `on_call` préservée). Le rôle +// (function) voyage AVEC sa fenêtre → posé sur le quart local ; conservé au brouillon/publication (hub publish stampe function). +async function onSetWindows (list) { + if (!menu.tech || !menu.day) return + pushHistory() + const techId = menu.tech.id; const techName = menu.tech.name; const iso = menu.day.iso + for (const a of cellsOf(techId, iso).slice()) { const t = tplByName.value[a.shift]; if (t && !t.on_call) removeShift(techId, iso, a.shift) } + for (const w of (list || [])) { if (w && w.max > w.min) { const tpl = await ensureWindowTpl(w.min, w.max); if (tpl) addShift(techId, techName, iso, tpl, w.function || '') } } + scheduleDraftSave() +} const menuIsAbsent = computed(() => (menu.tech && menu.day) ? isAbsent(menu.tech.id, menu.day.iso) : false) function toggleAbsentMenu () { if (menu.tech && menu.day) { toggleAbsentCells([menu.tech.id + '|' + menu.day.iso]); menu.show = false } } const menuIsGarde = computed(() => (menu.tech && menu.day) ? onGarde(menu.tech.id, menu.day.iso) : false) function toggleGardeMenu () { if (menu.tech && menu.day) { toggleGardeCells([menu.tech.id + '|' + menu.day.iso]); menu.show = false } } -function removeShiftFromMenu (a) { pushHistory(); removeShift(a.tech, a.date, a.shift) } function clearOne () { if (menu.tech && menu.day) { pushHistory(); clearLocal(menu.tech.id, menu.day.iso); menu.show = false } } // Menu : copier / coller (marche au clic, sans Cmd+clic) + ajuster l'horaire au slider function copyFromMenu () { if (!menu.tech || !menu.day) return; cellClipboard.value = cellsOf(menu.tech.id, menu.day.iso).map(a => a.shift); $q.notify({ type: 'positive', message: cellClipboard.value.length ? (cellClipboard.value.length + ' shift(s) copié(s) — ouvre une autre case puis Coller') : 'Case vide copiée (Coller la videra)' }) } @@ -6033,11 +6538,6 @@ async function ensureWindowTpl (min, max) { if (!tpl) { try { await roster.createTemplate({ template_name: nm, start_time: s + ':00', end_time: e + ':00', hours: calcHours(s, e), color: '#1976d2', default_required: 1, on_call: 0 }); await refreshTemplates(); tpl = templates.value.find(t => t.template_name === nm) } catch (e2) { err(e2); return null } } return tpl } -async function applyWindow (min, max) { - if (!menu.tech || !menu.day || max <= min) return - const tpl = await ensureWindowTpl(min, max) - if (tpl) { pushHistory(); setCellReplace(menu.tech.id, menu.tech.name, menu.day.iso, tpl); menu.show = false } -} // Saisie rapide d'heures : « 8-17 » · « 8:30-16 » · « 830-16 » · « 85 » (=8→17, dernier chiffre en pm si ≤ début). function parseHM (tok) { tok = String(tok).trim().toLowerCase().replace(/h/g, ':').replace(/[^\d:]/g, ''); if (!tok) return null; if (tok.includes(':')) { const [h, m] = tok.split(':'); return Number(h) + (Number(m || 0)) / 60 } if (tok.length >= 3) return Number(tok.slice(0, -2)) + Number(tok.slice(-2)) / 60; return Number(tok) } function parseQuickShift (str) { @@ -6121,9 +6621,11 @@ function onLegacyUpdate (data) { for (const c of ch) { if (c.job) flashJob.value = c.job } scheduleLegacyRefresh() } -const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } }) +// onReconnect : après une coupure du flux (ex. redéploiement du hub), on RESYNCHRONISE le board (le hub ne rejoue pas les +// deltas manqués) → recharge occupation + pool + géofence pour rattraper les tickets qui ont bougé pendant l'absence. +const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate }, onReconnect: () => { reloadOccupancy(); if (reloadPool) reloadPool() } }) -onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } if (route.query.day || route.query.skill) focusDay(route.query.day, route.query.skill); if (route.query.sched) { const _t = (techs.value || []).find(x => String(x.id) === String(route.query.sched)); if (_t) nextTick(() => openTechSchedule(_t)) } await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } try { await reloadPool() } catch (e) { /* */ } /* charge le pool (compteurs badge + « N à assigner » par jour) SANS ouvrir le panneau flottant — il s'ouvre à la demande : bouton « À assigner » ou clic sur l'alerte d'un jour */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ }) +onMounted(async () => { loadLS(); loadGardeQueues(); /* files de garde partagées (hub) — reconcilie le cache localStorage, migre au besoin */ document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } if (route.query.day || route.query.skill) focusDay(route.query.day, route.query.skill); if (route.query.sched) { const _t = (techs.value || []).find(x => String(x.id) === String(route.query.sched)); if (_t) nextTick(() => openTechSchedule(_t)) } await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } try { await reloadPool() } catch (e) { /* */ } /* charge le pool (compteurs badge + « N à assigner » par jour) SANS ouvrir le panneau flottant — il s'ouvre à la demande : bouton « À assigner » ou clic sur l'alerte d'un jour */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000); refreshOverlapCount(); /* ligne « maintenant » + badge chevauchements (scan hub autoritaire) */ autoRollGarde() /* prolonge la garde si l'horizon matérialisé descend sous ~6 sem. (tail-only, sûr) */ }) onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload); if (_nowTimer) clearInterval(_nowTimer); stopLivePositions(); if (_kbRO) _kbRO.disconnect() }) // Actions principales de la Planif → joignables via la palette (⌘K), même reléguées dans un menu. @@ -6135,6 +6637,8 @@ registerActions('planif', [ { id: 'planif-create', label: 'Créer une tâche / intervention', icon: 'add', group: 'Planification', keywords: 'creer job tache intervention soumission', run: () => { createChooser.value = true } }, { id: 'planif-assign', label: 'Jobs à assigner', icon: 'assignment_ind', group: 'Planification', keywords: 'assigner pool repartir', run: openAssignPanel }, { id: 'planif-leave', label: 'Congés / absences', icon: 'beach_access', group: 'Planification', keywords: 'conges absences vacances', run: () => { showLeave.value = true } }, + { id: 'planif-skill-exc', label: 'Ajustement temporaire', icon: 'healing', group: 'Planification', keywords: 'ajustement competence temporaire restriction blessure mal de dos retirer ajouter skill', run: () => { openSkillExc('') } }, + { id: 'planif-functions', label: 'Modèles de rôle', icon: 'badge', group: 'Planification', keywords: 'modele role fonction skillset support vente porte a porte quart catalogue competences', run: () => { showFunctionCatalog.value = true } }, { id: 'planif-templates', label: 'Modèles de semaine', icon: 'bookmark', group: 'Planification', keywords: 'modeles semaine patron template horaire', run: () => { showWeekTemplates.value = true } }, { id: 'planif-apply-default', label: 'Appliquer le modèle par défaut (★)', icon: 'star', group: 'Planification', keywords: 'modele defaut appliquer patron star', run: applyDefault }, ]) @@ -6146,6 +6650,13 @@ onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 — flush le bro /* Barre d'actions de sélection : flottante (fixed) → hors flux, aucune incidence sur la hauteur de la grille. */ .sel-actions { position: fixed; left: 50%; transform: translateX(-50%); bottom: 18px; z-index: 4000; display: flex; align-items: center; flex-wrap: wrap; gap: 2px; max-width: 94vw; padding: 6px 12px; background: #e0f2f1; color: #00695c; border: 1px solid #4db6ac; border-radius: 9px; box-shadow: 0 6px 22px rgba(0,0,0,.20); } .garde-editor { background: #faf8f6; border: 1px solid #e8e0d8; } /* sous-panneau éditeur de garde */ +/* Chips du roster de file de garde (ordre de rotation ; l'actuel surligné ; cliquable = contact) */ +.garde-q-chip { display: inline-flex; align-items: center; gap: 3px; padding: 1px 8px 1px 3px; border: 1px solid #e2ddd6; border-radius: 12px; background: #fff; font-size: 12px; font-weight: 600; color: #5b4636; cursor: pointer; user-select: none; } +.garde-q-chip:hover { border-color: #b98a5e; background: #fbf5ee; } +.garde-q-chip.on-call { border-color: #43a047; background: #e9f6ea; color: #1b5e20; } +.garde-q-pos { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; border-radius: 50%; background: #efe7dd; color: #7a6653; font-size: 10px; font-weight: 700; } +.garde-q-chip.on-call .garde-q-pos { background: #43a047; color: #fff; } +.garde-q-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; } /* Panneau flottant « jobs à assigner » (déplaçable, glisser-déposer) */ .assign-panel { position: fixed; z-index: 5000; width: 320px; max-height: 72vh; background: #fff; border: 1px solid #cfd8dc; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,.24); display: flex; flex-direction: column; } .assign-hdr { display: flex; align-items: center; gap: 5px; padding: 6px 10px; background: #5e35b1; color: #fff; border-radius: 8px 8px 0 0; cursor: move; font-weight: 600; font-size: 13px; user-select: none; } @@ -6309,6 +6820,18 @@ onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 — flush le bro .suggest-tech-chip { display: flex; align-items: center; gap: 6px; padding: 5px 8px; border: 1px solid #e2e8f0; border-radius: 8px; cursor: pointer; font-size: 12.5px; background: #fff; user-select: none; } .suggest-tech-chip:hover { background: #f8fafc; } .suggest-tech-chip.on { border-color: #6366f1; background: #eef2ff; } +/* P2 — tech en congé/absent sur la période : grisé + rayé, cliquable quand même (le répartiteur garde le dernier mot). */ +.suggest-tech-chip.suggest-unavail { opacity: 0.6; background: repeating-linear-gradient(45deg, #fff, #fff 6px, #fef2f2 6px, #fef2f2 12px); border-color: #fecaca; } +.suggest-tech-chip.suggest-unavail.on { opacity: 1; } +/* P6 — récap simplifié des jobs à répartir (table compacte, défilante). */ +.sjr-wrap { max-height: 220px; overflow: auto; border: 1px solid #eef2f7; border-radius: 8px; margin-top: 4px; } +.sjr-tbl { width: 100%; border-collapse: collapse; font-size: 11.5px; } +.sjr-tbl th { position: sticky; top: 0; background: #f8fafc; color: #64748b; font-weight: 600; text-align: left; padding: 4px 8px; border-bottom: 1px solid #e2e8f0; z-index: 1; } +.sjr-tbl td { padding: 3px 8px; border-bottom: 1px solid #f1f5f9; } +.sjr-tbl tr:hover td { background: #f8fafc; } +.sjr-street { color: #64748b; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sjr-dot { display: inline-block; width: 9px; height: 9px; border-radius: 3px; margin-right: 5px; vertical-align: middle; } +.sjr-ampm { font-size: 10px; font-weight: 700; color: #3949ab; background: #e8eaf6; border-radius: 6px; padding: 1px 5px; } .suggest-skill-chip { border: 1px solid; border-radius: 12px; padding: 1px 9px; font-size: 11px; cursor: pointer; user-select: none; background: #fff; } .suggest-grp-chip { display: inline-flex; align-items: center; gap: 3px; border: 1px solid #c7d2fe; background: #eef2ff; color: #4338ca; border-radius: 12px; padding: 1px 8px; font-size: 11px; font-weight: 600; cursor: pointer; user-select: none; } .suggest-grp-chip:hover { border-color: #6366f1; } @@ -6337,7 +6860,7 @@ onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 — flush le bro .roster-grid th, .roster-grid td { border: 1px solid #c7d0dc; text-align: center; padding: 2px; } /* séparateurs plus contrastés (était #eee, invisible sur le gris des cellules) */ .roster-grid th + th, .roster-grid td + td { border-left-color: #aab6c6; } /* séparateur de COLONNE (jour) plus marqué que les lignes — en-tête ET rangées */ .roster-grid thead th { position: sticky; top: 0; background: #fafafa; z-index: 1; } -.tech-col { position: sticky; left: 0; background: #fff; text-align: left !important; white-space: nowrap; padding: 2px 8px !important; min-width: 300px; max-width: 360px; z-index: 2; } +.tech-col { position: sticky; left: 0; background: #fff; text-align: left !important; white-space: nowrap; padding: 2px 8px !important; min-width: 300px; max-width: 360px; width: 360px; overflow: hidden; z-index: 2; } /* overflow:hidden + width fixe → la colonne Ressource NE s'élargit PAS (badges/triangles entassés clippés) : la largeur du tableau reste stable → barre de défilement horizontale proportionnelle aux colonnes-jour, pas gonflée par le débordement */ .roster-grid thead .tech-col { z-index: 3; } .roster-grid tbody tr:hover td { background: #f0f7ff; } .roster-grid tbody tr:hover .tech-col { background: #f0f7ff; } @@ -6511,7 +7034,6 @@ tr.res-hidden .hide-eye { opacity: 1; } .de-dur input { width: 46px; font-size: 11px; text-align: right; border: 1px solid #cfc4e8; border-radius: 4px; padding: 2px 3px; } .de-travel { font-size: 10px; color: #8a6d3b; padding: 1px 0 1px 40px; opacity: .85; } /* espace entre 2 jobs = transport */ .de-depart { color: #3949ab; font-weight: 600; opacity: 1; } /* trajet origine (dépôt/domicile) → 1er job */ -.de-detail { font-size: 11px; line-height: 1.4; color: #444; background: #f7f5fc; border-left: 3px solid #b39ddb; border-radius: 4px; margin: 0 4px 6px 40px; padding: 6px 8px; max-height: 280px; overflow: auto; } .de-msg { border-top: 1px solid #e6e0f0; padding: 4px 0; } .de-msg:first-of-type { border-top: none; } .de-msg-hdr { font-size: 11px; font-weight: 700; color: #5e35b1; } @@ -6527,12 +7049,30 @@ tr.res-hidden .hide-eye { opacity: 1; } .assign-msg { font-size: 11px; border-top: 1px solid #e0e0e0; padding: 3px 0; } .assign-msg:first-child { border-top: none; } .assign-msg-txt { white-space: pre-wrap; color: #37474f; } -.tl-shift { position: absolute; top: 0; bottom: 0; background: #fff; border-radius: 4px; border: 1px solid #dfe4ea; box-sizing: border-box; } /* quart = zone BLANCHE (lumière = dispo) sur le fond gris de la cellule */ +.tl-shift { position: absolute; top: 0; bottom: 0; background: #fff; border-radius: 4px; border: 1px solid #dfe4ea; box-sizing: border-box; overflow: hidden; } /* quart = zone BLANCHE (lumière = dispo) ; overflow:hidden → le label de rôle (.tl-fn-in) est clippé DANS sa bande, jamais débordant sur le fond gris */ .tl-shift.oncall { background: rgba(255,213,79,.30); border: 1px solid rgba(245,166,35,.55); } /* garde = sur appel (jaune pâle, harmonisé Kanban) */ .tl-shift.over { background: #fff1e0; border-color: #ffc183; } /* zone de quart en surcharge = teinte orange (au lieu du blanc) */ .tl-absent { position: absolute; inset: 0; border-radius: 2px; box-sizing: border-box; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg, #cfcfcf 0, #cfcfcf 3px, #f0f0f0 3px, #f0f0f0 6px); } /* absent = hachuré gris */ .tl-blk { position: absolute; top: 2px; bottom: 2px; border-radius: 4px; border: 1px solid; display: flex; align-items: center; justify-content: center; overflow: hidden; } /* fond PASTEL + contour couleur (via inline) + icône couleur centrée si elle rentre */ .tl-travel { position: absolute; top: 0; bottom: 0; background-image: repeating-linear-gradient(90deg, #78909c 0 3px, transparent 3px 7px); background-size: 100% 3px; background-repeat: no-repeat; background-position: 0 center; opacity: .85; } /* déplacement = pointillés */ +.tl-fn { position: absolute; top: 1px; left: 2px; max-width: 92%; font-size: 9.5px; line-height: 1.35; color: #64748b; background: rgba(255,255,255,.78); padding: 0 3px; border-radius: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; z-index: 3; } /* rôle (fonction) posé sur le quart : libellé DISCRET (pas de pastille vive) */ +.tl-fn-in { position: absolute; top: 1px; left: 3px; right: 3px; font-size: 9.5px; line-height: 1.3; color: #475569; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; z-index: 3; } /* rôle affiché DANS sa propre bande de quart (bandes séparées) */ +.dfa-fn { border: 1px solid #cbd5e1; border-radius: 6px; padding: 3px 6px; font-size: 12.5px; font-family: inherit; color: #334155; background: #fff; max-width: 160px; } /* sélecteur de rôle par quart (éditeur de jour) */ +/* Ligne GARDE (couche flottante) au-dessus des ressources — teinte brune discrète, alignée aux colonnes jours */ +.roster-grid tr.garde-lane td { background: #faf6f2; border-bottom: 2px solid #e8dccd; position: sticky; } +.garde-lane-hd { padding: 2px 6px; } +.garde-cell { cursor: pointer; text-align: center; padding: 3px 2px; min-height: 22px; vertical-align: middle; } +.garde-cell:hover { background: #f3eadf; } +.garde-cell.weekend { background: #f7f2ec; } +.garde-chip { display: inline-block; font-size: 10px; font-weight: 600; line-height: 1.4; padding: 1px 6px; margin: 1px; border-radius: 10px; border: 1px solid; white-space: nowrap; } +.garde-empty { font-size: 10px; color: #c8b89f; } +.gd-chip { display: inline-flex; align-items: center; font-size: 12px; font-weight: 500; padding: 3px 8px; border-radius: 12px; border: 1px solid; } +.ov-conf { border: 1px solid #eef0f3; border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; } /* un conflit de chevauchement */ +.ov-conf.vac { background: #fff8e1; border-color: #ffe082; } /* quart sur congé (congé prioritaire) */ +.ov-shift { padding: 2px 0; } +.tl-adj { position: absolute; bottom: 1px; left: 2px; max-width: 92%; font-size: 9px; line-height: 1.3; color: #8d6e00; background: rgba(255,236,179,.92); padding: 0 3px; border-radius: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; z-index: 3; display: inline-flex; align-items: center; gap: 2px; } /* ajustement temporaire (blessure…) : marqueur ambre discret en bas de cellule */ +.cov-banner { background: #fff8e1; border: 1px solid #ffe082; border-radius: 6px; padding: 3px 10px; } /* bandeau couverture terrain : discret, ambre pâle */ +.cov-day { font-size: 11px; font-weight: 500; color: #8d6e00; background: #ffecb3; border-radius: 10px; padding: 1px 8px; } .tl-shift-click { cursor: pointer; } /* bande de quart/garde → clic ouvre la tournée (voir les jobs) */ .tl-shift-click:hover { filter: brightness(0.92); outline: 1px solid rgba(55,71,79,.4); outline-offset: -1px; } .tl-blk-click { cursor: pointer; } diff --git a/services/targo-hub/lib/legacy-dispatch-sync.js b/services/targo-hub/lib/legacy-dispatch-sync.js index 7b64d78..0625e86 100644 --- a/services/targo-hub/lib/legacy-dispatch-sync.js +++ b/services/targo-hub/lib/legacy-dispatch-sync.js @@ -33,6 +33,7 @@ const addrdb = require('./address-db') // pool PG local (camping_registry) const muni = require('./municipality') // résolveur de municipalités QC (centre-ville en dernier recours) const sse = require('./sse') // diffusion temps-réel vers Ops (topic 'dispatch') const audit = require('./dispatch-audit') // journal d'audit d'assignation (append-only JSONL) +const skillResolver = require('./skill-resolver') // SOURCE UNIQUE raison→compétence (ne PAS dupliquer la logique ici) const fs = require('fs') // Décodeur d'entités HTML UNIQUE (osTicket stocke les sujets encodés : ' é & …). Appliqué à l'ÉCRITURE // du sujet des jobs → la donnée stockée est propre partout (Dispatch, Planification, app terrain, rapports), pas de @@ -1471,18 +1472,10 @@ async function legacyTechTickets (staffId) { return { ok: true, staff_id: sid, total_open: all.length, hidden_non_field: all.length - jobs.length, jobs } } -// Dépt/sujet legacy → COMPÉTENCE (réplique roster.js deptToSkill) → couleur du bloc identique aux jobs importés. -function deptSkill (txt) { - const d = String(txt || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') - if (/teleph/.test(d)) return 'telephone' - if (/tele|televis/.test(d)) return 'tv' - if (/fusion|episs/.test(d)) return 'épissure' - if (/monteur|aerien/.test(d)) return 'monteur' - if (/netadmin|net admin/.test(d)) return 'netadmin' - if (/repar|desinstall/.test(d)) return 'réparation' - if (/install|fibre/.test(d)) return 'installation' - return '' -} +// Dépt/sujet legacy → COMPÉTENCE. DÉLÈGUE au résolveur PARTAGÉ (skill-resolver.deptToSkill) = source unique : +// couvre modem/ONU/panne/signal → réparation, et NE force PLUS « fibre » en installation (bug corrigé au partagé). +// (Avant : copie locale périmée qui classait « changement d'ONU » comme rien et « fibre » comme installation.) +function deptSkill (txt) { return skillResolver.deptToSkill(txt) } // Durée estimée d'un job legacy : « est. time XhYm » dans le sujet, sinon défaut par dépt. function estLegacyHours (subject, dept) { const s = String(subject || '') @@ -2156,9 +2149,13 @@ async function handle (req, res, method, path) { // ─── Réconciliation F→OPS : aligner les Dispatch Job legacy ACTIFS sur la vérité de F (autoritaire) ─── // Pour chaque DJ actif (open/assigned/in_progress) portant un legacy_ticket_id, on lit l'état RÉEL dans F (action pont -// `ticket_status`) puis : F fermé/résolu → ANNULE le DJ ; F renvoyé au pool (Tech Targo) ou à personne → ANNULE ; -// F réassigné à un AUTRE tech terrain → CORRIGE `assigned_tech` (+ scheduled_date = due_date F) ; F donné à un non-tech -// (bureau) → ANNULE (pas de bloc terrain pour du bureau) ; F = OPS → rien. N'ÉCRIT QUE ERPNext (jamais F). Aperçu par défaut. +// `ticket_status`) puis : F **fermé** → DJ **Completed** si un tech est assigné (le travail a été fait, fermé côté F ; +// compte au leaderboard), **Cancelled** seulement si personne n'était assigné (fermé sans visite). « Annulé » n'est +// JAMAIS le défaut d'une fermeture F — il est réservé aux vraies annulations (client annule / reste chez son fournisseur). +// F **pending** (reporté/en attente, PAS fermé) → DJ **On Hold** — surtout pas Completed/Cancelled. +// F renvoyé au pool (Tech Targo) ou à personne → ANNULE ; F réassigné à un AUTRE tech terrain → CORRIGE `assigned_tech` +// (+ scheduled_date = due_date F) ; F donné à un non-tech (bureau) → ANNULE (pas de bloc terrain pour du bureau) ; +// F = OPS → rien. N'ÉCRIT QUE ERPNext (jamais F). Aperçu par défaut. async function reconcileLegacyJobs (opts = {}) { const apply = opts.confirm === 'RECONCILE' const sleep = (ms) => new Promise(r => setTimeout(r, ms)) @@ -2167,7 +2164,7 @@ async function reconcileLegacyJobs (opts = {}) { const techToStaff = {}; const staffToTech = {} for (const t of (techs || [])) { const m = String(t.technician_id || '').match(/(\d+)$/); if (!m) continue; const s = Number(m[1]); techToStaff[t.technician_id] = s; if (s >= 2 && s !== TARGO_TECH_STAFF_ID) staffToTech[s] = t.technician_id } const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'in_progress']]], fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'scheduled_date', 'status', 'subject'], limit: 5000 }) - const plan = { reassign: [], cancel_closed: [], cancel_pool: [], cancel_office: [], agree: [], not_in_f: [] } + const plan = { reassign: [], cancel_closed: [], hold_pending: [], cancel_pool: [], cancel_office: [], agree: [], not_in_f: [] } const ids = [...new Set((djs || []).map(d => String(d.legacy_ticket_id)).filter(Boolean))] const fState = {} for (let i = 0; i < ids.length; i += 400) { @@ -2181,8 +2178,9 @@ async function reconcileLegacyJobs (opts = {}) { const subj = decode(j.subject || '').slice(0, 50) if (!f) { plan.not_in_f.push({ job: j.name, tid, subj }); continue } const fStaff = Number(f.assign_to) || 0 - const open = String(f.status || '').toLowerCase() === 'open' - if (!open) { plan.cancel_closed.push({ job: j.name, tid, subj, f_status: f.status }); continue } + const fs = String(f.status || '').toLowerCase() + if (fs === 'pending') { if (j.status !== 'On Hold') plan.hold_pending.push({ job: j.name, tid, subj, tech: j.assigned_tech || '' }); else plan.agree.push({ job: j.name, tid }); continue } + if (fs !== 'open') { plan.cancel_closed.push({ job: j.name, tid, subj, f_status: f.status, tech: j.assigned_tech || '' }); continue } if (fStaff === TARGO_TECH_STAFF_ID || fStaff < 2) { if (!j.assigned_tech) { plan.agree.push({ job: j.name, tid }); continue } // DJ NON assigné + F au pool = backlog COHÉRENT (les deux au pool) → on laisse plan.cancel_pool.push({ job: j.name, tid, subj }); continue // DJ assigné à un tech mais F au pool → vraie divergence (stagé non publié OU périmé) @@ -2193,15 +2191,19 @@ async function reconcileLegacyJobs (opts = {}) { const iso = f.due_date ? new Date(Number(f.due_date) * 1000).toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) : (j.scheduled_date || null) plan.reassign.push({ job: j.name, tid, subj, from: j.assigned_tech || '(aucun)', to: fTech, sched: iso }) } - const counts = { active_total: (djs || []).length, reassign: plan.reassign.length, cancel_closed: plan.cancel_closed.length, cancel_pool: plan.cancel_pool.length, cancel_office: plan.cancel_office.length, agree: plan.agree.length, not_in_f: plan.not_in_f.length } - const samples = { reassign: plan.reassign.slice(0, 10), cancel_closed: plan.cancel_closed.slice(0, 10), cancel_pool: plan.cancel_pool.slice(0, 10), cancel_office: plan.cancel_office.slice(0, 10), not_in_f: plan.not_in_f.slice(0, 10) } + const counts = { active_total: (djs || []).length, reassign: plan.reassign.length, cancel_closed: plan.cancel_closed.length, hold_pending: plan.hold_pending.length, cancel_pool: plan.cancel_pool.length, cancel_office: plan.cancel_office.length, agree: plan.agree.length, not_in_f: plan.not_in_f.length } + const samples = { reassign: plan.reassign.slice(0, 10), cancel_closed: plan.cancel_closed.slice(0, 10), hold_pending: plan.hold_pending.slice(0, 10), cancel_pool: plan.cancel_pool.slice(0, 10), cancel_office: plan.cancel_office.slice(0, 10), not_in_f: plan.not_in_f.slice(0, 10) } if (!apply) return { ok: true, apply: false, counts, samples, note: 'cancel_pool n\'est PAS appliqué par défaut (peut être une assignation OPS stagée non publiée) — opt-in purgePool=1 requis.' } - let done = 0; const errs = [] - // SÛRS : F dit explicitement fermé / bureau → on annule le DJ. cancel_pool EXCLU par défaut (ambigu staging vs périmé). - const toCancel = [...plan.cancel_closed, ...plan.cancel_office] - if (opts.purgePool) toCancel.push(...plan.cancel_pool) // OPT-IN EXPLICITE : F=pool → annule (RISQUE : efface une assignation OPS non encore poussée dans F) - for (const c of toCancel) { - try { await erp.update('Dispatch Job', c.job, { status: 'Cancelled' }) } catch (e) { errs.push({ job: c.job, action: 'cancel', err: String(e.message || e) }) } + let done = 0; let completedN = 0; let cancelledN = 0; let heldN = 0; const errs = [] + // SÛRS : F dit explicitement fermé / bureau. Fermé+tech assigné → Completed (travail fait, compte au leaderboard) ; + // fermé sans tech ou bureau/pool → Cancelled ; pending → On Hold (reporté, PAS terminé). + // cancel_pool EXCLU par défaut (ambigu staging vs périmé). + const toClose = plan.cancel_closed.map(c => ({ ...c, to: c.tech ? 'Completed' : 'Cancelled' })) + toClose.push(...plan.hold_pending.map(c => ({ ...c, to: 'On Hold' }))) + toClose.push(...plan.cancel_office.map(c => ({ ...c, to: 'Cancelled' }))) + if (opts.purgePool) toClose.push(...plan.cancel_pool.map(c => ({ ...c, to: 'Cancelled' }))) // OPT-IN EXPLICITE : F=pool → annule (RISQUE : efface une assignation OPS non encore poussée dans F) + for (const c of toClose) { + try { await erp.update('Dispatch Job', c.job, { status: c.to }); if (c.to === 'Completed') completedN++; else if (c.to === 'On Hold') heldN++; else cancelledN++ } catch (e) { errs.push({ job: c.job, action: c.to.toLowerCase(), err: String(e.message || e) }) } if (++done % 25 === 0) await sleep(200) } for (const r of plan.reassign) { @@ -2209,7 +2211,7 @@ async function reconcileLegacyJobs (opts = {}) { try { await erp.update('Dispatch Job', r.job, patch) } catch (e) { errs.push({ job: r.job, action: 'reassign', err: String(e.message || e) }) } if (++done % 25 === 0) await sleep(200) } - return { ok: true, apply: true, applied: done, cancelled: toCancel.length, reassigned: plan.reassign.length, pool_held: opts.purgePool ? 0 : plan.cancel_pool.length, error_count: errs.length, errors: errs.slice(0, 50), counts } + return { ok: true, apply: true, applied: done, completed: completedN, cancelled: cancelledN, held: heldN, reassigned: plan.reassign.length, pool_held: opts.purgePool ? 0 : plan.cancel_pool.length, error_count: errs.length, errors: errs.slice(0, 50), counts } } // ─── BACKFILL ONE-TIME : Service Location manquantes des Customers legacy ─────────────────────────── diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index a184799..5b0db09 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -58,6 +58,25 @@ function setJobSkills (name, list) { try { fs.mkdirSync(path.dirname(JOB_SKILLS_FILE), { recursive: true }) } catch (e) {} fs.writeFileSync(JOB_SKILLS_FILE, JSON.stringify(m)); return m } + +// FILES DE GARDE (rotations on-call) — store hub PARTAGÉ (avant : localStorage par-navigateur, non partagé entre +// répartiteurs). Une file = { id, name, dept, shift, shiftWeekend, weekdays[], anchor(YYYY-MM-DD), steps[techId] }. +// steps = ordre de rotation (round-robin) ; rotationTech (client) avance chaque semaine + saute les absents. +const GARDE_QUEUES_FILE = path.join(__dirname, '..', 'data', 'garde-queues.json') +function listGardeQueues () { try { const a = JSON.parse(fs.readFileSync(GARDE_QUEUES_FILE, 'utf8')); return Array.isArray(a) ? a : [] } catch { return [] } } +function saveGardeQueues (arr) { + const clean = (Array.isArray(arr) ? arr : []).filter(q => q && q.id).map(q => ({ + id: String(q.id), name: String(q.name || '').slice(0, 80), dept: String(q.dept || '').slice(0, 80), + shift: q.shift || '', shiftWeekend: q.shiftWeekend || '', + weekdays: Array.isArray(q.weekdays) ? q.weekdays.map(Number).filter(n => n >= 0 && n <= 6) : [], + anchor: /^\d{4}-\d{2}-\d{2}$/.test(q.anchor || '') ? q.anchor : '', + // steps = ordre de rotation : { tech, weeks } (weeks = semaines consécutives de ce tech avant de passer au suivant) + steps: Array.isArray(q.steps) ? q.steps.filter(s => s && s.tech).map(s => ({ tech: String(s.tech), weeks: Math.max(1, Number(s.weeks) || 1) })) : [], + })) + try { fs.mkdirSync(path.dirname(GARDE_QUEUES_FILE), { recursive: true }) } catch (e) {} + fs.writeFileSync(GARDE_QUEUES_FILE, JSON.stringify(clean, null, 2)); return clean +} +function upsertGardeQueue (q) { const a = listGardeQueues(); const i = a.findIndex(x => x.id === q.id); if (i >= 0) a[i] = q; else a.push(q); return saveGardeQueues(a) } // Priorité de job = champ ERPNext standard (low/medium/high), édité via /roster/job/update (comme le pool). Plus de store parallèle. // 🖥 Job « SANS DÉPLACEMENT » (configurable à distance, ex. boîtier tel via ACS) : exclu des tournées (aucun arrêt), à faire du bureau. const JOB_REMOTE_FILE = path.join(__dirname, '..', 'data', 'job-remote.json') @@ -194,11 +213,17 @@ async function optimizePlan (body) { ]) const tplBy = Object.fromEntries(templates.map(t => [t.name, t])) const winBy = {} // techId|iso → fenêtre de quart RÉELLE (min) + // Quarts du jour PAR tech (avec fonction éventuelle) → compétences dépendantes du jour via effectiveSkills. + // ⚠️ `a.function` reste indéfini (donc inerte = compétences de base) tant que le DocField `function` de + // Shift Assignment n'est pas migré ET lu par fetchAssignments. Les exceptions ponctuelles agissent déjà. + const shiftsBy = {} // techId|iso → [{ function, start, end }] for (const a of assignments) { - const tp = tplBy[a.shift]; if (!tp || tp.on_call) continue + const tp = tplBy[a.shift]; if (!tp) continue + const k = a.tech + '|' + a.date + ;(shiftsBy[k] = shiftsBy[k] || []).push({ function: a.function || '', start: tp.start_time, end: tp.end_time }) + if (tp.on_call) continue const s = hmToMin(tp.start_time); let e = hmToMin(tp.end_time); if (s == null || e == null) continue if (e <= s) e = 24 * 60 - const k = a.tech + '|' + a.date const w = winBy[k]; winBy[k] = w ? { start: Math.min(w.start, s), end: Math.max(w.end, e) } : { start: s, end: e } } let policy = {}; try { policy = JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')) } catch (e) {} @@ -236,6 +261,10 @@ async function optimizePlan (body) { for (const iso of dates) { const dayJobs = [...(byDay[iso] || []), ...spill]; spill = [] if (!dayJobs.length) continue + // Compétences EFFECTIVES par tech CE JOUR (base ± fonction du quart ± exception) — le solveur filtre (techCovers) + // ET classe (skill/skill_eff) sur cet ensemble : une compétence retirée ce jour ne pèse plus sur l'offre. + const effByTech = new Map(techs.map(t => [t.id, effectiveSkills(t, iso, { shifts: shiftsBy[t.id + '|' + iso] || [] })])) + const effOf = (t) => (t && effByTech.get(t.id)) || t // Occupation existante : minutes déjà prises par tech + index adresse→tech (coordination) const usedMin = {}; const addrTech = {} for (const t of techs) { @@ -255,7 +284,7 @@ async function optimizePlan (body) { if (hit) { const t = techs.find(x => x.id === hit.techId) const dur = durOf(j) - plan.push({ ...entryOf(j, iso, dur), techId: hit.techId, techName: hit.techName, pinned: true, coordWith: hit.subject, capable: techCovers(t, j) }) + plan.push({ ...entryOf(j, iso, dur), techId: hit.techId, techName: hit.techName, pinned: true, coordWith: hit.subject, capable: techCovers(effOf(t), j) }) usedMin[hit.techId] = (usedMin[hit.techId] || 0) + Math.round(dur * 60) } else rest.push(j) } @@ -267,7 +296,8 @@ async function optimizePlan (body) { if (!w) { if (isWE(iso)) continue; w = { start: 480, end: 960 }; assumedShift[t.id + '|' + iso] = true } const used = usedMin[t.id] || 0 const o = originOf(t.id) - vehicles.push({ id: t.id, name: t.name, skills: t.skills || [], skill_eff: t.skill_eff || {}, efficiency: Number(t.efficiency) || 1, home_lat: o ? o.lat : null, home_lon: o ? o.lon : null, shift_start_min: Math.min(w.end - 15, w.start + used), shift_end_min: w.end, overtime_min: Math.round(0.2 * (w.end - w.start)) }) + const et = effOf(t) // compétences/cadence effectives du jour (fonction du quart ± exception) + vehicles.push({ id: t.id, name: t.name, skills: et.skills || [], skill_eff: et.skill_eff || {}, efficiency: Number(t.efficiency) || 1, home_lat: o ? o.lat : null, home_lon: o ? o.lon : null, shift_start_min: Math.min(w.end - 15, w.start + used), shift_end_min: w.end, overtime_min: Math.round(0.2 * (w.end - w.start)) }) } if (!vehicles.length) { for (const j of rest) unassigned.push(entryOf(j, iso, durOf(j))); continue } // MÊME ADRESSE = 1 ARRÊT : fusion des jobs du pool par clé d'adresse → 1 nœud (temps additionnés, skill = 1re non vide du groupe) @@ -297,7 +327,7 @@ async function optimizePlan (body) { for (const st of (rt.stops || [])) { const ui = Number(String(st.job_id).slice(1)); const g = units[ui]; if (!g) continue placed.add(st.job_id) - for (const j of g) plan.push({ ...entryOf(j, iso, durOf(j)), techId: t.id, techName: t.name, capable: techCovers(t, j), sameAddrN: g.length }) + for (const j of g) plan.push({ ...entryOf(j, iso, durOf(j)), techId: t.id, techName: t.name, capable: techCovers(effOf(t), j), sameAddrN: g.length }) } } for (const uid of (res.unassigned || [])) { @@ -343,7 +373,7 @@ async function _fetchTechniciansRaw () { filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'full_name', 'status', 'color_hex', 'tech_group', 'efficiency', 'skills', 'cost_salary_h', 'cost_charges_pct', 'cost_other_h', 'traccar_device_id', - 'absence_from', 'absence_until', 'employee', 'phone', '_user_tags', 'skill_levels', 'skill_eff', 'weekly_schedule'], + 'absence_from', 'absence_until', 'employee', 'phone', 'email', '_user_tags', 'skill_levels', 'skill_eff', 'weekly_schedule'], limit: 500, }) const arch = loadArchivedSet() // ids archivés (store hub) → masqués partout @@ -359,6 +389,7 @@ async function _fetchTechniciansRaw () { cost_h: Math.round(((Number(t.cost_salary_h) || 0) * (1 + (Number(t.cost_charges_pct) || 0) / 100) + (Number(t.cost_other_h) || 0)) * 100) / 100, color: t.color_hex || '#1976d2', phone: t.phone, + email: t.email || '', traccar_device_id: t.traccar_device_id || '', // appareil Traccar associé (pour GPS live + tracé) — éditable inline employee: t.employee, skills: splitCsv(t.skills || t._user_tags), // champ skills (ou tags Frappe) @@ -431,13 +462,15 @@ async function fetchAssignments (start, days) { const dates = rangeDates(start, days) const rows = await erp.list('Shift Assignment', { filters: [['assignment_date', 'in', dates]], - fields: ['name', 'technician', 'technician_name', 'assignment_date', 'shift_template', 'zone', 'hours', 'status', 'source'], + // ⚠️ `function` : à activer UNIQUEMENT après la migration (bench migrate ajoute le DocField). Avant, le demander + // ferait échouer erp.list (« Field not permitted ») + empoisonnerait le cache erp → à déployer AVEC la migration. + fields: ['name', 'technician', 'technician_name', 'assignment_date', 'shift_template', 'zone', 'hours', 'status', 'source', 'function'], limit: 2000, }) - // Normaliser vers la forme canonique {tech, date, shift} (= sortie du solveur + UI) + // Normaliser vers la forme canonique {tech, date, shift} (= sortie du solveur + UI). `function` = fonction du quart (skillset). return rows.map(r => ({ name: r.name, tech: r.technician, tech_name: r.technician_name, date: r.assignment_date, - shift: r.shift_template, zone: r.zone, hours: r.hours, status: r.status, source: r.source, + shift: r.shift_template, zone: r.zone, hours: r.hours, status: r.status, source: r.source, function: r.function || '', })) } @@ -459,7 +492,12 @@ async function ensureShiftTemplate (start, end, cache) { // start/end 'HH:MM' else log('[materialize] ensureShiftTemplate FAIL ' + key + ' → ' + JSON.stringify(r).slice(0, 220)) return name } -async function materializeShifts ({ dryRun = true, weeks = 4, techId = null } = {}) { +// rolesToManual : sur un jour qui a des quarts MANUELS (donc non re-généré), applique quand même le RÔLE du patron +// aux quarts dont la fenêtre correspond (rôle seul — ne crée/supprime/re-horaire jamais). Réservé à l'application +// EXPLICITE (« Définir l'horaire »), PAS au cron (qui ne doit pas écraser silencieusement un rôle posé à la main). +// replaceManual : sur un jour à quarts MANUELS, SUPPRIME les quarts réguliers manuels (garde préservée) et laisse le patron +// reprendre la main (crée ses quarts + rôles). Réservé à l'application EXPLICITE APRÈS confirmation utilisateur (« Remplacer »). +async function materializeShifts ({ dryRun = true, weeks = 4, techId = null, rolesToManual = false, replaceManual = false } = {}) { const weeksN = Math.max(1, Math.min(12, Number(weeks) || 4)) const start = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) const days = weeksN * 7 @@ -468,30 +506,69 @@ async function materializeShifts ({ dryRun = true, weeks = 4, techId = null } = const techs = techsAll.filter(t => t.status !== PAUSE_STATUS && t.weekly_schedule && (!techId || t.id === techId)) const rowsByKey = {}; for (const a of existing) { (rowsByKey[a.tech + '|' + a.date] = rowsByKey[a.tech + '|' + a.date] || []).push(a) } const tplCache = {} + // Templates de GARDE (on_call) → à préserver quand on « remplace » les quarts réguliers manuels par le patron. + const onCallTpls = replaceManual ? new Set((await fetchTemplates()).filter(t => t.on_call).map(t => t.name)) : new Set() let created = 0, updated = 0, deleted = 0, keptManual = 0, skipHoliday = 0, skipVacation = 0, tplNull = 0, createFail = 0 log('[materialize] start=' + start + ' days=' + days + ' techs=' + techs.length + '/' + techsAll.length + ' avec_patron dryRun=' + dryRun) for (const t of techs) { const sched = t.weekly_schedule || {} for (const iso of dates) { - const day = sched[DOW_KEYS[new Date(iso + 'T12:00:00Z').getUTCDay()]] // {start,end} ou null/absent + const dayVal = sched[DOW_KEYS[new Date(iso + 'T12:00:00Z').getUTCDay()]] // MULTI-SLOT : tableau [{start,end,function}] ; RÉTRO-COMPAT : objet simple {start,end,function} ; ou null/absent const key = t.id + '|' + iso; const rows = rowsByKey[key] || [] - if (rows.some(r => r.source !== 'pattern')) { keptManual++; continue } // override manuel présent → intouchable - const pat = rows.filter(r => r.source === 'pattern') const holiday = holidaysQc.isHoliday(iso); const vacation = !!absences[key] - const wants = !!(day && day.start && day.end) && !holiday && !vacation - if (!wants) { // férié / vacances / jour OFF du patron → retirer le quart pattern + // Créneaux voulus ce jour (0..n). Objet simple → 1 slot (rétro-compat). Férié/vacances → aucun. + let slots = [] + if (!holiday && !vacation) { + if (Array.isArray(dayVal)) slots = dayVal.filter(s => s && s.start && s.end) + else if (dayVal && dayVal.start && dayVal.end) slots = [{ start: dayVal.start, end: dayVal.end, function: dayVal.function || '' }] + } + if (rows.some(r => r.source !== 'pattern')) { // override MANUEL présent sur ce jour + if (replaceManual) { // REMPLACER : supprimer les quarts réguliers MANUELS (garde on_call préservée), le patron reprend la main ci-dessous + for (const r of rows) { if (r.source !== 'pattern' && !onCallTpls.has(r.shift)) { if (dryRun) deleted++; else { const rr = await erp.remove('Shift Assignment', r.name); if (rr && rr.ok) deleted++ } } } + // pas de `continue` → on tombe dans le reconcile pattern (crée les quarts du patron avec leur rôle) + } else { // CONSERVER : on ne re-génère pas ce jour… + keptManual++ + if (rolesToManual && slots.length) { // …mais (application explicite) on applique le RÔLE du patron aux quarts dont la fenêtre correspond + for (const s of slots) { + const fn = s.function || ''; if (!fn) continue + const tpl = await ensureShiftTemplate(s.start, s.end, tplCache); if (!tpl) continue + const m = rows.find(r => r.shift === tpl && fn !== (r.function || '')) + if (m && !dryRun) { const u = await retryWrite(() => erp.update('Shift Assignment', m.name, { function: fn })); if (u && u.ok) updated++ } + else if (m && dryRun) updated++ + } + } + continue + } + } + const pat = rows.filter(r => r.source === 'pattern') + if (!slots.length) { // férié / vacances / jour OFF du patron → retirer TOUS les quarts pattern if (holiday) skipHoliday++; else if (vacation) skipVacation++ for (const r of pat) { if (dryRun) deleted++; else { const rr = await erp.remove('Shift Assignment', r.name); if (rr && rr.ok) deleted++ } } continue } - const tplName = await ensureShiftTemplate(day.start, day.end, tplCache); if (!tplName) { tplNull++; continue } - if (pat.some(r => r.shift === tplName)) continue // déjà bon (idempotent) - const hours = Math.round((_timeH(day.end) - _timeH(day.start)) * 100) / 100 - if (pat.length) { // patron changé → mettre à jour le quart pattern (+ nettoyer doublons) - if (dryRun) updated++; else { const rr = await erp.update('Shift Assignment', pat[0].name, { shift_template: tplName, hours }); if (rr && rr.ok) updated++ } - for (const extra of pat.slice(1)) { if (!dryRun) await erp.remove('Shift Assignment', extra.name) } - } else if (dryRun) created++ - else { const rr = await retryWrite(() => erp.create('Shift Assignment', { technician: t.id, technician_name: t.name, assignment_date: iso, shift_template: tplName, hours, status: 'Publié', source: 'pattern' })); if (rr && rr.ok) created++; else { createFail++; if (createFail <= 3) log('[materialize] SA create FAIL ' + t.name + ' ' + iso + ' → ' + JSON.stringify(rr).slice(0, 220)) } } + // 1 quart par slot (template = heures). Réconcilie : garde les existants (curation `function` préservée), + // supprime les slots retirés + doublons, crée les manquants (rôle du patron sur les NOUVEAUX seulement). + const wantTpls = [] + for (const s of slots) { const tpl = await ensureShiftTemplate(s.start, s.end, tplCache); if (!tpl) { tplNull++; continue } wantTpls.push({ tpl, fn: s.function || '', hours: Math.round((_timeH(s.end) - _timeH(s.start)) * 100) / 100 }) } + const wantSet = new Set(wantTpls.map(w => w.tpl)) + const fnByTpl = {}; for (const w of wantTpls) if (!(w.tpl in fnByTpl)) fnByTpl[w.tpl] = w.fn || '' // rôle voulu par le patron, par template + const seen = new Set() + for (const r of pat) { + if (wantSet.has(r.shift) && !seen.has(r.shift)) { // voulu + 1re occurrence → conserver, MAIS synchroniser le rôle du patron + seen.add(r.shift) + const wantFn = fnByTpl[r.shift] || '' + if (!dryRun && wantFn !== (r.function || '')) { const u = await retryWrite(() => erp.update('Shift Assignment', r.name, { function: wantFn })); if (u && u.ok) updated++ } // le rôle du patron s'applique AUSSI aux quarts existants + continue + } + if (dryRun) deleted++; else { const rr = await erp.remove('Shift Assignment', r.name); if (rr && rr.ok) deleted++ } // slot retiré OU doublon + } + for (const w of wantTpls) { + if (seen.has(w.tpl)) continue + seen.add(w.tpl) + if (dryRun) { created++; continue } + const rr = await retryWrite(() => erp.create('Shift Assignment', { technician: t.id, technician_name: t.name, assignment_date: iso, shift_template: w.tpl, hours: w.hours, status: 'Publié', source: 'pattern', function: w.fn })) + if (rr && rr.ok) created++; else { createFail++; if (createFail <= 3) log('[materialize] SA create FAIL ' + t.name + ' ' + iso + ' → ' + JSON.stringify(rr).slice(0, 220)) } + } } } const result = { ok: true, dryRun, weeks: weeksN, techs: techs.length, created, updated, deleted, kept_manual: keptManual, skipped_holiday: skipHoliday, skipped_vacation: skipVacation, tpl_null: tplNull, create_fail: createFail } @@ -683,7 +760,7 @@ function skillForJob (job) { // Sert à colorer les tickets par la couleur de leur compétence (éditable via le gestionnaire de tags). // SOURCE UNIQUE : la logique vit dans lib/skill-resolver.js (partagée avec le résolveur raison→compétence // et le prédicat de disponibilité) ; ré-exportée ici pour ne rien casser des appelants existants. -const { deptToSkill, techHasSkills } = require('./skill-resolver') +const { deptToSkill, techHasSkills, effectiveSkills, listFunctions, setFunction, deleteFunction, getSkillExceptions, getSkillExceptionsMap, setSkillExceptions } = require('./skill-resolver') // Enrichit des jobs avec une adresse LISIBLE (le champ service_location est un code « LOC-… »). // Batch : 1 seule requête sur Service Location pour tous les codes distincts. async function attachLocations (jobs) { @@ -791,7 +868,11 @@ const BOOKING_TRAVEL_BUF_H = 0.25 // 15 min function techGaps (a, d, skill, zone, bufferH = 0) { const t = d.techById[a.tech]; if (!t || t.status === PAUSE_STATUS) return null if (d.unavail && d.unavail[a.tech] && d.unavail[a.tech].has(a.date)) return null // absence/congé approuvé ce jour-là → pas de créneaux - if (skill && !(t.skills || []).includes(skill)) return null + if (skill) { + const _tp = d.tplByName[a.shift] + const _eff = effectiveSkills(t, a.date, { shifts: [{ function: a.function || '', start: _tp && _tp.start_time, end: _tp && _tp.end_time }] }).skills + if (!_eff.includes(skill)) return null // compétence EFFECTIVE de ce quart (fonction ± exception) ; `function` inerte avant migration + } if (zone && a.zone && a.zone !== zone) return null const tpl = d.tplByName[a.shift]; if (!tpl) return null if (tpl.on_call) return null // garde (sur appel) = capacité d'urgence, JAMAIS offerte au booking client @@ -1407,7 +1488,7 @@ async function occupancyByTechDay (start, days) { const s = j.start_time ? timeToH(j.start_time) : null if (s != null && !cancelled) o.blocks.push({ s, e: s + dur, skill, job: j.name, done }) // 1 bloc = 1 job, coloré par sa compétence const actualMin = (j.actual_start && j.actual_end) ? Math.max(0, Math.round((Date.parse(j.actual_end.replace(' ', 'T')) - Date.parse(j.actual_start.replace(' ', 'T'))) / 60000)) : null - o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', customer_id: j.customer || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: estMin, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', status: j.status || '', done, cancelled, legacy_id: j.legacy_ticket_id || '', legacy_dept: j.legacy_dept || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin }) + o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', customer_id: j.customer || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: estMin, priority: j.priority || 'low', job_type: j.job_type || '', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', status: j.status || '', done, cancelled, legacy_id: j.legacy_ticket_id || '', legacy_dept: j.legacy_dept || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin }) } // ── ÉQUIPE : reporte la charge des assistants ÉPINGLÉS sur LEUR propre lane (mirror) ── // 1 requête enfant UNIQUE (pas de N+1). Le lead reste compté via assigned_tech ci-dessus ; @@ -1455,6 +1536,27 @@ async function absencesByTechDay (start, days) { return m } +// ── COUVERTURE terrain : par jour, nb de techs avec un quart RÉGULIER (garde exclue) et PRÉSENTS (ni pause ni congé). +// Signale les jours SANS aucun tech terrain (week-ends exclus — souvent volontairement vides). Le helpdesk est géré à part. +async function coverageCheck (start, days) { + const dates = rangeDates(start, days) + const [asgs, templates, techsActive, absences] = await Promise.all([ + fetchAssignments(start, days), fetchTemplates(), fetchTechnicians(), absencesByTechDay(start, days), + ]) + const tpl = {}; for (const t of templates) tpl[t.name] = t + const valid = new Set(techsActive.map(t => t.id)) // exclut les archivés + const byDay = {}; for (const d of dates) byDay[d] = new Set() + for (const a of asgs) { + if (!byDay[a.date] || !valid.has(a.tech)) continue + if (absences[a.tech + '|' + a.date]) continue // pause / congé approuvé + const t = tpl[a.shift]; if (t && t.on_call) continue // garde (sur appel) ≠ couverture terrain régulière + byDay[a.date].add(a.tech) + } + const daysOut = dates.map(d => { const dow = new Date(d + 'T12:00:00Z').getUTCDay(); return { date: d, dow, weekend: dow === 0 || dow === 6, techs: byDay[d].size, covered: byDay[d].size > 0 } }) + const gaps = daysOut.filter(o => !o.covered && !o.weekend) // jours de semaine sans aucun tech terrain + return { ok: gaps.length === 0, days: daysOut, gaps } +} + // ── Routeur ────────────────────────────────────────────────────────────────── // technician_id n'est pas le docname → résoudre le docname Dispatch Technician. async function resolveTechName (techId) { @@ -1482,12 +1584,21 @@ async function capacityByDay (start, days, skills = null) { // Capacité RÉELLE : un quart matérialisé subsiste même quand le tech est en congé/pause/archivé → on retranche ces // (tech, jour) sinon le dénominateur gonfle (ex. « 24/150 » alors que plusieurs techs sont absents). Aligné sur le // moteur de créneaux + le solveur (buildUnavailability = En pause + absence_from/until + Tech Availability approuvé). - let techsActive = await fetchTechnicians() // exclut les techs archivés - // FILTRE COMPÉTENCE (optionnel) : le dénominateur ne compte QUE les techs qui ont la/les compétence(s) demandée(s). - // Ex. « installation » → seuls les installateurs comptent (fini « 150 h dispo » alors que la moitié ne peut pas installer). + const techsActive = await fetchTechnicians() // exclut les techs archivés + const techByIdCap = {}; for (const t of techsActive) techByIdCap[t.id] = t + // FILTRE COMPÉTENCE (optionnel) : le dénominateur ne compte QUE les techs qui ont la/les compétence(s) demandée(s) + // CE JOUR-LÀ (base ± fonction du quart ± exception) — évalué PAR JOUR (pas en pré-filtre), car une fonction peut + // retirer/ajouter une compétence pour une journée. Ex. « installation » → seuls les installateurs de ce jour comptent. const wantSkills = (Array.isArray(skills) ? skills : (skills ? [skills] : [])).map(s => String(s || '').trim()).filter(Boolean) - if (wantSkills.length) techsActive = techsActive.filter(t => techHasSkills(t.skills, wantSkills)) const validTech = new Set(techsActive.map(t => t.id)) + // Quarts du jour par tech (fonction éventuelle → compétences dépendantes du jour). `function` inerte tant que non migré+lu. + const capShiftsBy = {} + for (const a of asgs) { (capShiftsBy[a.tech + '|' + a.date] = capShiftsBy[a.tech + '|' + a.date] || []).push({ function: a.function || '' }) } + const dayCovers = (techId, date) => { + if (!wantSkills.length) return true + const t = techByIdCap[techId]; if (!t) return false + return techHasSkills(effectiveSkills(t, date, { shifts: capShiftsBy[techId + '|' + date] || [] }).skills, wantSkills) + } const unavail = await buildUnavailability(techsActive, dates) const isUnavailable = (tech, date) => !validTech.has(tech) || (unavail[tech] && unavail[tech].has(date)) const jobs = await erp.list('Dispatch Job', { @@ -1500,6 +1611,7 @@ async function capacityByDay (start, days, skills = null) { for (const a of asgs) { const o = out[a.date]; if (!o) continue if (isUnavailable(a.tech, a.date)) continue // congé / pause / archivé → ne compte pas dans la capacité du jour + if (wantSkills.length && !dayCovers(a.tech, a.date)) continue // ce jour-là, la fonction/exception retire la compétence demandée const w = tpl[a.shift]; if (!w || !(w.e > w.s)) continue DAY_SEGMENTS.forEach((seg, i) => { o.segments[i].cap += segOverlap(w.s, w.e, seg.from, seg.to) }) const tk = a.date + '|' + a.tech; if (!techSeen[tk]) { techSeen[tk] = 1; o.techs++ } @@ -1509,7 +1621,7 @@ async function capacityByDay (start, days, skills = null) { // Sous filtre compétence : ne compter que ce qui occupe VRAIMENT un tech qualifié (assigné à un tech du dénominateur). // On ignore les jobs assignés à un tech non qualifié + les jobs non placés (compétence inconnue ici) → used/due // restent cohérents avec la capacité réduite (sinon due_h > cap_h ⇒ « surbooké » faux systématique). - if (wantSkills.length && (!j.assigned_tech || !validTech.has(j.assigned_tech))) continue + if (wantSkills.length && (!j.assigned_tech || !dayCovers(j.assigned_tech, j.scheduled_date))) continue const dur = Number(j.duration_h) || 0 o.due_h += dur; o.jobs_due++ const sh = j.start_time ? timeToH(j.start_time) : null @@ -1726,14 +1838,29 @@ async function handle (req, res, method, path, url) { if (!start) return json(res, 400, { error: 'start requis' }) return json(res, 200, { absences: await absencesByTechDay(start, days) }) } + if (path === '/roster/field-coverage' && method === 'GET') { // couverture terrain par jour (jours de semaine sans aucun tech de quart) — distinct de /roster/coverage (rapport solveur par compétence) + if (!start) return json(res, 400, { error: 'start requis' }) + return json(res, 200, await coverageCheck(start, days)) + } // Absence d'UN JOUR depuis la grille (approuvée → hachurée tout de suite) ou retrait (jour unique seulement). if (path === '/roster/absence/set' && method === 'POST') { const b = await parseBody(req) if (!b.tech || !b.date) return json(res, 400, { error: 'tech + date requis' }) if (b.remove) { - const rows = await erp.list('Tech Availability', { filters: [['technician', '=', b.tech], ['from_date', '=', b.date], ['to_date', '=', b.date]], fields: ['name'], limit: 20 }) - let removed = 0; for (const r of rows) { const x = await retryWrite(() => erp.remove('Tech Availability', r.name)); if (x.ok) removed++ } - return json(res, 200, { ok: true, removed }) + // Retire le jour `date` de TOUTE dispo qui le COUVRE (pas seulement les enregistrements 1 jour) : supprime si mono-jour, + // rogne si le jour est au bord, SCINDE si le jour est à l'intérieur d'une plage multi-jours. Corrige « le congé revient + // à la publication » (avant : filtre from=to=date → aucun match sur une plage → rien supprimé). + const d = b.date + const prev = iso(addDays(parseISO(d), -1)); const next = iso(addDays(parseISO(d), 1)) + const rows = await erp.list('Tech Availability', { filters: [['technician', '=', b.tech], ['from_date', '<=', d], ['to_date', '>=', d]], fields: ['name', 'from_date', 'to_date', 'availability_type', 'status', 'reason'], limit: 50 }) + let removed = 0; let trimmed = 0 + for (const r of rows) { + if (r.from_date === d && r.to_date === d) { const x = await retryWrite(() => erp.remove('Tech Availability', r.name)); if (x && x.ok) removed++ } + else if (r.from_date === d) { const x = await retryWrite(() => erp.update('Tech Availability', r.name, { from_date: next })); if (x && x.ok) trimmed++ } + else if (r.to_date === d) { const x = await retryWrite(() => erp.update('Tech Availability', r.name, { to_date: prev })); if (x && x.ok) trimmed++ } + else { const origTo = r.to_date; const u = await retryWrite(() => erp.update('Tech Availability', r.name, { to_date: prev })); await retryWrite(() => erp.create('Tech Availability', { technician: b.tech, from_date: next, to_date: origTo, availability_type: r.availability_type || 'Congé', status: r.status || 'Approuvé', reason: r.reason || 'Grille' })); if (u && u.ok) trimmed++ } + } + return json(res, 200, { ok: true, removed, trimmed }) } const r = await retryWrite(() => erp.create('Tech Availability', { technician: b.tech, from_date: b.date, to_date: b.date, availability_type: b.type || 'Congé', status: 'Approuvé', reason: 'Grille' })) return json(res, r.ok ? 200 : 500, r) @@ -1924,6 +2051,61 @@ async function handle (req, res, method, path, url) { const m = setJobSkills(b.name, b.skills); invalidatePool() // le pool porte required_skills/required_skill → rafraîchir return json(res, 200, { ok: true, name: b.name, skills: m[String(b.name)] || [] }) } + // ── FONCTIONS (skillsets nommés réutilisables) — catalogue hub ────────────────────────────────── + // Une fonction portée par un quart définit ce que le dispatch TERRAIN peut envoyer ce quart-là : + // mode 'replace' (remplace les compétences de base ce jour) ou 'add' (s'y ajoute). Le helpdesk = à part. + if (path === '/roster/functions' && method === 'GET') { + return json(res, 200, { functions: listFunctions() }) + } + if (path === '/roster/functions' && method === 'POST') { + const b = await parseBody(req) + if (!b || !(b.id || b.name)) return json(res, 400, { error: 'id ou name requis' }) + setFunction(b) + return json(res, 200, { ok: true, functions: listFunctions() }) + } + if (path === '/roster/functions/delete' && method === 'POST') { + const b = await parseBody(req); if (!b || !b.id) return json(res, 400, { error: 'id requis' }) + deleteFunction(b.id) + return json(res, 200, { ok: true, functions: listFunctions() }) + } + // ── FILES DE GARDE (rotations on-call) — store hub PARTAGÉ (remplace le localStorage par-navigateur) ── + if (path === '/roster/garde/queues' && method === 'GET') { + return json(res, 200, { queues: listGardeQueues() }) + } + if (path === '/roster/garde/queues' && method === 'POST') { // upsert UNE file (id requis) + const b = await parseBody(req); if (!b || !b.id) return json(res, 400, { error: 'id requis' }) + return json(res, 200, { ok: true, queues: upsertGardeQueue(b) }) + } + if (path === '/roster/garde/queues/replace' && method === 'POST') { // remplace TOUT le tableau (migration + réordonnancement) + const b = await parseBody(req) + return json(res, 200, { ok: true, queues: saveGardeQueues(b && b.queues) }) + } + if (path === '/roster/garde/queues/delete' && method === 'POST') { + const b = await parseBody(req); if (!b || !b.id) return json(res, 400, { error: 'id requis' }) + return json(res, 200, { ok: true, queues: saveGardeQueues(listGardeQueues().filter(q => q.id !== String(b.id))) }) + } + // Horizon matérialisé de la garde : date la PLUS LOINTAINE avec une garde (on_call) publiée → le client sait + // s'il doit prolonger (auto top-up de la queue). Cheap : 1 requête max-date. Aucune écriture. + if (path === '/roster/garde/status' && method === 'GET') { + const gardeTpls = (await fetchTemplates()).filter(t => t.on_call).map(t => t.name) + let furthest = null + if (gardeTpls.length) { + try { const rows = await erp.list('Shift Assignment', { filters: [['shift_template', 'in', gardeTpls]], fields: ['assignment_date'], orderBy: 'assignment_date desc', limit: 1 }); if (rows && rows[0]) furthest = rows[0].assignment_date } catch (e) {} + } + return json(res, 200, { furthest }) + } + // ── EXCEPTIONS de compétence PONCTUELLES (par tech, plage de dates) ────────────────────────────── + // Ex. « mal de dos → pas d'installation cette semaine » = {mode:'remove', skills:['installation']}. + if (path === '/roster/skill-exceptions' && method === 'GET') { + const tech = qs.get('tech') || '' + if (!tech) return json(res, 200, { all: getSkillExceptionsMap() }) // sans ?tech → tous les techs (pour afficher les ajustements sur la grille) + return json(res, 200, { tech, exceptions: getSkillExceptions(tech) }) + } + if (path === '/roster/skill-exceptions' && method === 'POST') { + const b = await parseBody(req); if (!b || !b.tech) return json(res, 400, { error: 'tech requis' }) + setSkillExceptions(b.tech, b.exceptions || []) + return json(res, 200, { ok: true, tech: b.tech, exceptions: getSkillExceptions(b.tech) }) + } // MÉDIAS TERRAIN d'un job : photos prises par le tech (app terrain, /field/photo) + journal completion_notes // (arrivées géofence, scans d'appareil, notes). Sert le module « Photos & activité terrain » du détail ticket/job. // Les passent par /field/photo-file (URL signée fieldSign — même modèle de jeton que l'app terrain). @@ -2281,18 +2463,27 @@ async function handle (req, res, method, path, url) { let deleted = 0; let created = 0; let errors = 0; let unchanged = 0; let promoted = 0 for (const a of existing) { // supprimer ceux qui ne sont plus voulus if (desiredKeys.has(keyOf(a))) continue + // BUG « publié puis à republier » : l'AUTO-SAVE (draft) envoie l'état COURANT (fenêtre partielle / possiblement + // désynchro du cron de matérialisation ou d'un autre répartiteur). Il ne doit JAMAIS supprimer un quart PUBLIÉ — + // sinon le quart disparaît et doit être recréé (→ redevient « Proposé »). Seule une action EXPLICITE + // (publish/submit/approve) peut retirer un quart publié. Le draft ne nettoie que les brouillons réellement retirés. + if (mode === 'draft' && a.status === 'Publié') continue const r = await retryWrite(() => erp.remove('Shift Assignment', a.name)); if (r.ok) deleted++ } for (const a of desired) { const ex = existByKey[keyOf(a)] - if (ex) { // draft = laisser tel quel ; submit/approve/publish = faire MONTER le statut (jamais rétrograder un Publié) - if (mode !== 'draft' && ex.status !== 'Publié' && ex.status !== targetStatus) { const r = await retryWrite(() => erp.update('Shift Assignment', ex.name, { status: targetStatus })); if (r.ok) promoted++; else errors++ } else unchanged++ + if (ex) { // quart existant : monter le statut (jamais rétrograder un Publié) ET persister un rôle changé (même en brouillon) + const patch = {} + if (mode !== 'draft' && ex.status !== 'Publié' && ex.status !== targetStatus) patch.status = targetStatus + if ((a.function || '') !== (ex.function || '')) patch.function = a.function || '' // rôle du quart modifié → écrire (sinon perdu) + if (Object.keys(patch).length) { const r = await retryWrite(() => erp.update('Shift Assignment', ex.name, patch)); if (r.ok) { if (patch.status) promoted++; else unchanged++ } else errors++ } else unchanged++ continue } const r = await retryWrite(() => erp.create('Shift Assignment', { technician: a.tech, technician_name: a.tech_name || '', assignment_date: a.date, shift_template: a.shift, zone: a.zone || '', hours: Number(a.hours) || 0, status: (mode === 'draft' ? 'Proposé' : targetStatus), source: a.source || 'manuel', + function: a.function || '', // rôle du quart posé dans la grille → conservé au brouillon ET à la publication })) if (r.ok) created++; else errors++ } @@ -2518,6 +2709,32 @@ async function handle (req, res, method, path, url) { const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { traccar_device_id: deviceId })); if (r && r.ok) invalidateTechCache() return json(res, r.ok ? 200 : 500, { ...r, technician: techId, traccar_device_id: deviceId }) } + // Détection des CONFLITS de quarts sur l'horizon : chevauchements réguliers (souvent doublons manuel+patron) ET quarts + // posés sur un CONGÉ approuvé (le congé est prioritaire). → l'outil « Corriger les chevauchements » les résout 1 par 1. + if (path === '/roster/overlaps' && method === 'GET') { + const u = new URL(req.url, 'http://localhost') + const weeks = Math.max(1, Math.min(12, Number(u.searchParams.get('weeks')) || 6)) + const start = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) + const days = weeks * 7 + const [asgs, tpls, absences] = await Promise.all([fetchAssignments(start, days), fetchTemplates(), absencesByTechDay(start, days)]) + const win = {}; for (const t of tpls) win[t.name] = { start: t.start_time || '', end: t.end_time || '', on_call: !!t.on_call, label: String(t.start_time || '').slice(0, 5) + '–' + String(t.end_time || '').slice(0, 5) } + const byKey = {}; for (const a of asgs) { (byKey[a.tech + '|' + a.date] = byKey[a.tech + '|' + a.date] || []).push(a) } + const mk = (a) => ({ name: a.name, shift: a.shift, label: (win[a.shift] && win[a.shift].label) || a.shift_name || a.shift, role: a.function || '', source: a.source || '', status: a.status || '' }) + const conflicts = [] + for (const key of Object.keys(byKey)) { + const [tech, date] = key.split('|'); const rows = byKey[key] + const reg = rows.filter(a => win[a.shift] && !win[a.shift].on_call) + if (!reg.length) continue + const nm = reg[0].tech_name || tech + if (absences[key]) { conflicts.push({ tech, tech_name: nm, date, type: 'vacation', shifts: reg.map(mk) }); continue } // quart sur congé → congé prioritaire + const sorted = reg.slice().sort((x, y) => String(win[x.shift].start).localeCompare(String(win[y.shift].start))) + let overlap = false + for (let i = 1; i < sorted.length; i++) { if (String(win[sorted[i].shift].start) < String(win[sorted[i - 1].shift].end)) { overlap = true; break } } // aStart < prevEnd (compare HH:MM:SS) + if (overlap) conflicts.push({ tech, tech_name: nm, date, type: 'overlap', shifts: reg.map(mk) }) + } + conflicts.sort((a, b) => String(a.date).localeCompare(String(b.date)) || String(a.tech_name).localeCompare(String(b.tech_name))) + return json(res, 200, { ok: true, weeks, count: conflicts.length, conflicts }) + } // Patron d'horaire RÉCURRENT (weekly_schedule) — source des quarts matérialisés (hybride). body.schedule = {mon:{start,end}|null,…} const mSched = path.match(/^\/roster\/technician\/(.+)\/weekly-schedule$/) if (mSched && method === 'POST') { @@ -2527,10 +2744,18 @@ async function handle (req, res, method, path, url) { const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { weekly_schedule: sched ? JSON.stringify(sched) : '' })); if (r && r.ok) invalidateTechCache() return json(res, r.ok ? 200 : 500, { ...r, technician: techId }) } + // Fonction (skillset) posée sur UN quart précis (override ponctuel d'un Shift Assignment). '' = retirer. + // ⚠️ écrit le DocField `function` → nécessite la migration ; à déployer AVEC elle. + const mAsgFn = path.match(/^\/roster\/assignment\/(.+)\/function$/) + if (mAsgFn && method === 'POST') { + const name = decodeURIComponent(mAsgFn[1]); const b = await parseBody(req) + const r = await retryWrite(() => erp.update('Shift Assignment', name, { function: String(b.function || '') })) + return json(res, r && r.ok ? 200 : 500, r && r.ok ? { ok: true, name, function: String(b.function || '') } : { ok: false, error: (r && r.error) || 'échec' }) + } // HYBRIDE : matérialise les quarts depuis les patrons (fériés/vacances sautés, manuels préservés). GET=aperçu / POST=applique. ?weeks= ?tech= if (path === '/roster/materialize-shifts' && (method === 'GET' || method === 'POST')) { const u = new URL(req.url, 'http://localhost'); const b = method === 'POST' ? await parseBody(req) : {} - return json(res, 200, await materializeShifts({ dryRun: method === 'GET', weeks: Number(b.weeks || u.searchParams.get('weeks')) || 4, techId: b.tech || u.searchParams.get('tech') || null })) + return json(res, 200, await materializeShifts({ dryRun: method === 'GET' || !!b.dry_run, weeks: Number(b.weeks || u.searchParams.get('weeks')) || 4, techId: b.tech || u.searchParams.get('tech') || null, rolesToManual: !!(b.roles_to_manual || u.searchParams.get('roles_to_manual')), replaceManual: !!(b.replace_manual || u.searchParams.get('replace_manual')) })) } // Supprimer une assignation publiée const mDelA = path.match(/^\/roster\/assignment\/(.+)$/) diff --git a/services/targo-hub/lib/skill-resolver.js b/services/targo-hub/lib/skill-resolver.js index 7aaf0ad..67418cd 100644 --- a/services/targo-hub/lib/skill-resolver.js +++ b/services/targo-hub/lib/skill-resolver.js @@ -10,6 +10,8 @@ // // N'importe RIEN de roster.js/dispatch.js (pour éviter tout cycle) — au contraire, // roster.js ré-exporte `deptToSkill` d'ici pour ne pas dupliquer la logique. +const fs = require('fs') +const path = require('path') const cfg = require('./config') const { log } = require('./helpers') @@ -19,6 +21,10 @@ const SKILL_VOCAB = ['installation', 'réparation', 'tv', 'telephone', 'épissur function normSkill (s) { return String(s || '').trim().toLowerCase() } function stripAccents (s) { return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '') } +// Liste depuis un tableau OU une CSV brute (champ ERP / _user_tags), triée par ordre (= priorité). +function toSkillList (v) { return Array.isArray(v) ? v.filter(Boolean) : String(v || '').split(/[,;]/).map(s => s.trim()).filter(Boolean) } +// 'HH:MM' → heure décimale (pour borner un quart) ; '' → NaN. +function hmToH (t) { const p = String(t || '').split(':'); const h = Number(p[0]); const m = Number(p[1]); return Number.isFinite(h) ? h + (Number.isFinite(m) ? m / 60 : 0) : NaN } // ── Prédicat PARTAGÉ « ce technicien a-t-il la compétence ? » ────────────────── // Sémantique IDENTIQUE à celle qui vivait (dupliquée) dans dispatch.js techOccupancy @@ -52,7 +58,9 @@ function deptToSkill (txt) { if (/netadmin|net admin/.test(d)) return 'netadmin' // Diagnostic / panne / dépannage → réparation, AVANT l'installation : « voir signal fibre », « panne », « internet lent », // « coupé », « ne fonctionne pas » = une RÉPARATION, pas une pose. (« fibre » seul = une techno, PAS une intention d'installer.) - if (/repar|desinstall|panne|signal|lent|coupe|coupure|hors.?service|ne fonctionne|fonctionne pas|marche pas|sans internet|pas d.?internet|no internet|deconnect|intermitten|verifi|diagnostic|depann|probleme|trouble|\bbris\b|defect/.test(d)) return 'réparation' + // Échange/remplacement d'équipement (modem / ONU / borne) = une RÉPARATION fibre, pas une nouvelle pose : « changer modem », + // « remplacement ONU », « swap borne » (accents déjà retirés par stripAccents → tokens sans accent). + if (/repar|desinstall|panne|signal|lent|coupe|coupure|hors.?service|ne fonctionne|fonctionne pas|marche pas|sans internet|pas d.?internet|no internet|deconnect|intermitten|verifi|diagnostic|depann|probleme|trouble|\bbris\b|defect|\bmodem\b|\bonu\b|remplac|\bswap\b|echange/.test(d)) return 'réparation' if (/install|raccord|activ|nouveau|nouvelle|mise en service|branchement/.test(d)) return 'installation' return '' } @@ -129,9 +137,134 @@ async function resolveSkills ({ text = '', department = '', jobType = '', useAI return { skills: [], primary: '', confidence: 'low', source: 'none', department, reason: '' } } +// ── FONCTIONS (skillsets nommés, réutilisables) — catalogue hub durable ─────── +// Une « fonction » = un rôle terrain porté par un QUART : elle définit ce que le +// dispatch TERRAIN peut envoyer au tech pendant ce quart. +// mode 'replace' (défaut) : remplace les compétences de base pour ce quart +// (« lundi = support » → ce jour-là il ne fait QUE support) ; +// mode 'add' : s'ajoute aux compétences de base (accorde une capacité en plus). +// Le helpdesk/support est géré SÉPARÉMENT — une fonction « hors terrain » (ex. support/remote) +// ne fait que RETIRER le tech du dispatch terrain ; on n'injecte personne dans l'horaire du helpdesk. +// Store JSON durable (même patron que job-skills.json ; l'API Custom Field ERPNext v16 est cassée +// et de toute façon un catalogue de rôles n'est pas de la donnée transactionnelle). +const FUNCTIONS_FILE = path.join(__dirname, '..', 'data', 'functions.json') +function getFunctionsMap () { try { return JSON.parse(fs.readFileSync(FUNCTIONS_FILE, 'utf8')) || {} } catch { return {} } } +function _normFn (id, raw) { + return { + id, + name: String(raw.name || id).trim(), + skills: [...new Set(toSkillList(raw.skills).map(s => s.trim()).filter(Boolean))], + mode: raw.mode === 'add' ? 'add' : 'replace', + color: raw.color || '', + icon: raw.icon || '', + } +} +function listFunctions () { const m = getFunctionsMap(); return Object.keys(m).map(id => _normFn(id, m[id])) } +// Résolution TOLÉRANTE : par id, sinon par nom (casse/accents ignorés) — un quart stocke le nom ou l'id. +function getFunction (idOrName) { + if (!idOrName) return null + const m = getFunctionsMap() + if (m[idOrName]) return _normFn(idOrName, m[idOrName]) + const want = stripAccents(idOrName).toLowerCase() + const k = Object.keys(m).find(k => stripAccents(k).toLowerCase() === want || stripAccents(m[k].name || '').toLowerCase() === want) + return k ? _normFn(k, m[k]) : null +} +function setFunction (fn) { + const m = getFunctionsMap() + const id = String((fn && (fn.id || fn.name)) || '').trim() + if (!id) return m + m[id] = { name: String(fn.name || id).trim(), skills: [...new Set(toSkillList(fn.skills))], mode: fn.mode === 'add' ? 'add' : 'replace', color: fn.color || '', icon: fn.icon || '' } + try { fs.mkdirSync(path.dirname(FUNCTIONS_FILE), { recursive: true }) } catch (e) {} + fs.writeFileSync(FUNCTIONS_FILE, JSON.stringify(m)); return m +} +function deleteFunction (id) { + const m = getFunctionsMap(); if (!m[id]) return m + delete m[id] + try { fs.writeFileSync(FUNCTIONS_FILE, JSON.stringify(m)) } catch (e) {} + return m +} + +// ── EXCEPTIONS de compétence PONCTUELLES (par tech, plage de dates) ──────────── +// [{ from, to, mode:'remove'|'add', skills:[], reason }]. Ex. « mal de dos → pas d'installation +// cette semaine » = {mode:'remove', skills:['installation']}. (Le cas « reste à la maison +// aujourd'hui, prend le support » se fait plutôt via la FONCTION du quart de ce jour.) +const SKILL_EXC_FILE = path.join(__dirname, '..', 'data', 'skill-exceptions.json') +function getSkillExceptionsMap () { try { return JSON.parse(fs.readFileSync(SKILL_EXC_FILE, 'utf8')) || {} } catch { return {} } } +function getSkillExceptions (techId) { const v = getSkillExceptionsMap()[String(techId)]; return Array.isArray(v) ? v : [] } +function setSkillExceptions (techId, list) { + const m = getSkillExceptionsMap() + const arr = (Array.isArray(list) ? list : []).map(e => ({ + from: String(e.from || ''), to: String(e.to || e.from || ''), + mode: e.mode === 'add' ? 'add' : 'remove', + skills: [...new Set(toSkillList(e.skills))], + reason: String(e.reason || '').slice(0, 200), + })).filter(e => e.from && e.skills.length) + if (arr.length) m[String(techId)] = arr; else delete m[String(techId)] + try { fs.mkdirSync(path.dirname(SKILL_EXC_FILE), { recursive: true }) } catch (e) {} + fs.writeFileSync(SKILL_EXC_FILE, JSON.stringify(m)); return m +} + +// Compétences effectives d'UN quart : la valeur portée par le quart est un ENSEMBLE de compétences EXPLICITE +// (les « chips » curés par le répartiteur). Une fonction du catalogue n'est qu'un GABARIT qui PRÉ-REMPLIT ces +// chips côté UI ; on retire/ajoute ensuite à la main (ex. Technicien = installation+réparation+tv+téléphone, +// puis on retire « installation » car mal de dos). L'ensemble curé REMPLACE les compétences de base ce quart-là +// (pas de réconciliation automatique — le répartiteur voit et contrôle exactement ce qui est posé). +// • valeur vide → compétences de base. +// • RÉTRO-COMPAT : si la valeur est le NOM d'une fonction du catalogue, on l'étend à ses compétences. +// • sinon (CSV de compétences) → ces compétences telles quelles. +function _shiftEffective (base, fnVal) { + if (!fnVal) return base.slice() + const f = getFunction(fnVal) + const skills = f ? f.skills.slice() : toSkillList(fnVal) + return skills.length ? [...new Set(skills)] : base.slice() +} + +// ── COMPÉTENCES EFFECTIVES d'un tech pour un JOUR (point d'entrée UNIQUE) ────── +// Le dispatch terrain, l'occupation, le solveur et la capacité doivent TOUS voir les mêmes +// compétences dépendantes du jour. Retourne un objet tech « shadow » : skills restreint + +// skill_levels / skill_eff RÉDUITS aux seules compétences effectives — les ★ (niveau) et la +// cadence d'une compétence retirée disparaissent aussi (elles ne doivent PAS peser sur l'offre). +// +// tech : objet tech ({ skills[], skill_levels{}, skill_eff{}, id }) +// iso : 'YYYY-MM-DD' +// opts.shifts : quarts du tech CE JOUR = [{ function|fn, start, end }] (fournis par l'appelant : +// fetchAssignments enrichi du champ `function`, repli patron weekly_schedule). +// opts.time : 'HH:MM' → restreint au quart couvrant cette heure ; sinon UNION des quarts du jour. +// +// Aucun quart fourni (ou aucune fonction) → compétences de BASE (rétro-compat total : les +// appelants qui ne passent pas `shifts` obtiennent exactement le comportement d'avant). +function effectiveSkills (tech, iso, opts = {}) { + const base = toSkillList(tech && tech.skills) + const shifts = Array.isArray(opts.shifts) ? opts.shifts : [] + let eff + if (!shifts.length) { + eff = base.slice() + } else { + let use = shifts + if (opts.time) { + const th = hmToH(opts.time) + const covering = shifts.filter(s => { const a = hmToH(s.start), b = hmToH(s.end); return !Number.isFinite(a) || !Number.isFinite(b) || (a <= th && th < b) }) + if (covering.length) use = covering // si l'heure ne tombe dans aucun quart → repli sur tous les quarts du jour + } + eff = [...new Set(use.flatMap(s => _shiftEffective(base, s.function || s.fn || '')))] + } + // Exceptions ponctuelles (après la fonction : elles ajustent l'ensemble de travail du jour). + const exs = (tech && tech.id != null) ? getSkillExceptions(tech.id).filter(e => iso >= e.from && iso <= e.to) : [] + for (const e of exs) { + if (e.mode === 'add') eff = [...new Set([...eff, ...e.skills])] + else eff = eff.filter(s => !e.skills.some(x => normSkill(x) === normSkill(s))) + } + // Réduire niveaux ★ + cadence aux seules compétences effectives (sinon une compétence retirée + // continuerait de peser sur le scoring / l'offre — ce que l'utilisateur ne veut pas). + const inEff = (k) => eff.some(s => normSkill(s) === normSkill(k)) + const scope = (obj) => { const o = {}; if (obj) for (const k of Object.keys(obj)) if (inEff(k)) o[k] = obj[k]; return o } + return { ...tech, skills: eff, skill_levels: scope(tech && tech.skill_levels), skill_eff: scope(tech && tech.skill_eff) } +} + module.exports = { SKILL_VOCAB, normSkill, + toSkillList, techHasSkill, techHasSkills, deptToSkill, @@ -139,4 +272,13 @@ module.exports = { DEPARTMENT_SKILLS, aiSkills, resolveSkills, + // Fonctions (catalogue) + exceptions + résolveur jour-conscient : + listFunctions, + getFunction, + setFunction, + deleteFunction, + getSkillExceptions, + getSkillExceptionsMap, + setSkillExceptions, + effectiveSkills, }