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
b39d328af1
commit
ab323f0aca
|
|
@ -561,7 +561,7 @@
|
|||
<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="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">
|
||||
<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>
|
||||
|
|
@ -574,7 +574,7 @@
|
|||
</div>
|
||||
<div class="assign-sub">
|
||||
<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 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>
|
||||
|
|
@ -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(`<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()
|
||||
|
|
@ -1299,20 +1312,21 @@ 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
|
||||
$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 {
|
||||
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
|
||||
$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 {
|
||||
const r = await roster.batchCloseLegacy(sel.map(j => j.legacy_ticket_id))
|
||||
if (r && r.ok) {
|
||||
|
|
@ -1322,6 +1336,7 @@ async function bulkCloseSelected () {
|
|||
$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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user