feat(dispatch/assign): vue date DUE, confirm Quasar, carte→liste
- Panneau « Jobs à assigner » : tri/groupes par DATE DUE (≠ création) avec étiquettes
relatives (« Aujourd'hui », « MM-DD ⏰ en retard », future) + date due affichée sur chaque ligne
(overdue en orange). ASC/DESC place aujourd'hui dans l'ordre chronologique.
- Fermeture (unitaire + lot) : confirm via $q.dialog (au lieu de window.confirm) — propre + fiable.
- Carte → liste : clic sur un pin sélectionne + scrolle la ligne dans la liste + déplie son fil + flash visuel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3d65b994e5
commit
ebad7066d6
|
|
@ -561,7 +561,7 @@
|
||||||
<div v-for="grp in assignGroups" :key="grp.key" class="assign-grp" :class="{ 'grp-hl': groupSelected(grp) }">
|
<div v-for="grp in assignGroups" :key="grp.key" class="assign-grp" :class="{ 'grp-hl': groupSelected(grp) }">
|
||||||
<div v-if="grp.label" class="assign-grp-lbl">{{ grp.label }} <span style="opacity:.6">({{ grp.jobs.length }})</span></div>
|
<div v-if="grp.label" class="assign-grp-lbl">{{ grp.label }} <span style="opacity:.6">({{ grp.jobs.length }})</span></div>
|
||||||
<div v-if="assignSort === 'group' && grp.jobs.length > 1" class="assign-grp-hdr" @click="toggleGroupSel(grp)"><q-icon name="account_tree" size="12px" /> Groupe ({{ grp.jobs.length }}) — tout sélectionner (terrain)</div>
|
<div v-if="assignSort === 'group' && grp.jobs.length > 1" class="assign-grp-hdr" @click="toggleGroupSel(grp)"><q-icon name="account_tree" size="12px" /> Groupe ({{ grp.jobs.length }}) — tout sélectionner (terrain)</div>
|
||||||
<div v-for="(j, idx) in grp.jobs" :key="j.name" class="assign-job" :class="{ blocked: j.status === 'On Hold', child: assignSort === 'group' && grp.jobs.length > 1 && idx > 0, sel: !!selectedJobs[j.name], dragging: draggingSet.has(j.name) }" :style="{ borderLeft: '5px solid ' + panelJobColor(j) }" draggable="true" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
|
<div v-for="(j, idx) in grp.jobs" :key="j.name" :data-jobname="j.name" class="assign-job" :class="{ blocked: j.status === 'On Hold', child: assignSort === 'group' && grp.jobs.length > 1 && idx > 0, sel: !!selectedJobs[j.name], dragging: draggingSet.has(j.name), 'job-flash': flashJob === j.name }" :style="{ borderLeft: '5px solid ' + panelJobColor(j) }" draggable="true" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
|
||||||
<div class="row items-center no-wrap">
|
<div class="row items-center no-wrap">
|
||||||
<q-checkbox dense size="xs" :model-value="!!selectedJobs[j.name]" @update:model-value="selectedJobs[j.name] = $event" @click.stop @mousedown.stop class="q-mr-xs" />
|
<q-checkbox dense size="xs" :model-value="!!selectedJobs[j.name]" @update:model-value="selectedJobs[j.name] = $event" @click.stop @mousedown.stop class="q-mr-xs" />
|
||||||
<q-icon :name="jobIsOnsite(j) ? 'home_repair_service' : 'cloud'" size="13px" :color="jobIsOnsite(j) ? 'teal' : 'grey-5'" class="q-mr-xs"><q-tooltip>{{ jobIsOnsite(j) ? 'Sur site (terrain)' : 'À distance / netadmin — pas pour un tech terrain' }}</q-tooltip></q-icon>
|
<q-icon :name="jobIsOnsite(j) ? 'home_repair_service' : 'cloud'" size="13px" :color="jobIsOnsite(j) ? 'teal' : 'grey-5'" class="q-mr-xs"><q-tooltip>{{ jobIsOnsite(j) ? 'Sur site (terrain)' : 'À distance / netadmin — pas pour un tech terrain' }}</q-tooltip></q-icon>
|
||||||
|
|
@ -574,7 +574,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="assign-sub">
|
<div class="assign-sub">
|
||||||
<span v-if="j.required_skill" class="assign-skill" :style="{ background: getTagColor(j.required_skill) }">{{ j.required_skill }}</span>
|
<span v-if="j.required_skill" class="assign-skill" :style="{ background: getTagColor(j.required_skill) }">{{ j.required_skill }}</span>
|
||||||
{{ j.customer_name || j.location_label || j.service_location || '' }}<span v-if="j.depends_on"> · après {{ j.depends_on }}</span> · {{ j.duration_h || 1 }}h
|
{{ j.customer_name || j.location_label || j.service_location || '' }}<span v-if="j.depends_on"> · après {{ j.depends_on }}</span> · {{ j.duration_h || 1 }}h<span v-if="j.scheduled_date" :class="isOverdue(j.scheduled_date) ? 'text-deep-orange-7 text-weight-bold' : 'text-grey-7'"> · 📅 {{ fmtDueLabel(j.scheduled_date) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="j._showThread" class="assign-thread" @mousedown.stop @click.stop>
|
<div v-if="j._showThread" class="assign-thread" @mousedown.stop @click.stop>
|
||||||
<div v-if="j._thread && j._thread.loading" class="text-grey-6" style="font-size:11px"><q-spinner size="12px" class="q-mr-xs" />Chargement du fil…</div>
|
<div v-if="j._thread && j._thread.loading" class="text-grey-6" style="font-size:11px"><q-spinner size="12px" class="q-mr-xs" />Chargement du fil…</div>
|
||||||
|
|
@ -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 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')) })
|
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) }
|
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 assignGroups = computed(() => {
|
||||||
const jobs = assignJobsFiltered.value
|
const jobs = assignJobsFiltered.value
|
||||||
if (assignSort.value === 'group') { // défaut : groupe parent-enfant (installation avant activation…), ordonné par step_order
|
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 === 'city' ? jobCity(j)
|
||||||
: assignSort.value === 'priority' ? (j.priority || 'low')
|
: assignSort.value === 'priority' ? (j.priority || 'low')
|
||||||
: (j.scheduled_date || 'Sans date')
|
: (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 g = {}; for (const j of jobs) { const k = keyOf(j); (g[k] = g[k] || []).push(j) }
|
||||||
const dir = assignSortDir.value === 'desc' ? -1 : 1
|
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)
|
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 assignStops () { return assignLocations.value.stops }
|
||||||
function toggleAssignMap () { assignPanel.showMap = !assignPanel.showMap }
|
function toggleAssignMap () { assignPanel.showMap = !assignPanel.showMap }
|
||||||
function focusAssignJob (j) { if (_assignMap && _hasJobLL(j)) _assignMap.easeTo({ center: [+j.longitude, +j.latitude], zoom: 13, duration: 400 }) }
|
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 () {
|
async function initAssignMap () {
|
||||||
if (!MAPBOX_TOKEN || !assignMapEl.value || _assignMap) return
|
if (!MAPBOX_TOKEN || !assignMapEl.value || _assignMap) return
|
||||||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !assignMapEl.value) return
|
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !assignMapEl.value) return
|
||||||
|
|
@ -1159,6 +1171,7 @@ async function initAssignMap () {
|
||||||
_assignMap.on('click', 'aj-c', (e) => {
|
_assignMap.on('click', 'aj-c', (e) => {
|
||||||
const f = e.features[0]; const p = f.properties; const names = JSON.parse(p.names || '[]')
|
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
|
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(`<div style="font-size:12px;line-height:1.45"><b>${_esc(p.letter)}</b> · ${_esc(p.title)}<br>✓ ${p.count} job(s) sélectionné(s)<br><span style="color:#888">glisse-les sur un tech × jour</span></div>`).addTo(_assignMap)
|
new window.mapboxgl.Popup({ offset: 13 }).setLngLat(f.geometry.coordinates).setHTML(`<div style="font-size:12px;line-height:1.45"><b>${_esc(p.letter)}</b> · ${_esc(p.title)}<br>✓ ${p.count} job(s) sélectionné(s)<br><span style="color:#888">glisse-les sur un tech × jour</span></div>`).addTo(_assignMap)
|
||||||
})
|
})
|
||||||
refreshAssignMap()
|
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 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
|
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.
|
// 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 (!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
|
$q.dialog({ title: 'Fermer le ticket', message: 'Fermer le ticket legacy <b>#' + j.legacy_ticket_id + '</b> ?<br><span class="text-grey-7">' + (j.subject || '').replace(/[<>]/g, '') + '</span><br>→ il sortira du dispatch (Completed).', html: true, cancel: 'Annuler', ok: { label: 'Fermer', color: 'negative', unelevated: true }, persistent: true }).onOk(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await roster.closeLegacyTicket(j.legacy_ticket_id)
|
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' }) }
|
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) || '?') })
|
else $q.notify({ type: 'warning', message: 'Échec fermeture : ' + ((r && r.error) || '?') })
|
||||||
} catch (e) { err(e) }
|
} catch (e) { err(e) }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// Fermeture EN LOT des jobs cochés (1 seul appel) — option « fermer » au lieu de « réassigner ».
|
// 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)
|
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 (!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
|
$q.dialog({ title: 'Fermer en lot', message: 'Fermer <b>' + sel.length + '</b> ticket(s) dans le legacy ?<br>→ ils sortent du dispatch (Completed).', html: true, cancel: 'Annuler', ok: { label: 'Fermer ' + sel.length, color: 'negative', unelevated: true }, persistent: true }).onOk(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await roster.batchCloseLegacy(sel.map(j => j.legacy_ticket_id))
|
const r = await roster.batchCloseLegacy(sel.map(j => j.legacy_ticket_id))
|
||||||
if (r && r.ok) {
|
if (r && r.ok) {
|
||||||
const done = new Set((r.results || []).filter(x => x.ok).map(x => String(x.t)))
|
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)))
|
assignPanel.jobs = assignPanel.jobs.filter(j => !done.has(String(j.legacy_ticket_id)))
|
||||||
for (const k in selectedJobs) delete selectedJobs[k]
|
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)' : '') })
|
$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) || '?') })
|
} else $q.notify({ type: 'warning', message: 'Échec : ' + ((r && r.error) || '?') })
|
||||||
} catch (e) { err(e) }
|
} 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 '' } }
|
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 () {
|
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.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.child { margin-left: 14px; border-left: 3px solid #b39ddb; }
|
||||||
.assign-job.blocked { opacity: .65; }
|
.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-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-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; }
|
.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; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user