feat(planif): éditeur de JOURNÉE contextuel au clic sur le progressbar (drag-drop réordonner + retirer)
- clic sur le progressbar → q-dialog ciblé sur le tech×jour (garde le contexte de la grille derrière) : timeline visuelle (blocs colorés par compétence) + liste éditable des jobs - réordonnancement par DRAG-DROP (dragstart/dragover/dragend → route_order) + sélecteur de priorité + Enregistrer - retrait d'un job (✕ → hub POST /roster/unassign-job : assigned_tech null, status open → retour au pool) - bouton « Dispatch » comme échappatoire vers le tableau complet (gotoDispatch) - réutilise occupancy/cellBands/cellBlocks/blockStyle + reorderJobs ; best-practice détail-drawer (pas de navigation pleine page) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
455a66aeb9
commit
c9fbbdbe9e
|
|
@ -97,5 +97,7 @@ export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job,
|
|||
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 })
|
||||
// Retirer un job d'un tech (retour au pool non assigné)
|
||||
export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job })
|
||||
// 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 tl-click" @mousedown.stop @click.stop="gotoDispatch(t, d.iso)">
|
||||
<div class="tl tl-click" @mousedown.stop @click.stop="openDayEditor(t, d)">
|
||||
<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 -->
|
||||
|
|
@ -658,6 +658,50 @@
|
|||
</div>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
|
||||
<!-- Éditeur de JOURNÉE (clic sur le progressbar) : timeline + réordonner par drag-drop + retirer un job -->
|
||||
<q-dialog v-model="dayEditor.open">
|
||||
<q-card style="min-width:560px;max-width:680px">
|
||||
<q-card-section class="row items-center q-pb-sm">
|
||||
<q-icon name="view_timeline" color="indigo" size="22px" class="q-mr-sm" />
|
||||
<div>
|
||||
<div class="text-subtitle1 text-weight-bold">{{ dayEditor.tech && dayEditor.tech.name }} — {{ dayEditor.day && (dayEditor.day.dow + ' ' + dayEditor.day.dnum) }}</div>
|
||||
<div class="text-caption text-grey-7" v-if="dayOcc()">{{ dayOcc().usedH }}h occupé / {{ dayOcc().bookableH }}h · {{ dayOcc().pct }}%</div>
|
||||
</div>
|
||||
<q-space />
|
||||
<q-btn flat dense no-caps size="sm" icon="open_in_new" label="Dispatch" color="indigo" @click="gotoDispatch(dayEditor.tech, dayEditor.day && dayEditor.day.iso)"><q-tooltip>Ouvrir le tableau Dispatch complet</q-tooltip></q-btn>
|
||||
<q-btn flat round dense icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
<!-- timeline visuelle (réutilise les blocs colorés par compétence) -->
|
||||
<div class="tldlg-bar" style="height:20px">
|
||||
<div v-for="(b, bi) in dayBands()" :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 dayBlocks()" :key="'k' + bi" class="tl-blk" :style="blockStyle(b, dayOcc() && dayOcc().pct)"></div>
|
||||
<span v-for="tk in axisTicks" :key="'t' + tk.h" class="tldlg-tick" :style="{ left: tk.left }">{{ tk.h }}</span>
|
||||
</div>
|
||||
<div v-if="!dayEditor.list.length" class="text-grey-6 q-pa-md text-center">Aucun job ce jour.</div>
|
||||
<!-- liste éditable : glisser pour réordonner, ✕ pour retirer -->
|
||||
<div v-for="(j, i) in dayEditor.list" :key="j.name" class="de-row" :class="{ 'de-drag': dayEditor.dragIdx === i }"
|
||||
draggable="true" @dragstart="dayDragStart(i, $event)" @dragover.prevent="dayDragOver(i)" @dragend="dayDragEnd">
|
||||
<q-icon name="drag_indicator" size="16px" class="text-grey-5" style="cursor:grab" />
|
||||
<span class="de-ord">{{ i + 1 }}</span>
|
||||
<span class="de-dot" :style="{ background: j.skill ? getTagColor(j.skill) : prioColor(j.priority) }"></span>
|
||||
<div class="col" style="min-width:0">
|
||||
<div class="ellipsis text-weight-medium" style="font-size:13px">{{ j.subject }}</div>
|
||||
<div class="ellipsis text-grey-6" style="font-size:11px">{{ 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" class="de-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>
|
||||
<q-btn flat dense round size="sm" icon="close" color="negative" @click="removeFromDay(j)"><q-tooltip>Retirer du tech (retour au pool)</q-tooltip></q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section v-if="dayEditor.list.length" class="row items-center q-pt-none">
|
||||
<span class="text-caption text-grey-6">Glisser pour réordonner la tournée · ✕ retire du tech</span><q-space />
|
||||
<q-btn dense unelevated color="primary" :loading="dayEditor.saving" label="Enregistrer l'ordre" @click="saveDayOrder" />
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
|
|
@ -965,6 +1009,24 @@ function gotoDispatch (t, dateIso) {
|
|||
q.date = dateIso || (timelineDays.value[0] && timelineDays.value[0].iso) || start.value
|
||||
router.push({ path: '/dispatch', query: q })
|
||||
}
|
||||
// ── Éditeur de JOURNÉE (fenêtre contextuelle ciblée — clic sur le progressbar) ──
|
||||
// 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 })
|
||||
function openDayEditor (t, d) { dayEditor.tech = t; dayEditor.day = d; dayEditor.list = cellJobs(t.id, d.iso).map(j => ({ ...j })); dayEditor.dragIdx = null; dayEditor.open = true }
|
||||
const dayOcc = () => (dayEditor.tech && dayEditor.day) ? cellOcc(dayEditor.tech.id, dayEditor.day.iso) : null
|
||||
const dayBands = () => (dayEditor.tech && dayEditor.day) ? cellBands(dayEditor.tech.id, dayEditor.day.iso) : []
|
||||
const dayBlocks = () => (dayEditor.tech && dayEditor.day) ? cellBlocks(dayEditor.tech.id, dayEditor.day.iso) : []
|
||||
function dayDragStart (i, ev) { dayEditor.dragIdx = i; try { ev.dataTransfer.effectAllowed = 'move' } catch (e) {} }
|
||||
function dayDragOver (i) { const from = dayEditor.dragIdx; if (from == null || from === i) return; const l = dayEditor.list; const [x] = l.splice(from, 1); l.splice(i, 0, x); dayEditor.dragIdx = i }
|
||||
function dayDragEnd () { dayEditor.dragIdx = null }
|
||||
async function removeFromDay (j) {
|
||||
try { await roster.unassignJobRoster(j.name); dayEditor.list = dayEditor.list.filter(x => x.name !== j.name); await loadWeek(); $q.notify({ type: 'info', message: 'Retiré du tech (retour au pool « à assigner »)', timeout: 2200 }) } catch (e) { err(e) }
|
||||
}
|
||||
async function saveDayOrder () {
|
||||
dayEditor.saving = true
|
||||
const updates = dayEditor.list.map((j, i) => ({ job: j.name, route_order: i + 1, priority: j.priority }))
|
||||
try { const r = await roster.reorderJobs(updates); dayEditor.open = false; await loadWeek(); $q.notify({ type: 'positive', message: 'Ordre/priorités enregistrés (' + (r.updated || 0) + ')', timeout: 2000 }) } catch (e) { err(e) } finally { dayEditor.saving = false }
|
||||
}
|
||||
const timelineDays = computed(() => {
|
||||
const t = timelineDlg.tech; if (!t) return []
|
||||
const out = []
|
||||
|
|
@ -1692,10 +1754,13 @@ tr.res-hidden .hide-eye { opacity: 1; }
|
|||
.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; }
|
||||
/* Éditeur de journée (clic progressbar) — lignes draggables */
|
||||
.de-row { display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-bottom: 1px solid #eee; background: #fff; cursor: default; }
|
||||
.de-row.de-drag { opacity: .5; background: #ede7f6; }
|
||||
.de-row:hover { background: #f7f5fc; }
|
||||
.de-ord { font-size: 12px; font-weight: 700; color: #607d8b; min-width: 16px; text-align: center; }
|
||||
.de-dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; }
|
||||
.de-prio { font-size: 11px; border: 1px solid #ccc; border-left-width: 4px; border-radius: 4px; padding: 2px 4px; background: #fff; }
|
||||
.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 */
|
||||
|
|
|
|||
|
|
@ -789,6 +789,12 @@ async function handle (req, res, method, path, url) {
|
|||
}
|
||||
return json(res, 200, { ok: true, updated: ok, errors })
|
||||
}
|
||||
// Retirer un job d'un tech (depuis l'éditeur de journée) → retour au pool (non assigné).
|
||||
if (path === '/roster/unassign-job' && method === 'POST') {
|
||||
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
|
||||
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { assigned_tech: null, status: 'open', start_time: null }))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, job: b.job })
|
||||
}
|
||||
// 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