feat(planif): blocs cellule colorés par compétence + menu réordonner/re-prioriser au clic sur le progressbar
- Blocs d'occupation = 1 job, coloré par la COULEUR DE SA COMPÉTENCE (palette skills éditable), au lieu du dégradé vert→rouge par taux. Hub occupancyByTechDay attache skill+job à chaque bloc (skillForJob||deptToSkill) ; blockStyle → getTagColor(blk.skill). - Clic sur le progressbar (.tl, @click.stop+@mousedown.stop) → menu : liste des jobs du tech×jour avec réordonnancement (flèches → route_order) + re-priorisation (select) + Enregistrer. Hub POST /roster/reorder-jobs (route_order/priority, séquentiel) ; tri occupancy par route_order. - Clic HORS du progressbar dans la cellule → menu shift inchangé (créer/modifier). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5e57b72a8f
commit
368e22d57e
|
|
@ -95,5 +95,7 @@ export const unassignedJobs = () => jget('/roster/unassigned-jobs')
|
|||
export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date })
|
||||
// Fil complet d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only
|
||||
export const legacyTicketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id))
|
||||
// Réordonner / re-prioriser les jobs d'un tech×jour : updates = [{ job, route_order, priority? }]
|
||||
export const reorderJobs = (updates) => jpost('/roster/reorder-jobs', { updates })
|
||||
// Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? }
|
||||
export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body)
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@
|
|||
<div class="tl"><div class="tl-absent"></div><q-tooltip class="bg-grey-9">Absent · {{ absenceLabel(t.id, d.iso) }}</q-tooltip></div>
|
||||
</template>
|
||||
<template v-else-if="hasReg(t.id, d.iso) || onGarde(t.id, d.iso)">
|
||||
<div class="tl">
|
||||
<div class="tl tl-click" @mousedown.stop @click.stop="openCellJobs(t, d, $event)">
|
||||
<div v-for="(b, bi) in cellBands(t.id, d.iso)" :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 cellBlocks(t.id, d.iso)" :key="'j' + bi" class="tl-blk" :style="blockStyle(b, cellPct(t.id, d.iso))"></div>
|
||||
<!-- Aperçu d'occupation projetée pendant le drag : barre fantôme + delta -->
|
||||
|
|
@ -647,6 +647,36 @@
|
|||
</div>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
|
||||
<!-- Menu « jobs de la cellule » : clic sur le progressbar → détail + réordonner / re-prioriser -->
|
||||
<q-menu v-model="cellJobsMenu.show" :target="cellJobsMenu.target" anchor="bottom left" self="top left" max-height="80vh">
|
||||
<div style="width:340px;padding:6px 8px" @click.stop @mousedown.stop>
|
||||
<div class="row items-center q-mb-xs">
|
||||
<div class="text-weight-bold ellipsis">{{ cellJobsMenu.tech && cellJobsMenu.tech.name }} — {{ cellJobsMenu.day && cellJobsMenu.day.dnum }}</div>
|
||||
<q-space /><span class="text-caption text-grey-6">{{ cellJobsMenu.list.length }} job(s)</span>
|
||||
</div>
|
||||
<div v-if="!cellJobsMenu.list.length" class="text-grey-6 q-pa-sm text-center">Aucun job assigné ce jour.</div>
|
||||
<div v-for="(j, i) in cellJobsMenu.list" :key="j.name" class="cjm-row">
|
||||
<div class="column" style="gap:0">
|
||||
<q-btn flat dense round size="8px" icon="keyboard_arrow_up" :disable="i === 0" @click="moveCellJob(i, -1)" />
|
||||
<q-btn flat dense round size="8px" icon="keyboard_arrow_down" :disable="i === cellJobsMenu.list.length - 1" @click="moveCellJob(i, 1)" />
|
||||
</div>
|
||||
<span class="cjm-ord">{{ i + 1 }}</span>
|
||||
<span class="cjm-dot" :style="{ background: j.skill ? getTagColor(j.skill) : prioColor(j.priority) }"></span>
|
||||
<div class="col" style="min-width:0">
|
||||
<div class="ellipsis" style="font-size:12px;font-weight:500">{{ j.subject }}</div>
|
||||
<div class="ellipsis" style="font-size:10px;color:#888">{{ j.start || '—' }} · {{ j.dur }}h<span v-if="j.customer"> · {{ j.customer }}</span><span v-if="j.skill"> · {{ j.skill }}</span></div>
|
||||
</div>
|
||||
<select :value="j.priority" @change="j.priority = $event.target.value" @click.stop class="cjm-prio" :style="{ borderColor: prioColor(j.priority) }">
|
||||
<option value="urgent">Urgent</option><option value="high">Élevée</option><option value="medium">Moyenne</option><option value="low">Basse</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="cellJobsMenu.list.length" class="row items-center q-mt-xs">
|
||||
<span class="text-caption text-grey-6">Flèches = ordre du tech · menu = priorité</span><q-space />
|
||||
<q-btn dense unelevated size="sm" color="primary" :loading="cellJobsMenu.saving" label="Enregistrer" @click="saveCellOrder" />
|
||||
</div>
|
||||
</div>
|
||||
</q-menu>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
|
|
@ -925,6 +955,20 @@ 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 }
|
||||
// ── Menu « jobs de la cellule » : détail + réordonner / re-prioriser (clic sur le progressbar) ──
|
||||
const cellJobsMenu = reactive({ show: false, target: null, tech: null, day: null, list: [], saving: false })
|
||||
function openCellJobs (t, d, ev) {
|
||||
cellJobsMenu.tech = t; cellJobsMenu.day = d
|
||||
cellJobsMenu.list = cellJobs(t.id, d.iso).map(j => ({ ...j })) // copie ordonnée (route_order → priorité → heure)
|
||||
cellJobsMenu.target = (ev && ev.currentTarget) || null
|
||||
cellJobsMenu.show = true
|
||||
}
|
||||
function moveCellJob (i, dir) { const l = cellJobsMenu.list; const j = i + dir; if (j < 0 || j >= l.length) return; const [x] = l.splice(i, 1); l.splice(j, 0, x) }
|
||||
async function saveCellOrder () {
|
||||
const updates = cellJobsMenu.list.map((j, i) => ({ job: j.name, route_order: i + 1, priority: j.priority }))
|
||||
cellJobsMenu.saving = true
|
||||
try { const r = await roster.reorderJobs(updates); cellJobsMenu.show = false; await loadWeek(); $q.notify({ type: 'positive', message: (r.updated || 0) + ' job(s) réordonné(s)', timeout: 2000 }) } catch (e) { err(e) } finally { cellJobsMenu.saving = false }
|
||||
}
|
||||
// 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 = {}
|
||||
|
|
@ -1152,7 +1196,8 @@ function cellBands (techId, iso) {
|
|||
}
|
||||
// Barre de statut OPAQUE selon l'occupation : vert (peu) → orange (plein) → rouge (surbooké).
|
||||
function occColor (pct) { if (pct == null) return '#9e9e9e'; if (pct >= 100) return '#e53935'; const t = Math.max(0, Math.min(1, pct / 100)); return 'hsl(' + Math.round(122 - t * 90) + ',68%,44%)' }
|
||||
function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), background: occColor(pct) } }
|
||||
// Bloc = 1 job, coloré par la COULEUR DE SA COMPÉTENCE (palette skills éditable). Repli : couleur d'occupation.
|
||||
function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), background: blk && blk.skill ? getTagColor(blk.skill) : occColor(pct) } }
|
||||
// Fenêtre des shifts (garde=true → seulement les quarts de garde ; garde=false → réguliers)
|
||||
function winOf (techId, iso, garde) { let s = Infinity; let e = -Infinity; for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || (!!t.on_call) !== garde) continue; const st = hToNum(t.start_time); const en = hToNum(t.end_time); if (st != null) s = Math.min(s, st); if (en != null) e = Math.max(e, en) } return isFinite(s) ? { s, e } : null }
|
||||
const occCells = computed(() => {
|
||||
|
|
@ -1653,6 +1698,12 @@ tr.res-hidden .hide-eye { opacity: 1; }
|
|||
.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; }
|
||||
.tl { position: relative; height: 11px; min-width: 64px; background: #f1f3f5; border-radius: 2px; margin: 2px 0; overflow: hidden; }
|
||||
.tl-click { cursor: pointer; } /* clic sur le progressbar → menu jobs (détail + réordonner) */
|
||||
.tl-click:hover { outline: 1px solid #1976d2; outline-offset: 1px; }
|
||||
.cjm-row { display: flex; align-items: center; gap: 6px; padding: 3px 2px; border-bottom: 1px solid #f0f0f0; }
|
||||
.cjm-ord { font-size: 11px; font-weight: 700; color: #607d8b; min-width: 14px; text-align: center; }
|
||||
.cjm-dot { width: 9px; height: 9px; border-radius: 2px; flex: 0 0 auto; }
|
||||
.cjm-prio { font-size: 10px; border: 1px solid #ccc; border-left-width: 4px; border-radius: 4px; padding: 1px 2px; background: #fff; max-width: 92px; }
|
||||
.tl-shift { position: absolute; top: 0; bottom: 0; background: #ccd2d8; border-radius: 2px; border: 1px solid rgba(55,65,120,.5); box-sizing: border-box; } /* fenêtre dispo (contour foncé pour la distinguer du fond) */
|
||||
.tl-shift.oncall { background: rgba(255,179,0,.14); border: 1px dashed #f9a825; } /* garde = sur appel hors heures (pointillé ambre) */
|
||||
.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 */
|
||||
|
|
|
|||
|
|
@ -525,7 +525,7 @@ async function occupancyByTechDay (start, days) {
|
|||
const dates = rangeDates(start, days)
|
||||
const jobs = await erp.list('Dispatch Job', {
|
||||
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]],
|
||||
fields: ['name', 'subject', 'customer_name', 'service_type', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority'], limit: 5000,
|
||||
fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order'], limit: 5000,
|
||||
})
|
||||
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage
|
||||
const m = {}
|
||||
|
|
@ -535,11 +535,13 @@ async function occupancyByTechDay (start, days) {
|
|||
const o = m[k] || (m[k] = { h: 0, blocks: [], jobs: [] })
|
||||
const dur = Number(j.duration_h) || 0
|
||||
o.h += dur
|
||||
const skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // compétence → couleur du bloc (palette skills)
|
||||
const s = j.start_time ? timeToH(j.start_time) : null
|
||||
if (s != null) o.blocks.push({ s, e: s + dur })
|
||||
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, priority: j.priority || 'low' })
|
||||
if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name }) // 1 bloc = 1 job, coloré par sa compétence
|
||||
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0 })
|
||||
}
|
||||
for (const k in m) m[k].jobs.sort((a, b) => (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // priorité puis heure
|
||||
// ordre = route_order manuel s'il existe, sinon priorité puis heure
|
||||
for (const k in m) m[k].jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
|
||||
return m
|
||||
}
|
||||
|
||||
|
|
@ -771,6 +773,22 @@ async function handle (req, res, method, path, url) {
|
|||
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, tech: b.tech, start_time: placed, duration_h: dur })
|
||||
}
|
||||
// Réordonner / re-prioriser les jobs d'un tech×jour (depuis le menu de la cellule).
|
||||
// body.updates = [{ job, route_order, priority? }] — SÉQUENTIEL (frappe_pg).
|
||||
if (path === '/roster/reorder-jobs' && method === 'POST') {
|
||||
const b = await parseBody(req); const ups = b.updates || []
|
||||
let ok = 0; let errors = 0
|
||||
for (const u of ups) {
|
||||
if (!u.job) continue
|
||||
const patch = {}
|
||||
if (u.route_order != null) patch.route_order = Number(u.route_order) || 0
|
||||
if (u.priority) patch.priority = u.priority
|
||||
if (!Object.keys(patch).length) continue
|
||||
const r = await retryWrite(() => erp.update('Dispatch Job', u.job, patch))
|
||||
if (r.ok) ok++; else errors++
|
||||
}
|
||||
return json(res, 200, { ok: true, updated: ok, errors })
|
||||
}
|
||||
// Backfill : pose un start_time (premier trou libre) sur les jobs DÉJÀ assignés mais SANS heure
|
||||
// → leurs blocs d'occupation apparaissent enfin sur la grille. Idempotent (ne touche que start_time vide).
|
||||
if (path === '/roster/backfill-start-times' && method === 'POST') {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user