roster(planif/dispatch): On-Hold bloqué, alerte hors-quart, deep-link Dispatch, aviser client (#58)

- On Hold : onCellDrop REFUSE d'assigner un job en attente d'un prérequis (notify), reste au panneau (≠ 🔒 visuel)
- Hors quart publié : marqueur ⚠ dans la cellule libre (offShiftJobs/rawCellJobs lit occByTechDay brut) +
  badge « hors quart » dans la timeline ressource — surface les jobs assignés un jour sans quart
- Deep-link : Planif gotoDispatch(tech) → /dispatch?tech=&date= ; DispatchPage lit route.query
  (goToDay(date+T12:00:00) anti-décalage tz + selectTechOnBoard)
- #58 : bouton « Désaffecter + aviser le client » dans le dialogue d'unassign Dispatch →
  roster.notifyReschedule (désassigne serveur + SMS lien /book au mobile du Customer)
- Doc docs/features/roster.md mise à jour (Fait récemment / TODO)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-06 09:13:17 -04:00
parent f1204ed459
commit bc5bb06794
3 changed files with 77 additions and 11 deletions

View File

@ -1,6 +1,8 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick, provide } from 'vue'
import { useRoute } from 'vue-router'
import { Notify } from 'quasar'
import * as roster from 'src/api/roster'
import { useDispatchStore } from 'src/stores/dispatch'
import { useAuthStore } from 'src/stores/auth'
import { TECH_COLORS, MAPBOX_TOKEN, BASE_URL } from 'src/config/erpnext'
@ -50,6 +52,7 @@ import CreateOfferModal from 'src/modules/dispatch/components/CreateOfferModal.v
import RecurrenceSelector from 'src/components/shared/RecurrenceSelector.vue'
const store = useDispatchStore()
const route = useRoute()
const auth = useAuthStore()
const erpUrl = BASE_URL || window.location.origin
@ -277,6 +280,28 @@ function cancelUnassign () {
confirmUnassignDialog.value = false
}
// #58 Désaffecter + AVISER LE CLIENT : le hub /roster/job/notify-reschedule désassigne côté serveur
// (booking_status « À reporter », vide le créneau), retrouve le mobile du Customer et envoie un SMS avec
// un lien /book pour rechoisir un créneau. On reflète ensuite le désassignement dans la vue locale.
const notifyingClient = ref(false)
async function unassignAndNotify (job) {
if (!job || notifyingClient.value) return
notifyingClient.value = true
let r
try { r = await roster.notifyReschedule({ job: job.name || job.id }) }
catch (e) { Notify.create({ type: 'negative', message: 'Échec de la notification : ' + (e.message || e) }); notifyingClient.value = false; return }
store.fullUnassign(job.id) // met à jour la vue (le serveur a déjà désassigné réécriture idempotente)
const j = store.jobs.find(x => x.id === job.id); if (j) { j.scheduledDate = null; j.startTime = null }
if (selectedJob.value?.job?.id === job.id) selectedJob.value = null
invalidateRoutes()
Notify.create({ type: r && r.sms ? 'positive' : 'warning', timeout: 5000,
message: r && r.sms ? ('Client avisé par SMS' + (r.phone ? ' (' + r.phone + ')' : '') + ' · job remis au pool « À reporter »')
: ('Désaffecté · ' + ((r && (r.note || r.error)) || 'SMS non envoyé (aucun mobile au dossier)')) })
notifyingClient.value = false
pendingUnassignJob.value = null
confirmUnassignDialog.value = false
}
const {
ctxMenu, techCtx, assistCtx, assistNoteModal,
openCtxMenu, closeCtxMenu, openTechCtx, openAssistCtx,
@ -1210,6 +1235,15 @@ onMounted(async () => {
nextTick(() => scrollToCenter())
if (boardScroll.value) boardScroll.value.addEventListener('scroll', onBoardScroll, { passive: true })
window.addEventListener('resize', measureCalColW)
// Deep-link depuis la Planification : ?tech=<id>&date=<YYYY-MM-DD> focus jour + ressource.
// 'T12:00:00' = midi local pour éviter le décalage de jour (new Date('YYYY-MM-DD') = UTC).
nextTick(() => {
try {
const q = route.query
if (q.date) goToDay(String(q.date) + 'T12:00:00')
if (q.tech) { const tk = store.technicians.find(t => t.id === String(q.tech)); if (tk && selectedTechId.value !== tk.id) selectTechOnBoard(tk) }
} catch (_) {}
})
})
// Re-measure column widths when switching views (dayweek changes periodDays 17)
@ -2106,6 +2140,7 @@ onUnmounted(() => {
</div>
<div class="sb-confirm-actions">
<button class="sb-rp-btn" @click="cancelUnassign">Annuler</button>
<button class="sb-rp-btn" :disabled="notifyingClient" @click="unassignAndNotify(pendingUnassignJob)" title="Désaffecter + envoyer au client un SMS avec un lien pour choisir un nouveau créneau">{{ notifyingClient ? 'Envoi…' : '📩 Désaffecter + aviser le client' }}</button>
<button class="sb-rp-btn sb-confirm-danger" @click="confirmUnassign">Désaffecter</button>
</div>
</div>

View File

@ -201,6 +201,7 @@
</q-tooltip>
</div>
</template>
<span v-else-if="offShiftJobs(t.id, d.iso).length" class="offshift-warn" @click.stop="openTimeline(t)"><q-icon name="warning" size="13px" color="orange-8" />{{ offShiftJobs(t.id, d.iso).length }}<q-tooltip class="bg-grey-9">{{ offShiftJobs(t.id, d.iso).length }} job(s) assigné(s) ce jour SANS quart publié publier un quart ou réassigner. Clic timeline.</q-tooltip></span>
<span v-else class="free">·</span>
<div v-if="isDropTarget(t.id, d.iso)" class="drop-badge" :class="{ over: projPct(t.id, d.iso) >= 100 }">+{{ dropPreview.addH }}h<template v-if="projPct(t.id, d.iso) != null"> {{ projPct(t.id, d.iso) }}%</template></div>
</td>
@ -571,18 +572,20 @@
<q-icon name="timeline" color="indigo" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Timeline {{ timelineDlg.tech && timelineDlg.tech.name }}</div>
<q-space />
<q-btn flat dense no-caps size="sm" icon="open_in_new" label="Dispatch" color="indigo" @click="$router.push('/dispatch')" />
<q-btn flat dense no-caps size="sm" icon="open_in_new" label="Dispatch" color="indigo" @click="gotoDispatch(timelineDlg.tech)"><q-tooltip>Ouvrir le tableau Dispatch sur cette ressource</q-tooltip></q-btn>
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-pt-none" style="max-height:70vh;overflow:auto">
<div v-if="!timelineDays.length" class="text-grey-6 q-pa-md text-center">Aucun job planifié cette semaine pour cette ressource.</div>
<div v-for="day in timelineDays" :key="day.iso" class="tldlg-day">
<div class="row items-center q-mb-xs">
<div class="text-weight-medium" :class="{ 'text-deep-orange-7': day.weekend }">{{ day.label }}</div><q-space />
<div class="text-weight-medium" :class="{ 'text-deep-orange-7': day.weekend }">{{ day.label }}</div>
<q-badge v-if="day.offShift" color="orange-8" class="q-ml-sm"><q-icon name="warning" size="11px" class="q-mr-xs" />hors quart publié</q-badge>
<q-space />
<q-badge v-if="day.pct != null" text-color="white" :style="{ background: occColor(day.pct) }">{{ day.usedH }}h · {{ day.pct }}%</q-badge>
<q-badge v-else color="grey-5" class="q-ml-xs">{{ day.usedH }}h</q-badge>
</div>
<div class="tldlg-bar">
<div v-if="!day.offShift" class="tldlg-bar">
<div v-for="(b, bi) in day.bands" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
<div v-for="(b, bi) in day.blocks" :key="'k' + bi" class="tl-blk" :style="blockStyle(b, day.pct)"></div>
<span v-for="tk in axisTicks" :key="'t' + tk.h" class="tldlg-tick" :style="{ left: tk.left }">{{ tk.h }}</span>
@ -668,7 +671,7 @@
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
// Icônes de rôle monochromes outline (Material Symbols, style « une couleur » demandé) : échelle = installation.
import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined'
import { onBeforeRouteLeave } from 'vue-router'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
import TechSelect from 'src/components/shared/TechSelect.vue'
@ -676,6 +679,7 @@ import SkillSelect from 'src/components/shared/SkillSelect.vue'
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags partagé (Dispatch) : condensé, création à la volée, couleurs
const $q = useQuasar()
const router = useRouter()
const DIRTY_MSG = 'Vous avez des modifications non publiées. Les abandonner ?'
const techs = ref([])
@ -900,9 +904,14 @@ async function onCellDrop (ev, t, d) {
dropCell.value = null; dropPreview.key = null
const raw = (ev.dataTransfer && ev.dataTransfer.getData('text/plain')) || draggingJobName.value; draggingJobName.value = null
const names = (raw || '').split(',').filter(Boolean); if (!names.length) return
// Garde-fou : un job « On Hold » attend une tâche précédente on REFUSE de l'assigner ( simple 🔒 visuel).
const statusBy = Object.fromEntries(assignPanel.jobs.map(j => [j.name, j.status]))
const blocked = names.filter(n => statusBy[n] === 'On Hold'); const assignable = names.filter(n => statusBy[n] !== 'On Hold')
if (blocked.length) $q.notify({ type: 'warning', message: blocked.length + ' job(s) en attente d\'une tâche précédente — non assigné(s). Termine d\'abord l\'étape requise.', timeout: 4000 })
if (!assignable.length) return
let ok = 0
for (const jn of names) { try { await roster.assignJob(jn, t.id, d.iso); ok++; delete selectedJobs[jn] } catch (e) { err(e) } } // SÉQUENTIEL
assignPanel.jobs = assignPanel.jobs.filter(j => !names.includes(j.name))
for (const jn of assignable) { try { await roster.assignJob(jn, t.id, d.iso); ok++; delete selectedJobs[jn] } catch (e) { err(e) } } // SÉQUENTIEL (frappe_pg)
assignPanel.jobs = assignPanel.jobs.filter(j => !assignable.includes(j.name)) // les bloqués restent dans le panneau
$q.notify({ type: 'positive', message: ok + ' job(s) assigné(s) à ' + t.name + ' · ' + d.dnum, timeout: 2800 }); await loadWeek()
}
let _panelDrag = null // déplacement du panneau via son en-tête
@ -914,14 +923,23 @@ function panelUp () { _panelDrag = null; document.removeEventListener('mousemove
// Réutilise les helpers de cellule (cellBands/cellBlocks/cellJobs/cellPct) 0 nouvel appel réseau.
const timelineDlg = reactive({ open: false, tech: null })
function openTimeline (t) { timelineDlg.tech = t; timelineDlg.open = true }
// Deep-link vers le tableau Dispatch focalisé sur la ressource + son 1er jour avec jobs (sinon début de semaine).
function gotoDispatch (t) {
const q = {}
if (t) q.tech = t.id
q.date = (timelineDays.value[0] && timelineDays.value[0].iso) || start.value
router.push({ path: '/dispatch', query: q })
}
const timelineDays = computed(() => {
const t = timelineDlg.tech; if (!t) return []
const out = []
for (const d of dayList.value) {
const jobs = cellJobs(t.id, d.iso); const shift = hasReg(t.id, d.iso) || onGarde(t.id, d.iso)
const shift = hasReg(t.id, d.iso) || onGarde(t.id, d.iso)
const jobs = shift ? cellJobs(t.id, d.iso) : rawCellJobs(t.id, d.iso) // hors quart : jobs bruts
if (!jobs.length && !shift) continue // on saute les jours vides
const o = cellOcc(t.id, d.iso)
out.push({ iso: d.iso, label: d.dow + ' ' + d.dnum, weekend: d.weekend, bands: cellBands(t.id, d.iso), blocks: cellBlocks(t.id, d.iso), jobs, pct: cellPct(t.id, d.iso), usedH: o ? o.usedH : 0 })
const usedH = shift ? (o ? o.usedH : 0) : Math.round(jobs.reduce((s, j) => s + (j.dur || 0), 0) * 10) / 10
out.push({ iso: d.iso, label: d.dow + ' ' + d.dnum, weekend: d.weekend, bands: cellBands(t.id, d.iso), blocks: cellBlocks(t.id, d.iso), jobs, pct: shift ? cellPct(t.id, d.iso) : null, usedH, offShift: !shift && jobs.length > 0 })
}
return out
})
@ -1148,6 +1166,8 @@ function hasReg (techId, iso) { return cellsOf(techId, iso).some(a => { const t
function cellBlocks (techId, iso) { const o = cellOcc(techId, iso); return o ? o.blocks : [] }
function cellPct (techId, iso) { const o = cellOcc(techId, iso); return o ? o.pct : null }
function cellJobs (techId, iso) { const o = cellOcc(techId, iso); return o ? (o.jobs || []) : [] } // jobs du jour, déjà triés prioritéheure côté hub
function rawCellJobs (techId, iso) { const o = occByTechDay.value[techId + '|' + iso]; return o ? (o.jobs || []) : [] } // jobs BRUTS (inclut les jours SANS quart publié)
function offShiftJobs (techId, iso) { return (hasReg(techId, iso) || onGarde(techId, iso)) ? [] : rawCellJobs(techId, iso) } // jobs assignés un jour où le tech n'a AUCUN quart publié
function prioColor (p) { return p === 'urgent' ? '#ef4444' : p === 'high' ? '#f59e0b' : p === 'medium' ? '#6366f1' : '#9e9e9e' }
// Aperçu en survol de drop : occupation projetée si on dépose la sélection ici.
function isDropTarget (techId, iso) { return dropPreview.key === techId + '|' + iso }
@ -1622,6 +1642,7 @@ tr.res-hidden .hide-eye { opacity: 1; }
.cell-dirty-demo { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 4px; font-weight: 700; font-size: 11px; background: #1976d2; color: #fff; box-shadow: inset 0 0 0 2px #ff9800; }
.ch-h { opacity: .7; font-weight: 400; font-size: 9px; margin-left: 1px; }
.free { color: #ccc; }
.offshift-warn { display: inline-flex; align-items: center; gap: 1px; font-size: 10px; font-weight: 700; color: #ef6c00; cursor: pointer; line-height: 1; } /* job assigné un jour sans quart publié */
.hdr-ruler { position: relative; height: 11px; margin-top: 3px; }
.hdr-ruler .tick { position: absolute; top: 2px; transform: translateX(-50%); font-size: 8px; color: #aab; line-height: 1; font-weight: 400; }
.hdr-ruler .tick::before { content: ''; position: absolute; top: -3px; left: 50%; width: 1px; height: 2px; background: #d0d0d8; }

View File

@ -76,11 +76,21 @@ réutilisé par `assign-job` **et** `backfill-start-times`) → `bookingSlots` /
- **Déploiement** : front via `apps/ops/deploy.sh` (`DEPLOY_BASE=/ops/ quasar build` + tar/scp → `/opt/ops-app/`) ;
hub via `scp lib/roster.js → /opt/targo-hub/lib/` + `docker restart targo-hub`. Custom fields via le script frappe-setup.
## Fait récemment
- ✅ **On Hold** : le dépôt d'un job en attente d'un prérequis est REFUSÉ (notify), plus seulement 🔒 visuel (`onCellDrop`).
- ✅ **Alerte hors quart** : un job assigné un jour où le tech n'a aucun quart publié → marqueur ⚠ dans la cellule libre
(`offShiftJobs`) + badge « hors quart publié » dans la timeline. (Le backfill ignore ces jobs : pas de bande à remplir.)
- ✅ **Deep-link Dispatch** : Planification → `gotoDispatch(tech)` ouvre `/dispatch?tech=&date=` ; DispatchPage lit `route.query`
(`goToDay(date+'T12:00:00')` anti-décalage tz + `selectTechOnBoard`).
- ✅ **#58 Aviser le client** : bouton « Désaffecter + aviser le client » dans le dialogue d'unassign Dispatch →
`roster.notifyReschedule` (désassigne serveur + SMS lien /book au mobile du Customer).
## TODO / dette
- Bloquer réellement (≠ 🔒 visuel) le dépôt d'un job « On Hold » avant son prérequis.
- Brancher la **proximité** (lat/lng Service Location ↔ base/secteur du tech) dans `techProximity`.
- Deep-link Dispatch filtré tech/date (DispatchPage ne lit pas encore `route.query`).
- Brancher la **proximité** (lat/lng Service Location ↔ base/secteur du tech) dans `techProximity` (hook neutre en place).
- Apprentissage IA compétence ↔ type de cas (historique des jobs).
- `#57` Hold côté client sur `/book` (Lovable) : bloquer la plage à la sélection.
- Compteur global « N jobs hors quart cette semaine » dans la barre d'outils (complément du marqueur ⚠).
- **Refactor** : `PlanificationPage.vue` (~1,5k lignes) gagnerait à extraire des composables
(`useGarde`, `useOccupancy`, `useAssignPanel`, `useSkillEditor`) — non fait (risque sur fichier prod).