feat(ops): quick filler job from cell menu (blocks tech from dispatch)

Add "Bloquer / job générique…" to the timeline cell menu (opens on empty-cell
click or right-click). Opens a small dialog prefilled with the tech + day:
editable title, full-day toggle (= that cell's shift length) or custom
start/duration, standard priority (default Moyenne), type, and a "generate a
linked ticket" toggle.

New hub POST /roster/filler-job creates a standard-priority assigned Dispatch
Job (+ optional ERPNext Issue via source_issue, best-effort so a ticket failure
never blocks the job). Since timed jobs now count toward occupancy, this removes
the tech from regular dispatch — unlike /reserve which stays soft for urgencies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-09 19:58:18 -04:00
parent 9145624949
commit 9c49054b8d
4 changed files with 231 additions and 340 deletions

View File

@ -66,6 +66,8 @@ export const groupJobs = (parent, children) => jpost('/roster/job-group', { pare
export const postJobComment = ({ job, lid, text, isPublic, agentName }) => jpost('/roster/job-comment', { job, lid, text, public: !!isPublic, agentName: agentName || '' })
// #5 — Réserver le temps d'un tech (bloc « Réservation » priorité moyenne) → dé-priorise au dispatch auto + soustrait des créneaux.
export const reserveTech = ({ tech, date, start_time, duration_h, reason }) => jpost('/roster/reserve', { tech, date, start_time, duration_h, reason })
// Job « bouche-trou » : job générique standard assigné → retire le tech du dispatch (option : génère un ticket).
export const createFillerJob = (body) => jpost('/roster/filler-job', body || {})
export async function deleteShiftTemplate (name) {
const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' })
if (!r.ok) throw new Error('Suppression modèle: ' + r.status)

View File

@ -31,6 +31,12 @@
</div>
</q-expansion-item>
<q-separator />
<!-- Job « bouche-trou » : occuper la journée du tech (formation, réunion, admin) le retire du dispatch -->
<q-item clickable dense @click="$emit('filler')">
<q-item-section avatar style="min-width:30px"><q-icon name="block" color="deep-orange-6" /></q-item-section>
<q-item-section><q-item-label>Bloquer / job générique</q-item-label><q-item-label caption>Occupe la journée (durée éditable) · retire du dispatch · ticket</q-item-label></q-item-section>
</q-item>
<q-separator />
<!-- Shifts en place + actions compactes -->
<q-item v-for="a in shifts" :key="'c' + (a.shift || a.name)" dense>
<q-item-section>{{ a.shift_name || a.shift }} <span class="text-grey-6">{{ a.hours }}h</span></q-item-section>
@ -58,7 +64,7 @@ const props = defineProps({
cellKey: { type: String, default: '' }, // change à chaque (ré)ouverture réinitialise saisie/slider
showCopy: { type: Boolean, default: true },
})
const emit = defineEmits(['window', 'toggle-garde', 'toggle-absent', 'remove-shift', 'clear', 'copy', 'paste'])
const emit = defineEmits(['window', 'toggle-garde', 'toggle-absent', 'remove-shift', 'clear', 'copy', 'paste', 'filler'])
const quickEntry = ref('')
const range = ref({ min: props.initialRange.min, max: props.initialRange.max })
// Réinitialise à chaque ouverture sur une nouvelle cellule (le composant reste monté, seul le q-menu parent s'affiche/masque).

View File

@ -254,38 +254,7 @@
</div>
<!-- Pool « À assigner » : chips de filtre (compétence) + tri (jour sélectionné d'abord) + liste BORNÉE (scroll interne, pas d'étirement). -->
<div class="pm-pool">
<div class="pm-pool-hd"><q-icon name="inbox" size="16px" class="q-mr-xs" />À assigner <span class="pm-count">{{ poolJobs.length }}</span><q-space /><span class="text-grey-5 q-mr-sm" style="font-size:10px;display:inline-flex;align-items:center;gap:1px">prio<i class="pm-leg" style="background:#ef4444"></i><i class="pm-leg" style="background:#f59e0b"></i><i class="pm-leg" style="background:#9e9e9e"></i></span><q-select v-model="poolSort" :options="poolSortOpts" dense options-dense borderless emit-value map-options class="pm-sort"><template #prepend><q-icon name="sort" size="15px" color="grey-6" /></template></q-select></div>
<div v-if="poolSkills.length" class="pm-chips">
<button v-for="s in poolSkills" :key="s.skill" type="button" class="pm-chip" :class="{ on: poolSkillSel.has(s.skill) }" :style="poolSkillSel.has(s.skill) ? { background: getTagColor(s.skill), borderColor: getTagColor(s.skill), color: '#fff' } : {}" @click="togglePoolSkill(s.skill)">{{ s.skill }} {{ s.n }}</button>
<button v-if="poolSkillSel.size" type="button" class="pm-chip pm-chip-x" @click="poolSkillSel = new Set()"> tout</button>
</div>
<div class="pm-pool-list">
<!-- Façon Gmail : GLISSER action (droite = reporter +1 j · gauche = sheet Options) + icônes DIRECTES ( urgent · 📝 note · options). Tap = sélectionner.
Swipe MAISON (pointer events) : le tap reste un vrai clic sur le bouton (jamais cassé), le glissement n'agit qu'au drag horizontal franc. -->
<template v-for="(j, pi) in poolJobs" :key="j.name"><div v-if="poolSort === 'sector' && (pi === 0 || jobCity(j) !== jobCity(poolJobs[pi - 1]))" class="pm-sector-hd" style="display:flex;align-items:center;gap:4px;padding:5px 8px;margin:6px 0 2px;background:#eef2f7;border-radius:6px;font-size:11px;font-weight:600;color:#475569;position:sticky;top:0;z-index:2;cursor:pointer" @click="selectAllSector(jobCity(j) || 'Sans secteur')"><q-icon name="place" size="13px" color="green-5" />{{ jobCity(j) || 'Sans secteur' }}<q-icon name="done_all" size="14px" color="primary" class="q-ml-xs"><q-tooltip class="bg-grey-9">Sélectionner / désélectionner tout ce secteur (puis « Assigner »)</q-tooltip></q-icon><span style="margin-left:auto;font-weight:400;color:#94a3b8">{{ (poolSectorCounts[jobCity(j) || 'Sans secteur'] || {}).n }} · {{ fmtMin((poolSectorCounts[jobCity(j) || 'Sans secteur'] || {}).mins || 0) }}</span></div><div class="pm-swipe">
<div class="pm-swipe-bg"><span class="pm-swipe-hint l"><q-icon name="check_circle" size="17px" /> Sélectionner</span><span class="pm-swipe-hint r">Sélectionner <q-icon name="check_circle" size="17px" /></span></div>
<button type="button" class="pm-job" :class="{ sel: selectedJobs[j.name], hold: j.status === 'On Hold', swiping: swipe.active && swipe.name === j.name }" :style="{ borderLeft: '4px solid ' + prioColor(j.priority), ...swipeStyle(j) }"
@click="onJobClick(j)" @pointerdown="swipeStart($event, j)" @pointermove="swipeMove($event)" @pointerup="swipeEnd($event, j)" @pointercancel="swipeReset">
<q-icon :name="selectedJobs[j.name] ? 'check_circle' : (jobIsOnsite(j) ? 'home_repair_service' : 'cloud')" size="16px" :color="selectedJobs[j.name] ? 'primary' : (jobIsOnsite(j) ? 'teal' : 'grey-5')" class="q-mr-xs" />
<span class="pm-job-main">
<span class="pm-job-t ellipsis">{{ j.subject || j.service_type || j.name }}</span>
<span class="pm-job-s ellipsis">{{ j.required_skill || '—' }}<template v-if="j.customer_name"> · {{ j.customer_name }}</template></span>
<span v-if="j.scheduled_date || j.creation" class="pm-job-d">
<span v-if="j.scheduled_date" :class="isOverdue(j.scheduled_date) ? 'text-warning text-weight-medium' : 'text-grey-6'">📅 {{ fmtDueLabel(j.scheduled_date) }}</span>
<span v-if="j.creation" class="text-grey-5"> · créé {{ String(j.creation).slice(0, 10) }}</span>
</span>
<span v-if="j.notes" class="pm-job-note ellipsis"><q-icon name="sticky_note_2" size="12px" color="warning" /> {{ j.notes }}</span>
</span>
<span class="pm-job-icons">
<q-icon :name="j.priority === 'high' ? 'star' : 'star_border'" :color="j.priority === 'high' ? 'amber-7' : 'grey-5'" size="21px" @click.stop="toggleUrgent(j)"><q-tooltip>{{ j.priority === 'high' ? 'Urgent — toucher pour retirer' : 'Marquer urgent (priorité, heure fixée / SLA)' }}</q-tooltip></q-icon>
<q-icon :name="j.notes ? 'sticky_note_2' : 'note_add'" :color="j.notes ? 'deep-orange-6' : 'grey-4'" size="19px" @click.stop="openJobSheet(j, { note: true })"><q-tooltip>Note importante / détails</q-tooltip></q-icon>
<q-icon name="more_vert" color="grey-6" size="19px" @click.stop="openJobSheet(j)"><q-tooltip>Options (priorité, statut, compétence, report)</q-tooltip></q-icon>
</span>
<span class="pm-job-h">{{ j.est_min ? fmtMin(j.est_min) : ((j.duration_h || 1) + 'h') }}</span>
</button>
</div></template>
<div v-if="!poolJobs.length" class="pm-empty">Rien à assigner 🎉</div>
</div>
<JobPool variant="mobile" :pool="mobilePool" :selected-day="mobileSelDay" />
</div>
<!-- Feuille d'assignation (overlay bas) : techs candidats — n'écrase PAS la liste (zéro décalage au défilement). -->
<div v-if="selectedNames.length" class="pm-sheet">
@ -501,6 +470,8 @@
<q-space />
<q-btn dense unelevated no-caps size="sm" color="deep-purple-6" text-color="white" icon="forward_to_inbox" label="Notifier" :disable="!dayRoutes.length" @click="openNotifyDlg"><q-tooltip>Envoyer à chaque tech (SMS/courriel) le lien de sa tournée + les infos clés (adresse · AM/PM · urgent). Aperçu avant envoi.</q-tooltip></q-btn>
<q-btn dense unelevated no-caps size="sm" :color="showLivePos ? 'teal-6' : 'grey-4'" :text-color="showLivePos ? 'white' : 'grey-8'" icon="my_location" :label="'GPS live' + (dayLivePositions.length ? ' (' + dayLivePositions.length + ')' : '')" @click="showLivePos = !showLivePos"><q-tooltip>Positions GPS des techs (Traccar), rafraîchies ~25 s. Cliquer pour {{ showLivePos ? 'masquer' : 'afficher' }}.</q-tooltip></q-btn>
<!-- Tracé GPS RÉEL du jour visible seulement quand UNE tournée est affichée (naturellement seule, ou isolée via les chips techs) -->
<q-btn v-if="soloRouteTech" dense unelevated no-caps size="sm" :color="showRouteTrack ? 'deep-orange-6' : 'grey-4'" :text-color="showRouteTrack ? 'white' : 'grey-8'" icon="timeline" :label="'Tracé réel' + (showRouteTrack && routeTrackMsg ? ' · ' + routeTrackMsg : '')" @click="showRouteTrack = !showRouteTrack"><q-tooltip>Parcours GPS réel (Traccar) de {{ soloRouteTech.name }} ce jour trait orange superposé à la tournée prévue. S'affiche quand une seule tournée est isolée.</q-tooltip></q-btn>
<span class="text-caption text-grey-6">{{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail</span>
</div>
<!-- Chips techs : clic = ISOLER la tournée de ce tech (masquer les autres) ; re-clic = tout réafficher. Pastille = nb de jobs, couleur du tech. -->
@ -537,7 +508,7 @@
</span>
</div>
</div>
<RouteMap ref="routesMapRef" :routes="dayRoutes" :live="showLivePos ? dayLivePositions : []" :pins="routeUnassignedPins" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" @pin-click="p => openJobDetail(p.job)" />
<RouteMap ref="routesMapRef" :routes="dayRoutes" :live="showLivePos ? dayLivePositions : []" :pins="routeUnassignedPins" :track="showRouteTrack ? routeTrack : null" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" @pin-click="p => openJobDetail(p.job)" />
<div class="suggest-legend q-mt-xs">
<span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)">
<span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.name }}
@ -550,19 +521,7 @@
<div v-if="boardView === 'kanban'" class="kbb-wrap">
<!-- Pool « À assigner » : liste verticale + recherche + tri (priorité/ville/distance/compétence/durée) -->
<div class="kbb-pool" :class="{ 'drop-hover': dropCell === '__pool__' }" @dragover.prevent="dropCell = '__pool__'" @dragleave="dropCell = null" @drop="onKanbanUnassign">
<div class="kbb-pool-hd"><q-icon name="inbox" size="15px" />&nbsp;À assigner <span class="kb-count">{{ kanbanPoolView.length }}</span></div>
<div class="kbb-pool-tools">
<q-input dense outlined v-model="kbSearch" placeholder="Rechercher…" clearable><template #prepend><q-icon name="search" size="16px" /></template></q-input>
<q-select dense outlined v-model="kbSort" emit-value map-options :options="[{ label: 'Priorité', value: 'prio' }, { label: 'Ville', value: 'city' }, { label: 'Distance (dépôt)', value: 'dist' }, { label: 'Compétence', value: 'skill' }, { label: 'Durée', value: 'dur' }]"><template #prepend><q-icon name="sort" size="16px" /></template></q-select>
</div>
<div class="kbb-pool-body">
<div v-for="j in kanbanPoolView" :key="j.name" class="kb-card" :class="{ hold: j.status === 'On Hold', dragging: draggingSet.has(j.name) }" :style="{ borderLeftColor: kbColor(j) }" :draggable="j.status !== 'On Hold'" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
<div class="kb-card-t"><q-icon :name="skillSym(j.required_skill)" size="13px" :style="{ color: kbColor(j) }" />&nbsp;{{ j.subject || j.service_type || j.name }}</div>
<div class="kb-card-m"><span class="kb-dot" :style="{ background: prioColor(j.priority) }"></span>{{ j.est_min ? fmtMin(j.est_min) : ((j.duration_h || 1) + 'h') }}<template v-if="kbCity(j)"> · {{ kbCity(j) }}</template></div>
<q-tooltip class="bg-grey-9" :delay="350" style="font-size:11px">{{ j.subject || j.service_type }}<template v-if="j.customer_name"><br>{{ j.customer_name }}</template><template v-if="j.address"><br>📍 {{ j.address }}</template><template v-if="j.required_skill"><br>🧩 {{ j.required_skill }}</template><template v-if="j.status === 'On Hold'"><br>🔒 en attente non assignable</template></q-tooltip>
</div>
<div v-if="!kanbanPoolView.length" class="kb-empty"> rien à assigner </div>
</div>
<JobPool variant="docked" :pool="kanbanPool" />
</div>
<!-- Board : techs en lanes horizontales + échelle d'heures (la liste de techs suit le filtre skill/sous-groupe) -->
@ -899,36 +858,13 @@
<!-- Panneau FLOTTANT déplaçable : jobs à assigner (groupes parent-enfant) glisser sur une case (tech × jour) -->
<div v-if="assignPanel.open" class="assign-panel" :style="{ left: assignPanel.x + 'px', top: assignPanel.y + 'px', width: assignPanel.w + 'px', height: assignPanel.h + 'px', maxHeight: '92vh' }">
<div class="assign-hdr" @mousedown="panelHeaderDown">
<q-icon name="drag_indicator" size="18px" /><span>Jobs à assigner ({{ assignTypeFilter.length ? assignJobsFiltered.length + '/' + assignPanel.jobs.length : assignPanel.jobs.length }})</span><q-space />
<q-icon name="drag_indicator" size="18px" /><span>Jobs à assigner ({{ floatingPool.skillFilter.length ? floatingPool.filteredJobs.length + '/' + assignPanel.jobs.length : assignPanel.jobs.length }})</span><q-space />
<q-btn v-if="assignNoCoord" flat dense no-caps size="sm" color="warning" icon="wrong_location" :label="String(assignNoCoord)" :loading="assignPanel.locating" class="q-mr-xs" @click="batchLocatePool"><q-tooltip>Localiser les {{ assignNoCoord }} job(s) sans coordonnées : adresse base RQA + GPS (ils apparaissent ensuite sur la carte)</q-tooltip></q-btn>
<q-btn flat dense no-caps size="sm" color="amber-4" icon="auto_awesome" :label="suggestFiltered ? 'Suggérer (' + suggestJobCount + ')' : 'Suggérer'" :loading="suggestDlg.building" class="q-mr-xs" @click="openSuggest"><q-tooltip>Proposer une répartition optimisée (proximité · charge · compétence){{ suggestFiltered ? ' — ' + suggestJobCount + ' job(s) ' + suggestScope : '' }} tu revois et ajustes avant d'appliquer</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="map" :color="assignPanel.showMap ? 'amber-4' : 'white'" @click="toggleAssignMap"><q-tooltip>Carte des jobs (regrouper par région)</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="refresh" color="white" :loading="assignPanel.loading" @click="openAssignPanel" />
<q-btn flat dense round size="sm" icon="close" color="white" @click="assignPanel.open = false" />
</div>
<div class="assign-sortbar" @mousedown.stop>
<span>Trier :</span>
<select v-model="assignSort" @mousedown.stop>
<option value="group">Groupe (parent-enfant)</option>
<option value="skill">Compétence</option>
<option value="date">Date</option>
<option value="city">Ville</option>
<option value="priority">Priorité</option>
</select>
<q-btn dense flat size="sm" :icon="assignSortDir === 'asc' ? 'arrow_upward' : 'arrow_downward'" @click="assignSortDir = assignSortDir === 'asc' ? 'desc' : 'asc'" @mousedown.stop><q-tooltip>{{ assignSortDir === 'asc' ? 'Croissant (ancien → récent)' : 'Décroissant (récent → ancien)' }}</q-tooltip></q-btn>
<q-btn dense flat size="sm" :icon="allCollapsed ? 'unfold_more' : 'unfold_less'" no-caps @click="toggleCollapseAll" @mousedown.stop><q-tooltip>{{ allCollapsed ? 'Tout déplier' : 'Tout replier (voir un seul groupe)' }}</q-tooltip></q-btn>
<q-space />
<q-btn v-if="selectedNames.length" dense flat size="sm" icon="deselect" :label="'✕ ' + selectedNames.length" no-caps color="grey-7" @click="clearSel" @mousedown.stop><q-tooltip>Tout désélectionner</q-tooltip></q-btn>
</div>
<div v-if="assignTypes.length > 1" class="assign-chips" @mousedown.stop>
<span v-for="t in assignTypes" :key="t.k" class="assign-chip-f" :style="assignTypeFilter.includes(t.k) ? { background: getTagColor(t.k), color: '#fff', borderColor: getTagColor(t.k) } : { borderColor: getTagColor(t.k) }" @click="toggleAssignType(t.k)">{{ t.k }} <b>{{ t.n }}</b></span>
<span v-if="assignTypeFilter.length" class="assign-chip-f clear" @click="assignTypeFilter = []"><q-tooltip>Tout afficher</q-tooltip></span>
</div>
<div v-if="assignDates.length > 1" class="assign-chips" @mousedown.stop>
<q-icon name="event" size="14px" color="grey-6" class="q-mr-xs" />
<span v-for="d in assignDates" :key="d.iso" class="assign-chip-f" :style="assignDateFilter.includes(d.iso) ? { background: d.overdue ? '#f59e0b' : '#6366f1', color: '#fff', borderColor: d.overdue ? '#f59e0b' : '#6366f1' } : (d.overdue ? { borderColor: '#f59e0b', color: '#b45309' } : {})" @click="toggleAssignDate(d.iso)">{{ d.label }} <b>{{ d.n }}</b></span>
<span v-if="assignDateFilter.length" class="assign-chip-f clear" @click="assignDateFilter = []"><q-tooltip>Toutes les dates</q-tooltip></span>
</div>
<!-- Carte des jobs à assigner : 1 pin par adresse (lettre = groupe de 2 jobs à la même adresse) voir les régions, clic = sélectionner le groupe local -->
<div v-show="assignPanel.showMap" class="assign-map-wrap" @mousedown.stop>
<div ref="assignMapEl" class="assign-map"></div>
@ -936,60 +872,8 @@
<q-btn dense round size="sm" class="map-lasso-btn" :color="assignLasso ? 'primary' : 'grey-8'" icon="highlight_alt" @click="toggleLasso"><q-tooltip>{{ assignLasso ? 'Lasso ACTIF — trace une zone à main levée pour sélectionner les jobs dedans (reclique pour quitter)' : 'Lasso : encercler un groupe de jobs (tracé libre)' }}</q-tooltip></q-btn>
<div class="assign-map-cap">📍 Pins par <b>compétence</b> · amas = <b>total de jobs</b> (clic = zoom) · <b><q-icon name="highlight_alt" size="12px" /> lasso</b> = sélectionner une zone<span v-if="assignNoCoord" class="text-warning"> · {{ assignNoCoord }} sans coords</span></div>
</div>
<div class="assign-body">
<div v-if="assignPanel.loading" class="text-grey-6 q-pa-md text-center">Chargement</div>
<div v-else-if="!assignPanel.jobs.length" class="text-grey-6 q-pa-md text-center">Aucun job à assigner 🎉</div>
<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" style="cursor:pointer;display:flex;align-items:center;gap:3px" @click="toggleCollapse(grp.key)"><q-icon :name="assignCollapsed.has(grp.key) ? 'chevron_right' : 'expand_more'" size="15px" /><span>{{ grp.label }} <span style="opacity:.6">({{ grp.jobs.length }})</span></span><q-space /><q-icon name="done_all" size="16px" :color="groupSelected(grp) ? 'primary' : 'grey-5'" @click.stop="toggleGroupSelAll(grp)"><q-tooltip>Sélectionner / désélectionner tout ce groupe</q-tooltip></q-icon></div>
<template v-if="!(grp.label && assignCollapsed.has(grp.key))">
<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" :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>
<span v-if="groupLabel(j)" class="assign-grp-badge q-mr-xs"><q-tooltip class="bg-grey-9">Même adresse de service (groupe {{ groupLabel(j)[0] }}) simple repère de proximité</q-tooltip>{{ groupLabel(j) }}</span>
<q-badge v-if="j.step_order" color="indigo" class="q-mr-xs">{{ j.step_order }}</q-badge>
<span class="ellipsis text-weight-medium" :style="assignPanel.showMap ? 'cursor:pointer' : ''" @click="assignPanel.showMap && focusAssignJob(j)">{{ j.subject || j.service_type || j.name }}<q-tooltip v-if="j.legacy_detail" max-width="380px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.legacy_detail }}</q-tooltip></span>
<!-- SLA : pastille rouge (dépassé) / orange (proche) réutilise les politiques SLA des tickets -->
<span v-if="jobSlaBadge(j)" class="assign-sla" :class="'sla-' + jobSlaBadge(j).state"><q-icon :name="jobSlaBadge(j).icon" size="11px" />{{ jobSlaBadge(j).short }}<q-tooltip class="bg-grey-9">{{ jobSlaBadge(j).label }}</q-tooltip></span>
<q-space />
<q-btn v-if="j.status !== 'On Hold'" flat dense round size="sm" icon="person_add" color="primary" class="q-ml-xs" @click.stop @mousedown.stop>
<q-tooltip>Assigner à un technicien (classés par pertinence : compétence · dispo · distance · charge)</q-tooltip>
<q-menu anchor="bottom right" self="top right" @click.stop>
<div class="assign-pick-hd">Assigner « {{ (j.subject || j.name).slice(0, 30) }} » · 📅 {{ fmtDueLabel(jobTargetDay(j)) }}</div>
<q-list dense style="min-width:270px;max-height:44vh;overflow:auto">
<q-item v-for="tk in techsForJob(j)" :key="tk.id" clickable v-close-popup @click="quickAssign(j, tk)" :class="{ 'assign-pick-incap': !tk.capable }">
<q-item-section avatar style="min-width:32px"><q-avatar size="26px" :color="tk.capable ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:10px;font-weight:700">{{ initials(tk.name) }}</q-avatar></q-item-section>
<q-item-section>
<q-item-label>{{ tk.name }}<q-icon v-if="!tk.capable && j.required_skill" name="warning" size="12px" color="orange" class="q-ml-xs"><q-tooltip>Compétence « {{ j.required_skill }} » absente</q-tooltip></q-icon></q-item-label>
<q-item-label caption><span v-if="tk.distKm != null">📍 {{ Math.round(tk.distKm) }} km</span><span v-else class="text-grey-5">dist. ?</span> · {{ tk.h }}/{{ tk.cap }}h<span v-if="tk.noShift" class="text-warning"> · ▲ sans quart</span><span v-else-if="tk.over" class="text-negative"> · surchargé</span></q-item-label>
</q-item-section>
<q-item-section side><q-icon name="chevron_right" size="16px" color="grey-5" /></q-item-section>
</q-item>
<q-item v-if="!techsForJob(j).length"><q-item-section class="text-grey-6 text-caption">Aucun technicien visible.</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
<q-icon v-if="j.legacy_ticket_id" name="forum" size="14px" :color="j._showThread ? 'indigo' : 'grey-5'" class="q-ml-xs" style="cursor:pointer" @click.stop="toggleAssignThread(j)" @mousedown.stop><q-tooltip>Voir le fil du ticket #{{ j.legacy_ticket_id }}</q-tooltip></q-icon>
<q-icon v-if="j.status === 'On Hold'" name="lock" size="13px" color="orange"><q-tooltip>En attente de {{ j.depends_on || 'la tâche précédente' }}</q-tooltip></q-icon>
</div>
<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-lvl" :class="{ set: j.required_level > 1 }" @click.stop @mousedown.stop><q-icon name="military_tech" size="11px" />niv&nbsp;{{ j.required_level || 1 }}<q-tooltip class="bg-grey-9">Niveau min requis pour ce job (persistant · mode « juste ce qu'il faut » → réserve les experts)</q-tooltip><q-menu anchor="bottom left" self="top left" @click.stop><q-list dense style="min-width:150px"><q-item v-for="n in [1,2,3,4,5]" :key="n" clickable v-close-popup @click="setJobReqLevel(j, n)" :active="(j.required_level||1)===n"><q-item-section>Niveau {{ n }}{{ n===1 ? ' — de base' : (n===5 ? ' — expert' : '') }}</q-item-section></q-item></q-list></q-menu></span>
{{ j.customer_name || j.location_label || j.service_location || '' }}<span v-if="j.depends_on"> · après {{ j.depends_on }}</span> · <span class="text-positive text-weight-medium"> {{ j.est_min ? fmtMin(j.est_min) : (j.duration_h || 1) + 'h' }}<q-tooltip v-if="j.est_labels && j.est_labels.length" class="bg-grey-9" style="font-size:11px">Estimé (auto) : {{ j.est_labels.join(' + ') }}</q-tooltip></span><span v-if="j.scheduled_date"> · 📅 <span :class="isOverdue(j.scheduled_date) ? 'text-warning text-weight-bold' : 'text-grey-7'">{{ fmtDueLabel(j.scheduled_date) }}</span><q-icon name="edit_calendar" size="14px" :color="isOverdue(j.scheduled_date) ? 'warning' : 'grey-5'" class="q-ml-xs" style="cursor:pointer" @click.stop @mousedown.stop><q-tooltip>Replanifier (aujourd'hui / demain / date)</q-tooltip><q-menu anchor="bottom left" self="top left" @click.stop><q-list dense style="min-width:160px"><q-item clickable v-close-popup @click="setJobDate(j, todayISO())"><q-item-section avatar style="min-width:28px"><q-icon name="today" size="18px" color="primary" /></q-item-section><q-item-section>Aujourd'hui</q-item-section></q-item><q-item clickable v-close-popup @click="setJobDate(j, tomorrowISO())"><q-item-section avatar style="min-width:28px"><q-icon name="event" size="18px" color="primary" /></q-item-section><q-item-section>Demain</q-item-section></q-item><q-separator /><q-date :model-value="j.scheduled_date" @update:model-value="v => setJobDate(j, v)" mask="YYYY-MM-DD" minimal today-btn /></q-list></q-menu></q-icon></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>
<template v-else-if="j._thread && j._thread.messages && j._thread.messages.length">
<div v-for="(m, mi) in j._thread.messages" :key="mi" class="assign-msg"><b>{{ m.author }}</b><span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span><div class="assign-msg-txt">{{ m.text }}</div></div>
</template>
<div v-else class="text-grey-6" style="font-size:11px;white-space:pre-wrap">{{ j.legacy_detail || 'Aucun fil pour ce ticket.' }}</div>
<div class="text-right q-mt-xs"><q-btn dense flat size="sm" color="negative" icon="block" label="Fermer ce ticket (legacy)" @click="closeLegacyTicket(j)"><q-tooltip>Ticket inutile status closed dans osTicket + sort du dispatch</q-tooltip></q-btn></div>
</div>
</div>
</template>
</div>
</div>
<JobPool v-else variant="floating" :pool="floatingPool" />
<div v-if="assignPanel.jobs.length" class="assign-foot">
<template v-if="selectedNames.length"><b>{{ selectedNames.length }}</b> sélectionné(s) · <b>{{ selectedHours }}h</b>
<q-btn dense unelevated size="sm" color="primary" icon="person_add" :label="'Assigner (' + selectedNames.length + ')'" class="q-ml-sm">
@ -1395,97 +1279,61 @@
<q-dialog v-model="jobDetail.open" position="right" full-height>
<q-card class="jd-card">
<q-card-section class="row items-center q-pb-sm">
<!-- Barre d'en-tête = titre + FERMER uniquement. Le X ne partage plus la ligne du champ d'assignation (ambiguïté « supprime-t-il le champ ? ») ; il est étiqueté « Fermer ». -->
<q-card-section class="row items-center no-wrap q-pb-sm">
<q-icon name="assignment" color="primary" size="22px" class="q-mr-sm" />
<div class="col" style="min-width:0">
<div class="text-h6" style="line-height:1.25;word-break:break-word">{{ jobDetail.subject }}</div>
<div class="text-caption text-grey-7"><router-link v-if="jobDetail.customerId" :to="'/clients/' + encodeURIComponent(jobDetail.customerId)" class="erp-link">{{ jobDetail.customer || jobDetail.customerId }}<q-icon name="open_in_new" size="11px" class="q-ml-xs" /></router-link><template v-else-if="jobDetail.customer">{{ jobDetail.customer }}</template><template v-if="jobDetail.lid"> · Ticket #{{ jobDetail.lid }}</template><template v-if="jobDetail.dept"> · {{ jobDetail.dept }}</template></div>
<div v-if="jobDetail.name" class="text-caption text-grey-5">{{ jobDetail.name }}</div>
<!-- Assignation MULTIFONCTION UN champ : À = assigné (lead) · CC = assistant (renfort, créneau grisé) · # = follower (abonné).
Champ partagé AssignmentField (réutilisé sur le détail ticket). « Assist » / « #follow » étendent l'autosuggest à tous les utilisateurs. -->
<AssignmentField class="q-mt-xs jd-assign"
doctype="Dispatch Job" :doc-name="jobDetail.name"
:assignee="jdAssignee" :assistants="jdAssistants"
:tech-options="jdTeamOptions" :can-onsite="jobDetail.canTeam" :assist-loading="jobDetail.teamLoading"
@assign="jdAssignTech" @set-assistant="jdSetAssistant" @unassign="jdUnassign" @remove-assistant="jdRemoveAssistant">
</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup><q-tooltip>Fermer</q-tooltip></q-btn>
</q-card-section>
<q-separator />
<!-- Assignation MULTIFONCTION module partagé JobTeamField (À = assigné · CC = assistant · # = follower).
Les assistants sont gérés par le module (roster) ; le LEAD reste sur la pipeline board (assignNames via jdAssignTech / jdUnassign).
Le MÊME module est monté dans le détail ticket une seule source. -->
<q-card-section class="q-py-sm">
<JobTeamField class="jd-assign"
:name="jobDetail.name" :tech-id="jobDetail.techId" :tech-name="jobDetail.techName"
:team="jobDetail.team" :tech-options="jdTeamOptions" :can-onsite="jobDetail.canTeam" :team-loading="jobDetail.teamLoading"
@assign-lead="jdAssignTech" @unassign-lead="jdUnassign" @updated="reloadOccupancy()">
<template #right>
<TicketStatusControl v-if="jobDetail.canTeam" doctype="Dispatch Job" :docname="jobDetail.name" :status="jobDetail.status" :scheduled-date="jobDetail.iso"
@changed="p => { if (p && p.status) jobDetail.status = p.status; reloadOccupancy(); reloadPool && reloadPool() }" />
</template>
</AssignmentField>
</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
</JobTeamField>
</q-card-section>
<q-separator />
<q-card-section class="jd-body scroll">
<!-- Détails SIMPLES d'abord (carte/tracé GPS repliés : non requis pour lire le ticket) -->
<!-- Détails SIMPLES d'abord. Le volet consomme désormais les MÊMES modules partagés que le détail ticket (IssueDetail). -->
<div class="jd-meta">
<div v-if="jobDetail.time"><q-icon name="schedule" size="16px" /> {{ jobDetail.time }}</div>
<div v-if="jobDetail.address"><q-icon name="place" size="16px" /> {{ jobDetail.address }}</div>
<div v-if="jdHasCoords" class="jd-geolinks">
<a :href="jdMapsUrl" target="_blank" rel="noopener" class="jd-geolink"><q-icon name="satellite_alt" size="14px" /> Carte / satellite</a>
<a :href="jdStreetViewUrl" target="_blank" rel="noopener" class="jd-geolink"><q-icon name="streetview" size="14px" /> Street View</a>
</div>
</div>
<!-- Ajuster la durée estimée (comme « Suggérer ») persiste duration_h -->
<div class="jd-dur row items-center q-gutter-sm q-my-sm">
<q-icon name="timer" size="16px" color="grey-6" /><span class="text-caption text-grey-7">Durée estimée</span>
<q-input dense outlined type="number" step="0.25" min="0" v-model.number="jobDetail.durH" style="width:96px" suffix="h" @change="jdSetDuration(jobDetail.durH)" @keyup.enter="jdSetDuration(jobDetail.durH)" />
<span class="text-caption text-grey-5"> {{ fmtMin(Math.round((jobDetail.durH || 0) * 60)) }}</span>
</div>
<!-- Compétences REQUISES (dispatch auto) LISTE : sélecteur/créateur en chips (autosuggest). Le tech doit les avoir TOUTES. 1re = principale. -->
<div class="jd-skill row items-center q-gutter-sm q-mb-xs">
<q-icon name="construction" size="16px" color="grey-6" /><span class="text-caption text-grey-7">Compétences requises</span>
<TagEditor :model-value="jobDetail.skills" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" placeholder="Ajouter une compétence requise…" style="min-width:220px;flex:1" @update:model-value="jdSetJobSkills" @create="onCreateRosterTag" />
</div>
<!-- Attribuer cette compétence à un tech précis il sera matché au dispatch automatique. -->
<!-- Durée estimée module partagé. Il persiste duration_h ; le board conserve son override d'optimisation + le rappel « -optimiser ». -->
<DurationField :name="jobDetail.name" :dur-h="jobDetail.durH"
@updated="p => { durOverride[jobDetail.name] = p.duration_h; jobDetail.durH = p.duration_h; $q.notify({ type: 'positive', message: 'Durée révisée : ' + p.duration_h + ' h — Ré-optimiser pour recalculer', timeout: 2500 }) }" />
<!-- Compétences requises module partagé (store hub /roster/job-skills). -->
<RequiredSkillsField :name="jobDetail.name" :skills="jobDetail.skills" :all-tags="tagCatalog" :get-color="getTagColor"
@updated="arr => { jobDetail.skills = arr; jobDetail.skill = arr[0] || ''; reloadPool && reloadPool() }" @create="onCreateRosterTag" />
<!-- Attribuer cette compétence à un tech précis matché au dispatch auto. BOARD-ONLY (nécessite la liste des techs). -->
<div v-if="jobDetail.skill" class="row items-center q-gutter-xs q-mb-sm no-wrap">
<span class="text-caption text-grey-6 no-wrap">Donner à :</span>
<TechSelect v-model="jdSkillTech" :options="jdAllTechOpts" label="un technicien…" style="min-width:180px;flex:1" />
<q-btn dense unelevated color="teal-7" icon="add" no-caps :disable="!jdSkillTech" :loading="jdSkillBusy" @click="jdGiveSkillToTech" label="Attribuer" />
</div>
<!-- Carte / tracé GPS : repliés par défaut -->
<div class="q-mb-sm">
<q-btn flat dense no-caps size="sm" color="grey-7" :icon="jdShowMap ? 'expand_less' : 'map'" :label="jdShowMap ? 'Masquer la carte' : 'Voir la carte / tracé GPS'" @click="jdShowMap = !jdShowMap" />
</div>
<div v-show="jdShowMap">
<div ref="jdMapEl" class="jd-map"></div>
<div v-if="kbCanTrack" class="jd-track-row"><q-toggle v-model="jdShowTrack" dense size="sm" color="warning" /><span class="text-caption text-weight-medium">Tracé GPS réel (Traccar)</span><span class="text-caption text-grey-6">{{ jdTrackMsg }}</span></div>
<div v-else class="text-caption text-grey-5 q-mb-sm">Tracé GPS réel : aujourd'hui / hier seulement (Traccar).</div>
</div>
<!-- Suivi terrain (géofencing GPS) : étapes façon suivi de colis En route Arrivé Reparti, avec l'heure atteinte. -->
<div v-if="jobDetail.geofence && jobDetail.geofence.state" class="jd-geo">
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="my_location" size="18px" class="q-mr-xs" color="teal-7" /> Suivi terrain (GPS)</div>
<div class="jd-geo-track">
<div v-for="s in geoTimeline" :key="s.k" class="jd-geo-step" :class="{ done: s.done, current: s.current }">
<div class="jd-geo-ic"><q-icon :name="s.icon" size="15px" /></div>
<div class="jd-geo-lbl">{{ s.label }}<span v-if="s.at" class="jd-geo-at">{{ s.at }}</span></div>
</div>
</div>
</div>
<!-- « Équipe / renfort » fusionnée dans le champ d'assignation To/Cc en haut du volet (À = assigné · Cc = assistants). -->
<div class="text-subtitle2 q-mt-md q-mb-xs row items-center">Commentaires / fil du billet<span v-if="jdMessages.length" class="text-grey-5 q-ml-sm" style="font-size:10px">· plus récent en haut</span></div>
<!-- Carte + tracé GPS (Traccar) module partagé. Le board fournit les points de contexte à proximité + le jour affiché. -->
<JobMapModule :name="jobDetail.name" :lat="jobDetail.lat" :lon="jobDetail.lon"
:tech-id="jobDetail.techId" :iso="(kanbanDay && kanbanDay.iso) || jobDetail.iso" :can-track="kbCanTrack" :today-iso="nowET.iso" :nearby="jdNearby()" />
<!-- Suivi terrain (géofencing) module partagé (objet déjà chargé dans openJobDetail). -->
<GeofenceTimeline :geofence="jobDetail.geofence" />
<!-- Fil du billet module partagé. Spinner board conservé pendant le chargement (évite un double fetch dans le module). -->
<div v-if="jobDetail.loading" class="text-center q-pa-md"><q-spinner size="26px" color="primary" /><div class="text-caption text-grey-6 q-mt-sm">Lecture du ticket</div></div>
<template v-else-if="jdMessages.length">
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ jdMessages.length }} message(s){{ jobDetail.thread.status ? ' · ' + jobDetail.thread.status : '' }}</div>
<div v-for="(m, mi) in jdMessages" :key="mi" class="de-msg" :class="{ 'de-msg-latest': mi === 0 }">
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span><span v-if="mi === 0 && jdMessages.length > 1" class="de-msg-badge">dernier</span></div>
<div class="de-msg-txt">{{ m.text }}</div>
</div>
</template>
<!-- Repli : corps brut du billet UNIQUEMENT s'il n'y a aucun message dans le fil (sinon = doublon du 1er commentaire) -->
<div v-else-if="jobDetail.detail" class="jd-detail">{{ jobDetail.detail }}</div>
<div v-else class="text-grey-6 q-pa-md text-center">{{ jobDetail.lid ? ((jobDetail.thread && jobDetail.thread.error) ? 'Détail indisponible' : 'Aucun commentaire.') : 'Pas de billet Legacy lié à ce job.' }}</div>
<!-- Répondre au fil depuis la job même geste que le panneau ticket + l'app terrain. Note INTERNE par défaut ; au client si coché. -->
<div v-if="jobDetail.lid || jobDetail.name" class="q-mt-sm">
<q-input v-model="jdReply" dense outlined type="textarea" autogrow placeholder="Écrire une note / réponse…" :input-style="{ minHeight: '48px' }" @keydown.ctrl.enter="sendJdReply" @keydown.meta.enter="sendJdReply" />
<div class="row items-center q-mt-xs">
<q-checkbox v-if="jobDetail.lid" v-model="jdReplyPublic" dense size="sm" label="Envoyer aussi au client" />
<q-space />
<q-btn dense unelevated color="primary" no-caps icon="send" :label="jdReplyPublic ? 'Répondre au client' : 'Note interne'" :disable="!jdReply.trim()" :loading="jdReplySending" @click="sendJdReply" />
</div>
</div>
<JobThread v-else :thread="jobDetail.thread" :detail="jobDetail.detail" :lid="jobDetail.lid" />
<!-- Répondre au fil module partagé. Recharge le fil du volet après envoi. -->
<JobReplyBox :name="jobDetail.name" :lid="jobDetail.lid" @sent="reloadJdThread" />
</q-card-section>
</q-card>
</q-dialog>
@ -1540,6 +1388,36 @@
</q-card>
</q-dialog>
<!-- Job « bouche-trou » (filler) : job générique de priorité STANDARD assigné occupe la journée retire le tech du dispatch. Optionnel : génère un ticket. -->
<q-dialog v-model="fillerDlg.open">
<q-card style="min-width:340px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="block" color="deep-orange-6" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Bloquer / job générique</div>
<q-space /><q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-gutter-sm">
<div class="text-body2"><b>{{ fillerDlg.tech && fillerDlg.tech.name }}</b> <span class="text-grey-6"> occupé toute la période retiré du dispatch régulier</span></div>
<q-input dense outlined v-model="fillerDlg.subject" label="Intitulé" placeholder="Ex. Formation · Réunion · Entretien véhicule" autofocus />
<div class="row items-center q-gutter-sm">
<q-toggle v-model="fillerDlg.fullDay" dense size="sm" color="deep-orange-6" label="Journée complète" @update:model-value="onFillerFullDay" />
<q-input class="col" dense outlined type="date" v-model="fillerDlg.date" label="Date" />
</div>
<div class="row q-col-gutter-sm">
<q-input class="col-4" dense outlined type="time" v-model="fillerDlg.start" label="Début" :disable="fillerDlg.fullDay" />
<q-input class="col-4" dense outlined type="number" v-model.number="fillerDlg.dur" label="Durée (h)" min="0.5" step="0.5" :disable="fillerDlg.fullDay" />
<q-select class="col-4" dense outlined emit-value map-options v-model="fillerDlg.priority" :options="POOL_PRIOS" option-value="value" option-label="label" label="Priorité" />
</div>
<q-input dense outlined v-model="fillerDlg.job_type" label="Type" placeholder="Interne" />
<q-toggle v-model="fillerDlg.createTicket" dense size="sm" color="primary" label="Générer un ticket lié" />
</q-card-section>
<q-card-actions align="right" class="q-px-md q-pb-md">
<q-btn flat no-caps label="Annuler" color="grey-7" v-close-popup />
<q-btn unelevated no-caps color="deep-orange-6" icon="add" label="Créer" :loading="fillerDlg.busy" :disable="!fillerDlg.subject" @click="doFiller" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Drop d'une job sur un tech SANS quart : créer un quart (durée) ou marquer absent (durée + retour) -->
<q-dialog v-model="dropAsk.open">
<q-card style="min-width:380px;max-width:440px">
@ -1694,7 +1572,8 @@
:shifts="menuCellShifts" :is-garde="menuIsGarde" :is-absent="menuIsAbsent"
:clipboard-count="cellClipboard.length" :initial-range="menuRange"
@window="e => applyWindow(e.min, e.max)" @toggle-garde="toggleGardeMenu" @toggle-absent="openAbsDialog"
@remove-shift="removeShiftFromMenu" @clear="clearOne" @copy="copyFromMenu" @paste="pasteFromMenu" />
@remove-shift="removeShiftFromMenu" @clear="clearOne" @copy="copyFromMenu" @paste="pasteFromMenu"
@filler="openFillerFromMenu" />
</q-menu>
<!-- Éditeur de JOURNÉE (clic sur le progressbar) : timeline + réordonner par drag-drop + retirer un job -->
@ -1906,7 +1785,7 @@
* 10. Chargement & solveur ................. loadBase/loadWeek/loadStats · doGenerate/doPublish
* 11. Helpers date/temps/couleur .......... iso/hToNum/numToTime · occColor/todColor/getTagColor
*/
import { ref, computed, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ref, computed, reactive, onMounted, onUnmounted, watch, nextTick, provide } from 'vue'
import { useRoute } from 'vue-router' // deep-link ?day=&skill= (tableau de bord, slot-finder)
// Icônes de rôle monochromes outline (Material Symbols, style « une couleur » demandé) : échelle = installation.
import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined'
@ -1925,6 +1804,14 @@ import { useSla, SLA_BADGE } from 'src/composables/useSla' // SLA existant (poli
import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client
import TechSelect from 'src/components/shared/TechSelect.vue'
import AssignmentField from 'src/components/shared/AssignmentField.vue'
// Modules détail on-site/dispatch PARTAGÉS (mêmes composants que IssueDetail) le volet job les consomme désormais.
import JobMapModule from 'src/components/shared/detail-sections/modules/JobMapModule.vue'
import GeofenceTimeline from 'src/components/shared/detail-sections/modules/GeofenceTimeline.vue'
import DurationField from 'src/components/shared/detail-sections/modules/DurationField.vue'
import RequiredSkillsField from 'src/components/shared/detail-sections/modules/RequiredSkillsField.vue'
import JobTeamField from 'src/components/shared/detail-sections/modules/JobTeamField.vue'
import JobThread from 'src/components/shared/detail-sections/modules/JobThread.vue'
import JobReplyBox from 'src/components/shared/detail-sections/modules/JobReplyBox.vue'
import LeaveDialog from 'src/components/planif/LeaveDialog.vue' // Congés & disponibilités (extrait décomposition #4)
import TechSyncDialog from 'src/components/planif/TechSyncDialog.vue' // Synchroniser les techniciens (extrait décomposition #4)
import ShiftTypesDialog from 'src/components/planif/ShiftTypesDialog.vue' // Types de shift (extrait décomposition #4)
@ -1936,6 +1823,8 @@ import SkillCadenceTable from 'src/components/planif/SkillCadenceTable.vue' //
import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' // bande d'occupation réutilisable (sélecteur de jour Tournées + tableau de bord)
import OverflowMenu from 'src/components/shared/OverflowMenu.vue' // « » standardisé pour les actions secondaires (désencombrer la barre)
import JobPool from 'src/components/planif/JobPool.vue' // pool « Jobs à assigner » UNIFIÉ (flottant · kanban · mobile) remplace les 3 réimplémentations
import { useJobPool } from 'src/composables/useJobPool' // cerveau filtre/tri/groupe/badges partagé par les 3 surfaces
import { useCommandPalette } from 'src/composables/useCommandPalette' // registre d'actions contextuelles joignables via K même si retirées de la barre
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) Dispatch déprécié, tout ici
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » pré-remplit la création (tech+date+heure+adresse)
@ -2153,24 +2042,7 @@ const boardDate = computed({
else if (v !== start.value) { start.value = v; onWeekChange() }
},
})
const kanbanPool = computed(() => { const f = skillFilter.value; return (assignPanel.jobs || []).filter(j => !f.length || f.includes(j.required_skill)) })
// Pool : recherche plein-texte + tri (priorité / ville / distance dépôt / compétence / durée), comme la fenêtre flottante.
const kbSearch = ref(''); const kbSort = ref('prio')
function kbCity (j) { if (j.municipalite) return j.municipalite; const m = String(j.address || '').match(/,\s*([^,]+?)\s*(?:,|$|\s[A-Z]\d[A-Z])/); return (m ? m[1] : '').trim() }
const kanbanPoolView = computed(() => {
let list = kanbanPool.value
const q = kbSearch.value.trim().toLowerCase()
if (q) list = list.filter(j => [j.subject, j.service_type, j.customer_name, j.address, j.required_skill].some(x => String(x || '').toLowerCase().includes(q)))
const pr = (j) => ({ urgent: 0, high: 1, medium: 2, low: 3 }[String(j.priority || '').toLowerCase()] ?? 2)
const dep = depot.value; const dist = (j) => (dep && dep.lat != null && j.latitude != null) ? (haversineKm(dep.lat, dep.lon, +j.latitude, +j.longitude) ?? 9e9) : 9e9
const s = kbSort.value
return [...list].sort((a, b) =>
s === 'city' ? (kbCity(a) || 'zz').localeCompare(kbCity(b) || 'zz') :
s === 'dist' ? dist(a) - dist(b) :
s === 'skill' ? String(a.required_skill || 'zz').localeCompare(String(b.required_skill || 'zz')) :
s === 'dur' ? (b.est_min || 0) - (a.est_min || 0) :
(pr(a) - pr(b) || (b.est_min || 0) - (a.est_min || 0)))
})
// Pool kanban instance useJobPool unifiée (créée plus bas avec les autres surfaces).
// Blocs d'une lane : depuis l'occupation BRUTE (occByTechDay = TOUS les jobs assignés ; robuste vs shift-gating qui
// faisait « disparaître » la carte). Timés (start_h) à leur heure ; non-timés en flow ; inclut les miroirs « assist ».
const KB_DAY_START = 8 // 8h AM (style Gaiia) : départ par défaut de la tournée
@ -2233,7 +2105,6 @@ async function fetchKbMatrix (techId) {
} catch (e) { /* repli haversine (géré dans kanbanLaneBlocks) */ } finally { _kbMatPending.delete(key) }
}
let _kbMatT = null // le watch déclencheur est enregistré PLUS BAS (après la déclaration de occByTechDay) pour éviter la zone morte temporelle
const kbColor = (j) => j.skill ? getTagColor(j.skill) : (j.required_skill ? getTagColor(j.required_skill) : (legacyDeptColor(j.legacy_dept) || '#90a4ae'))
async function reloadPool () { try { assignPanel.jobs = capPickups((await roster.unassignedJobs()).jobs) } catch (e) { /* non bloquant */ } }
// Création de job NATIVE OPS (#17) Dispatch déprécié tout depuis Planification. Réutilise UnifiedCreateModal (work-order).
// Job OPS-natif = PAS de legacy_ticket_id coexiste avec les jobs synchronisés de F (la sync F ne touche que les jobs à legacy_ticket_id). « Sync F transitoire » = coexistence, pas de push F pour l'instant.
@ -2432,6 +2303,8 @@ async function sendJdReply () {
} else { $q.notify({ type: 'negative', message: (r && r.error) || 'Échec de l\'envoi', timeout: 3000 }) }
} catch (e) { $q.notify({ type: 'negative', message: 'Échec de l\'envoi', timeout: 3000 }) } finally { jdReplySending.value = false }
}
// Recharge le fil du volet après un envoi via le module JobReplyBox (le module poste ; le volet possède jobDetail.thread).
async function reloadJdThread () { if (jobDetail.lid) { try { jobDetail.thread = await roster.ticketThread(jobDetail.lid) } catch (e) { /* garde l'existant */ } } }
async function jdAddAssistant () {
const id = jobDetail.teamAdd; if (!id || !jobDetail.name) return
const tech = (techs.value || []).find(t => t.id === id)
@ -2699,6 +2572,44 @@ function openReserve (t) {
const iso = mobileSelIso.value || kbSelIso.value || start.value || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
Object.assign(resvDlg, { open: true, tech: { id: t.id, name: t.name }, date: iso, start: '08:00', dur: 2, reason: '', busy: false })
}
// Job « bouche-trou » (filler) : depuis le menu de cellule (clic case vide / clic droit). Pré-remplit tech+date+durée.
// La durée « journée complète » = longueur du quart de la case s'il existe, sinon 8 h. Occupe l'occupation hors dispatch.
const fillerDlg = reactive({ open: false, tech: null, date: '', start: '08:00', dur: 8, fullDay: true, shiftH: 8, priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false })
function shiftHoursOf (techId, iso) { // longueur cumulée des quarts de la case (0 si aucun)
const occ = cellOcc(techId, iso)
if (occ && occ.hasReg && occ.shiftE > occ.shiftS) return Math.round((occ.shiftE - occ.shiftS) * 10) / 10
return 0
}
function openFillerFromMenu () {
if (!menu.tech || !menu.day) return
const sh = shiftHoursOf(menu.tech.id, menu.day.iso)
const occ = cellOcc(menu.tech.id, menu.day.iso)
const startH = (occ && occ.hasReg && occ.shiftS != null) ? occ.shiftS : 8
Object.assign(fillerDlg, {
open: true, tech: { id: menu.tech.id, name: menu.tech.name }, date: menu.day.iso,
start: numToTime(startH), dur: sh || 8, fullDay: true, shiftH: sh || 8,
priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false,
})
menu.show = false
}
function onFillerFullDay (v) { if (v) { fillerDlg.dur = fillerDlg.shiftH || 8 } } // journée complète durée = quart
async function doFiller () {
if (!fillerDlg.tech || !fillerDlg.date || !fillerDlg.subject || fillerDlg.busy) return
fillerDlg.busy = true
try {
const dur = fillerDlg.fullDay ? (fillerDlg.shiftH || 8) : (Number(fillerDlg.dur) || 8)
const r = await roster.createFillerJob({
tech: fillerDlg.tech.id, date: fillerDlg.date, start_time: fillerDlg.start, duration_h: dur,
priority: fillerDlg.priority, job_type: fillerDlg.job_type || 'Interne', subject: fillerDlg.subject,
create_ticket: fillerDlg.createTicket,
})
if (r && r.ok) {
$q.notify({ type: 'positive', icon: 'block', message: fillerDlg.tech.name + ' — ' + dur + 'h bloquées' + (r.issue ? ' · ticket ' + r.issue : '') })
fillerDlg.open = false
try { await reloadOccupancy() } catch (e) {}
} else $q.notify({ type: 'negative', message: (r && r.error) || 'Création impossible' })
} catch (e) { $q.notify({ type: 'negative', message: 'Création impossible : ' + (e.message || e) }) } finally { fillerDlg.busy = false }
}
async function doReserve () {
if (!resvDlg.tech || !resvDlg.date || resvDlg.busy) return
resvDlg.busy = true
@ -2904,6 +2815,14 @@ const draggingJobName = ref(null); const dropCell = ref(null); const dragHours =
const selectedJobs = reactive({}) // jobName true
const dropPreview = reactive({ key: null, addH: 0 })
const draggingSet = reactive(new Set()); let _dragGhost = null // jobs en cours de glissé (source estompée) + fantôme custom
// Pools « Jobs à assigner » UNIFIÉS : une instance useJobPool par surface (détenues ici la carte du panneau lit floatingPool.filteredJobs).
// Créées TÔT (avant assignLocations / le watch de la carte) ; les deps sont des fonctions hoistées, les closures (depot, mobileSelDay) sont paresseuses.
const _poolDist = (j) => { const dep = depot.value; return (dep && dep.lat != null && j.latitude != null) ? (haversineKm(dep.lat, dep.lon, +j.latitude, +j.longitude) ?? 9e9) : 9e9 }
const jpDeps = { jobCity, jobLocKey: _jobLocKey, todayISO, fmtDueLabel }
const floatingPool = useJobPool(() => assignPanel.jobs, jpDeps, { variant: 'floating' })
const kanbanPool = useJobPool(() => assignPanel.jobs, jpDeps, { variant: 'docked', distOf: _poolDist })
const mobilePool = useJobPool(() => assignPanel.jobs, jpDeps, { variant: 'mobile', selectedDay: () => mobileSelDay.value })
const jpShowMap = computed(() => assignPanel.showMap)
async function openAssignPanel () { assignPanel.open = true; assignPanel.loading = true; for (const k in selectedJobs) delete selectedJobs[k]; try { assignPanel.jobs = capPickups((await roster.unassignedJobs()).jobs) } catch (e) { err(e) } finally { assignPanel.loading = false } }
// Write-back legacy : aperçu (dryRun, 0 écriture) confirmation écrit ticket.assign_to dans osTicket.
@ -2921,9 +2840,6 @@ async function removeFromPush (s) {
$q.notify({ type: 'info', message: 'Job désassigné (retour au pool) — exclu du legacy', timeout: 2200 })
} catch (e) { err(e) } finally { legacyPush.applying = false }
}
const assignSort = ref('date') // défaut = date DUE (facilite le dispatch : aujourd'hui/en retard d'abord). Autres : group | skill | city | priority
const assignSortDir = ref('asc') // sens du tri (asc/desc) surtout pour la date
const ASSIGN_PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
function jobCity (j) {
if (j.municipalite) return j.municipalite // municipalité CANONIQUE résolue côté hub (regroupe Ste-Clotilde / ste-clotilde / en 1 groupe)
const a = String(j.location_label || j.service_location || '')
@ -2932,48 +2848,17 @@ function jobCity (j) {
const subj = String(j.subject || ''); if (subj.includes('|')) return subj.split('|')[0].trim() // sujets legacy « Ville | Nom »
return parts[0] || 'Sans ville'
}
// Chips-filtres par compétence/type (installation, réparation, tv) dans le panneau d'assignation.
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) })
// (Chips compétence + date du panneau fusionnés dans useJobPool : floatingPool.skillChips / .dateChips / .skillFilter / .dateFilter.)
// Chips « Trouver un créneau » : 4 compétences principales (les + fréquentes du pool, repli liste cœur) + durée PAR DÉFAUT par skill.
const SLOT_SKILL_DUR = { installation: 2, 'réparation': 1, reparation: 1, tv: 1, 'télévision': 1, television: 1, 'téléphonie': 0.5, telephone: 0.5, 'téléphone': 0.5, 'sans-fil': 1.5, fibre: 1.5, support: 0.5 }
const slotSkills = computed(() => {
const top = (assignTypes.value || []).map(t => t.k).filter(k => k && k.toLowerCase() !== 'autre').slice(0, 4)
const top = (floatingPool.skillChips.value || []).map(t => t.k).filter(k => k && k.toLowerCase() !== 'autre').slice(0, 4)
return (top.length ? top : ['installation', 'réparation', 'tv']).map(k => ({ skill: k, dur: SLOT_SKILL_DUR[k.toLowerCase()] || 1, color: getTagColor(k) }))
})
// Filtre par DATE DUE (chips) « n'afficher que les jobs d'une date précise »
const assignDateFilter = ref([]) // isos retenus (vide = toutes les dates)
const assignDates = computed(() => {
const t = todayISO(); const m = {}
for (const j of assignPanel.jobs) { const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'; m[d] = (m[d] || 0) + 1 }
// Toutes les dates PASSÉES UN SEUL chip « en retard » (déclutter). Les jours futurs + « Sans date » restent séparés.
const out = []; let overdueN = 0
for (const [iso, n] of Object.entries(m)) {
if (iso !== 'Sans date' && iso < t) { overdueN += n; continue }
out.push({ iso, n, label: iso === 'Sans date' ? 'Sans date' : fmtDueLabel(iso), overdue: false })
}
out.sort((a, b) => String(a.iso).localeCompare(String(b.iso)))
if (overdueN) out.unshift({ iso: '__overdue__', n: overdueN, label: '⏰ en retard', overdue: true })
return out
})
function toggleAssignDate (iso) { const s = new Set(assignDateFilter.value); s.has(iso) ? s.delete(iso) : s.add(iso); assignDateFilter.value = [...s] }
// Depuis l'aperçu « jobs sur la colonne du jour » ouvre le panneau « À assigner » filtré sur ce jour.
async function openDayInPanel (iso) { await openAssignPanel(); assignSort.value = 'date'; assignDateFilter.value = [iso] }
// Un job correspond-il aux CHIPS de date sélectionnées ? (vide = toutes). '__overdue__' = toutes dates passées.
// Partagé par le filtre d'affichage ET le dispatch auto (« Suggérer n'utilise que les jobs des jours cochés »).
function jobMatchesDateChips (j) {
const df = assignDateFilter.value; if (!df.length) return true
const ds = new Set(df); const t = todayISO()
const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'
return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t)
}
const assignJobsFiltered = computed(() => {
let arr = assignPanel.jobs
const f = assignTypeFilter.value; if (f.length) { const set = new Set(f); arr = arr.filter(j => set.has(j.required_skill || 'autre')) }
if (assignDateFilter.value.length) arr = arr.filter(jobMatchesDateChips)
return arr
})
function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) }
// Jour PRÉCIS sélectionné on borne au jour (chip date) ET on groupe par VILLE par défaut (la date étant déjà fixée, le secteur est l'axe utile pour dispatcher une tournée).
async function openDayInPanel (iso) { await openAssignPanel(); floatingPool.sort.value = 'city'; floatingPool.dateFilter.value = [iso] }
// (Filtre date+compétence-par-adresse, info d'adresse et badges fusionnés dans useJobPool : floatingPool.filteredJobs / .jobAddrBadge / .jobAssocOnly.)
// 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 + ' ⏰ en retard'; if (d === t) return "Aujourd'hui"; return d }
@ -2981,27 +2866,7 @@ function fmtDueLabel (d) { if (!d || d === 'Sans date') return 'Sans date due';
function capSegClass (s) { if (!s || s.cap <= 0) return 'cap-none'; if (s.free <= 0) return 'cap-full'; if (s.free < 1.5) return 'cap-low'; return 'cap-ok' }
const capFmt = (h) => (h === 0 ? '0' : (Number.isInteger(h) ? String(h) : h.toFixed(1)))
const fmtMin = (m) => { m = Math.round(Number(m) || 0); const h = Math.floor(m / 60), mm = m % 60; return h ? (h + 'h' + (mm ? String(mm).padStart(2, '0') : '')) : (mm + 'min') }
const assignGroups = computed(() => {
const jobs = assignJobsFiltered.value
if (assignSort.value === 'group') { // défaut : groupe parent-enfant (installation avant activation), ordonné par step_order
const g = {}; for (const j of jobs) { const k = j.parent_job || j.name; (g[k] = g[k] || []).push(j) }
return Object.keys(g).map(k => ({ key: k, label: null, jobs: g[k].slice().sort((a, b) => (a.step_order || 0) - (b.step_order || 0)) }))
}
const keyOf = j => assignSort.value === 'skill' ? (j.required_skill || 'Sans compétence')
: 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) : 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)
return keys.map(k => ({ key: k, label: labelOf(k), jobs: g[k] }))
})
// Repli des groupes « tout replier, n'afficher que certains jobs groupés par date »
const assignCollapsed = ref(new Set())
function toggleCollapse (key) { const s = new Set(assignCollapsed.value); s.has(key) ? s.delete(key) : s.add(key); assignCollapsed.value = s }
const allCollapsed = computed(() => { const gs = assignGroups.value; return gs.length > 0 && gs.every(g => assignCollapsed.value.has(g.key)) })
function toggleCollapseAll () { assignCollapsed.value = allCollapsed.value ? new Set() : new Set(assignGroups.value.map(g => g.key)) }
// (assignGroups / repli des groupes fusionnés dans useJobPool : floatingPool.groups / floatingPool.collapsed.)
function toggleGroupSelAll (grp) { const anyOff = grp.jobs.some(j => !selectedJobs[j.name]); grp.jobs.forEach(j => { if (anyOff) selectedJobs[j.name] = true; else delete selectedJobs[j.name] }); if (assignPanel.showMap) refreshAssignMap() }
// Terrain vs à distance : l'activation / config / netadmin ne va PAS à un tech sur site (heuristique skill + type/sujet).
function jobIsOnsite (j) {
@ -3092,7 +2957,7 @@ function _jobLocKey (j) { const a = _normAddr(j.address); if (a) return a; retur
// Localisations des jobs géolocalisés : 1 LETTRE (A, B, C) par ADRESSE = 1 pin (couleur = compétence). Lie la liste aux pins.
const assignLocations = computed(() => {
const by = {}; const order = []
for (const j of assignJobsFiltered.value) { // carte = MÊME filtre que la liste (date sélectionnée + compétence)
for (const j of floatingPool.filteredJobs.value) { // carte = MÊME filtre que la liste (date sélectionnée + compétence)
if (!_hasJobLL(j)) continue
const k = (Math.round(+j.latitude * 1e5) / 1e5) + ',' + (Math.round(+j.longitude * 1e5) / 1e5)
if (!by[k]) { by[k] = { lat: +j.latitude, lon: +j.longitude, jobs: [] }; order.push(k) }
@ -3376,12 +3241,12 @@ async function openLegacyBlock (b) {
// Synchroniser les techniciens extrait dans components/planif/TechSyncDialog.vue (état + fonctions déplacés ; @applied loadBase).
const showTechSync = ref(false)
const _hasJobLL = (j) => j && j.latitude != null && j.longitude != null && isFinite(+j.latitude) && isFinite(+j.longitude) && (Math.abs(+j.latitude) > 0.01 || Math.abs(+j.longitude) > 0.01)
const assignNoCoord = computed(() => assignJobsFiltered.value.filter(j => !_hasJobLL(j)).length) // « sans coords » = dans le filtre courant (cohérent avec la carte)
const assignNoCoord = computed(() => floatingPool.filteredJobs.value.filter(j => !_hasJobLL(j)).length) // « sans coords » = dans le filtre courant (cohérent avec la carte)
// LOT (pool « à assigner ») : faire correspondre l'adresse de chaque job sans coords à la base RQA GPS, écrit
// sur le Dispatch Job + en mémoire (j.latitude/longitude) les jobs apparaissent sur la carte du pool. Ambigus = à la main.
async function batchLocatePool () {
// MÊME périmètre que le badge assignNoCoord (jobs sans coords du filtre courant) plus d'incohérence badge/notification.
const coordless = assignJobsFiltered.value.filter(j => !_hasJobLL(j))
const coordless = floatingPool.filteredJobs.value.filter(j => !_hasJobLL(j))
if (!coordless.length) { $q.notify({ type: 'positive', message: 'Tous les jobs à assigner ont déjà des coordonnées 🎉', timeout: 2000 }); return }
const targets = coordless.filter(j => String(j.address || j.location_label || '').trim().length >= 3)
if (!targets.length) { // sans coords MAIS sans adresse exploitable pas d'auto-localisation possible : on ouvre le sélecteur manuel sur le 1er
@ -3566,7 +3431,7 @@ async function fetchTechRoute (home, stops) {
} catch (e) { return null }
}
watch(() => assignPanel.showMap, (on) => { if (on) { if (assignPanel.h < 600) assignPanel.h = 640; nextTick(initAssignMap) } else destroyAssignMap() })
watch(assignJobsFiltered, () => { if (assignPanel.showMap) nextTick(() => { _assignMap ? refreshAssignMap() : initAssignMap() }) }) // carte suit le filtre (date/compétence) + les jobs
watch(floatingPool.filteredJobs, () => { if (assignPanel.showMap) nextTick(() => { _assignMap ? refreshAssignMap() : initAssignMap() }) }) // carte suit le filtre (date/compétence) + les jobs
watch(selectedJobs, () => { if (assignPanel.showMap && _assignMap) refreshAssignMap() }) // surlignage doré des pins/amas suit la sélection
watch(() => assignPanel.open, (o) => { if (!o) destroyAssignMap() })
@ -4538,7 +4403,7 @@ async function quickAssignSelection (tech) {
}
// Sélectionne / désélectionne TOUS les jobs d'un secteur (municipalité canonique) depuis l'en-tête de secteur du pool.
function selectAllSector (city) {
const jobs = poolJobs.value.filter(j => (jobCity(j) || 'Sans secteur') === city && j.status !== 'On Hold')
const jobs = mobilePool.filteredJobs.value.filter(j => (jobCity(j) || 'Sans secteur') === city && j.status !== 'On Hold')
const allSel = jobs.length && jobs.every(j => selectedJobs[j.name])
jobs.forEach(j => { if (allSel) delete selectedJobs[j.name]; else selectedJobs[j.name] = true })
if (assignPanel.showMap) refreshAssignMap()
@ -4583,6 +4448,7 @@ function onRoutesStopClick (stop, rid) { // onglet TOURNÉES : l'arrêt porte le
const j = stop.job || {}
openJobDetail({
name: stop.name || j.name, subject: stop.subject || j.subject, customer: j.customer || j.customer_name || '',
customer_id: j.customer_id || '', // lien vers la fiche client depuis un job ouvert sur la carte (était perdu : reconstruit sans customer_id)
address: j.address || j.location_label || '', skill: j.skill || '', dur: j.dur,
detail: j.legacy_detail || '', legacy_id: j.legacy_id || j.legacy_ticket_id || null, dept: j.legacy_dept || j.dept || '',
lat: stop.lat, lon: stop.lon,
@ -4760,7 +4626,7 @@ function buildSuggestion () {
// En retard (date < fenêtre) ou sans date : NE PLUS balayer automatiquement sur le(s) jour(s) choisi(s).
// Seulement si EXPLICITEMENT demandé : sélection lasso, ou chip « en retard » / « Sans date » coché.
// sélectionner un seul jour ne dispatche QUE les jobs dus ce jour-là (retour terrain : les retards ne doivent pas s'inviter).
const wantBacklog = useSel || (assignDateFilter.value || []).includes('__overdue__') || (assignDateFilter.value || []).includes('Sans date')
const wantBacklog = useSel || (floatingPool.dateFilter.value || []).includes('__overdue__') || (floatingPool.dateFilter.value || []).includes('Sans date')
if (!wantBacklog) continue
cand = win
}
@ -4872,7 +4738,7 @@ const suggestWindow = computed(() => {
// Vue Tournées : la fenêtre = EXACTEMENT le jour sélectionné (autorité = la bande de dates), jamais les chips de date du pool.
// « Suggérer » ne balaie PAS les jobs en retard (date < jour choisi) sur ce jour ; seuls les jobs DUS ce jour-là sont répartis.
if (boardView.value === 'routes' && routesDay.value) return [routesDay.value]
const f = assignDateFilter.value || []
const f = floatingPool.dateFilter.value || []
if (!f.length) return [suggestDay.value || todayISO()] // aucun chip coché sélecteur de jour du dialogue
const days = new Set(f.filter(iso => iso !== '__overdue__' && iso !== 'Sans date')) // jours concrets cochés (auj./futur)
if (f.includes('__overdue__') || f.includes('Sans date') || !days.size) days.add(todayISO()) // en retard / sans date placés aujourd'hui
@ -4884,13 +4750,13 @@ const suggestJobs = () => {
if (selectedNames.value.length) return (assignPanel.jobs || []).filter(j => j.status !== 'On Hold' && selectedJobs[j.name]) // sélection lasso explicite
// Vue Tournées : périmètre = EXACTEMENT la bande filtrée (jour sélectionné + compétence + secteur + priorité) « ce que je filtre = ce que Suggérer répartit ».
if (boardView.value === 'routes') return (routeDayUnassignedView.value || []).filter(j => j.status !== 'On Hold')
let jobs = (assignJobsFiltered.value || []).filter(j => j.status !== 'On Hold') // pool tel qu'affiché (type + date)
let jobs = (floatingPool.filteredJobs.value || []).filter(j => j.status !== 'On Hold') // pool tel qu'affiché (type + date)
// Par DÉFAUT (aucun chip de date coché) : ne répartir QUE les jobs DATÉS sur le(s) jour(s) de la fenêtre jamais les
// jobs en retard/sans date/d'autres jours (souvent faits mais pas encore fermés) qui pollueraient la simulation.
// (Pour dispatcher les retards, cocher explicitement le chip « en retard » ou « Sans date ».)
// Filtre appliqué à la SOURCE (couvre aussi le solveur Optimiser qui prend suggestJobs()) : seuls les jobs
// datés DANS la fenêtre passent, SAUF si l'utilisateur demande explicitement les retards/sans-date.
const wantBacklog = (assignDateFilter.value || []).includes('__overdue__') || (assignDateFilter.value || []).includes('Sans date')
const wantBacklog = (floatingPool.dateFilter.value || []).includes('__overdue__') || (floatingPool.dateFilter.value || []).includes('Sans date')
if (!wantBacklog) {
const win = new Set(suggestWindow.value)
jobs = jobs.filter(j => j.scheduled_date && j.scheduled_date !== 'Sans date' && win.has(j.scheduled_date))
@ -4899,19 +4765,19 @@ const suggestJobs = () => {
}
const suggestJobCount = computed(() => suggestJobs().length)
// Y a-t-il un filtre restreignant le dispatch ? (lasso, chips de date ou de type)
const suggestFiltered = computed(() => !!(selectedNames.value.length || assignDateFilter.value.length || assignTypeFilter.value.length))
const suggestFiltered = computed(() => !!(selectedNames.value.length || floatingPool.dateFilter.value.length || floatingPool.skillFilter.value.length))
// Libellé du périmètre : « sélectionnés » (lasso) · « des jours/types cochés » (chips) décrit ce qui limite le jeu de jobs.
const suggestScope = computed(() => {
if (selectedNames.value.length) return 'sélectionnés'
const parts = []
if (assignDateFilter.value.length) parts.push('jours')
if (assignTypeFilter.value.length) parts.push('types')
if (floatingPool.dateFilter.value.length) parts.push('jours')
if (floatingPool.skillFilter.value.length) parts.push('types')
return parts.length ? ('des ' + parts.join(' + ') + ' cochés') : 'du pool'
})
const windowDayLabel = iso => iso === todayISO() ? "Aujourd'hui" : (iso === tomorrowISO() ? 'Demain' : (fmtDueLabel(iso) || iso))
const suggestWindowLabel = computed(() => {
const w = suggestWindow.value; if (!w.length) return '—'
if ((assignDateFilter.value || []).length) return w.map(windowDayLabel).join(', ')
if ((floatingPool.dateFilter.value || []).length) return w.map(windowDayLabel).join(', ')
return w.length === 1 ? windowDayLabel(w[0]) : (windowDayLabel(w[0]) + ' + ' + windowDayLabel(w[w.length - 1]))
})
function suggestSelectTechs (which) { // 'all' | 'shift' | 'none'
@ -5327,6 +5193,25 @@ const dayLivePositions = computed(() => {
})
watch(boardView, (v) => { if (v === 'routes') startLivePositions(); else stopLivePositions() }, { immediate: true })
watch(routesDay, () => { for (const k in dayRouteMetrics) delete dayRouteMetrics[k]; hiddenRouteTechs.value = new Set() })
// Tracé GPS RÉEL du jour : quand UNE seule tournée est affichée (une seule visible, ou isolée via les chips techs),
// on superpose le parcours GPS réel du tech (Traccar) en orange, au 1er plan pour comparer réel vs prévu.
const soloRouteTech = computed(() => dayRoutes.value.length === 1 ? dayRoutes.value[0] : null)
const showRouteTrack = ref(true)
const routeTrack = ref(null); const routeTrackMsg = ref('')
async function loadRouteTrack () {
const t = soloRouteTech.value; const iso = routesDay.value
if (boardView.value !== 'routes' || !t || !iso || !showRouteTrack.value) { routeTrack.value = null; routeTrackMsg.value = ''; return }
routeTrackMsg.value = '…'
const from = new Date(iso + 'T00:00:00').toISOString()
const to = (iso === nowET.value.iso) ? new Date().toISOString() : new Date(iso + 'T23:59:59').toISOString()
try {
const r = await roster.traccarTrack(t.id, from, to); const coords = (r && r.coords) || []
if (coords.length < 2) { routeTrack.value = null; routeTrackMsg.value = 'aucun tracé / appareil'; return }
routeTrack.value = { coords }
routeTrackMsg.value = (r.km != null ? 'réel ' + r.km + ' km' : coords.length + ' pts') + (r.moving_min != null ? ' · ' + fmtMin(r.moving_min) : '')
} catch (e) { routeTrack.value = null; routeTrackMsg.value = 'indispo' }
}
watch([soloRouteTech, routesDay, showRouteTrack, boardView], loadRouteTrack)
watch(boardView, (v) => { if (v === 'routes' && (!routesDay.value || !dayList.value.some(d => d.iso === routesDay.value))) { const t = todayISO(); routesDay.value = ((dayList.value || []).find(d => d.iso >= t) || (dayList.value || [])[0] || {}).iso || t } }, { immediate: true })
// Un tech « couvre » une file s'il possède TOUTES les compétences requises par ses jobs (compétences vides ignorées).
const covers = (t, skills) => (skills || []).filter(Boolean).every(s => (t.skills || []).includes(s))
@ -5396,32 +5281,7 @@ async function applySuggestion () {
}
// Mobile : assignation AU TOUCHER (le glisser HTML5 ne marche pas au doigt) + MULTI-sélection.
// Tap un (ou plusieurs) job(s) du pool tap un tech = assigne TOUTE la sélection. Réutilise selectedJobs/selectedNames.
// Chips de filtre du pool (par compétence) multi-sélection.
const poolSkillSel = ref(new Set())
const poolSort = ref('smart') // smart (défaut, jour sélectionné d'abord) | date | prio | dur
const poolSortOpts = [{ value: 'smart', label: 'Pertinence' }, { value: 'sector', label: 'Secteur' }, { value: 'date', label: 'Date prévue' }, { value: 'prio', label: 'Priorité' }, { value: 'dur', label: 'Durée' }]
const poolSkills = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) { const s = j.required_skill; if (s) m[s] = (m[s] || 0) + 1 } return Object.entries(m).map(([skill, n]) => ({ skill, n })).sort((a, b) => b.n - a.n) })
function togglePoolSkill (s) { const set = new Set(poolSkillSel.value); if (set.has(s)) set.delete(s); else set.add(s); poolSkillSel.value = set }
// Pool : filtré par chips + trié selon poolSort. Défaut « Pertinence » = dû le JOUR SÉLECTIONNÉ en retard aujourd'hui futur sans date ; puis priorité ; puis date.
const poolJobs = computed(() => {
const t = todayISO(); const sel = mobileSelDay.value; const fs = poolSkillSel.value
const pr = j => ({ urgent: 0, high: 1, élevée: 1, moyenne: 2, medium: 2, normal: 2, low: 3, basse: 3 }[String(j.priority || '').toLowerCase()] ?? 2)
const dueRank = j => { const d = j.scheduled_date; if (d && d === sel) return 0; if (!d || d === 'Sans date') return 4; if (d < t) return 1; if (d === t) return 2; return 3 }
const durMin = j => (+j.est_min || (+j.duration_h || 1) * 60)
const byDate = (a, b) => { const da = a.scheduled_date && a.scheduled_date !== 'Sans date' ? a.scheduled_date : '9999-99-99'; const db = b.scheduled_date && b.scheduled_date !== 'Sans date' ? b.scheduled_date : '9999-99-99'; return String(da).localeCompare(String(db)) }
let arr = assignPanel.jobs || []
if (fs.size) arr = arr.filter(j => fs.has(j.required_skill))
const secOf = j => jobCity(j) || 'zz Sans secteur'; const mrcOf = j => String(j.mrc || 'zz') // secteur=municipalité canonique ; ordonné par MRC pour regrouper les voisines
const mode = poolSort.value
const cmp = mode === 'date' ? (a, b) => byDate(a, b) || pr(a) - pr(b)
: mode === 'prio' ? (a, b) => pr(a) - pr(b) || byDate(a, b)
: mode === 'dur' ? (a, b) => durMin(b) - durMin(a) || dueRank(a) - dueRank(b)
: mode === 'sector' ? (a, b) => mrcOf(a).localeCompare(mrcOf(b)) || secOf(a).localeCompare(secOf(b)) || dueRank(a) - dueRank(b) || pr(a) - pr(b)
: (a, b) => dueRank(a) - dueRank(b) || pr(a) - pr(b) || byDate(a, b)
return [...arr].sort(cmp)
})
// Comptes par secteur (municipalité) pour les en-têtes du regroupement « Secteur ».
const poolSectorCounts = computed(() => { const m = {}; for (const j of poolJobs.value) { const k = jobCity(j) || 'Sans secteur'; const e = m[k] || (m[k] = { n: 0, mins: 0 }); e.n++; e.mins += (+j.est_min || (+j.duration_h || 1) * 60) } return m })
// Filtre/tri/groupe du pool mobile instance useJobPool `mobilePool` (créée avec les autres surfaces).
// initials composables/useFormatters (source unique ; 2 premiers mots · '?')
function toggleSel (j) {
if (j.status === 'On Hold') { $q.notify({ type: 'warning', message: 'En attente d\'une tâche précédente — non assignable', timeout: 2500 }); return }
@ -5564,30 +5424,7 @@ function openJobSheet (j, opts) { jobSheet.job = j; snoozeDate.value = addDaysIS
function onJobSheetHide () { const j = jobSheet.job; if (j && (sheetNoteText.value || '') !== (j.notes || '')) patchJob(j, { notes: sheetNoteText.value || '' }, 'Note enregistrée') }
function openNote (j) { noteDialog.job = j; noteDialog.text = j.notes || ''; noteDialog.open = true }
function saveNote () { const j = noteDialog.job; if (j) patchJob(j, { notes: noteDialog.text || '' }, 'Note enregistrée'); noteDialog.open = false }
// Swipe MAISON, sûr pour le tap : on ne « glisse » qu'au drag horizontal franc (|dx|>10 et dominant) ; sinon le clic natif du bouton (toggleSel) reste intact.
const swipe = reactive({ name: null, x0: 0, y0: 0, dx: 0, active: false })
let swipeGuard = false
function swipeStart (e, j) { if (e.pointerType === 'mouse' && e.button !== 0) return; swipe.name = j.name; swipe.x0 = e.clientX; swipe.y0 = e.clientY; swipe.dx = 0; swipe.active = false }
function swipeMove (e) {
if (swipe.name == null) return
const dx = e.clientX - swipe.x0; const dy = e.clientY - swipe.y0
if (!swipe.active) {
if (Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy) * 1.4) { swipe.active = true; try { e.currentTarget.setPointerCapture(e.pointerId) } catch (_) {} }
else if (Math.abs(dy) > 10) { swipe.name = null; return } // défilement vertical on lâche
}
if (swipe.active) { e.preventDefault(); swipe.dx = Math.max(-120, Math.min(120, dx)) }
}
function swipeEnd (e, j) {
if (swipe.name == null) return
const active = swipe.active; const dx = swipe.dx
swipe.name = null; swipe.active = false; swipe.dx = 0
if (!active) return // c'était un tap onJobClick ouvre les DÉTAILS
swipeGuard = true; setTimeout(() => { swipeGuard = false }, 380) // empêcher le clic « fantôme » post-glissement
if (Math.abs(dx) > 55) toggleSel(j) // glissé G/D SÉLECTIONNE pour assignation (la feuille de techs s'ouvre)
}
function swipeReset () { swipe.name = null; swipe.active = false; swipe.dx = 0 }
function onJobClick (j) { if (swipeGuard) { swipeGuard = false; return } openJobSheet(j) } // TAP = détails (thread + options) ; SWIPE = sélectionner pour assignation
function swipeStyle (j) { return (swipe.active && swipe.name === j.name) ? { transform: 'translateX(' + swipe.dx + 'px)' } : {} }
// (Le swipe mobile tap = feuille / glissé = sélectionner vit désormais DANS JobPool.vue, variant mobile.)
function clearSel () { for (const k in selectedJobs) delete selectedJobs[k] }
// Tâches « sœurs » : même adresse + même client + même jour qu'une tâche sélectionnée (clé exigeant les 3 non vides), non sélectionnées.
function jobBatchKey (j) {
@ -5662,6 +5499,18 @@ function rawCellJobs (techId, iso) { const o = occByTechDay.value[techId + '|' +
function offShiftJobs (techId, iso) { return (hasReg(techId, iso) || onGarde(techId, iso)) ? [] : rawCellJobs(techId, iso).filter(j => !j.cancelled) } // jobs RÉELS (annulés exclus) assignés un jour sans quart publié
const offShiftWeekCount = computed(() => { let n = 0; for (const t of visibleTechs.value) for (const d of dayList.value) n += offShiftJobs(t.id, d.iso).length; return n }) // total jobs hors quart sur la période visible
function prioColor (p) { return p === 'high' ? '#ef4444' : p === 'medium' ? '#f59e0b' : '#9e9e9e' }
// Contexte partagé fourni UNE fois JobPool.vue l'injecte (selectedJobs/draggingSet gardent la même identité).
provide('jobPoolCtx', {
selectedJobs, draggingSet, selectedNames, flashJob, showMap: jpShowMap,
onJobDragStart, onJobDragEnd,
techsForJob, quickAssign, initials, jobTargetDay,
setJobDate, setJobReqLevel, toggleUrgent, toggleAssignThread, openJobSheet, toggleSel, closeLegacyTicket, clearSel,
toggleGroupSel, toggleGroupSelAll, groupSelected, groupLabel,
selectAllSector, focusAssignJob,
getTagColor, prioColor, fmtMin, jobCity, fmtDueLabel, isOverdue, jobIsOnsite, jobSlaBadge, legacyDeptColor, fmtDT, panelJobColor,
todayISO, tomorrowISO,
})
// Aperçu en survol de drop : occupation projetée si on dépose la sélection ici.
function isDropTarget (techId, iso) { return dropPreview.key === techId + '|' + iso }
function projPct (techId, iso) { const o = cellOcc(techId, iso); if (!o || !o.bookableH) return null; return Math.round((o.usedH + dropPreview.addH) / o.bookableH * 100) }
@ -6256,6 +6105,10 @@ onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 — flush le bro
.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.assoc { background: #fbfbfd; border-style: dashed; } /* affiché par co-localisation (compétence hors filtre) */
.assign-linked { display: inline-flex; align-items: center; gap: 1px; color: #6d4c9f; background: #ede7f6; border-radius: 6px; padding: 0 4px; font-size: 9px; font-weight: 700; margin-right: 3px; }
.assign-addr { display: inline-flex; align-items: center; gap: 1px; color: #37474f; background: #eceff1; border-radius: 6px; padding: 0 4px; font-size: 9px; font-weight: 700; margin-right: 3px; }
.assign-addr.multi { color: #fff; background: #7e57c2; } /* ≥2 compétences distinctes à l'adresse → tech polyvalent requis */
.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; }

View File

@ -1658,6 +1658,36 @@ async function handle (req, res, method, path, url) {
return json(res, 500, { ok: false, error: (r && r.error) || 'création échouée' })
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
}
// Job « bouche-trou » (filler) : job GÉNÉRIQUE de priorité STANDARD assigné à un tech pour occuper sa journée
// (formation, réunion, admin, entretien véhicule…) → il compte dans l'occupation ⇒ RETIRÉ du dispatch régulier
// (contrairement à /reserve = « soft », les urgences le traversent). Optionnel : génère aussi un TICKET (Issue) lié.
if (path === '/roster/filler-job' && method === 'POST') {
const b = await parseBody(req)
if (!b.tech || !b.date) return json(res, 400, { ok: false, error: 'tech + date requis' })
try {
const subject = String(b.subject || 'Tâche interne — indisponible au dispatch').slice(0, 140)
// Ticket (Issue) best-effort : un échec de billet ne doit PAS empêcher la création du job (le job = le cœur).
let issueName = ''
if (b.create_ticket) {
try {
const prio = { low: 'Low', medium: 'Medium', high: 'High' }[String(b.priority || 'medium')] || 'Medium'
const iss = await retryWrite(() => erp.create('Issue', { subject, status: 'Open', priority: prio }))
issueName = (iss && (iss.name || (iss.data && iss.data.name))) || ''
} catch (e) { log('filler: ticket create failed — ' + e.message) }
}
const ref = await require('./dispatch').nextJobRef()
const payload = {
ticket_id: ref, subject,
job_type: b.job_type || 'Interne', priority: b.priority || 'medium', status: 'assigned',
assigned_tech: b.tech, scheduled_date: b.date,
start_time: b.start_time || '08:00', duration_h: Number(b.duration_h) || 8,
source_issue: issueName, notes: b.notes || '',
}
const r = await retryWrite(() => erp.create('Dispatch Job', payload))
if (r && r.ok !== false) { invalidatePool(); return json(res, 200, { ok: true, name: r.name || (r.data && r.data.name), ref, issue: issueName }) }
return json(res, 500, { ok: false, error: (r && r.error) || 'création échouée' })
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
}
if (path === '/roster/job-comment' && method === 'POST') {
const b = await parseBody(req)
const text = String(b.text || '').trim(); if (!text) return json(res, 400, { ok: false, error: 'texte requis' })