feat(planif): éditeur journée = planificateur de tournée (heures recalculées, pas d'overlap, RDV verrouillables, détails)
- FIX overlap/ordre : packedDay recalcule les heures depuis la SÉQUENCE (ordre liste) + durées + transport ;
le timeline et les heures affichées en découlent → réordonner/allonger repousse les suivants, plus d'overlap
- RDV à heure FIXE : verrou par job (🔒, défaut = booking_status 'Confirmé') → garde son heure ; flexibles = enchaînés
- clic sur un job → détails (legacy_detail : dates + description) pour juger urgence/durée ; + tooltip au survol
- save persiste start_time recalculé (+ route_order/priority/duration_h) via reorder-jobs
- hub occupancy renvoie booking_status/legacy_detail/legacy_id ; reorder-jobs accepte start_time
- fix collision fmtH → fmtHM (HH:MM padded)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bae6771b34
commit
0298f414ed
|
|
@ -696,20 +696,22 @@
|
||||||
<q-icon name="drag_indicator" size="16px" class="text-grey-5" style="cursor:grab" />
|
<q-icon name="drag_indicator" size="16px" class="text-grey-5" style="cursor:grab" />
|
||||||
<span class="de-ord">{{ i + 1 }}</span>
|
<span class="de-ord">{{ i + 1 }}</span>
|
||||||
<span class="de-dot" :style="{ background: j.skill ? getTagColor(j.skill) : prioColor(j.priority) }"></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="col" style="min-width:0;cursor:pointer" @click="j.showDetail = !j.showDetail"><q-tooltip v-if="j.detail && !j.showDetail" max-width="360px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.detail }}</q-tooltip>
|
||||||
<div class="ellipsis text-weight-medium" style="font-size:13px">{{ j.subject }}</div>
|
<div class="ellipsis text-weight-medium" style="font-size:13px">{{ j.subject }} <q-icon name="info_outline" size="12px" class="text-grey-5" /></div>
|
||||||
<div class="ellipsis text-grey-6" style="font-size:11px">{{ j.start || '—' }}<span v-if="j.customer"> · {{ j.customer }}</span><span v-if="j.skill"> · {{ j.skill }}</span></div>
|
<div class="ellipsis text-grey-6" style="font-size:11px">{{ fmtHM(packedDay[i].startMin) }}–{{ fmtHM(packedDay[i].endMin) }}<span v-if="j.locked" class="text-deep-orange-7"> · 🔒 RDV fixe</span><span v-if="j.customer"> · {{ j.customer }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="de-dur"><input type="number" min="5" step="5" :value="jobMinutes(j)" @change="setJobMinutes(j, $event.target.value)" @click.stop @mousedown.stop /><span>min</span></div>
|
<div class="de-dur"><input type="number" min="5" step="5" :value="jobMinutes(j)" @change="setJobMinutes(j, $event.target.value)" @click.stop @mousedown.stop /><span>min</span></div>
|
||||||
<select :value="j.priority" @change="j.priority = $event.target.value" class="de-prio" :style="{ borderColor: prioColor(j.priority) }">
|
<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>
|
<option value="urgent">Urgent</option><option value="high">Élevée</option><option value="medium">Moyenne</option><option value="low">Basse</option>
|
||||||
</select>
|
</select>
|
||||||
|
<q-btn flat dense round size="sm" :icon="j.locked ? 'lock' : 'lock_open'" :color="j.locked ? 'deep-orange' : 'grey-5'" @click="j.locked = !j.locked"><q-tooltip>{{ j.locked ? 'Heure FIXE (RDV) — verrouillée, non replanifiée' : 'Heure flexible — replanifiée par la tournée' }}</q-tooltip></q-btn>
|
||||||
<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>
|
<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>
|
</div>
|
||||||
|
<div v-if="j.showDetail" class="de-detail">{{ j.detail || 'Aucun détail importé pour ce ticket.' }}</div>
|
||||||
</template>
|
</template>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section v-if="dayEditor.list.length" class="row items-center q-pt-none">
|
<q-card-section v-if="dayEditor.list.length" class="row items-center q-pt-none">
|
||||||
<span class="text-caption text-grey-6">Flèches/glisser = ordre · durée en min · ✕ retire · total <b>{{ dayTotalH() }}h</b></span><q-space />
|
<span class="text-caption text-grey-6">Glisser/flèches = ordre (heures recalculées) · 🔒 = RDV fixe · clic = détails · total <b>{{ dayTotalH() }}h</b></span><q-space />
|
||||||
<q-btn dense unelevated color="primary" :loading="dayEditor.saving" label="Enregistrer" @click="saveDayOrder" />
|
<q-btn dense unelevated color="primary" :loading="dayEditor.saving" label="Enregistrer" @click="saveDayOrder" />
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -1024,10 +1026,16 @@ function gotoDispatch (t, dateIso) {
|
||||||
// ── Éditeur de JOURNÉE (fenêtre contextuelle ciblée — clic sur le progressbar) ──
|
// ── É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.
|
// 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 })
|
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 }
|
function openDayEditor (t, d) {
|
||||||
|
dayEditor.tech = t; dayEditor.day = d
|
||||||
|
// RDV confirmé (ou heure légacy précise) = heure FIXE → verrouillé ; sinon flexible (replanifiable par la tournée).
|
||||||
|
dayEditor.list = cellJobs(t.id, d.iso).map(j => ({ ...j, locked: j.booking_status === 'Confirmé', showDetail: false }))
|
||||||
|
dayEditor.dragIdx = null; dayEditor.open = true
|
||||||
|
}
|
||||||
const dayOcc = () => (dayEditor.tech && dayEditor.day) ? cellOcc(dayEditor.tech.id, dayEditor.day.iso) : null
|
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 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) : []
|
// Blocs RECALCULÉS depuis la SÉQUENCE éditée (packedDay) → l'ordre + les durées + le transport se reflètent + plus d'overlap.
|
||||||
|
const dayBlocks = () => packedDay.value.map(p => ({ s: p.startMin, e: p.endMin, skill: p.skill }))
|
||||||
// Réordonnancement : flèches ↑↓ (fiable) + drag-drop basé sur le DROP (robuste, pas de splice live jittery)
|
// Réordonnancement : flèches ↑↓ (fiable) + drag-drop basé sur le DROP (robuste, pas de splice live jittery)
|
||||||
function moveDayJob (i, dir) { const j = i + dir; const l = dayEditor.list; if (j < 0 || j >= l.length) return; const [x] = l.splice(i, 1); l.splice(j, 0, x) }
|
function moveDayJob (i, dir) { const j = i + dir; const l = dayEditor.list; if (j < 0 || j >= l.length) return; const [x] = l.splice(i, 1); l.splice(j, 0, x) }
|
||||||
function dayDragStart (i, ev) { dayEditor.dragIdx = i; try { ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.setData('text/plain', String(i)) } catch (e) {} }
|
function dayDragStart (i, ev) { dayEditor.dragIdx = i; try { ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.setData('text/plain', String(i)) } catch (e) {} }
|
||||||
|
|
@ -1039,14 +1047,31 @@ function setJobMinutes (j, min) { const m = Math.max(5, Math.round((Number(min)
|
||||||
// Temps de transport estimé entre 2 jobs (haversine via coords Service Location) — provisoire, en attendant la géoloc live (Capacitor)
|
// Temps de transport estimé entre 2 jobs (haversine via coords Service Location) — provisoire, en attendant la géoloc live (Capacitor)
|
||||||
function haversineKm (la1, lo1, la2, lo2) { if ([la1, lo1, la2, lo2].some(v => v == null)) return null; const R = 6371; const r = x => x * Math.PI / 180; const dLa = r(la2 - la1); const dLo = r(lo2 - lo1); const s = Math.sin(dLa / 2) ** 2 + Math.cos(r(la1)) * Math.cos(r(la2)) * Math.sin(dLo / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(s)) }
|
function haversineKm (la1, lo1, la2, lo2) { if ([la1, lo1, la2, lo2].some(v => v == null)) return null; const R = 6371; const r = x => x * Math.PI / 180; const dLa = r(la2 - la1); const dLo = r(lo2 - lo1); const s = Math.sin(dLa / 2) ** 2 + Math.cos(r(la1)) * Math.cos(r(la2)) * Math.sin(dLo / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(s)) }
|
||||||
function travelBetween (a, b) { const km = haversineKm(a && a.lat, a && a.lon, b && b.lat, b && b.lon); if (km == null) return null; return { km: Math.round(km * 10) / 10, min: Math.max(5, Math.round(km / 40 * 60) + 5) } } // 40 km/h + 5 min tampon
|
function travelBetween (a, b) { const km = haversineKm(a && a.lat, a && a.lon, b && b.lat, b && b.lon); if (km == null) return null; return { km: Math.round(km * 10) / 10, min: Math.max(5, Math.round(km / 40 * 60) + 5) } } // 40 km/h + 5 min tampon
|
||||||
|
const fmtHM = (h) => { if (h == null) return '—'; const m = Math.round(h * 60); const hh = Math.floor(m / 60), mm = m % 60; return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') } // heure décimale → HH:MM (padded, pour start_time)
|
||||||
|
function dayShiftStartH () { const t = dayEditor.tech, d = dayEditor.day; if (!t || !d) return 8; const w = winOf(t.id, d.iso, false); return w ? w.s : 8 }
|
||||||
|
// PLANIFICATEUR DE TOURNÉE : recalcule les heures depuis l'ordre de la liste + durées + transport.
|
||||||
|
// Job verrouillé (RDV fixe) → garde son heure ; flexible → enchaîné après le précédent (+ transport). Plus d'overlap.
|
||||||
|
const packedDay = computed(() => {
|
||||||
|
const list = dayEditor.list; const out = []; let cursor = dayShiftStartH()
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
const j = list[i]; const dur = Number(j.dur) || 1
|
||||||
|
const start = (j.locked && j.start_h != null) ? j.start_h : cursor
|
||||||
|
const end = start + dur
|
||||||
|
out.push({ ...j, startMin: start, endMin: end })
|
||||||
|
const trH = (i < list.length - 1 ? (travelBetween(j, list[i + 1]) || {}).min || 0 : 0) / 60
|
||||||
|
cursor = Math.max(cursor, end) + trH
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
const dayTotalH = () => Math.round(dayEditor.list.reduce((s, j) => s + (Number(j.dur) || 0), 0) * 10) / 10
|
const dayTotalH = () => Math.round(dayEditor.list.reduce((s, j) => s + (Number(j.dur) || 0), 0) * 10) / 10
|
||||||
async function removeFromDay (j) {
|
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) }
|
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 () {
|
async function saveDayOrder () {
|
||||||
dayEditor.saving = true
|
dayEditor.saving = true
|
||||||
const updates = dayEditor.list.map((j, i) => ({ job: j.name, route_order: i + 1, priority: j.priority, duration_h: Number(j.dur) || 1 }))
|
const packed = packedDay.value // heures recalculées par la tournée → on les persiste (start_time)
|
||||||
try { const r = await roster.reorderJobs(updates); dayEditor.open = false; await loadWeek(); $q.notify({ type: 'positive', message: 'Ordre · priorités · durées enregistrés (' + (r.updated || 0) + ')', timeout: 2200 }) } catch (e) { err(e) } finally { dayEditor.saving = false }
|
const updates = dayEditor.list.map((j, i) => ({ job: j.name, route_order: i + 1, priority: j.priority, duration_h: Number(j.dur) || 1, start_time: fmtHM(packed[i].startMin) }))
|
||||||
|
try { const r = await roster.reorderJobs(updates); dayEditor.open = false; await loadWeek(); $q.notify({ type: 'positive', message: 'Tournée enregistrée — ordre · heures · durées (' + (r.updated || 0) + ')', timeout: 2400 }) } catch (e) { err(e) } finally { dayEditor.saving = false }
|
||||||
}
|
}
|
||||||
const timelineDays = computed(() => {
|
const timelineDays = computed(() => {
|
||||||
const t = timelineDlg.tech; if (!t) return []
|
const t = timelineDlg.tech; if (!t) return []
|
||||||
|
|
@ -1785,6 +1810,7 @@ tr.res-hidden .hide-eye { opacity: 1; }
|
||||||
.de-dur { display: flex; align-items: center; gap: 2px; font-size: 10px; color: #888; }
|
.de-dur { display: flex; align-items: center; gap: 2px; font-size: 10px; color: #888; }
|
||||||
.de-dur input { width: 46px; font-size: 11px; text-align: right; border: 1px solid #cfc4e8; border-radius: 4px; padding: 2px 3px; }
|
.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-travel { font-size: 10px; color: #8a6d3b; padding: 1px 0 1px 40px; opacity: .85; } /* espace entre 2 jobs = transport */
|
||||||
|
.de-detail { font-size: 11px; line-height: 1.4; white-space: pre-wrap; color: #444; background: #f7f5fc; border-left: 3px solid #b39ddb; border-radius: 4px; margin: 0 4px 6px 40px; padding: 6px 8px; max-height: 160px; overflow: auto; }
|
||||||
.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 { 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-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 */
|
.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 dates = rangeDates(start, days)
|
||||||
const jobs = await erp.list('Dispatch Job', {
|
const jobs = await erp.list('Dispatch Job', {
|
||||||
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]],
|
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]],
|
||||||
fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude'], limit: 5000,
|
fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude', 'booking_status', 'legacy_detail', 'legacy_ticket_id'], limit: 5000,
|
||||||
})
|
})
|
||||||
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage
|
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage
|
||||||
const m = {}
|
const m = {}
|
||||||
|
|
@ -538,7 +538,7 @@ async function occupancyByTechDay (start, days) {
|
||||||
const skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // compétence → couleur du bloc (palette skills)
|
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
|
const s = j.start_time ? timeToH(j.start_time) : null
|
||||||
if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name }) // 1 bloc = 1 job, coloré par sa compétence
|
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, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null })
|
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, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400) })
|
||||||
}
|
}
|
||||||
// ordre = route_order manuel s'il existe, sinon 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)))
|
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)))
|
||||||
|
|
@ -784,6 +784,7 @@ async function handle (req, res, method, path, url) {
|
||||||
if (u.route_order != null) patch.route_order = Number(u.route_order) || 0
|
if (u.route_order != null) patch.route_order = Number(u.route_order) || 0
|
||||||
if (u.priority) patch.priority = u.priority
|
if (u.priority) patch.priority = u.priority
|
||||||
if (u.duration_h != null && Number(u.duration_h) > 0) patch.duration_h = Number(u.duration_h) // durée éditée dans le timeline
|
if (u.duration_h != null && Number(u.duration_h) > 0) patch.duration_h = Number(u.duration_h) // durée éditée dans le timeline
|
||||||
|
if (u.start_time) patch.start_time = (String(u.start_time).length === 5 ? u.start_time + ':00' : u.start_time) // heure recalculée par le planificateur de tournée
|
||||||
if (!Object.keys(patch).length) continue
|
if (!Object.keys(patch).length) continue
|
||||||
const r = await retryWrite(() => erp.update('Dispatch Job', u.job, patch))
|
const r = await retryWrite(() => erp.update('Dispatch Job', u.job, patch))
|
||||||
if (r.ok) ok++; else errors++
|
if (r.ok) ok++; else errors++
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user