diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 54c4b63..3b765ad 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -561,7 +561,7 @@
{{ grp.label }} ({{ grp.jobs.length }})
Groupe ({{ grp.jobs.length }}) — tout sélectionner (terrain)
-
+
{{ jobIsOnsite(j) ? 'Sur site (terrain)' : 'À distance / netadmin — pas pour un tech terrain' }} @@ -574,7 +574,7 @@
{{ j.required_skill }} - {{ j.customer_name || j.location_label || j.service_location || '' }} · après {{ j.depends_on }} · {{ j.duration_h || 1 }}h + {{ j.customer_name || j.location_label || j.service_location || '' }} · après {{ j.depends_on }} · {{ j.duration_h || 1 }}h · 📅 {{ fmtDueLabel(j.scheduled_date) }}
Chargement du fil…
@@ -1042,6 +1042,9 @@ const assignTypeFilter = ref([]) // types sélectionnés (vide = tous) const assignTypes = computed(() => { const m = {}; for (const j of assignPanel.jobs) { const k = j.required_skill || 'autre'; m[k] = (m[k] || 0) + 1 } return Object.entries(m).map(([k, n]) => ({ k, n })).sort((a, b) => b.n - a.n) }) const assignJobsFiltered = computed(() => { const f = assignTypeFilter.value; if (!f.length) return assignPanel.jobs; const set = new Set(f); return assignPanel.jobs.filter(j => set.has(j.required_skill || 'autre')) }) function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) } +// Date DUE (≠ date de création) : étiquette relative pour grouper/afficher (En retard / Aujourd'hui / future). +function isOverdue (d) { return !!d && d !== 'Sans date' && d < todayISO() } +function fmtDueLabel (d) { if (!d || d === 'Sans date') return 'Sans date due'; const t = todayISO(); if (d < t) return d.slice(5) + ' ⏰ en retard'; if (d === t) return "Aujourd'hui"; return d.slice(5) } const assignGroups = computed(() => { const jobs = assignJobsFiltered.value if (assignSort.value === 'group') { // défaut : groupe parent-enfant (installation avant activation…), ordonné par step_order @@ -1052,7 +1055,7 @@ const assignGroups = computed(() => { : assignSort.value === 'city' ? jobCity(j) : assignSort.value === 'priority' ? (j.priority || 'low') : (j.scheduled_date || 'Sans date') - const labelOf = k => assignSort.value === 'priority' ? (({ urgent: '🔴 Urgent', high: '🟠 Élevée', medium: '🔵 Moyenne', low: '⚪ Basse' })[k] || k) : k + const labelOf = k => assignSort.value === 'priority' ? (({ urgent: '🔴 Urgent', high: '🟠 Élevée', medium: '🔵 Moyenne', low: '⚪ Basse' })[k] || k) : assignSort.value === 'date' ? '📅 ' + fmtDueLabel(k) : k const g = {}; for (const j of jobs) { const k = keyOf(j); (g[k] = g[k] || []).push(j) } const dir = assignSortDir.value === 'desc' ? -1 : 1 const keys = Object.keys(g).sort((a, b) => (assignSort.value === 'priority' ? (ASSIGN_PRIO[a] ?? 9) - (ASSIGN_PRIO[b] ?? 9) : a.localeCompare(b)) * dir) @@ -1142,6 +1145,15 @@ const assignNoCoord = computed(() => assignPanel.jobs.filter(j => !_hasJobLL(j)) function assignStops () { return assignLocations.value.stops } function toggleAssignMap () { assignPanel.showMap = !assignPanel.showMap } function focusAssignJob (j) { if (_assignMap && _hasJobLL(j)) _assignMap.easeTo({ center: [+j.longitude, +j.latitude], zoom: 13, duration: 400 }) } +// Carte → liste : déplie les détails du job + scrolle la ligne + flash visuel. +const flashJob = ref(null) +function focusAssignJobInList (name) { + if (!name) return + const job = assignPanel.jobs.find(j => j.name === name) + if (job) { job._showThread = true; loadThread(job) } + flashJob.value = name; setTimeout(() => { if (flashJob.value === name) flashJob.value = null }, 1800) + nextTick(() => { const el = document.querySelector('.assign-body [data-jobname="' + name + '"]'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center', behavior: 'smooth' }) }) +} async function initAssignMap () { if (!MAPBOX_TOKEN || !assignMapEl.value || _assignMap) return const mapboxgl = await ensureMapbox(); if (!mapboxgl || !assignMapEl.value) return @@ -1159,6 +1171,7 @@ async function initAssignMap () { _assignMap.on('click', 'aj-c', (e) => { const f = e.features[0]; const p = f.properties; const names = JSON.parse(p.names || '[]') names.forEach(n => { selectedJobs[n] = true }) // clic sur le pin = sélectionne tous les jobs de cette adresse + if (names[0]) focusAssignJobInList(names[0]) // + scrolle/expand la job dans la liste new window.mapboxgl.Popup({ offset: 13 }).setLngLat(f.geometry.coordinates).setHTML(`
${_esc(p.letter)} · ${_esc(p.title)}
✓ ${p.count} job(s) sélectionné(s)
glisse-les sur un tech × jour
`).addTo(_assignMap) }) refreshAssignMap() @@ -1299,29 +1312,31 @@ async function loadThread (j) { function toggleJobDetail (j) { j.showDetail = !j.showDetail; if (j.showDetail) loadThread(j); focusDayJob(j) } // tournée : clic = déplie détails + fil + centre carte 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. -async function closeLegacyTicket (j) { +function closeLegacyTicket (j) { if (!j || !j.legacy_ticket_id) return - if (!window.confirm('Fermer le ticket legacy #' + j.legacy_ticket_id + ' ?\n' + (j.subject || '') + '\n→ il sortira du dispatch (status « Completed »).')) return - try { - const r = await roster.closeLegacyTicket(j.legacy_ticket_id) - if (r && r.ok) { assignPanel.jobs = assignPanel.jobs.filter(x => x.name !== j.name); $q.notify({ type: 'positive', message: 'Ticket #' + j.legacy_ticket_id + ' fermé dans le legacy' }) } - else $q.notify({ type: 'warning', message: 'Échec fermeture : ' + ((r && r.error) || '?') }) - } catch (e) { err(e) } + $q.dialog({ title: 'Fermer le ticket', message: 'Fermer le ticket legacy #' + j.legacy_ticket_id + ' ?
' + (j.subject || '').replace(/[<>]/g, '') + '
→ il sortira du dispatch (Completed).', html: true, cancel: 'Annuler', ok: { label: 'Fermer', color: 'negative', unelevated: true }, persistent: true }).onOk(async () => { + try { + const r = await roster.closeLegacyTicket(j.legacy_ticket_id) + if (r && r.ok) { assignPanel.jobs = assignPanel.jobs.filter(x => x.name !== j.name); $q.notify({ type: 'positive', message: 'Ticket #' + j.legacy_ticket_id + ' fermé dans le legacy' }) } + else $q.notify({ type: 'warning', message: 'Échec fermeture : ' + ((r && r.error) || '?') }) + } catch (e) { err(e) } + }) } // Fermeture EN LOT des jobs cochés (1 seul appel) — option « fermer » au lieu de « réassigner ». -async function bulkCloseSelected () { +function bulkCloseSelected () { const sel = assignPanel.jobs.filter(j => selectedJobs[j.name] && j.legacy_ticket_id) if (!sel.length) { $q.notify({ type: 'warning', message: 'Aucun ticket legacy coché' }); return } - if (!window.confirm('Fermer ' + sel.length + ' ticket(s) dans le legacy ?\n→ ils sortent du dispatch (Completed).')) return - try { - const r = await roster.batchCloseLegacy(sel.map(j => j.legacy_ticket_id)) - if (r && r.ok) { - const done = new Set((r.results || []).filter(x => x.ok).map(x => String(x.t))) - assignPanel.jobs = assignPanel.jobs.filter(j => !done.has(String(j.legacy_ticket_id))) - for (const k in selectedJobs) delete selectedJobs[k] - $q.notify({ type: 'positive', message: r.closed + ' ticket(s) fermé(s)' + (r.locked ? ' · ' + r.locked + ' verrouillé(s)' : '') + (r.skipped ? ' · ' + r.skipped + ' déjà fermé(s)' : '') }) - } else $q.notify({ type: 'warning', message: 'Échec : ' + ((r && r.error) || '?') }) - } catch (e) { err(e) } + $q.dialog({ title: 'Fermer en lot', message: 'Fermer ' + sel.length + ' ticket(s) dans le legacy ?
→ ils sortent du dispatch (Completed).', html: true, cancel: 'Annuler', ok: { label: 'Fermer ' + sel.length, color: 'negative', unelevated: true }, persistent: true }).onOk(async () => { + try { + const r = await roster.batchCloseLegacy(sel.map(j => j.legacy_ticket_id)) + if (r && r.ok) { + const done = new Set((r.results || []).filter(x => x.ok).map(x => String(x.t))) + assignPanel.jobs = assignPanel.jobs.filter(j => !done.has(String(j.legacy_ticket_id))) + for (const k in selectedJobs) delete selectedJobs[k] + $q.notify({ type: 'positive', message: r.closed + ' ticket(s) fermé(s)' + (r.locked ? ' · ' + r.locked + ' verrouillé(s)' : '') + (r.skipped ? ' · ' + r.skipped + ' déjà fermé(s)' : '') }) + } else $q.notify({ type: 'warning', message: 'Échec : ' + ((r && r.error) || '?') }) + } catch (e) { err(e) } + }) } function fmtDT (iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString('fr-CA', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' } } async function initDayMap () { @@ -2088,6 +2103,8 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return .assign-job.sel { border-color: #00897b; background: #e0f2f1; box-shadow: inset 0 0 0 1px #00897b; } /* sélectionné = à dispatcher */ .assign-job.child { margin-left: 14px; border-left: 3px solid #b39ddb; } .assign-job.blocked { opacity: .65; } +.assign-job.job-flash { animation: jobflash 1.7s ease-out; } +@keyframes jobflash { 0% { background: #fff59d; box-shadow: inset 0 0 0 2px #fbc02d; } 100% { background: #fafafa; box-shadow: none; } } .assign-sub { font-size: 10px; color: #888; margin-top: 1px; } .assign-skill { display: inline-block; color: #fff; border-radius: 6px; padding: 0 5px; font-size: 9px; font-weight: 600; margin-right: 3px; } .assign-foot { border-top: 1px solid #e0e0e0; padding: 6px 9px; font-size: 11px; color: #555; line-height: 1.45; background: #fafafa; border-radius: 0 0 8px 8px; }