gigafibre-fsm/apps/ops/src/pages/PlanificationPage.vue
louispaulb f6e5128859 feat(dispatch): skill-driven auto-dispatch — route grouping, placeholder queues, shift check
Overhaul of the Planification « Suggérer » (auto-dispatch) flow:

- fix: read latitude/longitude (hub pool field), not lat/lon — the
  distance scoring was inert (NaN) so jobs scattered; they now cluster
  by real route (haversine) with a same-city fallback
- skill = HARD filter: incapable techs are no longer candidates
  (Josée-Anne no longer gets réparation/installation). Assign/move menus
  list capable techs first and block the rest, unless no capable
  alternative exists (avoids trapping an unassignable queue)
- placeholder queues for any no-shift day (not just weekend), grouped by
  skill then geography into ~8h tournées; the owner is swappable to a
  real tech, and two queues can be merged
- per-tech occupation bar in the review (existing load + suggested /
  capacity, red on overload); "Créer quart 8-16" button when the
  assigned tech has no shift that day (local, saved on Publier)
- fix: "N jobs sans coordonnées" badge vs locate mismatch — honest
  message + open the manual map picker for address-less jobs; also sync
  pool jobs (latitude/longitude) on manual save so the badge refreshes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:37:40 -04:00

5318 lines
503 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<q-page padding>
<!-- Barre d'actions MOBILE (téléphone, < md) : ÉPURÉE — l'essentiel = Publier ; tout le secondaire dans le menu .
La bande de jours + la liste « À assigner » sont la vraie navigation tactile (cf. .plan-mobile). -->
<div class="row items-center q-mb-sm lt-md pm-toolbar">
<q-chip v-if="dirty" dense size="sm" color="orange" text-color="white" icon="circle">{{ dirtyCount }} non publié(s)</q-chip>
<q-chip v-if="offShiftWeekCount" dense size="sm" color="warning" text-color="white" icon="warning">{{ offShiftWeekCount }} hors quart</q-chip>
<span v-if="!dirty && !offShiftWeekCount" class="text-caption text-grey-6">{{ selDayLabel }}</span>
<q-space />
<q-btn dense flat round icon="more_vert" color="grey-8" aria-label="Plus d'actions">
<q-menu>
<q-list dense style="min-width:214px">
<q-item clickable v-close-popup @click="navToday"><q-item-section avatar><q-icon name="today" /></q-item-section><q-item-section>Aujourd'hui</q-item-section></q-item>
<q-item clickable v-close-popup @click="navWeek(-1)"><q-item-section avatar><q-icon name="chevron_left" /></q-item-section><q-item-section>Semaine précédente</q-item-section></q-item>
<q-item clickable v-close-popup @click="navWeek(1)"><q-item-section avatar><q-icon name="chevron_right" /></q-item-section><q-item-section>Semaine suivante</q-item-section></q-item>
<q-separator />
<q-item clickable v-close-popup @click="doGenerate"><q-item-section avatar><q-icon name="auto_awesome" color="primary" /></q-item-section><q-item-section>Générer (auto)</q-item-section></q-item>
<q-item clickable v-close-popup @click="openLeave"><q-item-section avatar><q-icon name="beach_access" color="teal" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
<q-item clickable v-close-popup @click="$router.push('/dispatch')"><q-item-section avatar><q-icon name="dashboard" color="indigo" /></q-item-section><q-item-section>Tableau Dispatch</q-item-section></q-item>
<q-separator />
<q-item tag="label" clickable><q-item-section avatar><q-icon name="sms" /></q-item-section><q-item-section>Notifier par SMS</q-item-section><q-item-section side><q-toggle v-model="notifySms" dense /></q-item-section></q-item>
<q-item clickable v-close-popup @click="openLegacyPush"><q-item-section avatar><q-icon name="sync_alt" color="deep-orange" /></q-item-section><q-item-section>Publier au legacy</q-item-section></q-item>
<q-item clickable v-close-popup @click="() => guard(loadWeek)"><q-item-section avatar><q-icon name="refresh" /></q-item-section><q-item-section>Rafraîchir</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
<q-btn unelevated no-caps color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" @click="doPublish" />
</div>
<!-- Rangée 1 : actions principales (nav · Outils · Générer · Publier) — DESKTOP/tablette uniquement (gt-sm) -->
<div class="row items-center q-mb-sm q-gutter-xs gt-sm">
<div class="text-h6 text-weight-bold">Planification</div>
<q-chip v-if="dirty" dense size="sm" color="orange" text-color="white" icon="circle">{{ dirtyCount }} non publié(s)</q-chip>
<q-chip v-if="offShiftWeekCount" dense size="sm" color="warning" text-color="white" icon="warning">{{ offShiftWeekCount }} hors quart<q-tooltip class="bg-grey-9">{{ offShiftWeekCount }} job(s) assigné(s) cette période un jour où la ressource n'a AUCUN quart publié. Repère le ⚠ dans la grille → publier un quart ou réassigner.</q-tooltip></q-chip>
<q-space />
<q-btn-group flat>
<q-btn dense flat icon="keyboard_double_arrow_left" @click="navWeek(-1)"><q-tooltip>Semaine précédente</q-tooltip></q-btn>
<q-btn dense flat icon="chevron_left" @click="navDay(-1)"><q-tooltip>Jour précédent</q-tooltip></q-btn>
<q-btn dense flat label="Auj." @click="navToday" />
<q-btn dense flat icon="chevron_right" @click="navDay(1)"><q-tooltip>Jour suivant</q-tooltip></q-btn>
<q-btn dense flat icon="keyboard_double_arrow_right" @click="navWeek(1)"><q-tooltip>Semaine suivante</q-tooltip></q-btn>
</q-btn-group>
<q-input dense outlined type="date" v-model="boardDate" style="width:150px"><q-tooltip>{{ boardView === 'kanban' ? 'Jour affiché (mode Jour)' : 'Début de la fenêtre (mode Semaine)' }}</q-tooltip></q-input>
<q-select v-show="boardView === 'grid'" dense outlined v-model="days" :options="[{ label: 'Semaine', value: 7 }, { label: '2 sem.', value: 14 }]" style="width:108px" emit-value map-options @update:model-value="onDaysChange"><q-tooltip>Étendue de la grille</q-tooltip></q-select>
<span class="text-caption text-grey-7" style="margin-right:-2px">Horaire :</span>
<q-btn-toggle v-model="boardView" dense unelevated no-caps :options="[{ value: 'grid', icon: 'calendar_view_week', label: 'Semaine' }, { value: 'kanban', icon: 'today', label: 'Jour' }]" toggle-color="primary" color="grey-3" text-color="grey-8"><q-tooltip>Semaine = grille (quarts + occupation, vue d'ensemble) · Jour = disposition d'une journée (glisser les jobs entre techs)</q-tooltip></q-btn-toggle>
<q-btn dense flat round icon="undo" :disable="!history.length" @click="undo"><q-tooltip>Annuler (Ctrl+Z)</q-tooltip></q-btn>
<q-btn dense flat round icon="redo" :disable="!future.length" @click="redo"><q-tooltip>Rétablir (Ctrl+Shift+Z)</q-tooltip></q-btn>
<q-separator vertical class="q-mx-xs" />
<!-- Accès RAPIDE au panneau « Jobs à assigner » (liste · carte · lasso · suggestion auto) — badge = nombre à répartir -->
<q-btn dense :outline="!assignPanel.open" :unelevated="assignPanel.open" no-caps color="deep-purple" icon="assignment_ind" label="À assigner" @click="assignPanel.open ? (assignPanel.open = false) : openAssignPanel()">
<q-badge v-if="assignPanel.jobs.length" color="red" floating>{{ assignPanel.jobs.length }}</q-badge>
<q-tooltip>Afficher / masquer les jobs à répartir (liste · carte · lasso · suggestion auto)</q-tooltip>
</q-btn>
<!-- Menu OUTILS : regroupe la config secondaire pour désencombrer -->
<q-btn-dropdown dense outline color="grey-8" icon="build" label="Outils" no-caps>
<q-list dense style="min-width:230px">
<q-item clickable v-close-popup @click="showDemand = !showDemand">
<q-item-section avatar><q-icon name="tune" :color="showDemand ? 'indigo' : 'grey-7'" /></q-item-section>
<q-item-section>Demande de personnel</q-item-section>
<q-item-section side><q-icon v-if="showDemand" name="check" color="indigo" /></q-item-section>
</q-item>
<q-item clickable v-close-popup @click="openDepotPicker">
<q-item-section avatar><q-icon name="warehouse" color="blue-grey-7" /></q-item-section>
<q-item-section>Point de départ (dépôt)<q-item-label caption class="ellipsis" style="max-width:210px">{{ depot && depot.address ? depot.address : 'non défini — cliquer pour situer' }}</q-item-label></q-item-section>
</q-item>
<q-item clickable v-close-popup @click="showDayJobs = !showDayJobs">
<q-item-section avatar><q-icon name="event_note" :color="showDayJobs ? 'indigo' : 'grey-7'" /></q-item-section>
<q-item-section>Jobs prévus en en-tête<q-item-label caption>liste par jour — glisser sur un tech</q-item-label></q-item-section>
<q-item-section side><q-icon v-if="showDayJobs" name="check" color="indigo" /></q-item-section>
</q-item>
<q-item clickable v-close-popup @click="showLegacyLoad = !showLegacyLoad">
<q-item-section avatar><q-icon name="assignment_ind" :color="showLegacyLoad ? 'brown' : 'grey-7'" /></q-item-section>
<q-item-section>Jobs Legacy sur les timelines<q-item-label caption>durées estimées des jobs osTicket datés (fenêtre affichée)</q-item-label></q-item-section>
<q-item-section side><q-icon v-if="showLegacyLoad" name="check" color="brown" /></q-item-section>
</q-item>
<q-item clickable>
<q-item-section avatar><q-icon name="bookmark" color="brown" /></q-item-section>
<q-item-section>Modèles de semaine</q-item-section>
<q-item-section side><q-icon name="chevron_right" color="grey-6" /></q-item-section>
<q-menu anchor="top end" self="top start">
<q-list dense style="min-width:250px">
<q-item clickable v-close-popup @click="saveTemplate"><q-item-section avatar><q-icon name="save" /></q-item-section><q-item-section>Enregistrer la semaine…</q-item-section></q-item>
<q-separator v-if="weekTemplates.length" />
<q-item-label v-if="weekTemplates.length" header>Appliquer</q-item-label>
<q-item v-for="(tm, i) in weekTemplates" :key="i" clickable @click="applyTemplate(tm)">
<q-item-section avatar><q-btn flat dense round size="sm" :icon="tm.default ? 'star' : 'star_border'" :color="tm.default ? 'amber-8' : 'grey-5'" @click.stop="setDefaultTemplate(i)"><q-tooltip>Modèle par défaut (★)</q-tooltip></q-btn></q-item-section>
<q-item-section>{{ tm.name }}</q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="delete" color="grey-6" @click.stop="deleteTemplate(i)" /></q-item-section>
</q-item>
</q-list>
</q-menu>
</q-item>
<q-item clickable v-close-popup @click="openGarde"><q-item-section avatar><q-icon name="shield" color="brown" /></q-item-section><q-item-section>Rotation de garde</q-item-section></q-item>
<q-separator />
<q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="speed" /></q-item-section><q-item-section>Cadence équipe</q-item-section></q-item>
<q-item clickable v-close-popup @click="showTagManager = true"><q-item-section avatar><q-icon name="sell" color="teal" /></q-item-section><q-item-section>Gérer les compétences (tags)</q-item-section></q-item>
<q-item clickable v-close-popup @click="openTechSync"><q-item-section avatar><q-icon name="group_add" color="green-7" /></q-item-section><q-item-section>Synchroniser les techniciens</q-item-section></q-item>
<q-item clickable v-close-popup @click="openJobChar"><q-item-section avatar><q-icon name="timer" color="warning" /></q-item-section><q-item-section>Durées par caractéristique</q-item-section></q-item>
<q-item clickable v-close-popup @click="openAssignPanel"><q-item-section avatar><q-icon name="drag_indicator" color="deep-purple" /></q-item-section><q-item-section>Jobs à assigner (glisser-déposer)</q-item-section></q-item>
<q-item clickable v-close-popup @click="$router.push('/dispatch')"><q-item-section avatar><q-icon name="open_in_new" color="indigo" /></q-item-section><q-item-section>Tableau Dispatch (détails & priorités)</q-item-section></q-item>
<q-item clickable v-close-popup @click="openLeave"><q-item-section avatar><q-icon name="beach_access" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
</q-list>
</q-btn-dropdown>
<q-btn v-if="defaultTemplate" dense flat color="warning" icon="star" :label="defaultTemplate.name" @click="applyDefault"><q-tooltip>Appliquer le modèle par défaut (consciente des absences)</q-tooltip></q-btn>
<q-separator vertical class="q-mx-xs" />
<q-btn unelevated color="primary" icon="auto_awesome" label="Générer" :loading="generating" @click="doGenerate" />
<q-checkbox v-model="notifySms" label="SMS" dense size="sm"><q-tooltip>Notifier les techs par SMS à la publication</q-tooltip></q-checkbox>
<q-btn :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" @click="doPublish" />
<q-btn outline color="deep-orange" icon="sync_alt" label="Publier au legacy" @click="openLegacyPush"><q-tooltip class="bg-grey-9">Réassigne les tickets aux techniciens DANS le système legacy (osTicket) selon l'assignation Ops — aperçu avant d'écrire.</q-tooltip></q-btn>
<q-btn flat dense round icon="refresh" :loading="loading" @click="() => guard(loadWeek)" />
</div>
<!-- Rangée 2 : filtres + légende en popover — DESKTOP/tablette uniquement (gt-sm) -->
<div class="row items-center q-gutter-sm q-mb-sm gt-sm">
<q-input dense outlined clearable v-model="search" placeholder="Rechercher un technicien…" style="width:230px"><template #prepend><q-icon name="search" /></template></q-input>
<q-select dense outlined clearable v-model="groupFilter" :options="groupOptions" emit-value map-options label="Équipe" style="width:180px" />
<q-input dense outlined type="number" v-model.number="maxHours" label="Max h/sem" style="width:110px" />
<span class="text-caption text-grey-6">{{ visibleTechs.length }} / {{ techs.length }} techs</span>
<q-chip v-if="cellClipboard.length" dense size="sm" color="indigo" text-color="white" icon="content_paste">{{ cellClipboard.length }} copié(s)</q-chip>
<q-space />
<q-btn dense flat round icon="help_outline" color="grey-7"><q-tooltip>Légende & raccourcis</q-tooltip>
<q-menu>
<div class="q-pa-md" style="max-width:420px">
<div class="text-subtitle2 q-mb-sm">Légende</div>
<div class="row items-center q-gutter-xs q-mb-xs"><span class="tod-leg"></span><span class="text-grey-8">dispo (matin → soir)</span></div>
<div class="row items-center q-gutter-xs q-mb-xs"><span class="occ-leg"></span><span class="text-grey-8">occupation (vert → rouge)</span></div>
<div class="row items-center q-gutter-xs q-mb-xs"><span class="leg-absent"></span><span class="text-grey-8">absent / congé</span></div>
<div class="row items-center q-gutter-xs q-mb-xs"><span class="leg-garde"></span><span class="text-grey-8">garde (hors bureau)</span></div>
<div class="row items-center q-gutter-xs q-mb-sm"><span class="free q-mr-xs">·</span><span class="text-grey-8">libre</span><span class="cell-dirty-demo q-ml-md q-mr-xs">J</span><span class="text-grey-8">modifié (non publié)</span></div>
<div class="text-subtitle2 q-mb-xs">Raccourcis</div>
<div class="text-caption text-grey-8"><b>glisser</b> = sélection · <b>shift+clic</b> = bloc · clic en-tête = colonne · clic nom = rangée · <b>ctrl+clic</b> = +1 · <b>ctrl+C/V</b> = copier/coller · <b>Suppr/⌫</b> = vider · <b>A</b> = absent · <b>G</b> = garde</div>
</div>
</q-menu>
</q-btn>
</div>
<!-- Filtre par compétence (chip/tag) → n'affiche que les techs capables, triés par priorité — DESKTOP/tablette (gt-sm) ; sur mobile le pool « À assigner » a ses propres chips -->
<div v-if="allSkills.length" class="row items-center q-gutter-xs q-mb-sm gt-sm">
<q-icon name="sell" size="16px" color="teal" /><span class="text-caption text-grey-7">Compétences :</span>
<q-chip v-for="sk in allSkills" :key="sk" clickable dense size="sm" :color="skillFilter.includes(sk) ? 'teal' : 'grey-3'" :text-color="skillFilter.includes(sk) ? 'white' : 'grey-8'" @click="toggleSkill(sk)">{{ sk }}</q-chip>
<template v-if="skillFilter.length">
<q-btn flat dense size="sm" color="grey-7" icon="close" label="Effacer" @click="skillFilter = []" />
<span class="text-caption text-teal text-weight-medium">▸ {{ skillFilter.length > 1 ? 'toutes requises (ET)' : 'capables' }} · {{ visibleTechs.length }} tech(s), triés par priorité</span>
</template>
</div>
<!-- Demande -->
<q-card v-if="showDemand" flat bordered class="q-mb-md">
<q-card-section class="q-pb-none">
<div class="row items-center">
<div class="text-subtitle2 text-weight-bold">Demande — effectif requis par créneau</div><q-space />
<q-btn dense flat icon="schedule" label="Types de shift" @click="openShiftEditor" />
<q-btn dense flat icon="add" label="Ajouter" @click="addDemand" />
<q-btn dense unelevated color="indigo" icon="playlist_add_check" label="Appliquer à la semaine" :loading="applying" class="q-ml-sm" @click="applyDemand" />
</div>
<div class="text-caption text-grey-7 q-mt-xs">Coche les jours <b>fériés</b> (F) dans l'en-tête · fin de semaine = sam/dim (auto). Si <b>Durée/job</b> &gt; 0, les nombres = <b>nb de jobs</b> → effectif = ⌈jobs × durée ÷ heures du shift⌉ (compétences requises = colonne Compétences).</div>
</q-card-section>
<q-card-section>
<table class="demand-tbl">
<thead><tr><th>Modèle</th><th>Zone</th><th>Compétences</th><th>Durée/job (h)</th><th>Semaine</th><th>Fin de sem.</th><th>Férié</th><th></th></tr></thead>
<tbody>
<tr v-for="(d, i) in demand" :key="i">
<td><q-select dense options-dense outlined v-model="d.shift" :options="tplOptions" emit-value map-options style="min-width:150px" @update:model-value="saveDemand" /></td>
<td><q-input dense outlined v-model="d.zone" style="width:120px" @update:model-value="saveDemand" /></td>
<td><SkillSelect v-model="d.skills" style="min-width:150px" @update:model-value="saveDemand" /></td>
<td><q-input dense outlined type="number" step="0.5" v-model.number="d.job_h" placeholder="0" style="width:80px" @update:model-value="saveDemand" /></td>
<td><q-input dense outlined type="number" v-model.number="d.weekday" style="width:70px" @update:model-value="saveDemand" /></td>
<td><q-input dense outlined type="number" v-model.number="d.weekend" style="width:70px" @update:model-value="saveDemand" /></td>
<td><q-input dense outlined type="number" v-model.number="d.holiday" style="width:70px" @update:model-value="saveDemand" /></td>
<td><q-btn flat dense round size="sm" icon="delete" color="grey-7" @click="removeDemand(i)" /></td>
</tr>
<tr v-if="!demand.length"><td colspan="8" class="text-grey-6 q-pa-sm">Aucune ligne — clique « Ajouter ».</td></tr>
</tbody>
</table>
</q-card-section>
</q-card>
<q-banner v-if="solverStats" dense rounded class="q-mb-md" :class="solverStats.shortfall ? 'bg-orange-1 text-warning' : 'bg-green-1 text-green-9'">
<q-icon :name="solverStats.shortfall ? 'warning' : 'check_circle'" class="q-mr-xs" />
{{ solverStats.assignments }} assignations · {{ solverStats.shortfall ? (solverStats.shortfall + ' poste(s) non couvert(s)') : 'couverture complète' }} · équité {{ solverStats.spread }} h · {{ solverStats.ms }} ms
</q-banner>
<!-- Barre d'actions FLOTTANTE (position: fixed) → n'altère JAMAIS la hauteur de la grille : pas de décalage
du tableau quand la sélection démarre pendant un glisser (le curseur reste sur la rangée visée). -->
<div v-if="selection.length" class="sel-actions" @mousedown.stop>
<span class="text-weight-medium q-mr-xs">{{ selection.length }} cellule(s) :</span>
<q-btn dense unelevated size="sm" color="primary" label="Jour" @click="bulkWindow(8, 17)" />
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir" @click="bulkWindow(16, 20)" />
<q-btn dense unelevated size="sm" color="brown" icon="shield" label="Garde" @click="bulkGarde" />
<q-btn dense unelevated size="sm" color="negative" icon="event_busy" label="Absent" @click="bulkAbsent" />
<q-input dense outlined v-model="quickEntry" placeholder="8-17" style="width:84px" @keyup.enter="bulkQuick" @mousedown.stop><q-tooltip>Saisie rapide : 8-17 · 830-16 · 85</q-tooltip></q-input>
<q-separator vertical class="q-mx-xs" />
<q-btn dense flat size="sm" icon="content_copy" label="Copier" @click="copyCell" />
<q-btn dense flat size="sm" icon="content_paste" :label="cellClipboard.length ? ('Coller (' + cellClipboard.length + ')') : 'Coller'" :disable="!cellClipboard.length" @click="pasteCells" />
<q-btn dense flat size="sm" icon="layers_clear" label="Libérer" @click="clearBulk" />
<q-btn dense flat size="sm" icon="close" label="Annuler" @click="selection = []" />
</div>
<!-- VUE MOBILE (téléphone, < md) en mode Semaine : bande de jours + barres de charge par tech (« heat level »
d'un coup d'œil). Remplace la grille large (cachée en gt-sm). Inspiré des apps de suivi quotidien. -->
<div v-if="boardView === 'grid'" class="plan-mobile lt-md">
<div v-if="nextHolidayNotify" class="pm-hol-banner">
<q-icon name="event_busy" color="warning" size="16px" />
<span class="ellipsis">Férié à venir : <b>{{ nextHolidayNotify.name }}</b> ({{ nextHolidayNotify.date }}) — {{ nextHolidayNotify.to_notify }} tech(s) à prévenir</span>
<q-space />
<q-btn dense unelevated no-caps size="sm" color="warning" icon="send" label="Préavis" @click="sendHolidayPreavis(nextHolidayNotify)" />
</div>
<div class="pm-strip">
<button v-for="d in mobileDayLoads" :key="d.iso" type="button" class="pm-day" :class="{ active: d.iso === mobileSelDay, today: d.iso === todayIso, weekend: d.weekend, holiday: isHoliday(d.iso) }" :title="(isHoliday(d.iso) ? '★ ' + holidayLabel(d.iso) + ' (férié) — ' : '') + Math.round(d.h) + 'h à faire ce jour' + (d.pending ? ' (dont ' + d.pending + 'h à répartir)' : '') + ' / ' + d.cap + 'h de capacité' + (d.noShift ? ' — ⚠ ' + d.noShift + ' tech(s) sans quart (8h estimés)' : '')" @click="mobileSelIso = d.iso">
<span v-if="isHoliday(d.iso)" class="pm-day-hol">★<q-tooltip>{{ holidayLabel(d.iso) }} (férié)</q-tooltip></span>
<span v-if="d.noShift" class="pm-day-warn" :title="d.noShift + ' tech(s) avec du travail mais aucun quart créé ce jour — disponibilité estimée à 8 h. Touchez le jour puis créez les quarts.'">▲</span>
<span class="pm-dow">{{ d.dow }}</span>
<span class="pm-num">{{ (d.dnum || '').split('/')[0] }}</span>
<span class="pm-bar"><span class="pm-fill" :style="{ height: (d.h > 0 ? Math.max(12, Math.min(100, Math.round(d.ratio * 100))) : 0) + '%', background: heatColor(d.ratio) }"></span></span>
<span class="pm-h">{{ d.cap > 0 ? Math.round(d.h) + '/' + Math.round(d.cap) : (d.h + 'h') }}<span v-if="d.over" class="pm-over">⚠</span></span>
</button>
</div>
<div class="pm-techs">
<div class="pm-techs-hd"><q-icon name="groups" size="16px" class="q-mr-xs" />Charge — <b class="q-ml-xs">{{ selDayLabel }}</b><q-space /><span class="text-grey-6">{{ mobileTechBusy.length }} occupé(s)</span></div>
<div class="pm-techs-list">
<button v-for="t in mobileTechBusy" :key="t.id" type="button" class="pm-tech" :class="{ 'no-shift': t.noShift }" @click="onTechTap(t)">
<span class="pm-ava">{{ initials(t.name) }}</span>
<span class="pm-tech-name ellipsis">{{ t.name }}</span>
<span class="pm-tbar"><span class="pm-tfill" :style="{ width: (t.h > 0 ? Math.max(8, Math.min(100, Math.round(t.ratio * 100))) : 0) + '%', background: heatColor(t.ratio) }"></span></span>
<span v-if="t.noShift" class="pm-warn" title="Aucun quart planifié ce jour — capacité estimée à 8 h. Touchez le tech pour créer un quart.">▲</span>
<span class="pm-th" :class="{ over: t.over }">{{ t.h }}<span class="pm-cap" :class="{ est: t.noShift }">/{{ t.cap }}h</span></span>
</button>
<div v-if="!mobileTechLoads.length" class="pm-empty">Aucun technicien (filtre ?).</div>
<div v-else-if="!mobileTechBusy.length" class="pm-empty">Aucune charge ce jour.</div>
<button v-if="mobileTechIdle.length" type="button" class="pm-more" @click="pmShowIdle = !pmShowIdle">{{ pmShowIdle ? '▲ Masquer les libres' : '▼ ' + mobileTechIdle.length + ' tech(s) libre(s) (0 h)' }}</button>
<template v-if="pmShowIdle">
<button v-for="t in mobileTechIdle" :key="t.id" type="button" class="pm-tech pm-tech-idle" @click="onTechTap(t)">
<span class="pm-ava">{{ initials(t.name) }}</span>
<span class="pm-tech-name ellipsis">{{ t.name }}</span>
<span class="pm-tbar"></span>
<span class="pm-th">0h</span>
</button>
</template>
</div>
</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' ? 'Prioritaire — toucher pour retirer' : 'Marquer prioritaire' }}</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>
</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">
<div class="pm-sheet-hd"><q-icon name="touch_app" size="18px" class="q-mr-xs" />{{ selectedNames.length }} job(s) → touchez un technicien<q-space /><q-btn flat dense no-caps size="sm" label="Annuler" color="white" @click="clearSel()" /></div>
<!-- Chips compétence DÉCOCHABLES : auto-cochées (compétences des jobs sélectionnés) ; décocher relâche le filtre ; tout décoché = TOUS les techs. -->
<div v-if="assignSkills.length" class="pm-sheet-skills">
<span class="pm-ss-lbl">Compétence :</span>
<button v-for="s in assignSkills" :key="s" type="button" class="pm-chip" :class="{ on: assignSkillActive.has(s) }" :style="assignSkillActive.has(s) ? { background: getTagColor(s), borderColor: getTagColor(s), color: '#fff' } : {}" @click="toggleAssignSkill(s)">{{ s }}<q-icon :name="assignSkillActive.has(s) ? 'check' : 'add'" size="12px" class="q-ml-xs" /></button>
<span class="pm-ss-hint">{{ assignSkillActive.size ? 'décochez pour tous les techs' : '✓ tous les techs' }}</span>
</div>
<div class="pm-sheet-body">
<button v-for="t in mobileTechAssign" :key="t.id" type="button" class="pm-tech assignable" :class="{ 'no-shift': t.noShift }" @click="onTechTap(t)">
<span class="pm-ava">{{ initials(t.name) }}</span>
<span class="pm-tech-name ellipsis">{{ t.name }}</span>
<span class="pm-tbar"><span class="pm-tfill" :style="{ width: (t.h > 0 ? Math.max(8, Math.min(100, Math.round(t.ratio * 100))) : 0) + '%', background: heatColor(t.ratio) }"></span></span>
<span v-if="t.noShift" class="pm-warn" title="Aucun quart ce jour — touchez pour créer un quart">▲</span>
<span class="pm-th" :class="{ over: t.over }">{{ t.h }}<span class="pm-cap" :class="{ est: t.noShift }">/{{ t.cap }}h</span></span>
</button>
<div v-if="!mobileTechAssign.length" class="pm-empty">Aucun technicien capable — ajustez les compétences (chips).</div>
</div>
</div>
<!-- Feuille d'OPTIONS du job (glisser-gauche ou bouton) : priorité directe, statut, compétence, report, note. -->
<q-dialog v-model="jobSheet.open" position="bottom" @hide="onJobSheetHide">
<q-card v-if="jobSheet.job" class="pm-opt-card">
<q-card-section class="row items-center q-pb-xs">
<q-icon name="work_outline" size="18px" class="q-mr-sm text-grey-7" />
<div class="text-subtitle2 ellipsis" style="max-width:74vw">{{ jobSheet.job.subject || jobSheet.job.service_type || jobSheet.job.name }}</div>
<q-space /><q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-pt-none">
<!-- Détails du ticket (lecture) -->
<div class="pm-tk-details">
<div v-if="jobSheet.job.customer_name" class="pm-tk-row"><q-icon name="person" size="14px" color="grey-6" />{{ jobSheet.job.customer_name }}</div>
<div v-if="jobSheet.job.address || jobSheet.job.service_location" class="pm-tk-row"><q-icon name="place" size="14px" color="grey-6" /><span class="ellipsis">{{ jobSheet.job.address || jobSheet.job.service_location }}</span></div>
<!-- Contact client (P1) : tél/courriel du compte F lié au ticket, tappables -->
<div v-if="sheetThread.contact && (sheetThread.contact.phone || sheetThread.contact.email)" class="pm-tk-row"><q-icon name="contact_phone" size="14px" color="grey-6" /><a v-if="sheetThread.contact.phone" :href="'tel:' + sheetThread.contact.phone" class="pm-tk-link">{{ sheetThread.contact.phone }}</a><span v-if="sheetThread.contact.phone && sheetThread.contact.email" class="text-grey-4">&nbsp;·&nbsp;</span><a v-if="sheetThread.contact.email" :href="'mailto:' + sheetThread.contact.email" class="pm-tk-link ellipsis">{{ sheetThread.contact.email }}</a></div>
<div class="pm-tk-row"><q-icon name="sell" size="14px" color="grey-6" />{{ jobSheet.job.required_skill || '—' }}<span v-if="jobSheet.job.est_min || jobSheet.job.duration_h" class="text-grey-6"> · ≈ {{ jobSheet.job.est_min ? fmtMin(jobSheet.job.est_min) : (jobSheet.job.duration_h + 'h') }}</span></div>
<div v-if="jobSheet.job.scheduled_date || jobSheet.job.creation" class="pm-tk-row"><q-icon name="event" size="14px" color="grey-6" /><span v-if="jobSheet.job.scheduled_date" :class="isOverdue(jobSheet.job.scheduled_date) ? 'text-warning text-weight-medium' : ''">{{ fmtDueLabel(jobSheet.job.scheduled_date) }}</span><span v-if="jobSheet.job.creation" class="text-grey-5"> · créé {{ String(jobSheet.job.creation).slice(0, 10) }}</span></div>
<div v-if="jobSheet.job.legacy_ticket_id || jobSheet.job.name" class="pm-tk-row text-grey-5"><q-icon name="confirmation_number" size="14px" color="grey-6" />{{ jobSheet.job.legacy_ticket_id || jobSheet.job.name }}</div>
<!-- Validation « service livré » (à la demande) : croise tech-présent (geofence) + service en ligne (modem) -->
<div class="pm-tk-row">
<q-icon name="router" size="14px" color="grey-6" />
<template v-if="serviceCheck.done">
<q-chip dense square class="q-my-none q-ml-none" :color="serviceCheck.online === true ? 'teal-1' : (serviceCheck.found ? 'deep-orange-1' : 'grey-3')" :text-color="serviceCheck.online === true ? 'teal-9' : (serviceCheck.found ? 'deep-orange-9' : 'grey-8')" :icon="serviceCheck.online === true ? 'check_circle' : (serviceCheck.found ? 'cancel' : 'help')">{{ serviceCheck.label }}</q-chip>
<q-btn flat dense round size="sm" icon="refresh" color="grey-6" :loading="serviceCheck.loading" @click="checkJobService(jobSheet.job)"><q-tooltip>Revérifier le service</q-tooltip></q-btn>
</template>
<q-btn v-else flat dense no-caps size="sm" color="primary" icon="router" :label="serviceCheck.loading ? 'Vérification…' : 'Vérifier le service'" :loading="serviceCheck.loading" @click="checkJobService(jobSheet.job)" />
</div>
</div>
<!-- Conversation client UNIFIÉE — UN seul champ : Réponse au client (Outbox courriel/SMS) OU 📌 Note importante (épinglée au job). Fil le plus récent en premier. -->
<div class="pm-opt-label">Conversation client <span v-if="sheetThread.messages.length" class="text-grey-5">({{ sheetThread.messages.length }})</span></div>
<div class="pm-postbox">
<q-input dense outlined v-model="composerText" type="textarea" rows="2" :placeholder="noteMode ? 'Note importante — épinglée au job (ex. appeler 30 min avant · préfère AM)…' : (replyChannel.ok ? ('Réponse au client par ' + replyChannel.label + '…') : 'Réponse au client…')" />
<div class="row items-center q-mt-xs q-gutter-xs">
<q-btn-toggle v-model="noteMode" dense unelevated no-caps size="sm" :options="[{ label: replyChannel.ok ? ('Réponse · ' + replyChannel.label) : 'Réponse', value: false, icon: 'reply', disable: !replyChannel.ok }, { label: 'Note importante', value: true, icon: 'push_pin' }]" toggle-color="primary" color="grey-3" text-color="grey-8" />
<q-space />
<q-btn dense unelevated no-caps size="sm" :color="noteMode ? 'deep-orange-6' : 'teal-7'" :icon="noteMode ? 'push_pin' : 'send'" :label="noteMode ? 'Enregistrer' : 'Envoyer'" :loading="postBusy" :disable="noteMode ? false : !postDraft.trim()" @mousedown.prevent="submitComposer()" />
</div>
<div class="row items-center q-mt-xs pm-postbox-foot">
<q-icon :name="noteMode ? 'push_pin' : (replyChannel.ok ? 'public' : 'block')" size="12px" :color="noteMode ? 'deep-orange-6' : (replyChannel.ok ? 'teal-7' : 'grey-5')" />
<span class="pm-postbox-hint">{{ noteMode ? 'Note interne épinglée au job — jamais envoyée au client' : (replyChannel.ok ? ('Envoyée au client par ' + replyChannel.label + (jobSheet.job.legacy_ticket_id ? ' · consignée au ticket' : '')) : 'Aucun courriel/téléphone au dossier client') }}</span>
<q-space />
<q-btn flat dense no-caps size="sm" color="primary" icon="open_in_new" label="Fil complet" @click="openFullThread()" />
</div>
</div>
<div class="pm-thread" v-if="sheetThread.loading || sheetThread.messages.length">
<div v-if="sheetThread.loading" class="pm-thread-empty"><q-spinner size="15px" class="q-mr-xs" />Chargement du fil…</div>
<div v-for="(m, i) in sheetThread.messages" :key="i" class="pm-post" :class="{ 'pm-post--client': m.fromClient }">
<div class="pm-post-hd">
<q-avatar size="20px" class="pm-post-av" :style="{ background: pmIdentity(m).color }">{{ pmIdentity(m).initials || '?' }}</q-avatar>
<b class="ellipsis">{{ pmIdentity(m).name || (m.fromClient ? 'Client' : 'Staff') }}</b>
<span v-if="m.fromClient" class="pm-post-tag">client</span>
<q-space />
<span v-if="m.at" class="pm-post-at">{{ relTime(m.at) }}</span>
</div>
<div class="pm-post-text">{{ m.text }}</div>
</div>
</div>
<div class="pm-opt-label">Priorité</div>
<div class="row q-gutter-xs">
<q-chip v-for="p in POOL_PRIOS" :key="p.value" clickable dense :selected="jobSheet.job.priority === p.value" :style="{ background: jobSheet.job.priority === p.value ? p.color : '#eef2f7', color: jobSheet.job.priority === p.value ? '#fff' : '#475569' }" @click="setJobPriority(jobSheet.job, p.value)">{{ p.label }}</q-chip>
</div>
<div class="pm-opt-label">Statut</div>
<div class="row q-gutter-xs">
<q-chip v-for="s in POOL_STATUSES" :key="s.value" clickable dense :icon="s.icon" :color="jobSheet.job.status === s.value ? s.color : 'grey-3'" :text-color="jobSheet.job.status === s.value ? 'white' : 'grey-8'" @click="setJobStatus(jobSheet.job, s.value)">{{ s.label }}</q-chip>
</div>
<div class="pm-opt-label">Compétence requise</div>
<q-select dense outlined options-dense clearable v-model="jobSheet.job.required_skill" :options="allSkills" @update:model-value="v => setJobSkill(jobSheet.job, v || '')" />
<div class="pm-opt-label">Reporter (date prévue)</div>
<div class="row q-gutter-sm items-center">
<q-btn dense unelevated no-caps color="indigo" icon="snooze" label="+1 jour" @click="snoozeJob(jobSheet.job, 1)" />
<q-input dense outlined type="date" v-model="snoozeDate" style="min-width:152px" @update:model-value="v => v && setJobDate(jobSheet.job, v)"><template #prepend><q-icon name="event" size="18px" color="indigo" /></template><q-tooltip>Choisir une date (pré-réglée à +1 semaine)</q-tooltip></q-input>
</div>
</q-card-section>
<!-- Barre du bas : bouton « Fermer » atteignable au pouce (en plus du X et du clic à l'extérieur). Ferme → @hide enregistre la note. -->
<q-card-actions align="right" class="pm-opt-actions"><q-btn unelevated no-caps color="primary" icon="check" label="Fermer" v-close-popup /></q-card-actions>
</q-card>
</q-dialog>
<!-- Note importante du répartiteur (champ Dispatch Job.notes) -->
<q-dialog v-model="noteDialog.open" position="bottom">
<q-card v-if="noteDialog.job" class="pm-opt-card">
<q-card-section class="row items-center q-pb-none">
<q-icon name="sticky_note_2" size="18px" color="warning" class="q-mr-sm" />
<div class="text-subtitle2">Note importante</div>
<q-space /><q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<q-input dense outlined type="textarea" autogrow autofocus v-model="noteDialog.text" placeholder="ex. appeler 30 min avant · client préfère AM" maxlength="2000" />
</q-card-section>
<q-card-actions align="right" class="q-pt-none">
<q-btn v-if="noteDialog.text" flat no-caps color="grey-7" label="Effacer" @click="noteDialog.text = ''; saveNote()" />
<q-btn unelevated no-caps color="warning" icon="check" label="Enregistrer" @click="saveNote" />
</q-card-actions>
</q-card>
</q-dialog>
</div>
<div class="grid-wrap gt-sm" v-show="boardView !== 'kanban'">
<table class="roster-grid" :class="{ 'day-mode': dayMode }">
<thead>
<tr>
<th class="tech-col"><div class="row items-center no-wrap">Ressource<q-space /><q-btn v-if="hiddenCount" flat dense round size="xs" :icon="showHidden ? 'visibility' : 'visibility_off'" :color="showHidden ? 'primary' : 'grey-6'" @click="showHidden = !showHidden"><q-badge floating color="grey-7" style="top:-7px;right:6px">{{ hiddenCount }}</q-badge><q-tooltip>{{ showHidden ? 'Cacher les ressources masquées' : (hiddenCount + ' masquée(s) — afficher en grisé') }}</q-tooltip></q-btn></div></th>
<th v-for="(d, di) in dayList" :key="d.iso" class="clk" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }" @click="maybeSelectCol(di)">
<div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div>
<div v-if="visStat(d.iso).hours || estLoad(d.iso)" class="cap-strip" @click.stop>
<span class="cap-seg load-cell" :class="loadClass(d.iso)">{{ estLoad(d.iso) }}/{{ visStat(d.iso).hours }}h
<q-tooltip class="bg-grey-9"><b>{{ estLoad(d.iso) }} h estimées</b> (jobs prévus) / <b>{{ visStat(d.iso).hours }} h de shift</b><template v-if="visStat(d.iso).hours"> · {{ Math.round(estLoad(d.iso) / visStat(d.iso).hours * 100) }}%</template><br><span style="opacity:.8">{{ skillFilter.length ? ('compétence(s) : ' + skillFilter.join(', ')) : 'ressources affichées' }} ({{ visibleTechs.length }} tech)</span></q-tooltip>
</span>
</div>
<div v-if="dayMode" class="grid-axis"><span v-for="tk in axisTicks" :key="'ga' + tk.h" class="grid-axis-tick" :style="{ left: tk.left }">{{ tk.h }}</span></div>
<div v-if="showDayJobs" class="dayjobs-hdr" @click.stop @mousedown.stop>
<div v-for="j in (unassignedByDay[d.iso] || []).slice(0, 8)" :key="j.name" class="dayjob" :class="{ dragging: draggingSet.has(j.name), hold: j.status === 'On Hold' }" :style="{ borderLeft: '3px solid ' + panelJobColor(j) }" draggable="true" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
<span class="dj-est">{{ j.est_min ? fmtMin(j.est_min) : ((j.duration_h || 1) + 'h') }}</span><span class="dj-sub">{{ j.subject || j.service_type || j.name }}</span>
<q-tooltip class="bg-grey-9" style="font-size:11px" :delay="300">{{ j.subject || j.service_type }}<template v-if="j.customer_name"> · {{ 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 d'une étape — non assignable</template></q-tooltip>
</div>
<div v-if="(unassignedByDay[d.iso] || []).length > 8" class="dayjobs-more" @click.stop="openDayInPanel(d.iso)">+{{ (unassignedByDay[d.iso] || []).length - 8 }} de plus — ouvrir la liste<q-tooltip>Ouvre le panneau « Jobs à assigner » filtré sur ce jour</q-tooltip></div>
<div v-if="!(unassignedByDay[d.iso] || []).length" class="dayjobs-empty">—</div>
</div>
<div class="hol-toggle" :class="{ on: isHoliday(d.iso) }" @click.stop="toggleHoliday(d.iso)"><q-tooltip>Marquer férié</q-tooltip>F</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(t, ti) in visibleTechs" :key="t.id" :class="{ paused: isPaused(t), 'res-hidden': isHidden(t.id) }">
<td class="tech-col">
<div class="tech-row">
<q-btn flat round dense size="9px" :icon="isRowSelected(ti) ? 'check_box' : 'check_box_outline_blank'" :color="isRowSelected(ti) ? 'primary' : 'grey-5'" @click.stop="maybeSelectRow(ti)"><q-tooltip>Sélectionner la rangée</q-tooltip></q-btn>
<q-btn flat round dense size="9px" :icon="isPaused(t) ? 'play_arrow' : 'pause'" :color="isPaused(t) ? 'grey' : 'primary'" @click.stop="togglePause(t)"><q-tooltip>{{ isPaused(t) ? 'Réactiver' : 'Pause' }}</q-tooltip></q-btn>
<q-badge v-if="techRank(t)" color="teal">{{ techRank(t) }}<q-tooltip>Priorité {{ techRank(t) }} — combine maîtrise (niveau) + vitesse (efficacité) + coût.</q-tooltip></q-badge>
<!-- Clic sur la RESSOURCE (nom ou chips) → propriétés de l'employé (compétences, horaire) -->
<span class="tech-name clk" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Compétences & horaire de {{ t.name }}</q-tooltip>{{ t.name }}</span>
<span v-if="t.group" class="grp">{{ t.group }}</span>
<span v-if="hoursOf(t.id)" class="th" :class="hoursOf(t.id) > maxHours ? 'text-red text-weight-bold' : 'text-grey-6'">{{ hoursOf(t.id) }}h<q-icon v-if="hoursOf(t.id) > maxHours" name="warning" color="red" size="12px" /></span>
<q-btn flat dense round size="9px" icon="more_vert" color="grey-6" @click.stop><q-tooltip>Actions — {{ t.name }}</q-tooltip>
<q-menu auto-close anchor="bottom right" self="top right">
<q-list dense style="min-width:190px">
<q-item clickable @click="openTimeline(t)"><q-item-section avatar><q-icon name="timeline" color="green-5" /></q-item-section><q-item-section>Timeline de la semaine</q-item-section></q-item>
<q-item clickable @click="techAppLinkMenu(t)"><q-item-section avatar><q-icon name="phone_iphone" color="deep-purple-5" /></q-item-section><q-item-section>Lien app terrain</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
<div class="tech-skills clk" @click.stop="openSkillEditor(t, $event)">
<span v-if="tSkills(t).length" class="skill-dot" :class="{ 'has-lvl': !!(t.skill_levels || {})[tSkills(t)[0]] }" :style="{ background: getTagColor(tSkills(t)[0]) }"><q-icon :name="skillSym(tSkills(t)[0])" size="11px" /><span v-if="(t.skill_levels || {})[tSkills(t)[0]]" class="sd-lvl">{{ (t.skill_levels || {})[tSkills(t)[0]] }}</span><q-tooltip class="bg-grey-9"><b>{{ tSkills(t)[0] }}</b> · niveau {{ (t.skill_levels || {})[tSkills(t)[0]] || '—' }}<template v-if="(t.skill_eff || {})[tSkills(t)[0]]"> · {{ effSuffix(skillEffOf(t, tSkills(t)[0])) }}</template></q-tooltip></span><span v-if="tSkills(t).length > 1" class="skill-more">+{{ tSkills(t).length - 1 }}<q-tooltip class="bg-grey-9" style="font-size:11px"><div class="text-weight-medium q-mb-xs">Autres compétences</div><div v-for="sk in tSkills(t).slice(1)" :key="sk" class="row items-center no-wrap" style="gap:5px;line-height:1.7"><q-icon :name="skillSym(sk)" size="13px" :style="{ color: getTagColor(sk) }" /><span>{{ sk }}</span><span v-if="(t.skill_levels || {})[sk]" class="text-grey-5">· niv {{ (t.skill_levels || {})[sk] }}</span></div></q-tooltip></span>
<span v-if="!(t.skills || []).length" class="add-skill-hint">+ compétences</span>
</div>
<q-btn flat dense round size="9px" class="hide-eye" :icon="isHidden(t.id) ? 'visibility_off' : 'visibility'" :color="isHidden(t.id) ? 'grey-5' : 'grey-6'" @click.stop="toggleHidden(t.id)"><q-tooltip>{{ isHidden(t.id) ? 'Réafficher / considérer cette ressource' : 'Masquer & ignorer (hors front-line)' }}</q-tooltip></q-btn>
</div>
</td>
<td v-for="(d, di) in dayList" :key="d.iso" class="cell" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso), sel: isSelected(t.id, d.iso), dirty: isCellDirty(t.id, d.iso), over: cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).over, noshift: (cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).noShift) || (officeBlockH(t.id, d.iso) && !hasReg(t.id, d.iso)), 'drop-hover': dropCell === t.id + '|' + d.iso }" @mousedown="onDown(ti, di, $event)" @mouseenter="onEnter(ti, di)" @click="onCellClick(t, d, $event, ti, di)" @contextmenu.prevent="onCellContext(t, d, $event, ti, di)" @dragover.prevent="onCellDragOver(t, d)" @dragleave="dropCell === t.id + '|' + d.iso && (dropCell = null, dropPreview.key = null)" @drop.prevent="onCellDrop($event, t, d)">
<template v-if="isAbsent(t.id, d.iso) || isPaused(t)">
<div class="tl"><div class="tl-absent"></div><q-tooltip class="bg-grey-9">Absent · {{ absenceLabel(t.id, d.iso) }}</q-tooltip></div>
</template>
<template v-else-if="hasReg(t.id, d.iso) || onGarde(t.id, d.iso) || (cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).noShift)">
<div class="tl">
<div v-if="cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).hasReg" class="tl-shift tl-shift-click" :class="{ over: cellOcc(t.id, d.iso).over }" :style="pos(cellOcc(t.id, d.iso).shiftS, cellOcc(t.id, d.iso).shiftE)" @click.stop="openDayFromCell(t, d)"></div>
<div v-for="(b, bi) in cellGardeBands(t.id, d.iso)" :key="'g' + bi" class="tl-shift tl-shift-click oncall" :style="{ left: b.left, width: b.width }" @click.stop="openDayFromCell(t, d)"></div>
<div v-for="(b, bi) in cellBlocks(t.id, d.iso)" :key="'j' + bi" class="tl-blk tl-blk-click" :class="{ 'tl-blk-legacy': b.legacy, 'tl-blk-done': b.done }" :style="blockStyle(b, cellPct(t.id, d.iso))" @click.stop="openDayFromCell(t, d)" @mousedown.stop><q-icon v-if="b.done || (b.e - b.s) >= (dayMode ? 0.75 : 2.5)" :name="blkIcon(b)" :style="{ color: blkColor(b) }" size="12px" /><q-tooltip class="bg-grey-9" :delay="350" style="font-size:11px"><template v-if="b.assist">👥 <b>Assistant (renfort)</b> · ≈{{ Math.round((b.e - b.s) * 10) / 10 }}h</template><template v-else><b>{{ b.subject || b.skill || 'Job' }}</b><br>{{ b.legacy ? (b.legacy_dept || b.dept || 'Ticket') : (b.skill || 'Job') }} · ≈{{ Math.round((b.e - b.s) * 10) / 10 }}h<span v-if="blkOpsOnly(b)"><br><span class="text-warning">⚠ Dispatché OPS — pas publié dans F</span></span><br><span class="text-info">clic = détails du jour (carte + trajet) · clic droit = renvoyer au pool</span></template></q-tooltip><q-menu v-if="b.job && !b.legacy && !b.assist" context-menu touch-position auto-close><q-list dense style="min-width:180px"><q-item clickable @click="unassignOne(b)"><q-item-section avatar><q-icon name="inbox" color="grey-7" /></q-item-section><q-item-section>Renvoyer au pool</q-item-section></q-item></q-list></q-menu></div>
<!-- Aperçu d'occupation projetée pendant le drag : barre fantôme + delta -->
<div v-if="isDropTarget(t.id, d.iso) && projPct(t.id, d.iso) != null" class="tl-proj" :style="{ width: Math.min(100, projPct(t.id, d.iso)) + '%', background: occColor(projPct(t.id, d.iso)) }"></div>
<!-- pas de bulle sur le FOND de cellule : clic sur un bloc = son ticket · clic ailleurs dans la cellule = éditeur de jour (liste + timeline + carte) -->
</div>
</template>
<span v-else-if="offShiftJobs(t.id, d.iso).length" class="offshift-warn" @click.stop="openTimeline(t)"><q-icon name="warning" size="13px" color="warning" />{{ offShiftJobs(t.id, d.iso).length }}<q-tooltip class="bg-grey-9">{{ offShiftJobs(t.id, d.iso).length }} job(s) assigné(s) ce jour SANS quart publié — publier un quart ou réassigner. Clic → timeline.</q-tooltip></span>
<span v-else class="free">·</span>
<div v-if="isDropTarget(t.id, d.iso)" class="drop-badge" :class="{ over: projPct(t.id, d.iso) >= 100 }">+{{ dropPreview.addH }}h<template v-if="projPct(t.id, d.iso) != null"> → {{ projPct(t.id, d.iso) }}%</template></div>
<q-icon v-if="cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).over" name="warning" color="warning" size="14px" class="ovl-warn"><q-tooltip class="bg-grey-9" style="font-size:11.5px; max-width:260px"><b>⚠ Surcharge</b><br>{{ overWhy(t.id, d.iso) }}</q-tooltip></q-icon>
<div v-if="officeBlockH(t.id, d.iso)" class="office-blk" :style="officeBlockStyle(t.id, d.iso)" @click.stop><q-menu anchor="bottom right" self="top right" auto-close><q-list dense style="min-width:250px; max-width:360px"><q-item-label header class="q-py-xs">{{ cellOfficeN(t.id, d.iso) }} ticket(s) sans déplacement · ≈{{ fmtOfficeH(t.id, d.iso) }} · {{ t.name }}</q-item-label><q-item v-for="(j, ji) in officeJobs(t.id, d.iso)" :key="'om' + ji" clickable @click="openLegacyBlock({ lid: j.id, subject: j.subject, dept: j.dept })"><q-item-section avatar style="min-width:18px"><span class="office-dot" :style="{ background: legacyDeptColor(j.dept) || '#9fa8da' }"></span></q-item-section><q-item-section><q-item-label lines="2">{{ j.subject }}</q-item-label><q-item-label caption>{{ j.dept }} · #{{ j.id }}<span v-if="j.est_min || j.duration_h"> · ≈{{ j.est_min ? fmtMin(j.est_min) : (j.duration_h + 'h') }}</span></q-item-label></q-item-section><q-item-section side><q-icon name="open_in_new" size="14px" color="grey-6" /></q-item-section></q-item></q-list></q-menu></div>
</td>
</tr>
<tr v-if="!visibleTechs.length"><td :colspan="dayList.length + 1" class="text-grey-6 q-pa-md text-center">Aucun technicien (filtre ?).</td></tr>
</tbody>
</table>
</div>
<!-- ── VUE BOARD (Kanban horizontal, façon Dispatch/Gaiia) : pool « À assigner » VERTICAL (recherche + tri) à gauche ;
techs en LANES horizontales à ÉCHELLE D'HEURES (réutilise cellBands + pos + axisTicks). Glisser = assigner (hub). ── -->
<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>
</div>
<!-- Board : techs en lanes horizontales + échelle d'heures (la liste de techs suit le filtre skill/sous-groupe) -->
<div class="kbb-board" ref="kbBoardEl">
<div class="kbb-daybar"><b>{{ kanbanDay ? (kanbanDay.dow + ' ' + kanbanDay.dnum) : '—' }}</b><span class="text-grey-6">· glisser une carte sur une lane = assigner · {{ visibleTechs.length }} tech(s)</span></div>
<div class="kbb-scroll" ref="kbScrollEl">
<div class="kbb-axis-row">
<div class="kbb-name-sp"></div>
<div class="kbb-axis" :style="{ width: kbInnerW + 'px' }"><span v-for="tk in kbTicks" :key="'a' + tk.h" class="grid-axis-tick" :style="{ left: tk.left }">{{ tk.h }}</span><div v-if="nowLblStyle" class="kbb-now-lbl" :style="nowLblStyle">{{ nowHHMM }}</div></div>
</div>
<div class="kbb-rows">
<!-- Trait « maintenant » CONTINU : 1 seul élément sur toute la hauteur des lanes -->
<div v-if="nowLineStyle" class="kbb-now-full" :style="nowLineStyle"></div>
<div v-for="t in visibleTechs" :key="t.id" class="kbb-row">
<div class="kbb-name"><span class="ellipsis">{{ t.name }}</span><q-icon v-if="kanbanDay && cellOcc(t.id, kanbanDay.iso) && cellOcc(t.id, kanbanDay.iso).over" name="warning" color="warning" size="15px"><q-tooltip class="bg-grey-9" style="font-size:11.5px; max-width:260px"><b>⚠ Surcharge</b><br>{{ overWhy(t.id, kanbanDay.iso) }}</q-tooltip></q-icon></div>
<div class="kbb-lane" :style="kbLaneStyle" :class="{ 'drop-hover': kanbanDay && dropCell === t.id + '|' + kanbanDay.iso }"
@dragover.prevent="kanbanDay && onCellDragOver(t, kanbanDay)" @dragleave="dropCell = null" @drop="kanbanDay && onKbDrop($event, t)"
@click.self="kanbanDay && openDayFromCell(t, kanbanDay)" @contextmenu.self.prevent="onKbLaneMenu(t, $event)">
<!-- quart prévu = délimitation pointillée fine (ex. 718h), garde = pointillé ambre -->
<div v-for="(b, bi) in kbShiftBands(t.id)" :key="'s' + bi" class="kbb-shift" :class="{ oncall: b.oncall }" :style="kbPos(b.s, b.e)"></div>
<!-- garde = shift SUR APPEL : bande jaune pâle + téléphone rouge (occupe la timeline, ne stretche pas l'axe) -->
<div v-for="(g, gi) in kbGardeBands(t.id)" :key="'g' + gi" class="kbb-garde" :style="kbPos(g.s, g.e)"><q-icon name="phone_in_talk" color="negative" size="13px" /><q-tooltip class="bg-grey-9" style="font-size:11px">📞 Sur appel (garde) {{ fmtH(g.s) }}h{{ fmtH(g.e) }}h — soir / fin de semaine</q-tooltip></div>
<template v-for="(b, bi) in kanbanLaneBlocks(t.id)" :key="'j' + bi + (b.assist ? 'a' : '')">
<!-- trajet d'approche (km/min) = zone pâle AVANT le job -->
<div v-if="b.legMin" class="kbb-leg" :style="kbPos(b.legS, b.legE)"><span class="kbb-leg-t">{{ b.legMin }}</span><q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px">{{ b.legReal ? '🚗 Trajet routier (Mapbox)' : '≈ Trajet estimé' }} : {{ b.legKm }} km · {{ b.legMin }} min avant ce job</q-tooltip></div>
<div class="kbb-blk" :class="{ assist: b.assist, 'kbb-blk-legacy': b.legacy, 'kbb-blk-opsonly': blkOpsOnly(b) }" :style="{ ...kbPos(b.s, Math.min(b.e, 24)), ...blkFill(b) }" :draggable="!b.assist && !b.legacy" @dragstart="!b.assist && !b.legacy && onJobDragStart($event, b)" @dragend="onJobDragEnd" @click.stop="b.legacy ? openLegacyBlock(b) : (!b.assist && kbBlockClick(t))" @dblclick.stop="!b.legacy && kbBlockDbl(b, t)">
<div class="kbb-blk-l1"><q-icon :name="blkIcon(b)" size="12px" :style="{ color: blkColor(b) }" /><span class="kbb-blk-t">{{ b.subject }}</span></div>
<div class="kbb-blk-l2">{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h<span v-if="b.legacy"> · {{ b.dept }}</span><span v-else-if="b.customer"> · {{ b.customer }}</span><span v-if="b.assist"> · assistant</span></div>
<q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px"><template v-if="b.legacy">🗂 <b>{{ b.subject }}</b><br>{{ b.dept || 'osTicket' }} · ≈{{ Math.round((b.dur || 0) * 10) / 10 }}h · #{{ b.lid }}<br><span class="text-light-green-4">✓ Assigné dans F (autoritaire)</span><br><span class="text-info">clic = ouvrir le ticket ↗</span></template><template v-else>{{ b.subject }}<template v-if="b.customer"><br>{{ b.customer }}</template><template v-if="b.address"><br>📍 {{ b.address }}</template><template v-if="b.assist"><br>👥 assistant (renfort)</template><br>{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h<template v-if="blkOpsOnly(b)"><br><span class="text-warning">⚠ Dispatché OPS — pas encore publié dans F</span></template><template v-if="!b.assist"><br><span class="text-info">clic droit = renvoyer au pool</span></template></template></q-tooltip>
<q-menu v-if="!b.assist && !b.legacy" context-menu touch-position auto-close><q-list dense style="min-width:190px"><q-item clickable @click="unassignOne(b)"><q-item-section avatar><q-icon name="inbox" color="grey-7" /></q-item-section><q-item-section>Renvoyer au pool<q-item-label caption>désassigner ce tech</q-item-label></q-item-section></q-item><q-item clickable @click="openJobDetail(b, t)"><q-item-section avatar><q-icon name="open_in_full" color="grey-7" /></q-item-section><q-item-section>Détails / équipe</q-item-section></q-item></q-list></q-menu>
</div>
</template>
<div v-if="kanbanDay && cellOfficeN(t.id, kanbanDay.iso)" class="office-dots kbb-office" @click.stop><span v-for="(j, ji) in officeJobs(t.id, kanbanDay.iso).slice(0, 12)" :key="'kod' + ji" class="office-dot" :style="{ background: legacyDeptColor(j.dept) || '#9fa8da' }"></span><q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px; max-width:320px"><b>{{ cellOfficeN(t.id, kanbanDay.iso) }} ticket(s) sans déplacement</b> (admin / support / NP) — hors charge dispatch.<br><span v-for="(j, ji) in officeJobs(t.id, kanbanDay.iso).slice(0, 12)" :key="'kop' + ji">• {{ j.subject }} <span class="text-grey-5">· {{ j.dept }}</span><br></span><br><span class="text-info">clic = liste · ouvrir un ticket ↗</span></q-tooltip><q-menu anchor="bottom right" self="top right" auto-close><q-list dense style="min-width:250px; max-width:360px"><q-item-label header class="q-py-xs">{{ cellOfficeN(t.id, kanbanDay.iso) }} ticket(s) sans déplacement · {{ t.name }}</q-item-label><q-item v-for="(j, ji) in officeJobs(t.id, kanbanDay.iso)" :key="'kom' + ji" clickable @click="openLegacyBlock({ lid: j.id, subject: j.subject, dept: j.dept })"><q-item-section avatar style="min-width:18px"><span class="office-dot" :style="{ background: legacyDeptColor(j.dept) || '#9fa8da' }"></span></q-item-section><q-item-section><q-item-label lines="2">{{ j.subject }}</q-item-label><q-item-label caption>{{ j.dept }} · #{{ j.id }}</q-item-label></q-item-section><q-item-section side><q-icon name="open_in_new" size="14px" color="grey-6" /></q-item-section></q-item></q-list></q-menu></div>
</div>
</div>
<div v-if="!visibleTechs.length" class="kb-empty" style="margin:10px">Aucun technicien (filtre ?).</div>
</div>
</div>
</div>
</div>
<!-- Matrice « Couverture — dispo vs requis » retirée : le besoin = toutes les spécialités chaque jour (règle simple, à automatiser via AI plus tard). -->
<q-dialog v-model="showShiftEditor">
<q-card style="min-width:580px">
<q-card-section class="row items-center q-pb-none">
<div class="text-subtitle1 text-weight-bold">Types de shift</div><q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<table class="demand-tbl">
<thead><tr><th>Nom</th><th>Début</th><th>Fin</th><th>Heures</th><th>Couleur</th><th>🛡️ Garde</th><th></th></tr></thead>
<tbody>
<tr v-for="t in editTpls" :key="t.name">
<td><span class="code-chip" :style="chip(t.color)">{{ (t.template_name||'?')[0].toUpperCase() }}</span> {{ t.template_name }}</td>
<td><q-input dense outlined type="time" v-model="t.start" style="width:115px" /></td>
<td><q-input dense outlined type="time" v-model="t.end" style="width:115px" /></td>
<td class="text-center text-weight-medium">{{ calcHours(t.start, t.end) }} h</td>
<td><input type="color" v-model="t.color" style="width:36px;height:26px;border:none;background:none" /></td>
<td class="text-center"><q-toggle dense v-model="t.on_call" :true-value="1" :false-value="0" color="brown"><q-tooltip>Quart de garde (urgences) — non offert au booking</q-tooltip></q-toggle></td>
<td><q-btn flat dense round size="sm" icon="save" color="primary" @click="saveShiftTpl(t)"><q-tooltip>Enregistrer</q-tooltip></q-btn><q-btn flat dense round size="sm" icon="delete" color="grey-7" @click="delShiftTpl(t)" /></td>
</tr>
</tbody>
</table>
<q-separator class="q-my-md" />
<div class="row items-center q-gutter-sm">
<q-input dense outlined v-model="newTpl.template_name" label="Nom (auto si vide)" style="width:150px" />
<div style="width:230px;padding:0 8px">
<q-range v-model="newTplRange" :min="0" :max="24" :step="0.5" snap label :left-label-value="fmtH(newTplRange.min) + 'h'" :right-label-value="fmtH(newTplRange.max) + 'h'" color="primary" />
</div>
<span class="text-caption text-grey-7 text-weight-medium">{{ fmtH(newTplRange.min) }}h{{ fmtH(newTplRange.max) }}h · {{ calcHours(newTpl.start, newTpl.end) }} h</span>
<input type="color" v-model="newTpl.color" style="width:36px;height:26px;border:none;background:none" />
<q-toggle dense v-model="newTpl.on_call" :true-value="1" :false-value="0" label="🛡️ Garde" color="brown" />
<q-btn dense unelevated color="primary" icon="add" label="Ajouter" @click="addShiftTpl" />
</div>
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="showLeave">
<q-card style="min-width:680px">
<q-card-section class="row items-center q-pb-none">
<div class="text-subtitle1 text-weight-bold">Congés &amp; disponibilités</div><q-space />
<q-select dense outlined v-model="leaveFilter" :options="['Demandé', 'Approuvé', 'Refusé', '']" style="width:130px" @update:model-value="loadLeave" />
<q-btn flat round dense icon="close" v-close-popup class="q-ml-sm" />
</q-card-section>
<q-card-section>
<table class="demand-tbl" style="width:100%">
<thead><tr><th>Technicien</th><th>Type</th><th>Du</th><th>Au</th><th>Motif</th><th>Statut</th><th></th></tr></thead>
<tbody>
<tr v-for="l in leaveRows" :key="l.name">
<td>{{ l.technician_name || l.technician }}</td><td>{{ l.availability_type }}</td><td>{{ l.from_date }}</td><td>{{ l.to_date }}</td><td>{{ l.reason }}</td>
<td><q-badge :color="l.status === 'Approuvé' ? 'green' : l.status === 'Refusé' ? 'red' : 'orange'">{{ l.status }}</q-badge></td>
<td><template v-if="l.status === 'Demandé'"><q-btn flat dense round size="sm" icon="check" color="green" @click="approveLeave(l, false)" /><q-btn flat dense round size="sm" icon="close" color="red" @click="approveLeave(l, true)" /></template></td>
</tr>
<tr v-if="!leaveRows.length"><td colspan="7" class="text-grey-6 q-pa-sm">Aucune demande.</td></tr>
</tbody>
</table>
<q-separator class="q-my-md" />
<div class="text-caption text-weight-bold q-mb-xs">Nouvelle demande</div>
<div class="row items-center q-gutter-sm">
<TechSelect v-model="newLeave.technician" :options="techOptions" label="Technicien" style="width:200px" />
<q-select dense outlined v-model="newLeave.availability_type" :options="['Congé', 'Pause', 'Indisponible', 'Maladie']" label="Type" style="width:130px" />
<q-input dense outlined type="date" v-model="newLeave.from_date" label="Du" style="width:140px" />
<q-input dense outlined type="date" v-model="newLeave.to_date" label="Au" style="width:140px" />
<q-input dense outlined v-model="newLeave.reason" label="Motif" style="width:150px" />
<q-toggle dense v-model="newLeave.long_term" :true-value="1" :false-value="0" label="Longue durée"><q-tooltip>Maternité, invalidité… → à remplacer (pas juste sauter comme des vacances)</q-tooltip></q-toggle>
<q-btn dense unelevated color="primary" icon="add" label="Créer" @click="createLeave" />
</div>
<div class="text-caption text-grey-7 q-mt-sm">Une demande <b>approuvée</b> rend le tech indisponible pour le solveur sur ces dates.</div>
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="showTeamEditor">
<q-card style="min-width:900px">
<q-card-section class="row items-center q-pb-none"><div class="text-subtitle1 text-weight-bold">Équipe — cadence &amp; coût</div><q-space /><q-btn flat round dense icon="close" v-close-popup /></q-card-section>
<q-card-section>
<div class="text-caption text-grey-7 q-mb-sm">Cadence : 1.00 normal · 1.10 = +10 % (plus lent) · 0.90 = 10 % (plus rapide). Coût chargé/h = salaire × (1 + charges %) + autres (véhicule, outils, frais). Le solveur préfère les techs rapides et moins coûteux.</div>
<div style="max-height:55vh;overflow:auto">
<table class="demand-tbl" style="width:100%">
<thead><tr><th>Technicien</th><th>Compétences</th><th>Cadence</th><th>Salaire/h</th><th>Charges %</th><th>Autres/h</th><th>Coût chargé/h</th></tr></thead>
<tbody>
<tr v-for="t in editTechs" :key="t.id">
<td>{{ t.name }}<span v-if="t.group" class="grp">{{ t.group }}</span></td>
<td><SkillSelect v-model="t.skills" style="min-width:160px" @update:model-value="saveSkills(t)" /></td>
<td><q-input dense outlined type="number" step="0.05" v-model.number="t.efficiency" style="width:80px" @blur="saveEff(t)" /></td>
<td><q-input dense outlined type="number" step="0.5" v-model.number="t.salary" style="width:80px" @blur="saveCost(t)" /></td>
<td><q-input dense outlined type="number" step="1" v-model.number="t.charges" style="width:80px" @blur="saveCost(t)" /></td>
<td><q-input dense outlined type="number" step="0.5" v-model.number="t.other" style="width:80px" @blur="saveCost(t)" /></td>
<td class="text-weight-bold text-center">{{ loadedCost(t) }} $</td>
</tr>
</tbody>
</table>
</div>
</q-card-section>
</q-card>
</q-dialog>
<!-- Rotation de garde par département -->
<q-dialog v-model="showGarde">
<q-card style="min-width:680px;max-width:760px">
<q-card-section class="row items-center q-pb-none">
<div class="text-subtitle1 text-weight-bold">🛡️ Rotation de garde (par département)</div><q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-gutter-y-md">
<!-- Règles actives -->
<div v-if="gardeRules.length">
<div class="text-caption text-weight-bold text-grey-7 q-mb-xs">Règles actives</div>
<q-list dense bordered class="rounded-borders">
<q-item v-for="(r, i) in gardeRules" :key="r.id">
<q-item-section>
<q-item-label class="text-weight-medium">{{ r.dept }} · {{ shiftName(r.shift) }}<span v-if="r.shiftWeekend"> · WE : {{ shiftName(r.shiftWeekend) }}</span></q-item-label>
<q-item-label caption>{{ gardeDowLabel(r) }} · dès {{ (r.anchor || '').slice(5) }} · {{ gardeSeqLabel(r) }}</q-item-label>
</q-item-section>
<q-item-section side class="row no-wrap">
<q-btn flat dense round size="sm" icon="edit" color="primary" @click="editGardeRule(r)"><q-tooltip>Modifier (ordre, techs, période)</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="removeGardeRule(i)" />
</q-item-section>
</q-item>
</q-list>
</div>
<!-- Éditeur de règle (sous-panneau) -->
<div class="garde-editor q-pa-md rounded-borders">
<div class="text-subtitle2 text-weight-bold q-mb-md">{{ editingGardeId ? '✏️ Modifier la règle' : ' Nouvelle règle' }}</div>
<!-- Section 1 : Quand & quel quart -->
<div class="text-caption text-weight-bold text-brown-7 q-mb-sm">1 · Quand &amp; quel quart</div>
<div class="row q-col-gutter-sm">
<div class="col-12 col-sm-6"><q-select dense outlined class="full-width" v-model="newGardeRule.dept" :options="groupNames" use-input fill-input hide-selected new-value-mode="add-unique" input-debounce="0" label="Département (optionnel)" /></div>
<div class="col-12 col-sm-6"><q-input dense outlined class="full-width" type="date" v-model="newGardeRule.anchor" label="Rotation démarre la semaine du" /></div>
<div class="col-12 col-sm-6"><q-select dense outlined class="full-width" v-model="newGardeRule.shift" :options="gardeTemplateOptions" emit-value map-options label="Quart en semaine (soir)" /></div>
<div class="col-12 col-sm-6"><q-select dense outlined class="full-width" clearable v-model="newGardeRule.shiftWeekend" :options="gardeTemplateOptions" emit-value map-options label="Quart fin de semaine" hint="sinon = quart de semaine" /></div>
</div>
<div class="q-mt-sm">
<div class="text-caption text-grey-7 q-mb-xs">Jours couverts (hors bureau) :</div>
<div class="row items-center q-gutter-xs">
<q-btn :outline="!isSetActive(WD_SEMAINE)" :unelevated="isSetActive(WD_SEMAINE)" dense size="sm" color="brown" no-caps label="Soirs de semaine" @click="toggleWeekdaysSet(WD_SEMAINE)" />
<q-btn :outline="!isSetActive(WD_FINSEM)" :unelevated="isSetActive(WD_FINSEM)" dense size="sm" color="brown" no-caps label="Fin de semaine" @click="toggleWeekdaysSet(WD_FINSEM)" />
<span class="text-grey-5 q-mx-xs">·</span>
<q-chip v-for="dw in GARDE_DOW" :key="dw.v" clickable dense size="sm" :color="newGardeRule.weekdays.includes(dw.v) ? 'brown' : 'grey-4'" :text-color="newGardeRule.weekdays.includes(dw.v) ? 'white' : 'grey-8'" @click="toggleGardeDow(dw.v)">{{ dw.l }}</q-chip>
</div>
</div>
<q-separator class="q-my-md" />
<!-- Section 2 : Séquence de rotation -->
<div class="text-caption text-weight-bold text-brown-7 q-mb-sm">2 · Séquence de rotation</div>
<div class="row items-center q-gutter-sm">
<q-select dense outlined v-model="gardePick" :options="techOptions" emit-value map-options label="Ajouter un tech à la suite" style="min-width:240px" class="col" />
<q-btn dense unelevated color="brown" icon="add" label="Ajouter" :disable="!gardePick" @click="addTechToSeq" />
</div>
<div v-if="newGardeRule.steps.length" class="q-mt-sm">
<div v-for="(s, i) in newGardeRule.steps" :key="i" class="row items-center no-wrap q-gutter-xs q-mb-xs">
<span class="text-caption text-weight-bold text-brown-7" style="min-width:22px">{{ i + 1 }}.</span>
<q-select dense outlined options-dense v-model="s.tech" :options="techOptions" emit-value map-options class="col" />
<q-input dense outlined type="number" min="1" v-model.number="s.weeks" style="width:88px" suffix="sem." />
<q-btn flat dense round size="xs" icon="arrow_upward" :disable="i === 0" @click="moveTech(i, -1)"><q-tooltip>Monter</q-tooltip></q-btn>
<q-btn flat dense round size="xs" icon="arrow_downward" :disable="i === newGardeRule.steps.length - 1" @click="moveTech(i, 1)"><q-tooltip>Descendre</q-tooltip></q-btn>
<q-btn flat dense round size="xs" icon="close" color="grey-6" @click="newGardeRule.steps.splice(i, 1)"><q-tooltip>Retirer</q-tooltip></q-btn>
</div>
<div class="text-caption text-grey-6">« sem. » = semaines consécutives. Pour <b>N semaines d'écart</b> entre 2 tours d'un tech, mets <b>N+1 étapes</b> (ex. A→B→C = 2 sem. d'écart).</div>
</div>
<div v-else class="text-caption text-grey-6 q-mt-xs">Ajoute les techs dans l'ordre de passage (doublons permis pour des tours inégaux).</div>
<div v-if="gardePreview.length" class="q-mt-sm bg-brown-1 rounded-borders q-pa-sm">
<div class="text-caption text-weight-medium text-brown-9">Aperçu — qui est de garde :</div>
<div class="row q-gutter-xs q-mt-xs">
<q-chip v-for="(p, i) in gardePreview" :key="i" dense size="sm" color="white" text-color="brown-9">{{ p.week.slice(8) }}/{{ p.week.slice(5, 7) }} → {{ p.name }}</q-chip>
</div>
</div>
<div class="row items-center q-mt-md">
<q-btn dense unelevated color="brown" :icon="editingGardeId ? 'save' : 'add'" :label="editingGardeId ? 'Mettre à jour la règle' : 'Enregistrer la règle'" @click="addGardeRule" />
<q-btn v-if="editingGardeId" flat dense class="q-ml-xs" label="Annuler" @click="editingGardeId = null; newGardeRule.steps = []; newGardeRule.weekdays = []" />
</div>
</div>
<!-- Section 3 : Publication -->
<div class="row items-center q-gutter-sm">
<div class="text-caption text-grey-6 col">La grille montre la garde <b>en direct</b> (calque). « Publier » la matérialise sur l'horizon (remplacement propre) pour dispatch &amp; les techs. Vacances ⇒ substitut auto.</div>
<q-select dense outlined v-model="gardeHorizon" :options="[4, 8, 12, 26]" emit-value map-options style="width:96px" label="semaines" />
<q-btn dense unelevated color="primary" icon="cloud_upload" label="Publier la garde" @click="applyGardeRules" />
</div>
</q-card-section>
</q-card>
</q-dialog>
<!-- Éditeur de compétences (modal, comme Dispatch) : overflow visible → dropdown + niveau/couleur jamais clippés -->
<!-- Éditeur compétences : POPOVER ancré au clic (sur la rangée), liste déjà ouverte (autofocus) -->
<q-menu v-model="skillMenuShown" :target="skillMenuTarget" anchor="bottom left" self="top left" no-focus max-height="80vh">
<div v-if="skillDialog" class="q-pa-sm" style="width:368px;min-height:300px" @click.stop @mousedown.stop>
<div class="row items-center q-mb-xs"><div class="text-subtitle2 text-weight-bold col ellipsis">🏷 {{ skillDialog.name }}</div><q-btn flat dense round size="sm" icon="close" v-close-popup /></div>
<TagEditor :model-value="skillDialog.skills" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" autofocus placeholder="Chercher ou créer une compétence…"
@update:model-value="items => onTagsChange(skillDialog, items)"
@create="onCreateRosterTag" />
<div v-if="(skillDialog.skills || []).length" class="q-mt-md">
<div class="row items-center text-caption text-grey-6 q-pb-xs">
<div class="col">Compétence</div>
<div style="width:90px" class="text-center">Score</div>
<div style="width:88px" class="text-center">Efficacité</div>
</div>
<div v-for="sk in skillDialog.skills" :key="sk" class="row items-center no-wrap q-py-xs" style="border-top:1px solid #eee">
<div class="col row items-center no-wrap">
<q-btn flat dense round size="xs" icon="circle" :style="{ color: getTagColor(sk) }"><q-tooltip>Couleur</q-tooltip>
<q-menu><div class="q-pa-xs" style="width:208px">
<div class="row">
<q-btn v-for="c in TAG_PALETTE" :key="c" v-close-popup flat dense round size="xs" icon="circle" :style="{ color: c }" @click="onUpdateRosterTag({ name: sk, color: c })" />
</div>
<div class="row items-center no-wrap q-mt-xs q-px-xs">
<span class="text-caption text-grey-7 q-mr-sm">Perso</span>
<input type="color" :value="getTagColor(sk)" @change="e => onUpdateRosterTag({ name: sk, color: e.target.value })" style="width:42px;height:26px;border:1px solid #ddd;border-radius:4px;background:none;cursor:pointer;padding:0" />
<span class="text-caption text-grey-5 q-ml-sm">toute couleur</span>
</div>
</div></q-menu>
</q-btn>
<span class="skill-chip" :style="{ background: getTagColor(sk) }">{{ sk }}</span>
</div>
<div style="width:90px" class="text-center no-wrap">
<q-icon v-for="n in 5" :key="n" :name="skillLevelOf(skillDialog, sk) >= n ? 'star' : 'star_outline'" :color="skillLevelOf(skillDialog, sk) >= n ? 'indigo' : 'grey-4'" size="16px" class="cursor-pointer" @click="setSkillLevel(skillDialog, sk, skillLevelOf(skillDialog, sk) === n ? 0 : n)" />
</div>
<div style="width:88px">
<q-input dense outlined type="number" step="5" debounce="600" :model-value="skillEffPct(skillDialog, sk)" @update:model-value="v => setSkillEffPct(skillDialog, sk, v)" suffix="%" placeholder="glob." input-class="text-right" />
</div>
</div>
<div class="text-caption text-grey-6 q-mt-xs"><b>Score</b> ★ = maîtrise · <b>Efficacité</b> : <b>+</b> plus vite / <b></b> plus lent pour CETTE compétence (ex. <b>+80</b>), vide = globale · × sur le chip = retirer.</div>
</div>
<q-separator class="q-my-sm" />
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🗓 Horaire — semaine affichée</div>
<div class="row q-gutter-xs">
<q-btn dense outline size="sm" no-caps color="primary" label="5×8h (LV)" @click="applyWeekPreset(skillDialog, [1,2,3,4,5], 8, 16)" />
<q-btn dense outline size="sm" no-caps color="primary" label="4×10h (LJ)" @click="applyWeekPreset(skillDialog, [1,2,3,4], 7, 17)" />
<q-btn dense outline size="sm" no-caps color="primary" label="3×12h (LM)" @click="applyWeekPreset(skillDialog, [1,2,3], 7, 19)" />
</div>
<q-separator class="q-my-sm" />
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🏠 Domicile <span class="text-grey-5 text-weight-regular">(départ de tournée si plus proche que le dépôt)</span></div>
<div class="row items-center no-wrap q-gutter-xs">
<q-btn dense outline size="sm" no-caps color="teal" :icon="techHomes[skillDialog.id] ? 'edit_location_alt' : 'add_location_alt'" :label="techHomes[skillDialog.id] ? 'Modifier' : 'Définir'" @click="openHomePicker(skillDialog)" />
<span class="text-caption text-grey-6 ellipsis col">{{ techHomes[skillDialog.id] && techHomes[skillDialog.id].address ? techHomes[skillDialog.id].address : 'non défini' }}</span>
</div>
</div>
</q-menu>
<!-- Impact d'un retrait de compétence : jobs assignés devenus invalides → redistribuer -->
<q-dialog v-model="skillImpactOpen">
<q-card style="min-width:420px;max-width:520px" v-if="skillImpactDialog">
<q-card-section class="row items-center q-pb-none">
<q-icon name="warning" color="orange" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold col">{{ skillImpactDialog.kind === 'absence' ? 'Absence — impact' : 'Compétence retirée — impact' }}</div>
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<div v-if="skillImpactDialog.kind === 'absence'" class="text-body2 q-mb-sm"><b>{{ skillImpactDialog.jobs.length }}</b> job(s) assigné(s) à <b>{{ skillImpactDialog.tech.name }}</b> tombent sur son absence — à redistribuer.</div>
<div v-else class="text-body2 q-mb-sm"><b>{{ skillImpactDialog.jobs.length }}</b> job(s) assigné(s) à <b>{{ skillImpactDialog.tech.name }}</b> exigent « <b>{{ skillImpactDialog.skill }}</b> », qu'on vient de retirer — il/elle ne peut plus les faire.</div>
<q-list dense bordered class="rounded-borders" style="max-height:320px;overflow:auto">
<q-item v-for="j in skillImpactDialog.jobs" :key="j.name" class="q-py-sm">
<q-item-section>
<q-item-label>{{ j.service_type || 'Job' }}{{ j.customer_name ? ' — ' + j.customer_name : '' }}</q-item-label>
<q-item-label caption><q-icon name="event" size="12px" /> {{ j.scheduled_date ? j.scheduled_date.slice(5) : '—' }} {{ j.start_time ? j.start_time.slice(0, 5) : '' }} · {{ j.location_label || j.service_location || '—' }}</q-item-label>
</q-item-section>
<q-item-section side style="min-width:188px">
<q-select dense outlined options-dense :model-value="impactPlan[j.name]" :options="candidateOptions(j.name)" emit-value map-options :loading="loadingCandidates" label="Réassigner à" @update:model-value="v => { impactPlan[j.name] = v }" />
</q-item-section>
</q-item>
</q-list>
<div class="text-caption text-grey-6 q-mt-sm">Choix <b>classés</b> = techs qualifiés <b>libres au même créneau</b> (le 1er = suggéré). « À recontacter » → file RDV (créneaux filtrés, SMS client possible).</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Ignorer" v-close-popup :disable="redistributing" />
<q-btn outline color="grey-8" no-caps label="Tout à recontacter" :loading="redistributing" @click="doRedistribute('requeue')" />
<q-btn unelevated color="primary" icon="check" no-caps label="Appliquer" :loading="redistributing" @click="applyImpactPlan" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Gestionnaire global des compétences (tags) : renommer / recolorer / supprimer partout -->
<q-dialog v-model="showTagManager">
<q-card style="min-width:420px;max-width:540px">
<q-card-section class="row items-center q-pb-none">
<div class="text-subtitle1 text-weight-bold col">🏷 Gérer les compétences</div>
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section style="max-height:62vh;overflow:auto">
<div v-if="!managedTags.length" class="text-grey-6">Aucune compétence — ajoute-en via un employé.</div>
<q-list v-else dense separator>
<q-item v-for="tg in managedTags" :key="tg.label">
<q-item-section avatar>
<q-btn flat dense round size="sm" icon="circle" :style="{ color: tg.color }"><q-tooltip>Couleur</q-tooltip>
<q-menu><div class="q-pa-xs" style="width:208px">
<div class="row"><q-btn v-for="c in TAG_PALETTE" :key="c" v-close-popup flat dense round size="xs" icon="circle" :style="{ color: c }" @click="onUpdateRosterTag({ name: tg.label, color: c })" /></div>
<div class="row items-center no-wrap q-mt-xs q-px-xs"><span class="text-caption text-grey-7 q-mr-sm">Perso</span><input type="color" :value="tg.color" @change="e => onUpdateRosterTag({ name: tg.label, color: e.target.value })" style="width:42px;height:26px;border:1px solid #ddd;border-radius:4px;background:none;cursor:pointer;padding:0" /></div>
</div></q-menu>
</q-btn>
</q-item-section>
<q-item-section>
<q-item-label class="cursor-pointer">{{ tg.label }}
<q-popup-edit :model-value="tg.label" auto-save v-slot="scope" @save="v => renameTagGlobal(tg.label, v)">
<q-input dense autofocus :model-value="scope.value" @update:model-value="scope.value = $event" label="Renommer (partout)" @keyup.enter="scope.set" />
</q-popup-edit>
</q-item-label>
<q-item-label caption>{{ tg.count }} technicien(s) · clic = renommer</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="deleteTagGlobal(tg)"><q-tooltip>Supprimer partout</q-tooltip></q-btn>
</q-item-section>
</q-item>
</q-list>
<div class="text-caption text-grey-6 q-mt-sm">Renommer/supprimer s'applique à <b>tous les techniciens</b> qui ont la compétence. Recolorer change la couleur partout.</div>
</q-card-section>
</q-card>
</q-dialog>
<!-- 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-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="selectedNames.length ? 'Suggérer (' + selectedNames.length + ')' : 'Suggérer'" :loading="suggestDlg.building" class="q-mr-xs" @click="openSuggest"><q-tooltip>Proposer une répartition optimisée (proximité · charge · compétence){{ selectedNames.length ? ' pour la sélection' : '' }} — 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>
<q-btn dense round size="sm" class="map-sat-btn" :color="satView ? 'primary' : 'grey-8'" :icon="satView ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satView ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
<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>
<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>
<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">
<q-tooltip>Assigner TOUTE la sélection à un technicien (chaque job garde sa date) — classés par compétence · dispo · distance · charge</q-tooltip>
<q-menu anchor="top right" self="bottom right">
<div class="assign-pick-hd">Assigner {{ selectedNames.length }} job(s) · {{ selectedHours }}h à…</div>
<q-list dense style="min-width:280px;max-height:46vh;overflow:auto">
<q-item v-for="tk in techsForSelection()" :key="tk.id" clickable v-close-popup @click="quickAssignSelection(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" name="warning" size="12px" color="orange" class="q-ml-xs"><q-tooltip>Manque une compétence requise par la sélection</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="!techsForSelection().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-btn dense flat size="sm" color="primary" icon="edit_calendar" label="Date" class="q-ml-sm"><q-tooltip>Replanifier toute la sélection (aujourd'hui / demain / date)</q-tooltip><q-menu><q-list dense style="min-width:160px"><q-item clickable v-close-popup @click="setSelDate(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="setSelDate(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="todayISO()" @update:model-value="v => setSelDate(v)" mask="YYYY-MM-DD" minimal today-btn /></q-list></q-menu></q-btn>
<q-btn dense unelevated size="sm" color="negative" icon="block" :label="'Fermer (' + selectedNames.length + ')'" class="q-ml-sm" @click="bulkCloseSelected"><q-tooltip>Fermer les tickets sélectionnés dans le legacy (au lieu de réassigner)</q-tooltip></q-btn></template>
<template v-else>Coche des jobs (groupe lié surligné), puis glisse la sélection sur une case <b>tech × jour</b>. <q-icon name="home_repair_service" size="11px" color="teal" />=terrain · <q-icon name="cloud" size="11px" color="grey-5" />=à distance · 🔒=bloqué.</template>
</div>
<div class="assign-resize" @mousedown.stop="panelResizeDown"><q-tooltip>Redimensionner</q-tooltip></div>
</div>
<!-- D : revue de la répartition suggérée (surcouche) — déplacer/retirer avant d'appliquer -->
<q-dialog v-model="suggestDlg.open">
<q-card style="width:940px;max-width:96vw;max-height:90vh;display:flex;flex-direction:column">
<q-card-section class="row items-center q-py-sm" style="border-bottom:1px solid #e2e8f0">
<q-icon name="auto_awesome" color="amber-7" size="22px" class="q-mr-sm" />
<div><div class="text-subtitle1 text-weight-bold">{{ suggestDlg.mode === 'config' ? 'Techniciens disponibles' : 'Répartition suggérée' }}</div><div class="text-caption text-grey-7"><template v-if="suggestDlg.mode === 'config'">Choisis qui participe au dispatch auto{{ suggestDlg.fromSel ? ' de la sélection' : '' }} · {{ suggestSelCount }} sélectionné(s)</template><template v-else>{{ suggestDlg.plan.length }} job(s) · {{ suggestGroups.length }} tech(s){{ suggestDlg.fromSel ? ' · sélection' : '' }} — proximité + charge + compétence. Ajuste puis applique.</template></div></div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="col scroll q-pa-sm" style="overflow:auto">
<!-- Étape 1 — disponibilité des techniciens + création de quarts 816 -->
<template v-if="suggestDlg.mode === 'config'">
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
<span class="text-caption text-grey-7 q-mr-xs">Stratégie :</span>
<q-btn-toggle v-model="suggestDlg.strategy" dense no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8" :options="[{ label: 'Intelligent', value: 'smart' }, { label: 'Meilleurs d\'abord', value: 'best' }, { label: 'Équilibré', value: 'balance' }, { label: 'Juste ce qu\'il faut', value: 'enough' }]" />
<span class="text-caption text-grey-6">{{ ({ smart: 'proximité + compétence + charge', best: 'concentre le travail sur les meilleurs (cascade)', balance: 'répartit également entre tous (round-robin)', enough: 'qualifié mais le moins sur-qualifié → réserve les experts (distance + temps pris en compte)' })[suggestDlg.strategy] }}</span>
</div>
<div class="text-caption q-mb-sm" style="color:#475569;background:#eef2ff;border-radius:6px;padding:4px 8px"><q-icon name="date_range" size="14px" class="q-mr-xs" color="primary" />Répartit sur : <b>{{ suggestWindowLabel }}</b><span class="text-grey-6"> — défaut aujourd'hui + demain ; filtre par date (chips de la liste) pour cibler d'autres jours. Les jobs datés au-delà sont laissés pour plus tard.</span></div>
<div class="row items-center q-mb-xs" style="gap:4px">
<span class="text-caption text-grey-7 q-mr-xs">Rapide :</span>
<q-btn dense flat size="sm" no-caps label="Tous" @click="suggestSelectTechs('all')" />
<q-btn dense flat size="sm" no-caps label="Avec quart" @click="suggestSelectTechs('shift')" />
<q-btn dense flat size="sm" no-caps label="Aucun" @click="suggestSelectTechs('none')" />
</div>
<div class="row items-center q-mb-sm" style="gap:5px;flex-wrap:wrap">
<span class="text-caption text-grey-7 q-mr-xs">Par compétence :</span>
<span v-for="s in suggestSkillGroups" :key="s.skill" class="suggest-skill-chip" :class="{ on: suggestSkillOn(s.skill) }" :style="suggestSkillOn(s.skill) ? { background: getTagColor(s.skill), color: '#fff', borderColor: getTagColor(s.skill) } : { borderColor: getTagColor(s.skill), color: getTagColor(s.skill) }" @click="suggestSelectSkill(s.skill)">{{ s.skill }} <b>{{ s.n }}</b></span>
</div>
<div class="suggest-tech-grid">
<div v-for="t in suggestTechsRanked" :key="t.id" class="suggest-tech-chip" :class="{ on: !!suggestDlg.techSel[t.id] }" @click="suggestDlg.techSel[t.id] = !suggestDlg.techSel[t.id]">
<q-icon :name="suggestDlg.techSel[t.id] ? 'check_circle' : 'radio_button_unchecked'" size="16px" :color="suggestDlg.techSel[t.id] ? 'primary' : 'grey-5'" />
<q-avatar size="22px" color="blue-grey-1" text-color="blue-grey-8" style="font-size:9px;font-weight:700">{{ initials(t.name) }}</q-avatar>
<span class="ellipsis" style="flex:1">{{ t.name }}</span>
<q-icon v-if="(Number(t.efficiency) || 1) < 0.95" name="bolt" size="14px" color="green-6"><q-tooltip>Rapide (efficacité {{ t.efficiency }})</q-tooltip></q-icon>
<q-icon v-else-if="(Number(t.efficiency) || 1) > 1.05" name="hourglass_bottom" size="13px" color="orange-6"><q-tooltip>Plus lent (efficacité {{ t.efficiency }})</q-tooltip></q-icon>
<q-icon v-if="!(dayList || []).some(d => hasShiftDay(t.id, d.iso))" name="event_busy" size="13px" color="amber-7"><q-tooltip>Aucun quart publié cette période</q-tooltip></q-icon>
<q-icon name="edit" size="14px" color="grey-6" class="cursor-pointer" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Modifier compétences &amp; niveaux (★) + efficacité</q-tooltip></q-icon>
</div>
</div>
<q-toggle v-model="suggestDlg.makeShifts" dense size="sm" color="primary" class="q-mt-sm" label="Créer un quart 816 pour les techs sans quart (à l'application)" />
</template>
<!-- Étape 2 — revue de la répartition proposée -->
<template v-else>
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
<q-btn-toggle v-model="suggestDlg.view" dense no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8" :options="[{ label: 'Liste', value: 'list', icon: 'view_list' }, { label: 'Carte des tournées', value: 'map', icon: 'map' }]" />
<template v-if="suggestDlg.view === 'map' && suggestWindow.length > 1">
<q-space />
<span class="text-caption text-grey-7">Jour :</span>
<q-btn-toggle v-model="suggestMapDay" dense no-caps unelevated toggle-color="indigo" color="grey-3" text-color="grey-8" :options="suggestWindow.map(iso => ({ label: windowDayLabel(iso), value: iso }))" />
</template>
</div>
<div v-show="suggestDlg.view === 'map'">
<div ref="suggestMapEl" class="suggest-map"></div>
<div class="suggest-legend">
<span v-for="r in suggestRoutes" :key="r.techId" class="suggest-leg"><span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.techName }} · {{ r.stops.length }} arrêt(s)<span v-if="realRoutes[r.techId]"> · 🚗 {{ realRoutes[r.techId].km }} km / {{ realRoutes[r.techId].mins }} min</span><span v-else-if="r.km"> · 🚗 ≈{{ r.km }} km</span></span>
<span v-if="!suggestRoutes.length" class="text-grey-6" style="font-size:12px">Aucun job géolocalisé ce jour (utilise « Localiser » sur le pool pour les placer sur la carte).</span>
</div>
</div>
<div v-show="suggestDlg.view !== 'map'">
<div v-if="!suggestDlg.plan.length" class="text-grey-6 text-center q-pa-lg">Rien à répartir (aucun job éligible).</div>
<div v-for="g in suggestGroups" :key="g.techId" class="suggest-tech" :class="{ 'suggest-wknd': g.placeholder }">
<div class="suggest-tech-hd">
<q-avatar size="24px" :color="g.placeholder ? 'amber-2' : 'blue-grey-1'" :text-color="g.placeholder ? 'amber-9' : 'blue-grey-8'" style="font-size:10px;font-weight:700"><q-icon v-if="g.placeholder" name="hourglass_top" size="15px" /><template v-else>{{ initials(g.techName) }}</template></q-avatar>
<b class="q-ml-sm">{{ g.techName }}</b>
<span class="text-grey-6 q-ml-sm" style="font-size:12px">{{ g.jobs }} job(s) · {{ Math.round(g.hours * 10) / 10 }}h<span v-if="g.km"> · 🚗 ≈{{ g.km }} km / {{ g.mins }} min</span><span v-if="!g.placeholder && g.existing > 0" class="text-orange-8" title="Heures déjà assignées à ce tech ces jours-là"> · déjà {{ g.existing }}h</span><span v-if="g.placeholder" class="text-amber-9"> · file à assigner</span></span>
<!-- Occupation projetée = charge déjà assignée + heures suggérées / capacité (quarts réels ou 8h) -->
<div v-if="!g.placeholder && g.cap" class="suggest-occ" :title="'Occupation projetée ' + g.projected + 'h / ' + g.cap + 'h (déjà ' + g.existing + 'h assigné + ' + (Math.round(g.hours*10)/10) + 'h proposé)'">
<div class="suggest-occ-bar"><div class="suggest-occ-fill" :class="{ over: g.over, high: !g.over && g.fillPct >= 85 }" :style="{ width: g.fillPct + '%' }"></div></div>
<span class="suggest-occ-txt" :class="{ 'text-negative': g.over }">{{ g.projected }}/{{ g.cap }}h</span>
</div>
<q-icon v-if="g.over" name="error" color="negative" size="16px" class="q-ml-xs"><q-tooltip>Surcharge : {{ g.projected }}h projetées > {{ g.cap }}h de capacité (inclut le déjà-assigné)</q-tooltip></q-icon>
<q-space />
<!-- Quart manquant (Q2 §1) : alerte + créer le quart 8-16 (local, enregistré au Publier) -->
<q-btn v-if="g.noShiftDays && g.noShiftDays.length" flat dense no-caps size="sm" color="amber-8" icon="event_busy" :label="'Créer quart' + (g.noShiftDays.length > 1 ? ' ×' + g.noShiftDays.length : '')" @click="createShiftForGroup(g)"><q-tooltip>{{ g.techName }} n'a pas de quart {{ g.noShiftDays.length > 1 ? 'sur ' + g.noShiftDays.length + ' jour(s)' : 'ce jour' }} — créer un quart 8-16</q-tooltip></q-btn>
<!-- Assigner / fusionner TOUTE la file (marche depuis un placeholder → vrai tech, ou pour fusionner 2 files) -->
<q-btn flat dense round size="sm" :icon="g.placeholder ? 'person_add' : 'drive_file_move'" :color="g.placeholder ? 'amber-9' : 'grey-7'"><q-tooltip>{{ g.placeholder ? 'Assigner cette tournée à un tech' : ('Déplacer / fusionner tous les jobs de ' + g.techName) }}</q-tooltip>
<q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:250px;max-height:42vh;overflow:auto">
<q-item-label header class="q-py-xs">{{ g.placeholder ? 'Assigner les' : 'Déplacer les' }} {{ g.jobs }} job(s) à…<span v-if="g.skills && g.skills.length" class="text-grey-6"> (compétence {{ g.skills.join(', ') }})</span></q-item-label>
<q-item v-for="t in capableTechsFirst(g.skills)" :key="t.id" clickable v-close-popup @click="moveSuggestGroup(g, t.id, t.name)" :disable="t.id === g.techId || (!covers(t, g.skills) && anyCovers(g.skills))">
<q-item-section avatar style="min-width:30px"><q-avatar size="22px" :color="covers(t, g.skills) ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:9px;font-weight:700">{{ initials(t.name) }}</q-avatar></q-item-section>
<q-item-section>{{ t.name }}<span v-if="planCountByTech[t.id] && t.id !== g.techId" class="text-teal-7" style="font-size:11px"> · fusionner ({{ planCountByTech[t.id] }})</span></q-item-section>
<q-item-section side v-if="!covers(t, g.skills)"><q-icon name="block" color="grey-5" size="15px"><q-tooltip>Sans la compétence requise</q-tooltip></q-icon></q-item-section>
</q-item>
</q-list></q-menu>
</q-btn>
</div>
<div v-for="d in g.dayList" :key="d.iso" class="suggest-day">
<div class="suggest-day-lbl">{{ d.label }} <span class="text-grey-6">· {{ Math.round(d.hours * 10) / 10 }}h<span v-if="d.km"> · 🚗 ≈{{ d.km }} km / {{ d.mins }} min</span></span></div>
<div v-for="(e, ei) in d.entries" :key="e.jobName" class="suggest-entry">
<span class="suggest-step">{{ ei + 1 }}</span>
<span v-if="e.skill" class="assign-skill" :style="{ background: getTagColor(e.skill) }">{{ e.skill }}</span>
<span v-if="e.lvl" class="suggest-mlvl" :title="'Maîtrise du tech : ' + e.lvl + (e.reqLevel > 1 ? ' / niveau requis ' + e.reqLevel : '')">★{{ e.lvl }}<span v-if="e.reqLevel > 1" style="opacity:.6">/{{ e.reqLevel }}</span></span>
<q-icon v-if="e.eff && e.eff < 0.95" name="bolt" size="12px" color="green-6" class="q-mr-xs"><q-tooltip>Tech rapide sur ce type ({{ e.eff }})</q-tooltip></q-icon>
<span class="suggest-entry-t ellipsis">{{ e.subject }}<span v-if="e.customer" class="text-grey-6"> · {{ e.customer }}</span></span>
<span class="text-grey-6 q-mx-xs" style="font-size:11px;white-space:nowrap">{{ Math.round(e.dur * 10) / 10 }}h</span>
<q-icon v-if="!e.capable" name="warning" size="14px" color="deep-orange" class="q-mr-xs"><q-tooltip>{{ e.techName }} n'a pas la compétence « {{ e.skill }} » — à réassigner</q-tooltip></q-icon>
<q-icon v-if="e.noShift" name="event_busy" size="14px" color="amber-8" class="q-mr-xs"><q-tooltip>Aucun quart publié ce jour (assignable, mais ⚠ hors quart)</q-tooltip></q-icon>
<q-space />
<q-btn flat dense round size="sm" icon="swap_horiz" color="grey-7"><q-tooltip>Déplacer vers un autre tech</q-tooltip>
<q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:220px;max-height:42vh;overflow:auto">
<q-item-label v-if="e.skill" header class="q-py-xs">Compétence « {{ e.skill }} »</q-item-label>
<q-item v-for="t in capableTechsFirst([e.skill])" :key="t.id" clickable v-close-popup @click="moveSuggestEntry(e, t.id, t.name)" :active="t.id === e.techId" :disable="!covers(t, [e.skill]) && anyCovers([e.skill])">
<q-item-section avatar style="min-width:30px"><q-avatar size="22px" :color="covers(t, [e.skill]) ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:9px;font-weight:700">{{ initials(t.name) }}</q-avatar></q-item-section>
<q-item-section>{{ t.name }}</q-item-section>
<q-item-section side v-if="!covers(t, [e.skill])"><q-icon name="block" color="grey-5" size="15px"><q-tooltip>Sans la compétence</q-tooltip></q-icon></q-item-section>
</q-item>
</q-list></q-menu>
</q-btn>
<q-btn flat dense round size="sm" icon="inbox" color="grey-6" @click="removeSuggestEntry(e)"><q-tooltip>Retirer (le job reste au pool)</q-tooltip></q-btn>
</div>
</div>
</div>
</div>
</template>
</q-card-section>
<q-card-actions align="right" style="border-top:1px solid #e2e8f0">
<q-btn v-if="suggestDlg.mode === 'review'" flat no-caps icon="arrow_back" label="Techniciens" @click="suggestDlg.mode = 'config'" />
<q-space v-if="suggestDlg.mode === 'review'" />
<q-btn flat no-caps label="Annuler" v-close-popup />
<q-btn v-if="suggestDlg.mode === 'config'" unelevated no-caps color="primary" icon="auto_awesome" :label="'Générer (' + suggestSelCount + ' tech)'" :loading="suggestDlg.building" :disable="!suggestSelCount" @click="runSuggestion" />
<q-btn v-else unelevated no-caps color="primary" icon="check" :label="'Appliquer ' + suggestDlg.plan.length + ' assignation(s)'" :loading="suggestDlg.applying" :disable="!suggestDlg.plan.length" @click="applySuggestion" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Write-back legacy : aperçu des réassignations osTicket → confirmer avant d'écrire dans le legacy -->
<q-dialog v-model="legacyPush.open">
<q-card style="min-width:540px;max-width:660px">
<q-card-section class="row items-center q-pb-sm">
<q-icon name="sync_alt" color="deep-orange" size="22px" class="q-mr-sm" />
<div>
<div class="text-subtitle1 text-weight-bold">Publier les assignations au legacy</div>
<div class="text-caption text-grey-7">Réassigne <code>ticket.assign_to</code> au technicien dans osTicket (selon l'assignation Ops).</div>
</div>
<q-space /><q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-pt-none">
<div v-if="legacyPush.loading" class="text-grey-6 q-pa-md text-center"><q-spinner size="20px" class="q-mr-sm" />Analyse des assignations…</div>
<template v-else-if="legacyPush.preview">
<q-banner v-if="!legacyPush.preview.pushed" dense rounded class="bg-blue-1 text-info q-mb-sm"><template #avatar><q-icon name="info" /></template>Rien à pousser. Assigne d'abord des jobs à des techs (glisser-déposer), puis reviens ici.</q-banner>
<div v-else class="text-body2 q-mb-sm"><b class="text-warning">{{ legacyPush.preview.pushed }}</b> ticket(s) seront réassignés dans le legacy<span v-if="legacyPush.preview.skipped" class="text-grey-6"> · {{ legacyPush.preview.skipped }} déjà à jour/fermé(s)</span><span v-if="legacyPush.preview.mismatch" class="text-warning"> · {{ legacyPush.preview.mismatch }} ignoré(s) (nom ≠)</span>.</div>
<q-list dense bordered class="rounded-borders" style="max-height:300px;overflow:auto">
<q-item v-for="(s, i) in (legacyPush.preview.samples || [])" :key="i" class="q-py-xs">
<q-item-section avatar style="min-width:28px"><q-icon :name="s.action ? 'warning' : 'arrow_forward'" :color="s.action ? 'deep-orange' : 'green'" size="16px" /></q-item-section>
<q-item-section>
<q-item-label v-if="s.action" style="font-size:12px" class="text-warning">Ticket #{{ s.ticket }} — Ops « {{ s.ops_nom }} » ≠ legacy « {{ s.legacy_staff }} » → <b>ignoré</b></q-item-label>
<q-item-label v-else style="font-size:12px">Ticket #{{ s.ticket }} → <b>{{ s.vers_staff }}</b><span class="text-grey-6"> · {{ s.sujet }}</span></q-item-label>
</q-item-section>
<q-item-section side v-if="s.job"><q-btn flat dense round size="sm" icon="close" color="negative" :disable="legacyPush.applying" @click="removeFromPush(s)"><q-tooltip>Désassigner ce job (retour au pool) — ne sera pas poussé</q-tooltip></q-btn></q-item-section>
</q-item>
</q-list>
<div class="text-caption text-grey-6 q-mt-xs">Sécurité : seuls les tickets <b>ouverts</b> sont touchés, le nom du staff legacy doit concorder, et c'est idempotent.</div>
</template>
</q-card-section>
<q-card-actions align="right">
<q-checkbox v-if="legacyPush.preview && legacyPush.preview.pushed" v-model="legacyPush.notify" dense size="sm" color="deep-orange" class="q-mr-auto" label="Notifier les techs par courriel">
<q-tooltip class="bg-grey-9" max-width="320px">Envoie à chaque tech réassigné le courriel d'assignation (résumé + adresse + lien <b>Répondre</b>), identique au legacy.</q-tooltip>
</q-checkbox>
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="deep-orange" icon="cloud_upload" label="Écrire dans le legacy" :loading="legacyPush.applying" :disable="!legacyPush.preview || !legacyPush.preview.pushed" @click="applyLegacyPush" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Sélecteur d'emplacement (jobs « hors carte ») : carte bornée au sud de Montréal + recherche d'adresse RQA -->
<q-dialog v-model="locPicker.open" @show="initLocMap" @hide="destroyLocMap">
<q-card style="width: 700px; max-width: 96vw">
<q-card-section class="row items-center q-pb-xs">
<div class="text-subtitle2">{{ locPicker.mode === 'depot' ? '🏁 Point de départ (dépôt)' : locPicker.mode === 'home' ? ('🏠 ' + locPicker.subject) : '📍 Situer le job' }}<span v-if="locPicker.mode === 'job'" class="text-grey-7"> — {{ locPicker.subject }}</span></div>
<q-space />
<q-btn dense flat round icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-pt-none">
<q-input dense outlined v-model="locPicker.search" placeholder="Chercher une adresse (base RQA Québec)…" class="q-mb-xs" :loading="locPicker.searching" @keyup.enter="locSearch" clearable>
<template #append><q-btn dense flat icon="search" @click="locSearch" /></template>
</q-input>
<div v-if="locPicker.mode === 'home'" class="row items-center q-mb-xs">
<q-btn dense outline no-caps size="sm" color="indigo" icon="my_location" label="Position GPS actuelle du tech" :loading="liveGpsBusy" @click="pickerUseLiveGps" />
<span class="text-caption text-grey-6 q-ml-sm">…ou cherche / clique sur la carte</span>
</div>
<div v-if="locPicker.mode === 'job' && locPicker.autoMatched" class="text-caption text-positive q-mb-xs row items-center no-wrap">
<q-icon name="check_circle" size="15px" class="q-mr-xs" /> Adresse reconnue automatiquement (base RQA) — vérifie la carte, puis « Utiliser cet emplacement ».
</div>
<div v-else-if="locPicker.mode === 'job' && locPicker.results.length > 1 && locPicker.lat == null" class="text-caption text-warning q-mb-xs row items-center no-wrap">
<q-icon name="info" size="15px" class="q-mr-xs" /> Plusieurs correspondances — choisis la bonne ci-dessous.
</div>
<q-list v-if="locPicker.results.length" bordered dense class="loc-results q-mb-xs">
<q-item v-for="(rr, i) in locPicker.results" :key="i" clickable @click="pickLocResult(rr)">
<q-item-section>
<q-item-label>{{ rr.address_full || ((rr.numero || '') + ' ' + (rr.rue || '')) }}</q-item-label>
<q-item-label caption>{{ rr.ville }} {{ rr.code_postal }}<span v-if="rr.fiber_available" class="text-positive"> · fibre</span></q-item-label>
</q-item-section>
<q-item-section side><q-icon name="place" color="warning" /></q-item-section>
</q-item>
</q-list>
<div class="loc-map-wrap">
<div ref="locMapEl" class="loc-map"></div>
<q-btn dense round size="sm" class="map-sat-btn" :color="satView ? 'primary' : 'grey-8'" :icon="satView ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satView ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
<div class="loc-map-hint">Clic = situer · glisser le repère · <b>clic droit = Street View</b></div>
</div>
<q-input dense outlined v-model="locPicker.address" label="Adresse (modifiable)" class="q-mt-sm" />
<div class="text-caption text-grey-6 q-mt-xs" v-if="locPicker.lat != null">Coordonnées : {{ locPicker.lat }}, {{ locPicker.lon }}</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="primary" icon="place" label="Utiliser cet emplacement" :loading="locPicker.saving" :disable="locPicker.lat == null" @click="saveLocPicker" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Détail d'un job Legacy (clic sur un bloc hachuré) : fil du ticket osTicket, lecture seule -->
<q-dialog v-model="legacyDetail.open">
<q-card style="min-width:520px;max-width:680px">
<q-card-section class="row items-center q-pb-sm">
<q-icon name="assignment" color="brown" size="20px" class="q-mr-sm" />
<div class="col" style="min-width:0">
<div class="text-subtitle1 text-weight-bold ellipsis">{{ legacyDetail.subject }}</div>
<div class="text-caption text-grey-7">Ticket Legacy #{{ legacyDetail.lid }}<span v-if="legacyDetail.dept"> · {{ legacyDetail.dept }}</span></div>
</div>
<q-space /><q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-pt-none" style="max-height:60vh;overflow:auto">
<div v-if="legacyDetail.loading" class="text-center q-pa-md"><q-spinner size="26px" color="brown" /><div class="text-caption text-grey-6 q-mt-sm">Lecture du ticket…</div></div>
<template v-else-if="legacyDetail.thread && legacyDetail.thread.messages && legacyDetail.thread.messages.length">
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ legacyDetail.thread.messages.length }} message(s){{ legacyDetail.thread.status ? ' · ' + legacyDetail.thread.status : '' }}</div>
<div v-for="(m, mi) in legacyDetail.thread.messages" :key="mi" class="de-msg">
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span></div>
<div class="de-msg-txt">{{ m.text }}</div>
</div>
</template>
<div v-else class="text-grey-6 q-pa-md text-center">{{ (legacyDetail.thread && legacyDetail.thread.error) ? 'Détail indisponible' : 'Aucun message dans ce ticket.' }}</div>
</q-card-section>
</q-card>
</q-dialog>
<!-- Volet DÉTAILS (double-clic sur un job du board) : grand panneau DROIT, pleine hauteur, ~pleine largeur — billet + commentaires -->
<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">
<q-icon name="assignment" color="primary" size="22px" class="q-mr-sm" />
<div class="col" style="min-width:0">
<div class="text-h6 ellipsis">{{ jobDetail.subject }}</div>
<div class="text-caption text-grey-7"><template v-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>
<q-space /><q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-separator />
<q-card-section class="jd-body scroll">
<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 class="jd-meta">
<div v-if="jobDetail.time"><q-icon name="schedule" size="16px" /> {{ jobDetail.time }}</div>
<div v-if="jobDetail.skill"><q-icon name="construction" size="16px" /> {{ jobDetail.skill }}</div>
<div v-if="jobDetail.address"><q-icon name="place" size="16px" /> {{ jobDetail.address }}</div>
</div>
<div v-if="jobDetail.canTeam" class="jd-team">
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="group" size="18px" class="q-mr-xs" /> Équipe / renfort<q-space /><q-spinner v-if="jobDetail.teamLoading" size="16px" color="deep-purple" /></div>
<div v-if="jobDetail.team && jobDetail.team.length" class="row items-center q-gutter-xs q-mb-xs">
<q-chip v-for="a in jobDetail.team" :key="a.tech_id" dense removable @remove="jdRemoveAssistant(a.tech_id)" color="deep-purple-1" text-color="deep-purple-9" icon="group">{{ a.tech_name || a.tech_id }}</q-chip>
</div>
<div v-else class="text-caption text-grey-6 q-mb-xs">Aucun assistant. Ajoutez un renfort → un bloc <b>hachuré</b> sera réservé dans son horaire (anti doublebooking).</div>
<div class="row items-center no-wrap q-gutter-xs">
<q-select dense outlined options-dense v-model="jobDetail.teamAdd" :options="jdTeamOptions" emit-value map-options label="Ajouter un assistant" style="flex:1" />
<q-btn dense unelevated color="deep-purple" icon="group_add" label="Ajouter" :disable="!jobDetail.teamAdd" @click="jdAddAssistant" no-caps />
</div>
</div>
<div v-if="jobDetail.detail" class="jd-detail">{{ jobDetail.detail }}</div>
<div class="text-subtitle2 q-mt-md q-mb-xs">Commentaires / fil du billet</div>
<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="jobDetail.thread && jobDetail.thread.messages && jobDetail.thread.messages.length">
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ jobDetail.thread.messages.length }} message(s){{ jobDetail.thread.status ? ' · ' + jobDetail.thread.status : '' }}</div>
<div v-for="(m, mi) in jobDetail.thread.messages" :key="mi" class="de-msg">
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span></div>
<div class="de-msg-txt">{{ m.text }}</div>
</div>
</template>
<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>
</q-card-section>
</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">
<q-card-section class="q-pb-xs">
<div class="text-subtitle1 row items-center"><q-icon name="report_problem" color="warning" size="22px" class="q-mr-sm" /><b>{{ dropAsk.tech && dropAsk.tech.name }}</b>&nbsp;n'a aucun quart</div>
<div class="text-caption text-grey-7">{{ dropAsk.day && (dropAsk.day.dow + ' ' + dropAsk.day.dnum) }} · {{ dropAsk.names.length }} job(s) à placer</div>
</q-card-section>
<!-- Choix initial -->
<q-card-section v-if="dropAsk.mode === 'choice'" class="q-gutter-sm">
<q-btn unelevated color="primary" icon="add_box" label="Créer un quart et assigner" class="full-width" no-caps @click="dropAsk.mode = 'shift'" align="left" />
<q-btn outline color="deep-orange" icon="event_busy" label="Marquer le technicien absent" class="full-width" no-caps @click="dropAsk.mode = 'absence'" align="left" />
<div class="text-caption text-grey-6 q-px-xs">Tant qu'aucun quart n'existe, la job reste au pool — elle n'est pas assignée en douce.</div>
</q-card-section>
<!-- Créer un quart -->
<q-card-section v-else-if="dropAsk.mode === 'shift'" class="q-gutter-sm">
<q-select dense outlined v-model="dropAsk.preset" :options="shiftPresets" emit-value map-options label="Durée du quart" />
<div v-if="dropAsk.preset === 'custom'" class="row q-gutter-sm">
<q-input dense outlined type="time" v-model="dropAsk.cStart" label="Début" style="flex:1" />
<q-input dense outlined type="time" v-model="dropAsk.cEnd" label="Fin" style="flex:1" />
</div>
<div class="text-caption text-grey-6">Quart créé (non publié) + {{ dropAsk.names.length }} job(s) assignée(s). Annulable ensuite.</div>
</q-card-section>
<!-- Marquer absent -->
<q-card-section v-else-if="dropAsk.mode === 'absence'" class="q-gutter-sm">
<q-option-group v-model="dropAsk.scope" :options="[{ label: 'Ce jour', value: 'day' }, { label: 'Cette semaine', value: 'week' }, { label: 'Plage de dates', value: 'range' }]" type="radio" inline dense color="deep-orange" />
<div v-if="dropAsk.scope === 'range'" class="row q-gutter-sm">
<q-input dense outlined type="date" v-model="dropAsk.from" label="Du" style="flex:1" />
<q-input dense outlined type="date" v-model="dropAsk.to" label="Au" style="flex:1" />
</div>
<q-select dense outlined v-model="dropAsk.absType" :options="absTypes" label="Motif" />
<div class="text-caption text-warning row items-center"><q-icon name="info" size="15px" class="q-mr-xs" />Retour prévu le <b class="q-ml-xs">{{ dropAskReturn }}</b>. Les job(s) iront au pool → proposition de réassignation.</div>
</q-card-section>
<q-card-actions align="right">
<q-btn v-if="dropAsk.mode !== 'choice'" flat dense label=" Retour" no-caps @click="dropAsk.mode = 'choice'" />
<q-space />
<q-btn flat label="Annuler" no-caps v-close-popup />
<q-btn v-if="dropAsk.mode === 'shift'" unelevated color="primary" icon="check" label="Créer & assigner" no-caps @click="dropAskCreateShift" />
<q-btn v-if="dropAsk.mode === 'absence'" unelevated color="deep-orange" icon="event_busy" label="Marquer absent" no-caps @click="dropAskAbsence" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Absence sur PLAGE de jours (défaut = jour affiché ; « cette semaine » ou plage du..au) -->
<q-dialog v-model="absDialog.open">
<q-card style="min-width:344px">
<q-card-section class="row items-center q-pb-sm">
<q-icon name="event_busy" color="negative" size="20px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Absence — {{ absDialog.techName }}</div>
<q-space /><q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-pt-none">
<div class="row q-gutter-xs q-mb-sm">
<q-btn dense unelevated size="sm" color="grey-7" label="Ce jour" @click="absDialog.to = absDialog.from" />
<q-btn dense unelevated size="sm" color="grey-7" label="Cette semaine" @click="absWeek" />
</div>
<div class="row q-col-gutter-sm">
<q-input class="col" dense outlined type="date" v-model="absDialog.from" label="Du" />
<q-input class="col" dense outlined type="date" v-model="absDialog.to" label="Au" />
</div>
<div class="text-caption text-grey-6 q-mt-xs">{{ absDays().length }} jour(s) · type « Congé »</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat dense label="Retirer l'absence" color="grey-7" @click="applyAbs(true)" />
<q-btn unelevated dense label="Marquer absent" color="negative" @click="applyAbs(false)" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Synchroniser les techniciens : réconciliation staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech -->
<q-dialog v-model="techSync.open">
<q-card style="width: 720px; max-width: 96vw">
<q-card-section class="row items-center q-pb-xs">
<div class="text-subtitle2">👥 Synchroniser les techniciens</div>
<q-space />
<q-btn dense flat round icon="close" v-close-popup />
</q-card-section>
<q-card-section v-if="techSync.loading" class="text-center q-pa-lg"><q-spinner size="28px" color="primary" /></q-card-section>
<template v-else-if="techSync.report">
<q-card-section class="q-pt-none">
<div class="text-caption text-grey-7 q-mb-sm">Staff legacy actifs : <b>{{ techSync.report.counts.staff_active }}</b> · Fiches roster : <b>{{ techSync.report.counts.fiches }}</b> · Groupe Authentik <code>tech</code> : <b>{{ techSync.report.authentik ? techSync.report.counts.tech_group : '—' }}</b></div>
<q-expansion-item default-opened dense icon="group_add" header-class="text-green-8" :label="`À ajouter au roster (${techSync.report.staff_missing_fiche.length})`">
<div v-if="!techSync.report.staff_missing_fiche.length" class="text-grey-6 q-pa-sm">Aucun — le roster est à jour ✅</div>
<q-list v-else dense bordered class="ts-list">
<q-item v-for="s in techSync.report.staff_missing_fiche" :key="s.staff_id" tag="label">
<q-item-section side><q-checkbox v-model="techSync.sel[s.staff_id]" dense color="green-7" /></q-item-section>
<q-item-section><q-item-label>{{ s.name }} <span class="text-grey-5">#{{ s.staff_id }}</span></q-item-label><q-item-label caption>{{ s.email || '— sans courriel —' }}</q-item-label></q-item-section>
<q-item-section side><q-chip dense size="sm" :color="s.in_tech_group ? 'green-2' : 'orange-2'" :text-color="s.in_tech_group ? 'green-9' : 'orange-9'">{{ s.in_tech_group ? 'accès tech ✓' : 'hors groupe tech' }}</q-chip></q-item-section>
</q-item>
</q-list>
</q-expansion-item>
<q-expansion-item v-if="techSync.report.authentik && techSync.report.fiche_no_access.length" dense icon="lock" header-class="text-warning" :label="`Fiches SANS accès Ops (${techSync.report.fiche_no_access.length})`">
<div class="text-caption text-grey-6 q-px-sm q-pt-xs">Pas dans le groupe Authentik <code>tech</code> → à ajouter manuellement sur auth.targo.ca (lecture seule ici).</div>
<q-list dense class="ts-list"><q-item v-for="t in techSync.report.fiche_no_access" :key="t.technician_id"><q-item-section><q-item-label>{{ t.name }}</q-item-label><q-item-label caption>{{ t.email }}</q-item-label></q-item-section></q-item></q-list>
</q-expansion-item>
<q-expansion-item v-if="techSync.report.authentik && techSync.report.access_no_fiche.length" dense icon="help_outline" :label="`Dans le groupe tech, hors roster (${techSync.report.access_no_fiche.length})`">
<div class="text-caption text-grey-6 q-px-sm q-pt-xs">Ont l'accès Ops mais aucune fiche (ex-employé ? courriel ≠ legacy ?).</div>
<div class="q-pa-sm" style="font-size:11px;color:#78909c;line-height:1.6">{{ techSync.report.access_no_fiche.join(' · ') }}</div>
</q-expansion-item>
<q-banner v-if="!techSync.report.authentik" dense class="bg-orange-1 text-warning q-mt-sm"><template #avatar><q-icon name="warning" /></template>Authentik non joignable — vérif d'accès indisponible, seul l'ajout au roster fonctionne.</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Fermer" v-close-popup />
<q-btn unelevated color="green-7" icon="group_add" :label="`Créer les fiches cochées (${techSyncSelCount})`" :loading="techSync.applying" :disable="!techSyncSelCount" @click="applyTechSync" />
</q-card-actions>
</template>
</q-card>
</q-dialog>
<!-- Tableur « durées par caractéristique » (additif) — édition inline -->
<q-dialog v-model="jobChar.open">
<q-card style="width: 860px; max-width: 97vw">
<q-card-section class="row items-center q-pb-xs">
<div class="text-subtitle2">⏱ Durées par caractéristique <span class="text-grey-6">— additif, hors transport</span></div>
<q-space /><q-btn dense flat round icon="close" v-close-popup />
</q-card-section>
<q-card-section v-if="jobChar.loading" class="text-center q-pa-md"><q-spinner color="primary" size="26px" /></q-card-section>
<q-card-section v-else class="q-pt-none">
<div class="text-caption text-grey-7 q-mb-sm">Durée d'un job = <b>1 base</b> + <b>caractéristiques</b> cochées (×qté), × vitesse du tech. Minutes éditables. Les valeurs <b>apprises</b> (geofence) viendront calibrer ces seeds par régression.</div>
<table class="jc-table">
<thead><tr><th>Catégorie</th><th>Libellé</th><th>Min</th><th>×Qté</th><th>Mots-clés (auto-détection)</th><th></th></tr></thead>
<tbody>
<tr v-for="(it, i) in jobChar.items" :key="i" :class="'jc-' + it.cat">
<td><q-select dense options-dense outlined v-model="it.cat" :options="['base', 'addon', 'modifier']" style="min-width:92px" /></td>
<td><q-input dense outlined v-model="it.label" /></td>
<td style="white-space:nowrap"><q-input dense outlined type="number" v-model.number="it.minutes" style="width:64px" /><span v-if="it.learned_min != null" class="jc-learn"> appris {{ it.learned_min }}m·{{ it.samples }}</span></td>
<td class="text-center"><q-toggle dense size="sm" v-model="it.per_qty" /></td>
<td><q-input dense outlined v-model="it.keywords" placeholder="ex. enfoui,enterré" /></td>
<td><q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="jobChar.items.splice(i, 1)" /></td>
</tr>
</tbody>
</table>
</q-card-section>
<q-card-actions align="right">
<q-btn flat dense icon="add" label="Ajouter une ligne" color="primary" @click="addJobCharRow" />
<q-space />
<q-btn flat label="Fermer" v-close-popup />
<q-btn unelevated color="primary" icon="save" label="Enregistrer" :loading="jobChar.saving" @click="saveJobChar" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Timeline contextuelle d'une ressource : dispatch des jobs de la semaine visible (heures + priorités) -->
<q-dialog v-model="timelineDlg.open">
<q-card style="min-width:560px;max-width:780px">
<q-card-section class="row items-center q-pb-sm">
<q-icon name="timeline" color="indigo" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Timeline — {{ timelineDlg.tech && timelineDlg.tech.name }}</div>
<q-space />
<q-btn flat dense no-caps size="sm" icon="open_in_new" label="Dispatch" color="indigo" @click="gotoDispatch(timelineDlg.tech)"><q-tooltip>Ouvrir le tableau Dispatch sur cette ressource</q-tooltip></q-btn>
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-pt-none" style="max-height:70vh;overflow:auto">
<div v-if="!timelineDays.length" class="text-grey-6 q-pa-md text-center">Aucun job planifié cette semaine pour cette ressource.</div>
<div v-for="day in timelineDays" :key="day.iso" class="tldlg-day">
<div class="row items-center q-mb-xs">
<div class="text-weight-medium" :class="{ 'text-warning': day.weekend }">{{ day.label }}</div>
<q-badge v-if="day.offShift" color="warning" class="q-ml-sm"><q-icon name="warning" size="11px" class="q-mr-xs" />hors quart publié</q-badge>
<q-space />
<q-badge v-if="day.pct != null" text-color="white" :style="{ background: occColor(day.pct) }">{{ day.usedH }}h · {{ day.pct }}%</q-badge>
<q-badge v-else color="grey-5" class="q-ml-xs">{{ day.usedH }}h</q-badge>
</div>
<div v-if="!day.offShift" class="tldlg-bar">
<div v-for="(b, bi) in day.bands" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
<div v-for="(b, bi) in day.blocks" :key="'k' + bi" class="tl-blk" :style="blockStyle(b, day.pct)"></div>
<span v-for="tk in axisTicks" :key="'t' + tk.h" class="tldlg-tick" :style="{ left: tk.left }">{{ tk.h }}</span>
</div>
<div v-for="j in day.jobs" :key="j.name" class="tldlg-job">
<span class="tldlg-dot" :style="{ background: prioColor(j.priority) }"><q-tooltip>{{ j.priority }}</q-tooltip></span>
<span class="tldlg-time">{{ j.start || '—' }}</span>
<span class="ellipsis">{{ j.subject }}</span>
<span v-if="j.customer" class="text-grey-6 ellipsis">· {{ j.customer }}</span>
<q-space /><span class="text-grey-6" style="flex:0 0 auto">{{ j.dur }}h</span>
</div>
</div>
</q-card-section>
<q-card-section v-if="timelineDays.length" class="q-pt-none text-caption text-grey-6">Trié par priorité puis heure · 🔴 urgent 🟠 élevée 🔵 moyenne ⚪ basse. Heures posées en premier-trou-libre.</q-card-section>
</q-card>
</q-dialog>
<div ref="menuAnchorEl" :style="{ position: 'fixed', width: '1px', height: '1px', left: menu.x + 'px', top: menu.y + 'px', pointerEvents: 'none', zIndex: 0 }"></div>
<q-menu v-model="menu.show" :target="menu.target" anchor="bottom right" self="top left" max-height="85vh">
<q-list dense style="width:262px;user-select:none;-webkit-user-select:none">
<q-item-label header class="q-py-xs">{{ menu.tech && menu.tech.name }} — {{ menu.day && menu.day.dnum }}</q-item-label>
<!-- 4 actions : Jour · Soir · Garde · Absent -->
<div class="row q-gutter-xs q-px-sm q-pb-xs">
<q-btn dense unelevated size="sm" color="primary" label="Jour 817" class="col" @click="quickShift(8, 17)" />
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir 1620" class="col" @click="quickShift(16, 20)" />
</div>
<div class="row q-gutter-xs q-px-sm q-pb-xs">
<q-btn dense :unelevated="menuIsGarde" :outline="!menuIsGarde" size="sm" color="brown" icon="shield" :label="menuIsGarde ? 'Garde ✓' : 'Garde'" class="col" @click="toggleGardeMenu"><q-tooltip>Mettre / retirer de garde (G) — en parallèle d'un shift</q-tooltip></q-btn>
<q-btn dense :unelevated="menuIsAbsent" :outline="!menuIsAbsent" size="sm" color="negative" icon="event_busy" :label="menuIsAbsent ? 'Absent ✓' : 'Absent'" class="col" @click="openAbsDialog"><q-tooltip>Absence : ce jour (défaut), la semaine, ou une plage de dates</q-tooltip></q-btn>
</div>
<!-- Saisie rapide d'heures : 8-17 · 830-16 · 85 (=8→17) -->
<div class="q-px-sm q-pb-xs" @click.stop @mousedown.stop>
<q-input dense outlined v-model="quickEntry" placeholder="Heures : 8-17 · 830-16 · 85" @keyup.enter="applyQuick()">
<template #append><q-btn flat dense round size="sm" icon="keyboard_return" color="primary" @click="applyQuick()"><q-tooltip>Appliquer</q-tooltip></q-btn></template>
</q-input>
</div>
<!-- Plage personnalisée (slider replié) -->
<q-expansion-item dense dense-toggle icon="tune" label="Personnaliser la plage" header-class="text-caption text-grey-7">
<div class="q-px-md q-pb-sm" @click.stop @mousedown.stop>
<q-range v-model="menuRange" :min="0" :max="24" :step="0.5" snap color="primary" class="q-mt-sm" />
<div class="row items-center no-wrap q-gutter-sm">
<span class="text-caption text-weight-bold">{{ fmtH(menuRange.min) }}h{{ fmtH(menuRange.max) }}h</span>
<q-space />
<q-btn dense unelevated size="sm" color="primary" label="Appliquer" @click="applyMenuRange" />
</div>
</div>
</q-expansion-item>
<q-separator />
<!-- Shifts en place + actions compactes -->
<q-item v-for="a in menuCellShifts" :key="'c' + a.shift" dense>
<q-item-section>{{ a.shift_name || a.shift }} <span class="text-grey-6">{{ a.hours }}h</span></q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="close" color="grey-7" @click="removeShiftFromMenu(a)"><q-tooltip>Retirer</q-tooltip></q-btn></q-item-section>
</q-item>
<div class="row items-center q-px-sm q-py-xs q-gutter-sm">
<q-btn flat dense size="sm" icon="content_copy" color="grey-8" @click="copyFromMenu"><q-tooltip>Copier la case</q-tooltip></q-btn>
<q-btn flat dense size="sm" icon="content_paste" color="grey-8" :disable="!cellClipboard.length" @click="pasteFromMenu"><q-tooltip>Coller{{ cellClipboard.length ? ' (' + cellClipboard.length + ')' : '' }}</q-tooltip></q-btn>
<q-space />
<q-btn v-if="menuCellShifts.length" flat dense size="sm" icon="layers_clear" color="grey-8" label="Vider" @click="clearOne" />
</div>
</q-list>
</q-menu>
<!-- Éditeur de JOURNÉE (clic sur le progressbar) : timeline + réordonner par drag-drop + retirer un job -->
<q-dialog v-model="dayEditor.open">
<q-card style="min-width:560px;max-width:680px">
<q-card-section class="row items-center q-pb-sm">
<q-icon name="view_timeline" color="indigo" size="22px" class="q-mr-sm" />
<div>
<div class="text-subtitle1 text-weight-bold">{{ dayEditor.tech && dayEditor.tech.name }} — {{ dayEditor.day && (dayEditor.day.dow + ' ' + dayEditor.day.dnum) }}</div>
<div class="text-caption text-grey-7" v-if="dayOcc()">{{ dayOcc().usedH }}h occupé / {{ dayOcc().bookableH }}h · {{ dayOcc().pct }}%</div>
</div>
<q-space />
<q-btn flat dense no-caps size="sm" icon="open_in_new" label="Dispatch" color="indigo" @click="gotoDispatch(dayEditor.tech, dayEditor.day && dayEditor.day.iso)"><q-tooltip>Ouvrir le tableau Dispatch complet</q-tooltip></q-btn>
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-pt-none">
<!-- Quart : STATIQUE par défaut ; « Modifier » révèle le curseur (édition accessible depuis le détail ouvert) -->
<div class="row items-center no-wrap q-mb-xs">
<q-icon name="schedule" size="16px" color="green-5" class="q-mr-xs" />
<span class="text-caption text-grey-7 q-mr-sm" style="min-width:34px">Quart</span>
<template v-if="!dayShiftEdit">
<span class="text-body2 text-weight-medium">{{ dayShiftLabel() }}</span>
<q-space />
<q-btn flat dense no-caps size="sm" icon="edit" :label="hasReg(dayEditor.tech && dayEditor.tech.id, dayEditor.day && dayEditor.day.iso) ? 'Modifier' : 'Ajouter'" color="green-5" @click="dayShiftEdit = true" />
</template>
<template v-else>
<q-range v-model="dayShift" :min="axisBounds.min" :max="axisBounds.max" :step="0.5" snap label :left-label-value="fmtH(dayShift.min) + 'h'" :right-label-value="fmtH(dayShift.max) + 'h'" color="green-5" class="col" style="margin:0 8px" @change="saveDayShift" />
<span class="text-caption text-grey-6 q-mr-xs" style="min-width:40px;text-align:right">{{ Math.round((dayShift.max - dayShift.min) * 10) / 10 }} h</span>
<q-btn flat dense round size="sm" icon="check" color="positive" @click="dayShiftEdit = false"><q-tooltip>Terminé</q-tooltip></q-btn>
</template>
</div>
<!-- timeline visuelle (réutilise les blocs colorés par compétence) -->
<div class="tldlg-bar" style="height:20px">
<div v-for="(b, bi) in dayBands()" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
<div v-for="(g, gi) in dayTravelSegs()" :key="'tr' + gi" class="tl-travel" :style="pos(g.s, Math.min(g.e, 24))"><q-tooltip class="bg-grey-9" style="font-size:11px">🚗 déplacement</q-tooltip></div>
<div v-for="(b, bi) in dayBlocks()" :key="'k' + bi" class="tl-blk" :style="blockStyle(b, dayOcc() && dayOcc().pct)"></div>
<span v-for="tk in axisTicks" :key="'t' + tk.h" class="tldlg-tick" :style="{ left: tk.left }">{{ tk.h }}</span>
</div>
<!-- carte interactive : itinéraire ROUTIER réel + pins numérotés, navigable (zoom molette/boutons, déplacement) -->
<div v-show="dayShowMap" class="de-map-wrap">
<div ref="dayMapEl" class="de-map-gl"></div>
<q-btn dense round size="sm" class="map-sat-btn" :color="satView ? 'primary' : 'grey-8'" :icon="satView ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satView ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
<div class="de-map-cap">🗺 Itinéraire routier · zoom molette · <b>clic droit = Street View</b><template v-if="dayHasDepot && dayHasHome"> · 🚩 Départ: <span class="dt-toggle" :class="{ on: dayOrigin && dayOrigin.kind === 'depot' }" @click="setDayOrigin('depot')">🏢 Bureau</span> <span class="dt-toggle" :class="{ on: dayOrigin && dayOrigin.kind === 'home' }" @click="setDayOrigin('home')">🏠 Domicile</span></template><span v-if="dayNoCoord" class="text-warning"> · ⚠ {{ dayNoCoord }} sans coords (absent de la carte)</span><span v-if="dayCanTrack" class="dt-toggle" :class="{ on: dayShowTrack }" @click="dayShowTrack = !dayShowTrack">● Tracé GPS réel{{ dayShowTrack && dayTrackMsg ? ' · ' + dayTrackMsg : '' }}<q-tooltip>Superposer le parcours GPS réel (Traccar) sur les jobs — voir si le tech est passé par là</q-tooltip></span></div>
</div>
<!-- Point de départ du tech (INLINE) : domicile éditable — sur la carte OU position GPS live actuelle. Les techs partent souvent de chez eux. -->
<div v-if="dayEditor.tech" class="row items-center no-wrap q-px-sm q-py-xs" style="gap:6px;font-size:12px;border-top:1px solid #eef2f7;border-bottom:1px solid #eef2f7">
<q-icon name="home_pin" size="16px" color="positive" />
<span class="text-grey-8 text-weight-medium">Départ&nbsp;:</span>
<span class="ellipsis text-grey-7 col">{{ techHomes[dayEditor.tech.id] && techHomes[dayEditor.tech.id].address ? techHomes[dayEditor.tech.id].address : (techHomes[dayEditor.tech.id] ? 'domicile situé (sans adresse)' : 'domicile non défini — dépôt par défaut') }}</span>
<q-btn dense flat no-caps size="sm" color="teal" icon="map" label="Carte" @click="openHomePicker(dayEditor.tech)"><q-tooltip>Choisir le domicile (point de départ) sur la carte</q-tooltip></q-btn>
<q-btn dense flat no-caps size="sm" color="indigo" icon="my_location" label="GPS actuel" :loading="liveGpsBusy" @click="setHomeFromLiveGps(dayEditor.tech)"><q-tooltip>Fixer le départ à la position GPS live actuelle du tech (Traccar)</q-tooltip></q-btn>
</div>
<!-- Appareil GPS (Traccar) du tech — INLINE ; prérequis du « GPS actuel » ci-dessus + du tracé réel. (migré de Dispatch) -->
<div v-if="dayEditor.tech" class="row items-center no-wrap q-px-sm q-pb-xs" style="gap:6px;font-size:12px;border-bottom:1px solid #eef2f7">
<q-icon name="gps_fixed" size="16px" :color="dayEditor.tech.traccar_device_id ? 'primary' : 'grey-4'" />
<span class="text-grey-8 text-weight-medium">Appareil GPS&nbsp;:</span>
<q-select dense options-dense borderless class="col" style="min-width:0" :model-value="dayEditor.tech.traccar_device_id ? String(dayEditor.tech.traccar_device_id) : null" :options="deviceOptions" emit-value map-options use-input input-debounce="0" @filter="filterDevices" :loading="devicesLoading" clearable placeholder="non associé — choisir l'appareil Traccar" popup-content-style="min-width:260px" @update:model-value="v => onPickDevice(dayEditor.tech, v)">
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar style="min-width:22px"><q-icon name="circle" size="9px" :color="scope.opt.online ? 'positive' : 'grey-4'" /></q-item-section>
<q-item-section><q-item-label>{{ scope.opt.label }}</q-item-label><q-item-label v-if="scope.opt.uniqueId" caption>{{ scope.opt.uniqueId }}</q-item-label></q-item-section>
</q-item>
</template>
<template #no-option><q-item><q-item-section class="text-grey-6">Aucun appareil Traccar</q-item-section></q-item></template>
</q-select>
</div>
<div v-if="!dayEditor.list.length" class="text-grey-6 q-pa-md text-center">Aucun job ce jour.</div>
<!-- liste éditable : glisser (souris) · ▲▼ (tactile) · 📌 figer 1er/dernier · durée en min · ✕ retirer -->
<template v-for="(j, i) in dayEditor.list" :key="j.name">
<!-- temps de transport estimé depuis le job précédent (l'espace entre 2 blocs) -->
<div v-if="i > 0 || dayOrigin" class="de-travel" :class="{ 'de-depart': i === 0 }">
<template v-if="i === 0">
🏁 Départ<template v-if="dayOrigin"> · {{ dayOrigin.kind === 'home' ? ('🏠 domicile ' + (dayEditor.tech && dayEditor.tech.name)) : (dayOrigin.label || 'Dépôt') }}</template><template v-if="dayLeg(0)"> · {{ dayLeg(0).real ? '🚗' : '📏' }} {{ dayLeg(0).real ? '' : '~' }}{{ dayLeg(0).min }} min<template v-if="dayLeg(0).km != null"> · {{ dayLeg(0).km }} km</template></template><span v-if="dayOrigin && dayOrigin.kind === 'home'" class="text-positive"> (domicile plus proche)</span><q-tooltip v-if="dayOrigin" class="bg-grey-9" style="font-size:11px">Le tech quitte {{ dayOrigin.kind === 'home' ? 'son domicile' : 'le dépôt' }}{{ dayOrigin.address ? ' (' + dayOrigin.address + ')' : '' }} à l'heure du shift.</q-tooltip>
</template>
<template v-else>
<template v-if="dayLeg(i)">{{ dayLeg(i).real ? '🚗' : '📏' }} {{ dayLeg(i).real ? '' : '~' }}{{ dayLeg(i).min }} min<template v-if="dayLeg(i).km != null"> · {{ dayLeg(i).km }} km</template><q-tooltip class="bg-grey-9" style="font-size:11px">{{ dayLeg(i).real ? 'Temps routier réel (routes Mapbox)' : 'Estimation à vol doiseau (coords approximatives ou Mapbox indisponible)' }}</q-tooltip></template>
<template v-else>🚗 transport ? (adresse/coords manquantes)</template>
</template>
</div>
<div class="de-row" :class="{ 'de-drag': dayEditor.dragIdx === i, 'de-legacy': j.legacy, 'de-pinned': j.pin }"
:draggable="!j.legacy" @dragstart="dayDragStart(i, $event)" @dragover.prevent @drop="dayDropOn(i)" @dragend="dayDragEnd">
<div class="column" style="gap:2px">
<template v-if="!j.legacy">
<q-btn flat dense round size="xs" icon="keyboard_arrow_up" :disable="i === 0" @click="moveDayJob(i, -1)"><q-tooltip>Monter</q-tooltip></q-btn>
<q-btn flat dense round size="xs" icon="keyboard_arrow_down" :disable="i === dayEditor.list.length - 1" @click="moveDayJob(i, 1)"><q-tooltip>Descendre</q-tooltip></q-btn>
</template>
</div>
<q-icon v-if="!j.legacy" name="drag_indicator" size="20px" class="text-grey-6" style="cursor:grab"><q-tooltip>Glisser pour réordonner (souris). Sur mobile : ▲▼ ou 📌 figer.</q-tooltip></q-icon>
<q-icon v-else name="lock" size="14px" class="text-green-4" style="margin:0 1px"><q-tooltip class="bg-grey-9" style="font-size:11px">Ticket F (legacy) — lecture seule. Position dans la tournée à titre indicatif (non réécrite dans F).</q-tooltip></q-icon>
<span class="de-ord">{{ i + 1 }}</span>
<span class="de-dot" :style="{ background: j.skill ? getTagColor(j.skill) : prioColor(j.priority) }"></span>
<div class="col" style="min-width:0;cursor:pointer" @click="toggleJobDetail(j)"><q-tooltip v-if="j.detail && !j.showDetail" max-width="360px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.detail }}</q-tooltip>
<div class="ellipsis text-weight-medium" style="font-size:13px">{{ j.subject }} <q-badge v-if="j.legacy" color="green-1" text-color="green-8" class="q-ml-xs" style="font-weight:700">F</q-badge> <q-icon name="info_outline" size="12px" class="text-grey-5" /></div>
<div class="ellipsis text-grey-6" style="font-size:11px">{{ fmtHM(packedDay[i].startMin) }}{{ fmtHM(packedDay[i].endMin) }}<span v-if="j.locked" class="text-warning"> · 🔒 RDV fixe</span><span v-if="j.customer"> · {{ j.customer }}</span></div>
<div v-if="j.address || !hasLL(j)" class="ellipsis" style="font-size:11px;color:#90a4ae"><template v-if="j.address"><q-icon name="place" size="11px" /> {{ j.address }}</template><span v-if="!hasLL(j) && !j.legacy" class="loc-pick-link text-warning" @click.stop="openLocPicker(j)"> · ⚠ sans coords — 📍 situer</span><span v-else-if="!hasLL(j)" class="text-grey-5"> · sans coords (absent de la carte)</span></div>
</div>
<template v-if="!j.legacy">
<div class="de-dur"><input type="number" min="5" step="5" :value="jobMinutes(j)" @change="setJobMinutes(j, $event.target.value)" @click.stop @mousedown.stop /><span>min</span></div>
<q-btn flat dense round size="sm" icon="open_in_new" color="green-5" @click.stop="openJobDetail(j, dayEditor.tech)"><q-tooltip>Détail du ticket (OPS) — fil, équipe, client</q-tooltip></q-btn>
<!-- sélecteur de priorité retiré (défaut « Moyenne » conservé en donnée) → gain de place ; priorité gérée au Dispatch -->
<q-btn v-if="!j.actual_start" flat dense round size="sm" icon="play_arrow" color="green-6" @click="startJobChrono(j)"><q-tooltip>Démarrer (chrono réel → apprentissage)</q-tooltip></q-btn>
<q-btn v-else-if="!j.actual_end" flat dense round size="sm" icon="stop_circle" color="deep-orange" class="chrono-run" @click="finishJobChrono(j)"><q-tooltip>Terminer (chrono réel)</q-tooltip></q-btn>
<span v-else class="de-actual" :style="ratioStyle(j)" :title="'Réel mesuré / estimé (min) — geofencing ou chrono'">⏱{{ j.actual_min != null ? j.actual_min : '?' }}/{{ jobMinutes(j) }}m</span>
<q-btn flat dense round size="sm" icon="edit_location_alt" :color="hasLL(j) ? 'blue-grey-5' : 'deep-orange'" @click="openLocPicker(j)"><q-tooltip>{{ hasLL(j) ? 'Repositionner sur la carte' : 'Situer sur la carte (sans coords)' }}</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="push_pin" :color="j.pin ? 'indigo' : 'grey-5'">
<q-tooltip>{{ j.pin === 'first' ? 'Figé en 1re position' : j.pin === 'last' ? 'Figé en dernière position' : 'Figer la position (1er / dernier) — respecté par « Optimiser »' }}</q-tooltip>
<q-menu auto-close anchor="bottom right" self="top right">
<q-list dense style="min-width:180px">
<q-item clickable @click="setPin(j, 'first')"><q-item-section avatar><q-icon name="vertical_align_top" :color="j.pin === 'first' ? 'indigo' : 'grey-7'" /></q-item-section><q-item-section>Figer en 1<sup>re</sup> position</q-item-section></q-item>
<q-item clickable @click="setPin(j, 'last')"><q-item-section avatar><q-icon name="vertical_align_bottom" :color="j.pin === 'last' ? 'indigo' : 'grey-7'" /></q-item-section><q-item-section>Figer en dernière position</q-item-section></q-item>
<q-item v-if="j.pin" clickable @click="setPin(j, null)"><q-item-section avatar><q-icon name="link_off" color="grey-7" /></q-item-section><q-item-section>Détacher</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
<q-btn flat dense round size="sm" :icon="j.locked ? 'lock' : 'lock_open'" :color="j.locked ? 'deep-orange' : 'grey-5'" @click="j.locked = !j.locked"><q-tooltip>{{ j.locked ? 'Heure FIXE (RDV) — verrouillée, non replanifiée' : 'Heure flexible — replanifiée par la tournée' }}</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="close" color="negative" @click="removeFromDay(j)"><q-tooltip>Retirer du tech (retour au pool)</q-tooltip></q-btn>
</template>
<q-btn v-else flat dense round size="sm" icon="open_in_new" color="green-5" @click.stop="openLegacyBlock({ lid: j.lid || j.legacy_id, subject: j.subject, dept: j.dept })"><q-tooltip>Ouvrir le ticket F (lecture seule)</q-tooltip></q-btn>
</div>
<div v-if="j.showDetail" class="de-detail">
<div v-if="j._thread && j._thread.loading" class="text-grey-6" style="font-size:11px"><q-spinner size="13px" class="q-mr-xs" />Chargement du fil…</div>
<template v-else-if="j._thread && j._thread.messages && j._thread.messages.length">
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ j._thread.messages.length }} message(s){{ j._thread.status ? ' · ' + j._thread.status : '' }}</div>
<div v-for="(m, mi) in j._thread.messages" :key="mi" class="de-msg">
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span></div>
<div class="de-msg-txt">{{ m.text }}</div>
</div>
</template>
<div v-else style="white-space:pre-wrap;font-size:11px">{{ j.detail || 'Aucun détail importé pour ce ticket.' }}<span v-if="j._thread && j._thread.error" class="text-warning"> · fil indisponible</span></div>
</div>
</template>
<!-- (jobs legacy F géocodés = fusionnés dans la liste éditable ci-dessus → pins sur la carte + tournée optimisable) -->
</q-card-section>
<q-card-section v-if="dayEditor.list.length" class="row items-center q-pt-none">
<span class="text-caption text-grey-6"><q-icon name="drag_indicator" size="13px" /> glisser (souris) · ▲▼ (mobile) = ordre · <q-icon name="push_pin" size="12px" /> figer 1<sup>er</sup>/dernier · 🔒 RDV fixe · total <b>{{ dayTotalH() }}h</b></span><q-space />
<q-btn v-if="dayEditor.list.some(j => !j.legacy && !hasLL(j) && j.address)" dense outline no-caps size="sm" color="warning" icon="place" :loading="batchLocating" :label="'Localiser ' + dayEditor.list.filter(j => !j.legacy && !hasLL(j) && j.address).length + ' sans GPS'" class="q-mr-sm" @click="batchLocate"><q-tooltip max-width="260px">Faire correspondre l'adresse de chaque job sans coordonnées à une adresse existante (base RQA) → ajoute le GPS. Adresses ambiguës = à situer à la main.</q-tooltip></q-btn>
<q-btn dense flat no-caps size="sm" color="indigo" icon="route" label="Optimiser depuis le départ" class="q-mr-sm" @click="optimizeFromOrigin"><q-tooltip max-width="280px">Réordonne par plus proche voisin depuis {{ dayOrigin ? (dayOrigin.kind === 'home' ? 'le domicile du tech' : 'le dépôt') : 'le 1er arrêt' }} → la 1re job devient la plus proche. Puis « Enregistrer » (les tickets F restent indicatifs).</q-tooltip></q-btn>
<q-btn v-if="dayEditor.list.some(j => !j.legacy)" dense unelevated color="primary" :loading="dayEditor.saving" label="Enregistrer" @click="saveDayOrder" />
<span v-else class="text-caption text-grey-6">Tickets F — ordre de tournée indicatif (non réécrit dans F)</span>
</q-card-section>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
/**
* PlanificationPage — grille hebdomadaire roster (ressources × jours) + prise en charge dispatch.
* Backend : targo-hub /roster/* (src/api/roster.js) → ERPNext facturation + solveur OR-Tools.
* Voir aussi : services/targo-hub/lib/roster.js (endpoints) et docs/ROSTER.md (vue d'ensemble).
*
* CARTE DES SECTIONS (chercher les bannières « ── … ── ») :
* 1. État réactif & constantes ............ refs techs/templates/assignments, LS_*, dayList
* 2. Calque de garde LIVE ................. gardeOverlay (règles) ⊕ manualGarde (touche G) → gardeEffective
* 3. Filtres & scoring priorité ........... skillFilter (ET), techCompetence/techSpeed/techProximity → priorityScores
* 4. Ressources masquées .................. hiddenTechs / visibleTechs
* 5. Compétences inline ................... skillLevel (1-5) + skillEff (%/compétence) + TagEditor + gestionnaire global
* 6. Sélection / peinture cellules ........ souris (onDown/onEnter) + clavier (onKey : A absent, G garde, copier/coller)
* 7. Occupation & timeline ................ occCells → cellBands/cellBlocks/cellPct/cellJobs ; openTimeline (dialogue)
* 8. Panneau « jobs à assigner » .......... multi-sélection + terrain/distant + drag-drop + aperçu occupation projetée
* 9. Dialogue d'impact .................... retrait compétence / absence → redistribution (candidats classés)
* 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'
// 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'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API Mapbox Matrix), déjà utilisé par le Dispatch
import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé avec le board Dispatch)
import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur)
import { relTime } from 'src/composables/useFormatters' // temps relatif COHÉRENT (même formateur que la Boîte)
import { messageIdentity } from 'src/composables/useConversationDisplay' // resolvers partagés nom/initiales/couleur (cohérence des fils)
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 SkillSelect from 'src/components/shared/SkillSelect.vue'
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags partagé (Dispatch) : condensé, création à la volée, couleurs
const $q = useQuasar()
const router = useRouter()
const authStore = useAuthStore()
const DIRTY_MSG = 'Vous avez des modifications non publiées. Les abandonner ?'
const techs = ref([])
const templates = ref([])
const assignments = ref([])
const coverageData = ref([])
// Capacité OFFRABLE par jour (journée 8h17h, AM+PM COMBINÉS — on ne gère que des heures/jour),
// calculée CLIENT-SIDE sur les ressources AFFICHÉES (visibleTechs) → suit le filtre par compétence
// (cliquer « installation » ⇒ visibleTechs = techs installation ⇒ capacité = leurs shifts).
// Le soir (17h+) reste une réserve garde/urgence, jamais proposée au client → exclu de la fenêtre.
const DAY_FROM = 8, DAY_TO = 17
const _segOv = (s, e, a, b) => Math.max(0, Math.min(e, b) - Math.max(s, a))
const _r1 = (x) => Math.round(x * 10) / 10
const _toH = (t) => { if (t == null) return 0; if (typeof t === 'number') return t; const p = String(t).split(':'); return (Number(p[0]) || 0) + (Number(p[1]) || 0) / 60 }
const capByDay = computed(() => {
const out = {}; const tids = visibleTechs.value.map(t => t.id); const cbtd = cellsByTechDay.value; const tpl = tplByName.value
for (const d of dayList.value) {
let cap = 0, used = 0
for (const tid of tids) {
const asgs = (cbtd[tid] && cbtd[tid][d.iso]) || [] // quarts publiés/proposés du tech ce jour
for (const a of asgs) { const t = tpl[a.shift]; if (!t || t.on_call) continue; const s = _toH(t.start_time), e = _toH(t.end_time); if (e > s) cap += _segOv(s, e, DAY_FROM, DAY_TO) }
const occ = occByTechDay.value[tid + '|' + d.iso] // jobs placés (blocs horaires)
if (occ && occ.blocks) for (const b of occ.blocks) used += _segOv(b.s, b.e, DAY_FROM, DAY_TO)
}
let due_h = 0, jobs_due = 0 // charge DUE = pool non assigné ce jour (filtré par compétence si actif)
for (const j of (assignPanel.jobs || [])) { if (j.scheduled_date !== d.iso) continue; if (skillFilter.value.length && !skillFilter.value.includes(j.required_skill)) continue; due_h += (j.est_min ? j.est_min / 60 : Number(j.duration_h || j.dur || 0)); jobs_due++ }
cap = _r1(cap); used = _r1(used)
out[d.iso] = { cap_h: cap, used_h: used, free_h: _r1(cap - used), due_h: _r1(due_h), jobs_due, overbooked: cap > 0 && due_h > cap }
}
return out
})
// Total des heures (et effectif) sur les RESSOURCES AFFICHÉES uniquement — pied de tableau.
// Source = assignments (cohérent avec hoursOf par tech), garde exclue.
const visStatByDay = computed(() => {
const vis = new Set(visibleTechs.value.map(t => t.id)); const tpl = tplByName.value; const agg = {}
for (const a of assignments.value) {
if (!vis.has(a.tech)) continue
const t = tpl[a.shift]; if (t && t.on_call) continue // garde = mise en dispo, pas travaillée
const o = agg[a.date] || (agg[a.date] = { hours: 0, staff: new Set() })
o.hours += Number(a.hours) || 0; o.staff.add(a.tech)
}
const out = {}; for (const k in agg) out[k] = { hours: _r1(agg[k].hours), staff: agg[k].staff.size }
return out
})
function visStat (iso) { return visStatByDay.value[iso] || { hours: 0, staff: 0 } }
// CHARGE estimée par jour = Σ heures estimées des jobs de cette date (assignés aux techs AFFICHÉS + pool non assigné,
// filtré compétence) → c'est le NUMÉRATEUR ; le dénominateur = heures dispo (visStat). Ratio = taux d'occupation prévu.
const estLoadByDay = computed(() => {
const vis = new Set(visibleTechs.value.map(t => t.id)); const out = {}
for (const key in occByTechDay.value) { // jobs déjà assignés (occupancy), seulement sur les techs affichés
const [tid, iso] = key.split('|'); if (!vis.has(tid)) continue
const blocks = (occByTechDay.value[key] || {}).blocks || []
out[iso] = (out[iso] || 0) + blocks.reduce((s, b) => s + Math.max(0, (b.e - b.s)), 0)
}
for (const j of (assignPanel.jobs || [])) { // + pool non assigné de cette date (même filtre compétence que la capacité)
if (skillFilter.value.length && !skillFilter.value.includes(j.required_skill)) continue
const iso = j.scheduled_date; if (!iso) continue
out[iso] = (out[iso] || 0) + (j.est_min ? j.est_min / 60 : Number(j.duration_h || j.dur || 0))
}
for (const k in out) out[k] = _r1(out[k]); return out
})
function estLoad (iso) { return estLoadByDay.value[iso] || 0 }
function loadClass (iso) { const est = estLoad(iso), dispo = visStat(iso).hours || 0; if (!dispo) return est > 0 ? 'cap-full' : 'cap-none'; const r = est / dispo; return r > 1 ? 'cap-full' : (r > 0.85 ? 'cap-low' : 'cap-ok') } // vert/orange/rouge selon est/dispo
// Vue « jobs prévus en en-tête de jour » : jobs NON assignés ayant une date, groupés par jour, glissables sur une case
// tech×jour (réutilise onJobDragStart/onCellDrop → assigne + replanifie à la date de la case). Suit le filtre compétence.
const showDayJobs = ref(false)
// Charge LEGACY appliquée aux timelines : durées estimées des jobs assignés dans osTicket (datés dans la fenêtre affichée).
const showLegacyLoad = ref((() => { try { return localStorage.getItem('plan_legacy_load') !== '0' } catch (e) { return true } })()) // défaut ON
const legacyLoad = ref({}) // 'technician_id|iso' → { h, n, jobs } (durées estimées des jobs legacy datés, fenêtre affichée)
async function loadLegacyWindow () {
if (!showLegacyLoad.value) { legacyLoad.value = {}; return }
try { const r = await roster.legacyWindowLoad(start.value, days.value); legacyLoad.value = (r && r.load) || {} } catch (e) { /* non bloquant — legacy indispo */ }
}
watch(showLegacyLoad, (v) => { try { localStorage.setItem('plan_legacy_load', v ? '1' : '0') } catch (e) {} loadLegacyWindow() })
const unassignedByDay = computed(() => {
const out = {}
const pr = j => ({ urgent: 0, high: 1, moyenne: 2, medium: 2, normal: 2, low: 3, basse: 3 }[String(j.priority || '').toLowerCase()] ?? 2)
for (const j of (assignPanel.jobs || [])) {
if (skillFilter.value.length && !skillFilter.value.includes(j.required_skill)) continue
const iso = j.scheduled_date; if (!iso) continue
;(out[iso] = out[iso] || []).push(j)
}
for (const k in out) out[k].sort((a, b) => pr(a) - pr(b) || (b.est_min || 0) - (a.est_min || 0))
return out
})
const dailyStats = ref([])
const solverStats = ref(null)
const loading = ref(false); const generating = ref(false); const publishing = ref(false); const applying = ref(false)
const days = ref(7)
const start = ref(thisMonday()) // défaut = semaine COURANTE (cohérent avec « Auj. ») — pas la semaine suivante
const lastWeek = reactive({ start: start.value, days: days.value })
const showDemand = ref(false)
const drag = reactive({ on: false, ti: 0, di: 0, moved: false, base: [] })
const justDragged = ref(false)
const selection = ref([])
const activeCell = ref(null) // dernière case cliquée {id, name, iso} — pour copier/coller au clavier sans multi-sélection
const anchor = ref(null)
const demand = ref([]); const holidays = ref([]); const weekTemplates = ref([])
const statHolidays = ref([]) // fériés QC DÉTERMINISTES (calendrier hub /roster/holidays) — fusionnés dans isHoliday, tous navigateurs
const gardeRules = ref([]); const showGarde = ref(false)
const manualGarde = ref({}) // overrides manuels de garde : 'techId|iso' → 'on' | 'off' (touche « G »)
const newGardeRule = reactive({ dept: '', shift: '', shiftWeekend: '', weekdays: [], anchor: '', steps: [] })
const GARDE_DOW = [{ v: 1, l: 'L' }, { v: 2, l: 'M' }, { v: 3, l: 'M' }, { v: 4, l: 'J' }, { v: 5, l: 'V' }, { v: 6, l: 'S' }, { v: 0, l: 'D' }]
const history = ref([]); const future = ref([])
const search = ref(''); const groupFilter = ref(null); const maxHours = ref(40); const skillFilter = ref([])
// Ressources masquées (hors front-line : compta, etc.) — masquées ET ignorées du calcul ; localStorage.
const hiddenTechs = ref([]); const showHidden = ref(false)
function isHidden (id) { return hiddenTechs.value.includes(id) }
function toggleHidden (id) { const i = hiddenTechs.value.indexOf(id); if (i >= 0) hiddenTechs.value.splice(i, 1); else hiddenTechs.value.push(id); localStorage.setItem('roster-hidden-techs-v1', JSON.stringify(hiddenTechs.value)) }
const hiddenCount = computed(() => techs.value.filter(t => isHidden(t.id)).length)
const showShiftEditor = ref(false); const editTpls = ref([])
const showTeamEditor = ref(false); const editTechs = ref([])
const notifySms = ref(false)
const showLeave = ref(false); const leaveRows = ref([]); const leaveFilter = ref('Demandé')
const newLeave = reactive({ technician: '', availability_type: 'Congé', from_date: '', to_date: '', reason: '', long_term: 0 })
const newTpl = reactive({ template_name: '', start: '08:00', end: '16:00', color: '#1976d2', on_call: 0 })
function numToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') }
// Slider à 2 poignées pour le nouveau modèle (heures custom) ↔ newTpl.start/end
const newTplRange = computed({ get: () => ({ min: hToNum(newTpl.start) || 8, max: hToNum(newTpl.end) || 16 }), set: (v) => { newTpl.start = numToTime(v.min); newTpl.end = numToTime(v.max) } })
const LS_DEMAND = 'roster-demand-v1'; const LS_HOL = 'roster-holidays-v1'; const LS_TPL = 'roster-week-templates-v1'; const LS_GARDE = 'roster-garde-rules-v1'; const LS_GARDE_MANUAL = 'roster-garde-manual-v1'
// Date LOCALE du jour (PAS toISOString, qui bascule au lendemain le soir en UTC → cause des « jours décalés »).
function todayISO () { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') }
function thisMonday () { return mondayISO(todayISO()) } // lundi de la semaine COURANTE — défaut au chargement ET bouton « Auj. »
function addDaysISO (iso, n) { const [y, m, d] = iso.split('-').map(Number); const dt = new Date(Date.UTC(y, m - 1, d)); dt.setUTCDate(dt.getUTCDate() + n); return dt.toISOString().slice(0, 10) }
const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
const dayList = computed(() => {
const [y, m, dd] = start.value.split('-').map(Number); const base = new Date(Date.UTC(y, m - 1, dd)); const out = []
for (let i = 0; i < days.value; i++) { const d = new Date(base); d.setUTCDate(d.getUTCDate() + i); const iso = d.toISOString().slice(0, 10); const dow = d.getUTCDay(); out.push({ iso, dow: FR_DOW[dow], dnum: iso.slice(8) + '/' + iso.slice(5, 7), weekend: dow === 0 || dow === 6 }) }
return out
})
function dowOf (iso) { const [y, m, d] = iso.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)).getUTCDay() }
const isWeekendIso = iso => { const d = dowOf(iso); return d === 0 || d === 6 } // samedi/dimanche → PAS de quart auto (techs de fin de semaine dédiés)
// File PLACEHOLDER (dispatch auto) : pas un vrai tech → « à assigner ». `__wknd__` (legacy) + `__hold__|<iso>|<n>` (tournées sans quart, tout jour).
const isHoldId = id => id === '__wknd__' || (typeof id === 'string' && id.startsWith('__hold__'))
// ── MODE JOUR (style Gaiia) : 1 seule colonne pleine largeur → timelines hautes + règle d'heures, idéal pour disposer
// les jobs (glisser depuis le panneau « jobs du jour » vers un tech, réordonner via l'éditeur de jour). ─────────────
const dayMode = computed(() => dayList.value.length === 1)
watch(dayMode, (on) => { if (on) showDayJobs.value = true }) // en mode Jour, affiche d'office le panneau de jobs à disposer
// ── VUE KANBAN (mode de vue, sur le socle hub) : colonne « À assigner » + 1 colonne/tech, cartes glissables entre lanes.
// Réutilise tout : occupation (avec équipes via job.assist), pool unassignedJobs, onCellDrop (= roster.assignJob hub). ──
const { prefs: planifPrefs, save: savePlanif } = useUserPrefs('planif', { view: 'grid' }) // préférence d'affichage SERVEUR (suit l'usager entre appareils)
const boardView = ref(planifPrefs.value.view === 'kanban' ? 'kanban' : 'grid') // 'grid' | 'kanban'
watch(boardView, (v) => { savePlanif({ view: v }); if (v === 'kanban') { kbSelIso.value = nowET.value.iso; if (!dayList.value.find(d => d.iso === kbSelIso.value)) { start.value = thisMonday(); loadWeek() } _kbDidScroll = false; nextTick(() => setTimeout(kbScrollToDefault, 160)) } else if (v === 'grid' && days.value < 7) { days.value = 7; loadWeek() } })
watch(() => planifPrefs.value.view, (v) => { if (v && v !== boardView.value) boardView.value = v }) // sync depuis le serveur (autre appareil) une fois chargé
const kbSelIso = ref('') // mode Jour : jour explicitement choisi ('' = auto → aujourd'hui)
const kanbanDay = computed(() => { // Jour : jour choisi, sinon AUJOURD'HUI (ET) si dans la fenêtre, sinon le 1er jour
for (const c of [kbSelIso.value, nowET.value.iso]) { if (!c) continue; const d = dayList.value.find(x => x.iso === c); if (d) return d }
return dayList.value[0] || null
})
// Sélecteur de date de la barre d'outils : en mode JOUR = le jour affiché (éditable) ; en SEMAINE = début de fenêtre.
const boardDate = computed({
get: () => (boardView.value === 'kanban' ? ((kanbanDay.value && kanbanDay.value.iso) || start.value) : start.value),
set: (v) => {
if (!v) return
if (boardView.value === 'kanban') { kbSelIso.value = v; if (!dayList.value.find(d => d.iso === v)) { start.value = mondayISO(v); loadWeek() } }
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)))
})
// 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
const kbMatrix = ref({}); const _kbMatPending = new Set() // cache matrice Mapbox par tournée (tech|jour) → 1 call/tournée
function kanbanLaneBlocks (techId) {
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return []
const o = occByTechDay.value[techId + '|' + iso] || { jobs: [] }
const dur = (j) => Number(j.dur) || 1
// Jobs réels (occupation) + jobs TERRAIN legacy (synthétique F, comme la grille semaine) : non draggables, clic = ouvre le ticket.
const base = (o.jobs || []).slice()
const lg = showLegacyLoad.value ? legacyLoad.value[techId + '|' + iso] : null
if (lg && Array.isArray(lg.jobs)) { for (const lj of lg.jobs) { if (!lj.field) continue; const d = Number(lj.est_h) || 0; if (d <= 0) continue; base.push({ name: 'LEG:' + lj.id, legacy: true, lid: lj.id, subject: lj.subject, dept: lj.dept, skill: lj.skill, dur: d, start_h: null, route_order: 9999 }) } }
if (!base.length) return []
// Ordre de tournée : route_order puis heure.
const jobs = base.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
// RDV CONFIRMÉS + miroirs ASSISTANT = verrouillés à leur heure (le renfort suit l'heure du lead) ; tout le reste COULE depuis 8h.
const fixed = jobs.filter(j => (j.booking_status === 'Confirmé' || j.assist) && j.start_h != null)
const out = fixed.map(j => ({ ...j, s: j.start_h, e: j.start_h + dur(j), fixed: true }))
let cursor = KB_DAY_START
const mat = (kbMatrix.value[techId + '|' + iso] || {}).map || null // matrice Mapbox RÉELLE (1 call/tournée) si chargée
const dep = depot.value
let prev = (dep && dep.lat != null) ? { lat: +dep.lat, lon: +dep.lon } : null
let prevName = (dep && dep.lat != null) ? 'DEPOT' : null
for (const j of jobs) {
if (j.assist) continue // miroir assistant : déjà placé (fixed) ; ne coule pas et ne compte pas dans le trajet de l'assistant
if (j.booking_status === 'Confirmé' && j.start_h != null) { if (j.lat != null) { prev = { lat: +j.lat, lon: +j.lon }; prevName = j.name } continue }
const d = dur(j); const blk = { ...j }
// Trajet d'approche : temps ROUTIER RÉEL (matrice Mapbox) si dispo, sinon repli à vol d'oiseau (~55 km/h).
let legMin = 0, legKm = 0
const real = (mat && prevName && j.name) ? mat[prevName + '>' + j.name] : null
if (real) { legMin = Math.min(120, real.min); legKm = real.km != null ? real.km : 0 }
else if (prev && j.lat != null && j.lon != null) { const km = haversineKm(prev.lat, prev.lon, +j.lat, +j.lon); if (km != null && km > 0.05) { legKm = km; legMin = Math.min(60, Math.round(km * 1.1)) } }
if (legMin >= 3) { blk.legS = cursor; blk.legMin = legMin; blk.legKm = Math.round(legKm * 10) / 10; blk.legReal = !!real; cursor += legMin / 60; blk.legE = cursor }
let guard = 0; while (guard++ < 50) { const c = fixed.find(f => cursor < (f.start_h + dur(f)) && (cursor + d) > f.start_h); if (c) cursor = c.start_h + dur(c); else break }
blk.s = cursor; blk.e = cursor + d; out.push(blk); cursor += d
if (j.lat != null && j.lon != null) { prev = { lat: +j.lat, lon: +j.lon }; prevName = j.name }
}
return out
}
// Matrice Mapbox RÉELLE par tournée (tech×jour) : 1 SEUL appel /tournée (≤25 coords) → temps de route réels pour tous les legs. Cache par signature (set de jobs) → pas de refetch inutile.
async function fetchKbMatrix (techId) {
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso || !MAPBOX_TOKEN) return
const key = techId + '|' + iso
const o = occByTechDay.value[key]
const jobs = (o && o.jobs ? o.jobs : []).filter(j => isFinite(+j.lat) && isFinite(+j.lon) && (+j.lat || +j.lon))
const sig = jobs.map(j => j.name).join(',')
if (jobs.length < 1) return
const cached = kbMatrix.value[key]; if ((cached && cached.sig === sig) || _kbMatPending.has(key)) return
const dep = depot.value; const pts = []
if (dep && isFinite(+dep.lat) && isFinite(+dep.lon)) pts.push({ name: 'DEPOT', lat: +dep.lat, lon: +dep.lon })
for (const j of jobs) pts.push({ name: j.name, lat: +j.lat, lon: +j.lon })
if (pts.length < 2) return
const use = pts.slice(0, 25) // Matrix = 25 coords max
_kbMatPending.add(key)
try {
const coords = use.map(p => `${p.lon.toFixed(6)},${p.lat.toFixed(6)}`).join(';')
const r = await fetch(`https://api.mapbox.com/directions-matrix/v1/mapbox/driving/${coords}?annotations=duration,distance&access_token=${MAPBOX_TOKEN}`)
if (!r.ok) throw new Error('matrix ' + r.status)
const d = await r.json(); const dur = d.durations || [], dist = d.distances || []; const map = {}
for (let i = 0; i < use.length; i++) for (let k = 0; k < use.length; k++) { if (i === k) continue; const sec = dur[i] && dur[i][k]; if (sec == null) continue; const m = dist[i] && dist[i][k]; map[use[i].name + '>' + use[k].name] = { min: Math.max(2, Math.round(sec / 60)), km: m != null ? Math.round(m / 100) / 10 : null } }
kbMatrix.value = { ...kbMatrix.value, [key]: { sig, map } } // réactif → les legs se recalculent (temps routiers réels)
} 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 = (await roster.unassignedJobs()).jobs || [] } catch (e) { /* non bloquant */ } }
// Drop sur la colonne « À assigner » = renvoyer au pool (désassigner, ERPNext only).
async function onKanbanUnassign (ev) {
ev.preventDefault()
const raw = (ev.dataTransfer && ev.dataTransfer.getData('text/plain')) || draggingJobName.value; draggingJobName.value = null
const names = (raw || '').split(',').filter(Boolean); onJobDragEnd(); if (!names.length) return
let ok = 0; for (const n of names) { try { await roster.unassignJobRoster(n); ok++ } catch (e) { err(e) } } // SÉQUENTIEL (frappe_pg)
if (ok) { $q.notify({ type: 'info', message: ok + ' job(s) renvoyé(s) au pool', timeout: 2200 }); await reloadOccupancy(); await reloadPool() }
}
// Clic droit sur une carte job → désassigner directement (retour au pool), sans devoir la glisser sur un autre tech.
async function unassignOne (b) {
const jn = b && (b.name || b.job); if (!jn || b.assist || b.legacy) return // Kanban=name, grille=job ; legacy/assist non désassignables
try { await roster.unassignJobRoster(jn); await reloadOccupancy(); await reloadPool(); $q.notify({ type: 'info', message: (b.subject || b.skill || 'Job') + ' renvoyé au pool', timeout: 2200 }) } catch (e) { err(e) }
}
// Ligne-curseur « maintenant » (Eastern Time, America/Toronto), rafraîchie chaque minute — style Gaiia.
const nowTick = ref(0); let _nowTimer = null
const nowET = computed(() => {
nowTick.value // dépendance réactive → recalcule à chaque tick (minute)
try {
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Toronto', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(new Date())
const o = {}; for (const p of parts) o[p.type] = p.value
const hh = o.hour === '24' ? 0 : Number(o.hour)
return { iso: `${o.year}-${o.month}-${o.day}`, h: hh + Number(o.minute) / 60 }
} catch (e) { return { iso: '', h: null } }
})
const nowHHMM = computed(() => { const n = nowET.value; if (n.h == null) return ''; const h = Math.floor(n.h); return String(h).padStart(2, '0') + ':' + String(Math.round((n.h - h) * 60)).padStart(2, '0') })
// ── Échelle d'heures du board en PIXELS (style Gaiia/dispatch) : 7h18h REMPLIT la largeur dispo (mesurée),
// scroll horizontal pour les heures hors plage ; lanes hautes + rangées serrées = plus de place pour les détails. ──
const KB_VIS_FROM = 6; const KB_VIS_TO = 18; const KB_NAME_W = 150 // fenêtre de REMPLISSAGE 618h ; 06 et 1824 restent SCROLLABLES (continuité)
const kbBoardEl = ref(null); const kbBoardW = ref(1100); let _kbRO = null
const kbScrollEl = ref(null); let _kbDidScroll = false
function kbScrollToDefault () { if (kbScrollEl.value) { kbScrollEl.value.scrollLeft = Math.max(0, (KB_VIS_FROM - kbAxis.value.min) * kbPxH.value); _kbDidScroll = true } } // cale la vue sur 6h (06 reste accessible en scrollant à gauche)
watch(kbBoardEl, (el) => { if (_kbRO) { _kbRO.disconnect(); _kbRO = null } if (el) { _kbRO = new ResizeObserver(es => { kbBoardW.value = es[0].contentRect.width; if (!_kbDidScroll) nextTick(kbScrollToDefault) }); _kbRO.observe(el); kbBoardW.value = el.clientWidth || 1100 } })
// Plage RENDUE : couvre au moins 718, étendue aux heures réelles des shifts (scroll pour le hors-plage).
// Vue calée sur 718h : on NE rend PAS 0006h (ni la nuit/garde). Min fixe à 7 ; max étendu aux jobs réels mais plafonné à 21h.
const kbAxis = computed(() => ({ min: 0, max: 24 })) // rendu PLEINE JOURNÉE → scroll continu (06 à gauche, 1824 à droite) ; 618 remplit la largeur
const kbPxH = computed(() => Math.max(46, (kbBoardW.value - KB_NAME_W - 4) / (KB_VIS_TO - KB_VIS_FROM))) // px/heure : 718 = largeur dispo
const kbInnerW = computed(() => Math.round((kbAxis.value.max - kbAxis.value.min) * kbPxH.value))
// Lane : fond BLANC + lignes verticales pâles toutes les 0,5 h (alignées sur l'axe : 0 = kbAxis.min).
const kbLaneStyle = computed(() => { const half = Math.max(8, kbPxH.value / 2); return { width: kbInnerW.value + 'px', backgroundColor: '#eef1f4', backgroundImage: 'repeating-linear-gradient(90deg, rgba(0,0,0,.05) 0 1px, transparent 1px ' + half + 'px)' } }) // gris pâle = indispo ; le quart (.kbb-shift) y crée une zone blanche
function kbPos (s, e) { const m = kbAxis.value.min, p = kbPxH.value; const L = (Math.max(m, s) - m) * p; const W = (Math.min(e, kbAxis.value.max) - Math.max(m, s)) * p; return { left: Math.round(L) + 'px', width: Math.max(6, Math.round(W)) + 'px' } }
const kbTicks = computed(() => { const a = kbAxis.value; const out = []; for (let h = a.min; h <= a.max; h++) out.push({ h, left: Math.round((h - a.min) * kbPxH.value) + 'px' }); return out })
// Bandes de quart (px) : réguliers + garde (brut depuis cellsOf/templates, sans pos %).
function kbShiftBands (techId) {
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return []
let s = Infinity, e = -Infinity
for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || t.on_call) continue; const ss = hToNum(t.start_time), ee = hToNum(t.end_time); if (ss != null) s = Math.min(s, ss); if (ee != null) e = Math.max(e, ee <= ss ? 24 : ee) }
// Allonge la zone blanche du quart pour COUVRIR les blocs qui débordent (jobs + trajets) ; garde = bande séparée.
const blks = kanbanLaneBlocks(techId)
if (!isFinite(s)) { if (!blks.length) return []; s = Math.min(...blks.map(b => b.s)); e = Math.max(...blks.map(b => b.e)) }
else for (const b of blks) { if (b.s < s) s = b.s; if (b.e > e) e = b.e }
return [{ s, e }]
}
const kbOnGarde = (techId) => kanbanDay.value ? onGarde(techId, kanbanDay.value.iso) : false
// Garde = shift SUR APPEL : bande jaune pâle sur la timeline (heures de garde) + téléphone rouge (pas une icône dans le nom).
function kbGardeBands (techId) {
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return []
const g = gardeEffective.value[techId + '|' + iso]; if (!g) return []
const t = tplByName.value[g]; if (!t) return []
const s = hToNum(t.start_time), e = hToNum(t.end_time); if (s == null || e == null) return []
return [{ s, e: e <= s ? 24 : e }]
}
// Ligne « maintenant » (px) — seulement si la colonne = aujourd'hui (ET) et dans la plage rendue.
const nowLeftPx = computed(() => { const n = nowET.value; if (n.h == null || !kanbanDay.value || kanbanDay.value.iso !== n.iso) return null; const a = kbAxis.value; if (n.h < a.min || n.h > a.max) return null; return (n.h - a.min) * kbPxH.value })
const nowLineStyle = computed(() => { const x = nowLeftPx.value; return x == null ? null : { left: (KB_NAME_W + Math.round(x)) + 'px' } }) // trait continu (inclut la colonne noms)
const nowLblStyle = computed(() => { const x = nowLeftPx.value; return x == null ? null : { left: Math.round(x) + 'px' } }) // étiquette dans la règle (après les noms)
// Export GPX du parcours GPS (jour courant) via Traccar — si le tech a un appareil associé.
const kbIsToday = computed(() => { const n = nowET.value; return !!(kanbanDay.value && n.iso && kanbanDay.value.iso === n.iso) })
function exportGpx (techId) {
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return
const from = new Date(iso + 'T00:00:00').toISOString(); const to = new Date(iso + 'T23:59:59').toISOString()
window.open(roster.gpxUrl(techId, from, to), '_blank')
}
// ── Détails d'un job : double-clic sur un bloc → grand volet DROIT (billet + commentaires) ; simple clic = éditeur de jour. ──
const jobDetail = reactive({ open: false, name: '', subject: '', customer: '', address: '', skill: '', time: '', detail: '', lid: null, dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null })
async function openJobDetail (b, t) {
if (!b) return
jdShowTrack.value = false
Object.assign(jobDetail, { open: true, name: b.name || '', subject: b.subject || b.name || 'Job', customer: b.customer || '', address: b.address || '', skill: b.skill || '', time: (b.start || (b.s != null ? fmtH(b.s) : '')) + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : ''), detail: b.detail || '', lid: b.legacy_id || null, dept: b.dept || '', techId: (t && t.id) || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : null, lon: b.lon != null ? +b.lon : null, loading: !!b.legacy_id, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null })
if (b.legacy_id) { try { jobDetail.thread = await roster.ticketThread(b.legacy_id) } catch (e) { jobDetail.thread = { error: true, messages: [] } } finally { jobDetail.loading = false } }
if (jobDetail.canTeam) { jobDetail.teamLoading = true; try { const r = await roster.getJobTeam(b.name); jobDetail.team = r.assistants || [] } catch (e) { jobDetail.team = [] } finally { jobDetail.teamLoading = false } }
}
// Options du sélecteur d'assistant : tous les techs sauf le lead courant + ceux déjà dans l'équipe.
const jdTeamOptions = computed(() => { const taken = new Set([jobDetail.techId, ...(jobDetail.team || []).map(a => a.tech_id)]); return (techs.value || []).filter(t => !taken.has(t.id)).map(t => ({ label: t.name, value: { id: t.id, name: t.name } })) })
async function jdAddAssistant () {
const t = jobDetail.teamAdd; if (!t || !jobDetail.name) return
try {
await roster.addAssistant(jobDetail.name, { tech_id: t.id, tech_name: t.name, duration_h: 0, pinned: 1 })
jobDetail.teamAdd = null
const r = await roster.getJobTeam(jobDetail.name); jobDetail.team = r.assistants || []
await reloadOccupancy()
$q.notify({ type: 'positive', icon: 'group_add', message: t.name + ' ajouté en renfort · bloc hachuré réservé dans son horaire', timeout: 2600 })
} catch (e) { err(e) }
}
async function jdRemoveAssistant (techId) {
if (!jobDetail.name) return
try { await roster.removeAssistant(jobDetail.name, techId); const r = await roster.getJobTeam(jobDetail.name); jobDetail.team = r.assistants || []; await reloadOccupancy(); $q.notify({ type: 'info', message: 'Assistant retiré', timeout: 1600 }) } catch (e) { err(e) }
}
let _kbClickT = null
function kbBlockClick (t) { clearTimeout(_kbClickT); _kbClickT = setTimeout(() => { if (kanbanDay.value) openDayFromCell(t, kanbanDay.value) }, 230) } // simple clic (différé) = éditeur de jour
function kbBlockDbl (b, t) { clearTimeout(_kbClickT); openJobDetail(b, t) } // double-clic = volet détails
// Clic DROIT sur le FOND d'une lane (hors job) → menu de quart (Jour/Soir/Garde/Absent/heures + créer un quart).
// Clic GAUCHE sur le fond = éditeur de jour (liste complète + carte) — voir le template (kbb-lane @click.self).
function onKbLaneMenu (t, ev) { if (!kanbanDay.value) return; openShiftMenu(t, kanbanDay.value, ev, -1, -1) }
// Drop d'une job sur une lane : assigne (onCellDrop → hub) PUIS crée un quart 816 auto si le tech n'a aucun quart régulier ce jour.
async function onKbDrop (ev, t) {
if (!kanbanDay.value) return
await onCellDrop(ev, t, kanbanDay.value) // si pas de quart → onCellDrop ouvre le dialogue (créer quart / absence) ; sinon assigne + toast Annuler
}
// ── Drop sur une lane SANS quart : demander quoi faire (créer un quart OU marquer absent) au lieu d'assigner en douce. ──
const dropAsk = reactive({ open: false, tech: null, day: null, names: [], mode: 'choice', preset: '8-16', cStart: '08:00', cEnd: '16:00', scope: 'day', from: '', to: '', absType: 'Congé' })
const shiftPresets = [{ label: 'Journée · 8 h16 h', value: '8-16' }, { label: 'Avant-midi · 8 h12 h', value: '8-12' }, { label: 'Après-midi · 12 h16 h', value: '12-16' }, { label: 'Soir · 13 h21 h', value: '13-21' }, { label: 'Personnalisé…', value: 'custom' }]
const absTypes = ['Congé', 'Maladie', 'Personnel', 'Formation', 'Absent']
const dropAskReturn = computed(() => { if (!dropAsk.open || dropAsk.mode !== 'absence') return ''; const days = dropAskDays(); return days.length ? addDaysISO(days[days.length - 1], 1) : '' })
function openDropAsk (names, t, d) { Object.assign(dropAsk, { open: true, tech: t, day: d, names: names.slice(), mode: 'choice', preset: '8-16', cStart: '08:00', cEnd: '16:00', scope: 'day', from: d.iso, to: d.iso, absType: 'Congé' }) }
function dropAskWindow () { if (dropAsk.preset === 'custom') return [hToNum(dropAsk.cStart), hToNum(dropAsk.cEnd)]; const [a, b] = dropAsk.preset.split('-').map(Number); return [a, b] }
async function dropAskCreateShift () {
const t = dropAsk.tech, d = dropAsk.day; const [min, max] = dropAskWindow()
if (!(max > min)) { $q.notify({ type: 'warning', message: 'La fin doit être après le début.' }); return }
const tpl = await ensureWindowTpl(min, max)
if (tpl) {
pushHistory(); addShift(t.id, t.name, d.iso, tpl)
// PERSISTE le quart tout de suite (sinon addShift reste local/Proposé → disparaît au rechargement, et le tech « n'a aucun quart »).
try { await roster.createShift({ tech: t.id, tech_name: t.name, date: d.iso, shift: tpl.name, zone: tpl.zone || '', hours: tpl.hours || 8 }) } catch (e) { err(e) }
}
const done = await assignNames(dropAsk.names, t, d)
dropAsk.open = false
notifyAssignUndo(done, t, d, tpl ? tpl.name : null) // « Annuler » → désassigne + retire le quart créé
}
function dropAskDays () {
if (dropAsk.scope === 'day') return [dropAsk.day.iso]
if (dropAsk.scope === 'week') { const m = mondayISO(dropAsk.day.iso); return Array.from({ length: 7 }, (_, i) => addDaysISO(m, i)) }
const out = []; let dd = dropAsk.from; let g = 0; while (dd && dropAsk.to && dd <= dropAsk.to && g++ < 400) { out.push(dd); dd = addDaysISO(dd, 1) }; return out
}
async function dropAskAbsence () {
const t = dropAsk.tech; const days = dropAskDays(); if (!days.length) { $q.notify({ type: 'warning', message: 'Plage de dates invalide.' }); return }
for (const dd of days) { try { await roster.setAbsence(t.id, dd, dropAsk.absType || 'Congé', false) } catch (e) { err(e) } } // séquentiel (frappe_pg)
await reloadAbsences(); await reloadOccupancy()
const retour = addDaysISO(days[days.length - 1], 1)
const noms = dropAsk.names.slice(); dropAsk.open = false
$q.notify({ type: 'warning', icon: 'event_busy', message: `${t.name} absent ${days.length} j (${dropAsk.absType}) · retour le ${retour}. ${noms.length} job(s) laissé(s) au pool.`, timeout: 8000, actions: [{ label: 'Assigner à un autre tech', color: 'white', handler: () => openAssignPanel() }] })
await checkAbsenceImpact(days.map(dd => t.id + '|' + dd)) // redistribue les jobs DÉJÀ assignés sur ces jours (dialogue existant)
}
// ── Absence sur PLAGE (comme l'ancien dispatch) : défaut = jour affiché ; options « ce jour / cette semaine / plage du..au ». ──
const absDialog = reactive({ open: false, techId: '', techName: '', from: '', to: '' })
function openAbsDialog () {
if (!menu.tech || !menu.day) return
absDialog.techId = menu.tech.id; absDialog.techName = menu.tech.name
absDialog.from = menu.day.iso; absDialog.to = menu.day.iso; absDialog.open = true; menu.show = false
}
function absWeek () { const m = mondayISO(absDialog.from || todayISO()); absDialog.from = m; absDialog.to = addDaysISO(m, 6) }
function absDays () { const out = []; let d = absDialog.from; if (!d || !absDialog.to || absDialog.to < d) return d ? [d] : out; let g = 0; while (d <= absDialog.to && g++ < 400) { out.push(d); d = addDaysISO(d, 1) }; return out }
async function applyAbs (remove) {
const days = absDays(); if (!days.length || !absDialog.techId) return
for (const d of days) { try { await roster.setAbsence(absDialog.techId, d, 'Congé', remove) } catch (e) { err(e) } } // séquentiel (frappe_pg)
await reloadAbsences(); await reloadOccupancy()
$q.notify({ type: 'info', message: remove ? ('Absence retirée (' + days.length + ' j)') : (days.length + ' jour(s) marqué(s) absent') })
absDialog.open = false
if (!remove) await checkAbsenceImpact(days.map(d => absDialog.techId + '|' + d))
}
// ── Mini-carte du volet détails : zoome sur le job + layer « tracé GPS réel » (Traccar) en option ──
const jdMapEl = ref(null); let _jdMap = null
const jdShowTrack = ref(false); const jdTrackMsg = ref('')
// Toggle Traccar disponible UNIQUEMENT aujourd'hui ou hier (ET) — sinon Traccar trop lourd/lent.
const kbCanTrack = computed(() => {
const iso = kanbanDay.value && kanbanDay.value.iso; const n = nowET.value.iso; if (!iso || !n) return false
const yest = new Date(Date.parse(n + 'T12:00:00Z') - 86400000).toISOString().slice(0, 10)
return iso === n || iso === yest
})
async function initJdMap () {
if (!MAPBOX_TOKEN || !jdMapEl.value) return
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !jdMapEl.value) return
if (_jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null }
const lat = jobDetail.lat, lon = jobDetail.lon; const hasLL = isFinite(lat) && isFinite(lon) && (lat || lon)
_jdMap = new mapboxgl.Map({ container: jdMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: hasLL ? [lon, lat] : [-73.6756, 45.1599], zoom: hasLL ? 14 : 9 })
_jdMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
_jdMap.on('load', () => {
_jdMap.resize()
if (hasLL) new mapboxgl.Marker({ color: '#1976d2' }).setLngLat([lon, lat]).addTo(_jdMap) // le point du job
_jdMap.addSource('jd-track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
_jdMap.addLayer({ id: 'jd-track-halo', type: 'line', source: 'jd-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ff6f00', 'line-width': 8, 'line-opacity': 0.22 } })
_jdMap.addLayer({ id: 'jd-track-l', type: 'line', source: 'jd-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ef6c00', 'line-width': 3, 'line-opacity': 0.78 } })
if (jdShowTrack.value) loadJdTrack()
})
}
async function loadJdTrack () {
jdTrackMsg.value = '…'
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso || !jobDetail.techId || !_jdMap) { jdTrackMsg.value = ''; return }
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(jobDetail.techId, from, to); const coords = (r && r.coords) || []
const src = _jdMap.getSource('jd-track')
if (src) src.setData(coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} } : { type: 'FeatureCollection', features: [] })
jdTrackMsg.value = coords.length >= 2 ? (coords.length + ' points GPS') : 'Aucun tracé ce jour (ou pas d\'appareil).'
} catch (e) { jdTrackMsg.value = 'Tracé indisponible' }
}
watch(jdShowTrack, (on) => { if (!_jdMap) return; if (on) loadJdTrack(); else { const s = _jdMap.getSource('jd-track'); if (s) s.setData({ type: 'FeatureCollection', features: [] }); jdTrackMsg.value = '' } })
watch(() => jobDetail.open, (open) => { if (open) nextTick(() => setTimeout(initJdMap, 280)); else if (_jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null } })
const tplOptions = computed(() => templates.value.map(t => ({ label: t.template_name, value: t.name })))
const techOptions = computed(() => techs.value.map(t => ({ label: t.name, value: t.id })))
const tplByName = computed(() => Object.fromEntries(templates.value.map(t => [t.name, t])))
// ── CALQUE de garde LIVE ──────────────────────────────────────────────────
// La garde n'est PAS matérialisée pour l'affichage : on la recalcule à la volée depuis les règles
// (rotation déterministe ancrée + saut d'absent, via rotationTech). Éditer la séquence ou marquer
// une absence se reflète INSTANTANÉMENT, sans régénérer → fini la désync « la suite est bousillée ».
// Clé techId|iso → nom du shift de garde (semaine vs week-end). Vacances ⇒ substitut auto.
const gardeOverlay = computed(() => {
const map = {}; const rules = gardeRules.value; if (!rules.length) return map
for (const d of dayList.value) {
const dow = dowOf(d.iso); const weekend = (dow === 0 || dow === 6)
for (const rule of rules) {
if (!(rule.weekdays || []).includes(dow)) continue
const sh = (weekend && rule.shiftWeekend) ? rule.shiftWeekend : rule.shift
if (!tplByName.value[sh]) continue
const id = rotationTech(rule, d.iso); if (!id) continue
map[id + '|' + d.iso] = sh // si 2 règles visent le même tech/jour, la dernière gagne (rare)
}
}
return map
})
// Shift de garde à utiliser pour un ajout MANUEL un jour donné : celui des règles (semaine/WE), sinon 1er modèle on_call.
function gardeShiftForDay (iso) {
const dow = dowOf(iso); const weekend = (dow === 0 || dow === 6)
for (const rule of gardeRules.value) { if (!(rule.weekdays || []).includes(dow)) continue; const sh = (weekend && rule.shiftWeekend) ? rule.shiftWeekend : rule.shift; if (tplByName.value[sh]) return sh }
const oc = templates.value.find(t => t.on_call); return oc ? oc.name : null
}
// Garde EFFECTIVE = rotation (gardeOverlay) + overrides MANUELS (touche « G »/menu, localStorage) :
// manualGarde[key]='on' → ajoute la garde (shift du jour) ; 'off' → la retire (override d'une garde de règle).
// Permet de DÉPLACER la garde à la main (tech en congé → la placer ailleurs) SANS toucher aux règles.
const gardeEffective = computed(() => {
const base = { ...gardeOverlay.value }
for (const key in manualGarde.value) {
const v = manualGarde.value[key]
if (v === 'off') delete base[key]
else if (v === 'on') { const sh = gardeShiftForDay(key.split('|')[1]); if (sh) base[key] = sh }
}
return base
})
function onGarde (techId, iso) { return !!gardeEffective.value[techId + '|' + iso] }
// Nb de techs de garde par jour (garde EFFECTIVE) → ligne de pied cohérente avec la grille
const gardeCountByDate = computed(() => { const m = {}; for (const k in gardeEffective.value) { const iso = k.split('|')[1]; m[iso] = (m[iso] || 0) + 1 } return m })
function chip (color) { return { background: color || '#1976d2', color: '#fff' } }
// techs visibles (recherche + groupe + tri)
const groupOptions = computed(() => { const s = new Set(); for (const t of techs.value) if (t.group) s.add(t.group); return [...s].sort().map(g => ({ label: g, value: g })) })
// Compétences distinctes (chips de filtrage) + score de PRIORITÉ provisoire.
const allSkills = computed(() => [...new Set(techs.value.flatMap(t => t.skills || []))].sort())
// Édition des compétences dans la cellule du nom (popover) + suggestions ALIGNÉES sur les catégories de job.
const jobTypes = ref([]) // service_types distincts (catégories de job) → suggérés comme compétences
const skillByType = ref({}) // type de job → compétence requise déduite (politique booking) — sert au repli reqSkill
const skillDialog = ref(null) // tech dont on édite les compétences
const skillMenuTarget = ref(null) // élément cliqué = ancre du popover (près de la souris, sur la rangée)
const skillMenuShown = ref(false)
function openSkillEditor (t, ev) { skillDialog.value = t; skillMenuTarget.value = (ev && ev.currentTarget) || null; skillMenuShown.value = true }
// TagEditor (showLevel) ↔ tech : skills = libellés (CSV) · skill_levels = {compétence: niveau 15} (JSON).
// Par compétence : SCORE (maîtrise 15, qualité) + EFFICACITÉ (facteur vitesse ; défaut = efficacité globale).
function skillLevelOf (t, sk) { return (t.skill_levels && t.skill_levels[sk]) || 0 } // 0 = non défini (étoiles vides) ; pas de défaut trompeur
// Applique un horaire standard à CE tech sur la semaine affichée (presets type Dispatch).
async function applyWeekPreset (t, dows, min, max) {
const tpl = await ensureWindowTpl(min, max); if (!tpl) return
pushHistory()
for (const d of dayList.value) { if (dows.includes(dowOf(d.iso))) { clearLocal(t.id, d.iso); addShift(t.id, t.name, d.iso, tpl) } }
skillMenuShown.value = false
$q.notify({ type: 'positive', message: t.name + ' : horaire appliqué (semaine affichée) — pense à Publier', timeout: 2500 })
}
function skillEffOf (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? Number(e) : (Number(t.efficiency) || 1) } // facteur (pour le calcul de priorité)
// Efficacité saisie en % de PERFORMANCE : + = plus VITE (moins de temps) · = plus LENT. Conversion ↔ facteur.
function effPctOf (factor) { return Math.round((1 - (Number(factor) || 1)) * 100) }
function factorFromPct (pct) { const f = 1 - (Number(pct) || 0) / 100; return Math.max(0.1, Math.min(3, Math.round(f * 100) / 100)) }
function skillEffPct (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? effPctOf(e) : '' } // '' = hérite de la globale
function setSkillEffPct (t, sk, pct) { const m = { ...(t.skill_eff || {}) }; if (pct === '' || pct == null) delete m[sk]; else m[sk] = factorFromPct(pct); t.skill_eff = m; queueSkillSave(t) } // libre (ex. +80)
function setSkillLevel (t, sk, v) { if (!t.skill_levels) t.skill_levels = {}; t.skill_levels = { ...t.skill_levels, [sk]: v }; queueSkillSave(t) }
// Efficacité GLOBALE du tech, éditable/réinitialisable depuis le popover (débouncée via setTechEfficiency).
// Couleur du cercle (vitesse PAR compétence) : vite → vert · normal → bleu-gris · lent → rouge. Intensité ∝ écart.
function effColor (factor) { const p = effPctOf(factor); if (!p) return '#607d8b'; const a = Math.min(Math.abs(p), 30) / 30; const hue = p > 0 ? 122 : 4; return 'hsl(' + hue + ',' + Math.round(50 + a * 28) + '%,' + (44 - Math.round(a * 8)) + '%)' }
function skillEffColor (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e == null || e === '') ? '#607d8b' : effColor(Number(e)) } // neutre si pas d'override (pure par-compétence)
// Icône Material (offline, jeu Quasar par défaut) représentant une compétence/type — par mots-clés, repli 'bolt'.
// Icône de compétence pour q-icon (accepte les SYMBOLES SVG importés) : échelle=installation, casque=support,
// outils=réparation ; sinon ligature classique unique (skillIcon). NB: les pins carte (HTML brut) gardent skillIcon.
function tSkills (t) { return [...(t.skills || [])].sort((a, b) => a.localeCompare(b)) } // compétences triées du tech (pour l'affichage 1 + « +N »)
function skillSym (sk) {
const s = String(sk || '').toLowerCase()
if (/t[ée]l[ée]vis|\btv\b|iptv|t[ée]l[ée]\b/.test(s)) return 'live_tv' // télé/TV → TV — AVANT « install » (« Install/Reparation Télé » contient « install »)
if (/install/.test(s)) return symOutlinedToolsLadder
if (/support|service.?client|t[ée]l[ée]assist|\baide\b/.test(s)) return symOutlinedHeadsetMic
return skillIcon(sk) // réparation → 'build' (clé simple / wrench) via skillIcon ; autres → ligature unique
}
// Icône d'un bloc de tournée (vue JOUR et SEMAINE) : renfort → group ; sinon icône de COMPÉTENCE
// (skill/required_skill, ou dept pour les tickets legacy → ex. « Install/Reparation Télé » = TV).
// Le ticket générique n'est utilisé qu'en dernier recours (legacy sans skill ni dept).
function blkIcon (b) {
if (b && b.done) return 'check'
if (b && b.assist) return 'group'
const sk = b && (b.skill || b.required_skill || b.dept)
if (sk) return skillSym(sk)
return (b && b.legacy) ? 'confirmation_number' : skillSym('')
}
// Camion-nacelle (bucket truck) pour « monteur » — Material Symbols n'a pas d'équivalent → SVG custom au format
// q-icon Quasar (chemins séparés par && ; chaque chemin = d@@style). Aplats (carrosserie/roues/nacelle, roues
// évidées en evenodd) + bras articulé en trait épais. Hérite couleur (currentColor) et taille. viewBox 512×416.
const BUCKET_TRUCK = 'M120,300 H440 V322 H120 Z M120,276 H310 V300 H120 Z M206,226 H280 V300 H206 Z M56,64 H160 V144 H56 Z@@fill:currentColor'
+ '&&M300,300 V250 L322,222 H408 L438,260 V300 Z M330,252 H396 V284 H330 Z@@fill:currentColor;fill-rule:evenodd'
+ '&&M144,356 a40,40 0 1,0 80,0 a40,40 0 1,0 -80,0 Z M170,356 a14,14 0 1,1 28,0 a14,14 0 1,1 -28,0 Z M352,356 a40,40 0 1,0 80,0 a40,40 0 1,0 -80,0 Z M378,356 a14,14 0 1,1 28,0 a14,14 0 1,1 -28,0 Z@@fill:currentColor;fill-rule:evenodd'
+ '&&M243,250 L384,150 L120,104@@fill:none;stroke:currentColor;stroke-width:30;stroke-linecap:round;stroke-linejoin:round'
+ '|0 0 512 416'
function skillIcon (sk) {
const s = String(sk || '').toLowerCase()
if (/install/.test(s)) return 'construction'
if (/r[ée]par|d[ée]pann|bris/.test(s)) return 'build'
if (/fibre|fusion|soud|épissure|epissure/.test(s)) return 'cable'
if (/t[ée]l[ée]vis|\btv\b|iptv|t[ée]l[ée]\b/.test(s)) return 'live_tv'
if (/t[ée]l[ée]phon|voip|\b3cx\b/.test(s)) return 'call'
if (/r[ée]seau|net\s?admin|router|bgp|olt|acs/.test(s)) return 'dns'
if (/\bwifi\b|wi-?fi|mesh/.test(s)) return 'wifi' // wifi MAISON (couverture interne) → symbole wifi simple
if (/sans.?fil|wireless|\bfwa\b|\blte\b|radio|antenne|\btour\b|micro.?onde|point.?[àa].?point|\bptp\b|liaison/.test(s)) return 'cell_tower' // sans fil = PtP inter-bâtiments/tours → tour
if (/monteur|poteau|hauteur|nacelle|a[ée]rien|grimp/.test(s)) return BUCKET_TRUCK // monteur / aérien → camion-nacelle
if (/d[ée]sinstall|retrait|ramass|d[ée]mant/.test(s)) return 'delete_sweep'
if (/info|ordinateur|\bpc\b|cam[ée]ra/.test(s)) return 'computer'
if (/vente|sales|soumission/.test(s)) return 'sell'
if (/factur|paiement|compta/.test(s)) return 'receipt_long'
return 'bolt'
}
function onTagsChange (t, items) {
const newLabels = (items || []).map(x => typeof x === 'string' ? x : x.tag).filter(Boolean)
const removed = (t.skills || []).filter(s => !newLabels.includes(s)) // compétences retirées → vérifier l'impact sur les jobs assignés
t.skills = newLabels; queueSkillSave(t)
if (removed.length) checkSkillImpact(t, removed)
}
// IROPS : un retrait de compétence peut invalider des jobs assignés qui l'exigent → proposer de redistribuer.
const skillImpactDialog = ref(null) // { tech, skill, jobs }
const skillImpactOpen = computed({ get: () => !!skillImpactDialog.value, set: v => { if (!v) skillImpactDialog.value = null } })
const redistributing = ref(false)
const impactCandidates = reactive({}) // jobName → [{tech,tech_name}] candidats classés
const impactPlan = reactive({}) // jobName → techId choisi | '__requeue'
const loadingCandidates = ref(false)
function candidateOptions (jobName) { return [...((impactCandidates[jobName] || []).map(x => ({ label: x.tech_name || x.tech, value: x.tech }))), { label: '→ À recontacter (client)', value: '__requeue' }] }
async function loadImpactCandidates () { // candidats classés par job + pré-sélection du meilleur
const d = skillImpactDialog.value; if (!d) return
loadingCandidates.value = true
for (const k in impactCandidates) delete impactCandidates[k]; for (const k in impactPlan) delete impactPlan[k]
for (const j of d.jobs) {
try { const r = await roster.jobCandidates(j.name, d.tech.id); impactCandidates[j.name] = r.candidates || []; impactPlan[j.name] = (r.candidates && r.candidates[0]) ? r.candidates[0].tech : '__requeue' }
catch (e) { impactCandidates[j.name] = []; impactPlan[j.name] = '__requeue' }
}
loadingCandidates.value = false
}
async function checkSkillImpact (t, removedSkills) {
for (const sk of removedSkills) {
try { const r = await roster.skillImpact(t.id, sk); if (r.jobs && r.jobs.length) { skillImpactDialog.value = { tech: t, kind: 'skill', skill: sk, jobs: r.jobs }; await loadImpactCandidates(); return } } catch (e) { /* non bloquant */ }
}
}
// Même principe pour une ABSENCE : jobs assignés tombant sur les jours d'absence → dialogue de redistribution.
async function checkAbsenceImpact (targets) {
const byTech = {}; for (const k of targets) { const [tid, iso] = k.split('|'); (byTech[tid] = byTech[tid] || []).push(iso) }
for (const tid in byTech) {
try { const r = await roster.absenceImpact(tid, byTech[tid]); if (r.jobs && r.jobs.length) { const t = techs.value.find(x => x.id === tid) || { id: tid, name: tid }; skillImpactDialog.value = { tech: t, kind: 'absence', jobs: r.jobs }; await loadImpactCandidates(); return } } catch (e) { /* non bloquant */ }
}
}
async function applyImpactPlan () { // applique le plan choisi (tech par job) puis RECHARGE (occupation à jour)
const d = skillImpactDialog.value; if (!d) return
redistributing.value = true
const plan = d.jobs.map(j => (!impactPlan[j.name] || impactPlan[j.name] === '__requeue') ? { job: j.name, requeue: true } : { job: j.name, tech: impactPlan[j.name] })
try { const r = await roster.redistributePlan(plan); $q.notify({ type: 'positive', message: (r.reassigned || 0) + ' réassigné(s) · ' + (r.requeued || 0) + ' à recontacter', timeout: 4500 }); skillImpactDialog.value = null; await loadWeek() }
catch (e) { err(e) } finally { redistributing.value = false }
}
async function doRedistribute (mode) { // « Tout à recontacter » (bascule simple)
const d = skillImpactDialog.value; if (!d) return
redistributing.value = true
try {
const r = await roster.redistributeSkillJobs(d.jobs.map(j => j.name), d.kind === 'skill' ? d.skill : '', mode) // absence → compétence par job côté hub
$q.notify({ type: 'positive', message: (r.reassigned || 0) + ' réassigné(s) · ' + (r.requeued || 0) + ' à recontacter', timeout: 4500 })
skillImpactDialog.value = null; await loadWeek() // refresh : occupation/jobs à jour (fix bar 4h résiduelle)
} catch (e) { err(e) } finally { redistributing.value = false }
}
// ── Panneau FLOTTANT « jobs à assigner » (multi-sélection + glisser-déposer + aperçu d'occupation) ──
const assignPanel = reactive({ open: false, x: 40, y: 130, w: 340, h: 560, jobs: [], loading: false, showMap: false, locating: false })
const draggingJobName = ref(null); const dropCell = ref(null); const dragHours = ref(0)
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
async function openAssignPanel () { assignPanel.open = true; assignPanel.loading = true; for (const k in selectedJobs) delete selectedJobs[k]; try { assignPanel.jobs = (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.
const legacyPush = reactive({ open: false, loading: false, applying: false, preview: null, notify: true })
async function openLegacyPush () { legacyPush.open = true; legacyPush.loading = true; legacyPush.preview = null; try { legacyPush.preview = await roster.pushLegacyPreview() } catch (e) { err(e) } finally { legacyPush.loading = false } }
async function applyLegacyPush () { legacyPush.applying = true; try { const r = await roster.pushLegacyApply(legacyPush.notify); $q.notify({ type: r.errors ? 'warning' : 'positive', message: `Legacy : ${r.pushed} ticket(s) réassigné(s)` + (legacyPush.notify && r.notified != null ? ` · ${r.notified} avisé(s) par courriel (boîte du tech)` : '') + (r.mismatch ? ` · ${r.mismatch} ignoré(s) (nom ≠)` : '') + (r.errors ? ` · ${r.errors} erreur(s)` : '') }); if (legacyPush.notify && r.notify_errors && r.notify_errors.length) { $q.notify({ type: 'warning', icon: 'mark_email_unread', message: `⚠ ${r.notify_errors.length} avis NON envoyé(s) — ${(r.notify_errors[0] && r.notify_errors[0].error) || ''}`, timeout: 6000 }) } legacyPush.open = false } catch (e) { err(e) } finally { legacyPush.applying = false } }
// X sur une ligne de l'aperçu → désassigne le job dans Ops (retour au pool) → l'exclut du push, puis rafraîchit l'aperçu + la grille.
async function removeFromPush (s) {
if (!s || !s.job) { $q.notify({ type: 'warning', message: 'Job introuvable' }); return }
legacyPush.applying = true
try {
await roster.unassignJobRoster(s.job)
await reloadOccupancy()
legacyPush.preview = await roster.pushLegacyPreview()
$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 || '')
const parts = a.split(',').map(s => s.trim()).filter(Boolean)
if (parts.length >= 2) return parts[parts.length - 1] // dernier segment d'adresse = ville
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) })
// 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 }
return Object.entries(m).map(([iso, n]) => ({ iso, n, label: iso === 'Sans date' ? 'Sans date' : fmtDueLabel(iso), overdue: iso !== 'Sans date' && iso < t })).sort((a, b) => String(a.iso).localeCompare(String(b.iso)))
})
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] }
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')) }
const df = assignDateFilter.value; if (df.length) { const ds = new Set(df); arr = arr.filter(j => ds.has((j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date')) }
return arr
})
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 + ' ⏰ en retard'; if (d === t) return "Aujourd'hui"; return d }
// Couleur d'un segment de capacité (heures-tech libres restantes) : rouge=plein, orange=presque plein, vert=ok, gris=aucun quart.
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)) }
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) {
const txt = ((j.required_skill || '') + ' ' + (j.service_type || '') + ' ' + (j.subject || '')).toLowerCase()
if (/activation|config|netadmin|à distance|a distance|distant|remote|provision/.test(txt)) return false
return true
}
const selectedNames = computed(() => assignPanel.jobs.filter(j => selectedJobs[j.name]).map(j => j.name))
const jobHours = (j) => Number(j.duration_h) || 1 // défaut système = 1h (cf. hub)
const selectedHours = computed(() => Math.round(assignPanel.jobs.filter(j => selectedJobs[j.name]).reduce((s, j) => s + jobHours(j), 0) * 10) / 10)
function toggleGroupSel (grp) { const anyOnsiteOff = grp.jobs.some(j => jobIsOnsite(j) && !selectedJobs[j.name]); for (const j of grp.jobs) selectedJobs[j.name] = anyOnsiteOff ? jobIsOnsite(j) : false } // (dé)sélectionne le groupe ; pré-coche les terrain
function groupSelected (grp) { return grp.jobs.some(j => selectedJobs[j.name]) }
function onJobDragStart (ev, job) {
const names = selectedJobs[job.name] ? selectedNames.value : [job.name] // job non coché → on glisse juste lui
draggingJobName.value = names.join(','); dragHours.value = Math.round(assignPanel.jobs.filter(j => names.includes(j.name)).reduce((s, j) => s + jobHours(j), 0) * 10) / 10
draggingSet.clear(); names.forEach(n => draggingSet.add(n)) // source estompée (feedback)
try {
ev.dataTransfer.setData('text/plain', names.join(',')); ev.dataTransfer.effectAllowed = 'move'
// Fantôme COMPACT et semi-transparent décalé sous le curseur → ne masque plus le badge d'occupation projetée.
const g = document.createElement('div')
g.textContent = (names.length > 1 ? names.length + ' jobs · ' : '') + dragHours.value + 'h'
g.style.cssText = 'position:fixed;top:-1000px;left:-1000px;padding:2px 9px;background:rgba(94,53,177,.78);color:#fff;font:600 11px sans-serif;border-radius:10px;white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,.3)'
document.body.appendChild(g); _dragGhost = g
ev.dataTransfer.setDragImage(g, -12, -10) // curseur en haut-gauche du fantôme → fantôme bas-droite, badge (haut) lisible
} catch (e) {}
}
function onJobDragEnd () { dropCell.value = null; dropPreview.key = null; draggingSet.clear(); if (_dragGhost) { _dragGhost.remove(); _dragGhost = null } }
function onCellDragOver (t, d) { dropCell.value = t.id + '|' + d.iso; dropPreview.key = t.id + '|' + d.iso; dropPreview.addH = dragHours.value }
async function onCellDrop (ev, t, d, opts = {}) {
dropCell.value = null; dropPreview.key = null
const raw = (ev.dataTransfer && ev.dataTransfer.getData('text/plain')) || draggingJobName.value; draggingJobName.value = null
const names = (raw || '').split(',').filter(Boolean); if (!names.length) return []
// Garde-fou : un job « On Hold » attend une tâche précédente → on REFUSE de l'assigner (≠ simple 🔒 visuel).
const statusBy = Object.fromEntries(assignPanel.jobs.map(j => [j.name, j.status]))
const blocked = names.filter(n => statusBy[n] === 'On Hold'); const assignable = names.filter(n => statusBy[n] !== 'On Hold')
if (blocked.length) $q.notify({ type: 'warning', message: blocked.length + ' job(s) en attente d\'une tâche précédente — non assigné(s). Termine d\'abord l\'étape requise.', timeout: 4000 })
if (!assignable.length) return []
// Pas de quart RÉGULIER ce jour → on N'ASSIGNE PAS en douce : on demande quoi faire (créer un quart OU marquer absent).
const hasShift = hasShiftDay(t.id, d.iso)
// Le tech a-t-il DÉJÀ du travail ce jour (job assigné, occupation serveur) ? Si oui, ne pas re-demander un quart :
// le 1er job a pu créer un quart NON publié (perdu au rechargement de `assignments`), mais l'occupation persiste côté serveur.
const occ = occByTechDay.value[t.id + '|' + d.iso]
const hasWork = !!(occ && (((+occ.h) || 0) > 0.01 || (occ.jobs || []).length))
if (!hasShift && !hasWork && !opts.forceAssign) { openDropAsk(assignable, t, d); return [] }
const done = await assignNames(assignable, t, d)
if (!opts.silent) notifyAssignUndo(done, t, d, null) // toast avec « Annuler » (désassigne)
return done
}
// Boucle d'assignation serveur (séquentielle, frappe_pg) — partagée par le drop direct et le dialogue « créer un quart ».
async function assignNames (names, t, d) {
const done = []
// OPTIMISTE : créditer la charge sur la barre du tech IMMÉDIATEMENT (occByTechDay.h → usedH → barres), réconcilié par reloadOccupancy.
const key = t.id + '|' + d.iso
const addH = names.reduce((s, n) => { const j = assignPanel.jobs.find(x => x.name === n); return s + (j ? (j.est_min ? j.est_min / 60 : Number(j.duration_h || 1)) : 0) }, 0)
const cur = occByTechDay.value[key] || { h: 0, blocks: [], jobs: [] }
occByTechDay.value = { ...occByTechDay.value, [key]: { ...cur, h: (cur.h || 0) + addH, jobs: [...(cur.jobs || []), ...names] } }
for (const jn of names) { try { await roster.assignJob(jn, t.id, d.iso); done.push(jn); delete selectedJobs[jn] } catch (e) { err(e) } }
assignPanel.jobs = assignPanel.jobs.filter(j => !names.includes(j.name))
await reloadOccupancy()
return done
}
// Toast d'assignation AVEC bouton « Annuler » → désassigne le(s) job(s) + retire le quart auto-créé. Corrige : l'undo local ne touchait que les quarts, pas l'assignation serveur.
function notifyAssignUndo (names, t, d, autoShiftName) {
if (!names || !names.length) return
$q.notify({ type: 'positive', message: names.length + ' job(s) assigné(s) à ' + t.name + ' · ' + d.dnum + (autoShiftName ? ' · quart 816 créé' : ''), timeout: 6500, actions: [{ label: 'Annuler', color: 'white', handler: () => undoDrop(names, t, d, autoShiftName) }] })
}
async function undoDrop (names, t, d, autoShiftName) {
let ok = 0; for (const n of (names || [])) { try { await roster.unassignJobRoster(n); ok++ } catch (e) { err(e) } } // SÉQUENTIEL (frappe_pg)
if (autoShiftName) { pushHistory(); removeShift(t.id, d.iso, autoShiftName) } // retire aussi le quart auto-créé par le drop
await reloadOccupancy(); await reloadPool()
$q.notify({ type: 'info', message: 'Annulé — ' + ok + ' job(s) renvoyé(s) au pool' + (autoShiftName ? ' + quart retiré' : ''), timeout: 2600 })
}
let _panelDrag = null // déplacement du panneau via son en-tête
function panelHeaderDown (ev) { _panelDrag = { dx: ev.clientX - assignPanel.x, dy: ev.clientY - assignPanel.y }; document.addEventListener('mousemove', panelMove); document.addEventListener('mouseup', panelUp) }
function panelMove (ev) { if (!_panelDrag) return; assignPanel.x = Math.max(0, ev.clientX - _panelDrag.dx); assignPanel.y = Math.max(0, ev.clientY - _panelDrag.dy) }
function panelUp () { _panelDrag = null; document.removeEventListener('mousemove', panelMove); document.removeEventListener('mouseup', panelUp) }
// ── Panneau d'assignation : redimensionnement (poignée coin) ──
let _panelResize = null
function panelResizeDown (ev) { _panelResize = { x: ev.clientX, y: ev.clientY, w: assignPanel.w, h: assignPanel.h }; document.addEventListener('mousemove', panelResizeMove); document.addEventListener('mouseup', panelResizeUp); ev.preventDefault() }
function panelResizeMove (ev) { if (!_panelResize) return; assignPanel.w = Math.max(300, Math.round(_panelResize.w + (ev.clientX - _panelResize.x))); assignPanel.h = Math.max(320, Math.round(_panelResize.h + (ev.clientY - _panelResize.y))); if (_assignMap) _assignMap.resize() }
function panelResizeUp () { _panelResize = null; document.removeEventListener('mousemove', panelResizeMove); document.removeEventListener('mouseup', panelResizeUp) }
// ── Étiquettes de GROUPE par adresse de service : une adresse avec ≥2 jobs = groupe lettré (A, B…), rang interne (A1, A2…). Lie liste ↔ pins. ──
function _normAddr (s) { return String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, ' ').trim() }
function _colLetter (n) { let s = ''; n++; while (n > 0) { const m = (n - 1) % 26; s = String.fromCharCode(65 + m) + s; n = Math.floor((n - 1) / 26) } return s }
function _jobLocKey (j) { const a = _normAddr(j.address); if (a) return a; return j.service_location ? 'sl:' + j.service_location : '' }
// 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)
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) }
by[k].jobs.push(j)
}
const stops = []; const byName = {}
order.forEach((k, i) => {
const v = by[k]; const letter = _colLetter(i)
const skJob = v.jobs.find(j => j.required_skill || j.skill) || {}; const sk = skJob.required_skill || skJob.skill || ''
const j0 = v.jobs[0]
stops.push({ lat: v.lat, lon: v.lon, letter, color: sk ? getTagColor(sk) : '#90a4ae', count: v.jobs.length, names: v.jobs.map(j => j.name), title: (j0.address || j0.subject || '').slice(0, 60) })
for (const j of v.jobs) byName[j.name] = letter
})
return { stops, byName }
})
function groupLabel (j) { return assignLocations.value.byName[j.name] || '' } // lettre du pin de ce job (repère liste ↔ carte)
// ── Carte des jobs à assigner (Mapbox GL) : 1 pin par adresse, lettre = groupe ; clic = sélectionne les jobs co-localisés ──
const assignMapEl = ref(null); let _assignMap = null, _assignMapRO = null
// ── Bascule vue SATELLITE (couche raster Mapbox superposée SOUS les pins ; pas de setStyle = pins conservés) ──
// Préférence partagée par les 2 cartes (panneau + journée), persistée. addSatLayer() est appelé dans chaque on('load')
// AVANT les couches de pins → le raster reste dessous. toggleSat() bascule la visibilité sur les cartes vivantes.
const satView = ref((() => { try { return localStorage.getItem('plan_sat') === '1' } catch (e) { return false } })())
function addSatLayer (map) {
try {
if (!map.getSource('sat')) map.addSource('sat', { type: 'raster', url: 'mapbox://mapbox.satellite', tileSize: 256 })
if (!map.getLayer('sat')) map.addLayer({ id: 'sat', type: 'raster', source: 'sat', layout: { visibility: satView.value ? 'visible' : 'none' } })
} catch (e) { /* style pas prêt — ignoré */ }
}
function applySat (map) { try { if (map && map.getLayer && map.getLayer('sat')) map.setLayoutProperty('sat', 'visibility', satView.value ? 'visible' : 'none') } catch (e) {} }
function toggleSat () { satView.value = !satView.value; try { localStorage.setItem('plan_sat', satView.value ? '1' : '0') } catch (e) {} applySat(_assignMap); applySat(_dayMap); applySat(_locMap) }
// Clic DROIT sur une carte → Google Street View à la position cliquée (nouvel onglet). Branché sur les 3 cartes.
function openStreetView (lng, lat) { window.open(`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${lat},${lng}`, '_blank', 'noopener') }
// ── SÉLECTEUR D'EMPLACEMENT (jobs « hors carte ») : carte Mapbox bornée au sud de Montréal (Valleyfield ↔ Lacolle ↔
// frontière US). Clic / glisser le repère OU recherche d'adresse RQA → coords + adresse, écrits sur le Dispatch Job. ──
const locMapEl = ref(null); let _locMap = null, _locMarker = null
const TERR_BOUNDS = [[-74.95, 44.95], [-73.0, 45.55]] // SW (Valleyfield/frontière) → NE
const locPicker = reactive({ open: false, mode: 'job', job: null, tech: null, techName: '', subject: '', lat: null, lon: null, address: '', search: '', results: [], searching: false, saving: false, autoMatched: false })
const batchLocating = ref(false)
function openLocPicker (j) {
locPicker.mode = 'job'; locPicker.job = j.name; locPicker.tech = null; locPicker.subject = j.subject || j.name
locPicker.lat = hasLL(j) ? +j.lat : null; locPicker.lon = hasLL(j) ? +j.lon : null
locPicker.address = j.address || ''; locPicker.search = j.address || ''; locPicker.results = []; locPicker.autoMatched = false
locPicker.open = true
// Job avec une adresse mais SANS GPS → faire correspondre tout de suite à une adresse existante (RQA) + coords.
if (!hasLL(j) && (j.address || '').trim().length >= 3) autoMatchAddress()
}
// Définir le DÉPÔT (point de départ par défaut des tournées) via le même sélecteur de carte.
function openDepotPicker () {
const d = depot.value || {}; locPicker.mode = 'depot'; locPicker.job = null; locPicker.tech = null; locPicker.subject = 'Point de départ (dépôt)'
locPicker.lat = hasLL(d) ? +d.lat : null; locPicker.lon = hasLL(d) ? +d.lon : null
locPicker.address = d.address || ''; locPicker.search = d.address || ''; locPicker.results = []
locPicker.open = true
}
// Définir le DOMICILE d'un tech (origine si plus proche du travail que le dépôt).
function openHomePicker (t) {
const h = (techHomes.value[t.id]) || {}; locPicker.mode = 'home'; locPicker.job = null; locPicker.tech = t.id; locPicker.techName = t.name || t.id; locPicker.subject = 'Domicile — ' + (t.name || t.id)
locPicker.lat = hasLL(h) ? +h.lat : null; locPicker.lon = hasLL(h) ? +h.lon : null
locPicker.address = h.address || ''; locPicker.search = h.address || ''; locPicker.results = []
locPicker.open = true
}
// Fixer le domicile (= point de départ) d'un tech à sa POSITION GPS LIVE actuelle (Traccar) — INLINE, sans ouvrir la carte.
const liveGpsBusy = ref(false)
async function setHomeFromLiveGps (t) {
if (!t || !t.id) return
liveGpsBusy.value = true
try {
const r = await roster.techLivePosition(t.id)
if (!r || !r.ok || r.lat == null) { $q.notify({ type: 'warning', message: (r && r.error) || 'Aucune position GPS live (appareil Traccar non associé ?)' }); return }
let address = ''
if (MAPBOX_TOKEN) { try { const g = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${r.lon},${r.lat}.json?access_token=${MAPBOX_TOKEN}&language=fr&country=ca&types=address,poi`); const jj = await g.json(); const f = (jj.features || [])[0]; if (f && f.place_name) address = f.place_name.replace(/, Canada$/, '') } catch (_) { /* géocodage inverse non bloquant */ } }
const homes = { ...techHomes.value, [t.id]: { address, lat: +(+r.lat).toFixed(6), lon: +(+r.lon).toFixed(6) } }
await roster.savePolicy({ tech_homes: homes }); techHomes.value = homes
if (_dayMap) refreshDayMap()
$q.notify({ type: 'positive', icon: 'my_location', message: 'Départ fixé à la position GPS actuelle — ' + (t.name || t.id) })
} catch (e) { err(e) } finally { liveGpsBusy.value = false }
}
// Variante DANS le sélecteur de carte (mode domicile) : amène le marqueur sur la position GPS live, ajustable ensuite.
async function pickerUseLiveGps () {
if (locPicker.mode !== 'home' || !locPicker.tech) return
liveGpsBusy.value = true
try {
const r = await roster.techLivePosition(locPicker.tech)
if (!r || !r.ok || r.lat == null) { $q.notify({ type: 'warning', message: (r && r.error) || 'Aucune position GPS live' }); return }
if (_locMap) { _locMap.easeTo({ center: [r.lon, r.lat], zoom: 15, duration: 400 }); placeLocMarker(r.lon, r.lat, true) } else { locPicker.lon = +r.lon; locPicker.lat = +r.lat; reverseGeocode(r.lon, r.lat) }
} catch (e) { err(e) } finally { liveGpsBusy.value = false }
}
// Appareils Traccar — pour ASSOCIER un tech à son GPS, inline (prérequis du GPS live + tracé). La page Dispatch
// gérait ça ; on le rapatrie dans Planif. Chargé une fois à l'ouverture de l'éditeur de jour.
const traccarDevices = ref([]); const devicesLoading = ref(false); let _devicesLoaded = false
const deviceFilter = ref('')
async function loadTraccarDevices () {
if (_devicesLoaded || devicesLoading.value) return
devicesLoading.value = true
try { const d = await roster.listTraccarDevices(); traccarDevices.value = Array.isArray(d) ? d : (d && d.devices) || []; _devicesLoaded = true } catch (e) { /* non bloquant */ } finally { devicesLoading.value = false }
}
function filterDevices (val, update) { update(() => { deviceFilter.value = (val || '').toLowerCase() }) }
const deviceOptions = computed(() => {
const q = deviceFilter.value
return traccarDevices.value
.map(d => ({ label: d.name || d.uniqueId || ('#' + d.id), value: String(d.id), uniqueId: d.uniqueId || '', online: d.status === 'online' }))
.filter(o => !q || o.label.toLowerCase().includes(q) || (o.uniqueId && o.uniqueId.toLowerCase().includes(q)))
.sort((a, b) => (b.online ? 1 : 0) - (a.online ? 1 : 0) || a.label.localeCompare(b.label))
})
async function onPickDevice (tech, deviceId) {
if (!tech || !tech.id) return
const prev = tech.traccar_device_id || ''; const v = deviceId == null ? '' : String(deviceId)
if (v === prev) return
tech.traccar_device_id = v // optimiste
try { await roster.setTechTraccarDevice(tech.id, v); $q.notify({ type: 'positive', icon: 'gps_fixed', message: v ? 'Appareil GPS associé — ' + (tech.name || tech.id) : 'Appareil GPS dissocié', timeout: 1800 }) }
catch (e) { tech.traccar_device_id = prev; err(e) }
}
async function initLocMap () {
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !locMapEl.value) return
if (_locMap) { _locMap.resize(); return }
mapboxgl.accessToken = MAPBOX_TOKEN
_locMap = new mapboxgl.Map({ container: locMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [locPicker.lon || -73.95, locPicker.lat || 45.18], zoom: locPicker.lat != null ? 14 : 8.5 })
_locMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
_locMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
_locMap.on('load', () => {
_locMap.resize(); addSatLayer(_locMap)
if (locPicker.lat == null) _locMap.fitBounds(TERR_BOUNDS, { padding: 18, duration: 0 }) // zoom sur la région au sud de Montréal
else placeLocMarker(locPicker.lon, locPicker.lat, false)
})
_locMap.on('click', (e) => placeLocMarker(e.lngLat.lng, e.lngLat.lat, true))
}
function placeLocMarker (lon, lat, doReverse) {
locPicker.lon = +(+lon).toFixed(6); locPicker.lat = +(+lat).toFixed(6)
if (!_locMarker) {
_locMarker = new window.mapboxgl.Marker({ draggable: true, color: '#e8590c' }).setLngLat([lon, lat]).addTo(_locMap)
_locMarker.on('dragend', () => { const ll = _locMarker.getLngLat(); locPicker.lon = +ll.lng.toFixed(6); locPicker.lat = +ll.lat.toFixed(6); reverseGeocode(ll.lng, ll.lat) })
} else _locMarker.setLngLat([lon, lat])
if (doReverse) reverseGeocode(lon, lat)
}
async function reverseGeocode (lon, lat) {
if (!MAPBOX_TOKEN) return
try {
const r = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${lon},${lat}.json?access_token=${MAPBOX_TOKEN}&language=fr&country=ca&types=address,poi`)
const j = await r.json(); const f = (j.features || [])[0]
if (f && f.place_name) locPicker.address = f.place_name.replace(/, Canada$/, '')
} catch (e) { /* non bloquant */ }
}
async function locSearch () {
const q = (locPicker.search || '').trim(); if (q.length < 3) return
locPicker.searching = true; locPicker.autoMatched = false
try { const r = await addressApi.conformityCandidates(q); locPicker.results = (r.results || []).filter(x => isFinite(+x.latitude) && isFinite(+x.longitude)).slice(0, 8) } catch (e) { err(e) } finally { locPicker.searching = false }
}
// Correspondance AUTO (ouverture d'un job sans GPS) : cherche son adresse dans la base RQA et, si la
// correspondance est franche (1 seul candidat, ou score ≥ 0.6), l'applique (adresse canonique + coords).
// Sinon laisse la liste des candidats affichée pour un choix en 1 clic. Jamais bloquant.
async function autoMatchAddress () {
const q = (locPicker.search || '').trim(); if (q.length < 3) return
locPicker.searching = true; locPicker.autoMatched = false
try {
const r = await addressApi.conformityCandidates(q)
const cands = (r.results || []).filter(x => isFinite(+x.latitude) && isFinite(+x.longitude)).slice(0, 8)
locPicker.results = cands
const top = cands[0]
const strong = top && (cands.length === 1 || (top.similarity_score != null && +top.similarity_score >= 0.6))
if (strong) { pickLocResult(top, true); locPicker.autoMatched = true }
} catch (e) { /* non bloquant : recherche/situer à la main reste possible */ } finally { locPicker.searching = false }
}
function pickLocResult (rr, keepList = false) {
const lon = +rr.longitude, lat = +rr.latitude; if (!isFinite(lon) || !isFinite(lat)) return
locPicker.address = rr.address_full || [rr.numero, rr.rue, rr.ville, rr.code_postal].filter(Boolean).join(' ')
if (!keepList) { locPicker.results = []; locPicker.autoMatched = false }
if (_locMap) { _locMap.easeTo({ center: [lon, lat], zoom: 16, duration: 500 }); placeLocMarker(lon, lat, false) } else { locPicker.lon = lon; locPicker.lat = lat }
}
async function saveLocPicker () {
if (locPicker.lat == null || locPicker.lon == null) { $q.notify({ type: 'warning', message: 'Choisis un point sur la carte' }); return }
locPicker.saving = true
try {
if (locPicker.mode === 'depot') {
const obj = { label: 'Dépôt', address: locPicker.address || '', lat: locPicker.lat, lon: locPicker.lon }
await roster.savePolicy({ depot: obj }); depot.value = obj
locPicker.open = false; if (_dayMap) refreshDayMap()
$q.notify({ type: 'positive', icon: 'warehouse', message: 'Dépôt (point de départ) enregistré' })
} else if (locPicker.mode === 'home') {
const homes = { ...techHomes.value, [locPicker.tech]: { address: locPicker.address || '', lat: locPicker.lat, lon: locPicker.lon } }
await roster.savePolicy({ tech_homes: homes }); techHomes.value = homes
locPicker.open = false; if (_dayMap) refreshDayMap()
$q.notify({ type: 'positive', icon: 'home', message: 'Domicile enregistré — ' + locPicker.techName })
} else {
await roster.setJobLocation(locPicker.job, locPicker.lat, locPicker.lon, locPicker.address)
const j = dayEditor.list.find(x => x.name === locPicker.job); if (j) { j.lat = locPicker.lat; j.lon = locPicker.lon; if (locPicker.address) j.address = locPicker.address }
const pj = (assignPanel.jobs || []).find(x => x.name === locPicker.job); if (pj) { pj.latitude = locPicker.lat; pj.longitude = locPicker.lon; if (locPicker.address) pj.address = locPicker.address } // pool « à assigner » : latitude/longitude → rafraîchit le badge sans coords
locPicker.open = false
await reloadOccupancy(); if (_dayMap) refreshDayMap()
$q.notify({ type: 'positive', icon: 'place', message: 'Emplacement enregistré sur le job' })
}
} catch (e) { err(e) } finally { locPicker.saving = false }
}
// LOT : localiser d'un coup tous les jobs du jour qui ont une adresse mais pas de GPS — chaque adresse est
// mise en correspondance avec la base RQA (top match franc) puis écrite sur le Dispatch Job. Les adresses
// ambiguës sont laissées telles quelles (à situer à la main via 📍).
async function batchLocate () {
const targets = dayEditor.list.filter(j => !j.legacy && !hasLL(j) && (j.address || '').trim().length >= 3)
if (!targets.length) { $q.notify({ type: 'info', message: 'Aucun job sans coordonnées à localiser', timeout: 1800 }); return }
batchLocating.value = true
let ok = 0, ambiguous = 0
try {
for (const j of targets) {
try {
const r = await addressApi.conformityCandidates((j.address || '').trim())
const cands = (r.results || []).filter(x => isFinite(+x.latitude) && isFinite(+x.longitude))
const top = cands[0]
const strong = top && (cands.length === 1 || (top.similarity_score != null && +top.similarity_score >= 0.6))
if (strong) {
const lat = +top.latitude, lon = +top.longitude
const addr = top.address_full || [top.numero, top.rue, top.ville, top.code_postal].filter(Boolean).join(' ')
await roster.setJobLocation(j.name, lat, lon, addr)
j.lat = lat; j.lon = lon; j.address = addr; ok++
} else { ambiguous++ }
} catch (e) { ambiguous++ }
}
await reloadOccupancy(); if (_dayMap) refreshDayMap()
$q.notify({ type: ok ? 'positive' : 'warning', icon: 'place', timeout: 3500,
message: `${ok} job(s) localisé(s)` + (ambiguous ? ` · ${ambiguous} adresse(s) ambiguë(s) à situer à la main (📍)` : '') })
} catch (e) { err(e) } finally { batchLocating.value = false }
}
function destroyLocMap () { if (_locMarker) { try { _locMarker.remove() } catch (e) {} _locMarker = null } if (_locMap) { try { _locMap.remove() } catch (e) {} _locMap = null } }
// init/destroy via @show/@hide du q-dialog (le conteneur a sa taille finale à @show → carte fiable)
// ── Tableur « durées par caractéristique » (additif, hors transport) — éditable inline, seed + apprentissage ──
const jobChar = reactive({ open: false, loading: false, saving: false, items: [] })
async function openJobChar () { jobChar.open = true; jobChar.loading = true; try { const r = await roster.getJobChars(); jobChar.items = r.items || [] } catch (e) { err(e) } finally { jobChar.loading = false } }
function addJobCharRow () { jobChar.items.push({ id: 'c' + Date.now().toString(36), cat: 'addon', label: '', minutes: 30, per_qty: false, keywords: '', learned_min: null, samples: 0 }) }
async function saveJobChar () { jobChar.saving = true; try { const r = await roster.saveJobChars(jobChar.items); $q.notify({ type: r.ok ? 'positive' : 'negative', message: r.ok ? `Table enregistrée (${r.count} lignes)` : 'Échec d\'enregistrement' }); if (r.ok) jobChar.open = false } catch (e) { err(e) } finally { jobChar.saving = false } }
// Lien app terrain d'un tech : copie le lien (PWA /field?t=) + propose l'envoi SMS au tech (sans build natif requis).
async function techAppLinkMenu (t) {
try {
const r = await roster.techAppLink(t.id); if (!r || !r.ok) { err(new Error('lien app indisponible')); return }
try { await navigator.clipboard.writeText(r.url) } catch (e) {}
const actions = [{ label: 'OK', color: 'white' }]
if (r.phone) actions.unshift({ label: '📲 SMS au tech', color: 'yellow', handler: async () => { try { await sendSmsViaHub(r.phone, 'Ton app terrain Targo (interventions du jour) : ' + r.url, ''); $q.notify({ type: 'positive', message: 'Lien envoyé par SMS à ' + r.name }) } catch (e) { err(e) } } })
$q.notify({ message: '📱 Lien app copié' + (r.phone ? '' : ' (pas de téléphone au dossier)') + ' — ' + r.url, timeout: 9000, multiLine: true, actions })
} catch (e) { err(e) }
}
// ── Bloc Legacy sur la timeline : clic → détail du ticket (fil osTicket ingéré in-app, via ticketThread) ──
const legacyDetail = reactive({ open: false, lid: null, subject: '', dept: '', loading: false, thread: null })
async function openLegacyBlock (b) {
if (!b || !b.lid) return
legacyDetail.lid = b.lid; legacyDetail.subject = b.subject || ('#' + b.lid); legacyDetail.dept = b.dept || ''; legacyDetail.thread = null; legacyDetail.loading = true; legacyDetail.open = true
try { legacyDetail.thread = await roster.ticketThread(b.lid) } catch (e) { err(e); legacyDetail.thread = { error: true, messages: [] } } finally { legacyDetail.loading = false }
}
// ── Synchroniser les techniciens : rapport de réconciliation (staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech) ──
const techSync = reactive({ open: false, loading: false, applying: false, report: null, sel: {} })
const techSyncSelCount = computed(() => Object.values(techSync.sel).filter(Boolean).length)
async function openTechSync () {
techSync.open = true; techSync.loading = true; techSync.report = null; techSync.sel = {}
try {
const r = await roster.techSyncReport(); techSync.report = r
for (const s of (r.staff_missing_fiche || [])) techSync.sel[s.staff_id] = true // candidats cochés par défaut
} catch (e) { err(e) } finally { techSync.loading = false }
}
async function applyTechSync () {
const ids = Object.entries(techSync.sel).filter(([, v]) => v).map(([k]) => k)
if (!ids.length) return
techSync.applying = true
try {
const r = await roster.techSyncApply(ids)
$q.notify({ type: r.created ? 'positive' : 'info', icon: 'group_add', message: `${r.created} fiche(s) technicien créée(s)` + (r.results ? ` · ${r.results.filter(x => x.skipped).length} déjà présente(s)` : '') })
techSync.open = false
await loadBase() // recharge la liste des techniciens dans la grille
} catch (e) { err(e) } finally { techSync.applying = 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)
// 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))
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
$q.notify({ type: 'warning', icon: 'wrong_location', timeout: 4000, message: `${coordless.length} job(s) sans adresse exploitable — à situer à la main sur la carte` })
openLocPicker(coordless[0]); return
}
const noAddr = coordless.length - targets.length
assignPanel.locating = true
let ok = 0, ambiguous = 0
try {
for (const j of targets) {
const q = String(j.address || j.location_label || '').trim()
try {
const r = await addressApi.conformityCandidates(q)
const cands = (r.results || []).filter(x => isFinite(+x.latitude) && isFinite(+x.longitude))
const top = cands[0]
const strong = top && (cands.length === 1 || (top.similarity_score != null && +top.similarity_score >= 0.6))
if (strong) {
const lat = +top.latitude, lon = +top.longitude
const addr = top.address_full || [top.numero, top.rue, top.ville, top.code_postal].filter(Boolean).join(' ')
await roster.setJobLocation(j.name, lat, lon, addr)
j.latitude = lat; j.longitude = lon; j.address = addr; ok++
} else { ambiguous++ }
} catch (e) { ambiguous++ }
}
if (_assignMap) refreshAssignMap()
$q.notify({ type: ok ? 'positive' : 'warning', icon: 'place', timeout: 4000,
message: `${ok} job(s) localisé(s)` + (ambiguous ? ` · ${ambiguous} adresse(s) ambiguë(s) — situer à la main (📍)` : '') + (noAddr ? ` · ${noAddr} sans adresse (📍)` : '') })
} catch (e) { err(e) } finally { assignPanel.locating = false }
}
function assignStops () { return assignLocations.value.stops }
function toggleAssignMap () { assignPanel.showMap = !assignPanel.showMap }
// ── Lasso / boîte de sélection sur la carte : glisse un rectangle → sélectionne TOUS les jobs dedans (amas compris) ──
const assignLasso = ref(false)
function toggleLasso () {
assignLasso.value = !assignLasso.value
if (_assignMap) { assignLasso.value ? _assignMap.dragPan.disable() : _assignMap.dragPan.enable(); _assignMap.getCanvas().style.cursor = assignLasso.value ? 'crosshair' : '' }
}
function setupBoxSelect (map) { // LASSO FREEFORM : tracé libre (souris ou doigt) → polygone de sélection
const canvas = map.getCanvasContainer()
let pts = null, svg = null, poly = null
const pos = (e) => { const r = canvas.getBoundingClientRect(); const t = (e.touches && e.touches[0]) || e; return { x: t.clientX - r.left, y: t.clientY - r.top } }
const onMove = (e) => {
if (!pts) return; if (e.cancelable) e.preventDefault(); pts.push(pos(e))
if (!svg) {
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:10'
poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline')
poly.setAttribute('fill', 'rgba(99,102,241,.15)'); poly.setAttribute('stroke', '#6366f1'); poly.setAttribute('stroke-width', '2'); poly.setAttribute('stroke-dasharray', '5 3')
svg.appendChild(poly); canvas.appendChild(svg)
}
poly.setAttribute('points', pts.map(q => q.x + ',' + q.y).join(' '))
}
const onUp = () => {
document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp)
document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onUp)
if (svg) { svg.remove(); svg = null; poly = null }
const path = pts; pts = null
if (path && path.length >= 3) selectInLasso(map, path)
}
const onDown = (e) => {
if (!assignLasso.value || (e.button != null && e.button !== 0)) return
e.preventDefault(); e.stopPropagation(); pts = [pos(e)]
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp)
document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchend', onUp)
}
canvas.addEventListener('mousedown', onDown, true)
canvas.addEventListener('touchstart', onDown, true)
}
// Point-in-polygon (ray casting) sur coordonnées écran.
function _inPoly (pt, poly) {
let inside = false
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y
if (((yi > pt.y) !== (yj > pt.y)) && (pt.x < (xj - xi) * (pt.y - yi) / ((yj - yi) || 1e-9) + xi)) inside = !inside
}
return inside
}
async function selectInLasso (map, path) { // sélectionne pins + amas dont le CENTRE est dans le tracé
const names = new Set(); const clusterIds = []
try {
const feats = map.queryRenderedFeatures({ layers: ['aj-c', 'aj-cluster'] }) || []
for (const f of feats) {
const c = f.geometry && f.geometry.coordinates; if (!c) continue
const scr = map.project(c)
if (!_inPoly({ x: scr.x, y: scr.y }, path)) continue
if (f.properties && f.properties.cluster_id != null) clusterIds.push(f.properties.cluster_id)
else { try { JSON.parse(f.properties.names || '[]').forEach(n => names.add(n)) } catch (e) {} }
}
const src = map.getSource('aj')
await Promise.all(clusterIds.map(cid => new Promise(res => { try { src.getClusterLeaves(cid, 1000, 0, (err, leaves) => { if (!err && leaves) leaves.forEach(l => { try { JSON.parse(l.properties.names || '[]').forEach(n => names.add(n)) } catch (e) {} }); res() }) } catch (e) { res() } })))
} catch (e) {}
names.forEach(n => { selectedJobs[n] = true })
if (names.size) $q.notify({ type: 'positive', message: names.size + ' job(s) sélectionné(s) au lasso', timeout: 1800 })
else $q.notify({ type: 'info', message: 'Aucun job dans la zone', timeout: 1500 })
refreshAssignMap()
}
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
mapboxgl.accessToken = MAPBOX_TOKEN
_assignMap = new mapboxgl.Map({ container: assignMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.55, 45.2], zoom: 8 })
_assignMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
_assignMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
_assignMapRO = new ResizeObserver(() => { if (_assignMap) _assignMap.resize() }); _assignMapRO.observe(assignMapEl.value)
_assignMap.on('load', () => {
_assignMap.resize()
addSatLayer(_assignMap) // couche satellite (sous les pins), masquée par défaut
// Regroupement (clustering) natif Mapbox : les jobs proches fusionnent en une pastille portant le TOTAL de jobs.
// Résout le chevauchement des pastilles/texte au dézoom. jobsum = somme des jobs (pas juste le nb d'adresses).
_assignMap.addSource('aj', { type: 'geojson', cluster: true, clusterRadius: 44, clusterMaxZoom: 13, clusterProperties: { jobsum: ['+', ['get', 'count']], selsum: ['+', ['get', 'seln']] }, data: { type: 'FeatureCollection', features: [] } })
// Amas : bulle indigo dimensionnée par le nb de jobs ; contour DORÉ si l'amas contient de la sélection
_assignMap.addLayer({ id: 'aj-cluster', type: 'circle', source: 'aj', filter: ['has', 'point_count'], paint: { 'circle-color': '#6366f1', 'circle-opacity': 0.92, 'circle-radius': ['step', ['get', 'jobsum'], 15, 5, 19, 15, 25], 'circle-stroke-width': ['case', ['>', ['get', 'selsum'], 0], 4, 2.5], 'circle-stroke-color': ['case', ['>', ['get', 'selsum'], 0], '#fbbf24', '#fff'] } })
_assignMap.addLayer({ id: 'aj-cluster-count', type: 'symbol', source: 'aj', filter: ['has', 'point_count'], layout: { 'text-field': ['get', 'jobsum'], 'text-size': 13, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
// Pastilles individuelles : UNIQUEMENT hors amas ; contour DORÉ si sélectionnée (surlignage de la sélection)
_assignMap.addLayer({ id: 'aj-c', type: 'circle', source: 'aj', filter: ['!', ['has', 'point_count']], paint: { 'circle-radius': ['case', ['>', ['get', 'count'], 1], 12, 9], 'circle-color': ['get', 'color'], 'circle-stroke-width': ['case', ['get', 'sel'], 3.5, 2], 'circle-stroke-color': ['case', ['get', 'sel'], '#fbbf24', '#fff'] } })
_assignMap.addLayer({ id: 'aj-l', type: 'symbol', source: 'aj', filter: ['!', ['has', 'point_count']], layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
_assignMap.on('mouseenter', 'aj-c', () => { _assignMap.getCanvas().style.cursor = 'pointer' })
_assignMap.on('mouseleave', 'aj-c', () => { _assignMap.getCanvas().style.cursor = '' })
// Clic sur un amas → zoom d'expansion qui l'ÉCLATE (le « zoom automatique on clic » demandé).
_assignMap.on('mouseenter', 'aj-cluster', () => { _assignMap.getCanvas().style.cursor = 'zoom-in' })
_assignMap.on('mouseleave', 'aj-cluster', () => { _assignMap.getCanvas().style.cursor = '' })
_assignMap.on('click', 'aj-cluster', (e) => {
const f = _assignMap.queryRenderedFeatures(e.point, { layers: ['aj-cluster'] })[0]; if (!f) return
_assignMap.getSource('aj').getClusterExpansionZoom(f.properties.cluster_id, (err, zoom) => {
if (err) return
_assignMap.easeTo({ center: f.geometry.coordinates, zoom: Math.min((zoom || 14) + 0.3, 16), duration: 500 })
})
})
_assignMap.on('click', 'aj-c', (e) => {
const f = e.features[0]; const p = f.properties; const names = JSON.parse(p.names || '[]')
const allSel = names.length && names.every(n => selectedJobs[n]) // clic = BASCULE (sélectionne, ou désélectionne si déjà tout coché)
names.forEach(n => { if (allSel) delete selectedJobs[n]; else selectedJobs[n] = true })
if (!allSel && names[0]) focusAssignJobInList(names[0]) // + scrolle/expand la job dans la liste (à la sélection seulement)
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>${allSel ? '✕ retiré de la sélection' : '✓ ' + p.count + ' job(s) sélectionné(s)'}<br><span style="color:#888">${allSel ? 're-clique pour re-sélectionner' : 'glisse-les sur un tech × jour'}</span></div>`).addTo(_assignMap)
})
setupBoxSelect(_assignMap) // lasso : glisser un rectangle → sélectionne les jobs dedans
refreshAssignMap()
})
}
function refreshAssignMap () {
if (!_assignMap || !_assignMap.isStyleLoaded()) { setTimeout(refreshAssignMap, 200); return }
const stops = assignStops(); const src = _assignMap.getSource('aj'); if (!src) return
src.setData({ type: 'FeatureCollection', features: stops.map(s => { const sel = (s.names || []).some(n => !!selectedJobs[n]); return { type: 'Feature', geometry: { type: 'Point', coordinates: [s.lon, s.lat] }, properties: { label: s.letter + (s.count > 1 ? '·' + s.count : ''), color: s.color, count: s.count, names: JSON.stringify(s.names), letter: s.letter, title: s.title, sel, seln: sel ? 1 : 0 } } }) })
if (stops.length === 1) _assignMap.easeTo({ center: [stops[0].lon, stops[0].lat], zoom: 11, duration: 400 })
else if (stops.length > 1) { const b = new window.mapboxgl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat]); stops.forEach(s => b.extend([s.lon, s.lat])); _assignMap.fitBounds(b, { padding: 40, maxZoom: 12, duration: 400 }) }
}
function destroyAssignMap () { assignLasso.value = false; if (_assignMapRO) { try { _assignMapRO.disconnect() } catch (e) {} _assignMapRO = null } if (_assignMap) { try { _assignMap.remove() } catch (e) {} _assignMap = null } }
// ── Grande carte des TOURNÉES proposées (revue Suggérer) : ligne domicile→arrêts par tech, 1 couleur/tech, 1 jour ──
const suggestMapEl = ref(null); let _suggestMap = null, _suggestMapRO = null
async function initSuggestMap () {
if (!MAPBOX_TOKEN || !suggestMapEl.value || _suggestMap) return
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !suggestMapEl.value) return
mapboxgl.accessToken = MAPBOX_TOKEN
_suggestMap = new mapboxgl.Map({ container: suggestMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.55, 45.2], zoom: 8 })
_suggestMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
_suggestMapRO = new ResizeObserver(() => { if (_suggestMap) _suggestMap.resize() }); _suggestMapRO.observe(suggestMapEl.value)
_suggestMap.on('load', () => {
_suggestMap.resize()
_suggestMap.addSource('sr-line', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
_suggestMap.addLayer({ id: 'sr-line', type: 'line', source: 'sr-line', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 3, 'line-opacity': 0.75 } })
_suggestMap.addSource('sr-pt', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
_suggestMap.addLayer({ id: 'sr-home', type: 'circle', source: 'sr-pt', filter: ['==', ['get', 'kind'], 'home'], paint: { 'circle-radius': 7, 'circle-color': '#fff', 'circle-stroke-color': ['get', 'color'], 'circle-stroke-width': 3 } })
_suggestMap.addLayer({ id: 'sr-stop', type: 'circle', source: 'sr-pt', filter: ['==', ['get', 'kind'], 'stop'], paint: { 'circle-radius': 10, 'circle-color': ['get', 'color'], 'circle-stroke-color': '#fff', 'circle-stroke-width': 2 } })
_suggestMap.addLayer({ id: 'sr-seq', type: 'symbol', source: 'sr-pt', filter: ['==', ['get', 'kind'], 'stop'], layout: { 'text-field': ['get', 'seq'], 'text-size': 11, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
_suggestMap.on('click', 'sr-stop', (e) => { const p = e.features[0].properties; new window.mapboxgl.Popup({ offset: 12 }).setLngLat(e.features[0].geometry.coordinates).setHTML(`<div style="font-size:12px"><b>${_esc(p.tech)}</b> · arrêt ${p.seq}<br>${_esc(p.subject || '')}</div>`).addTo(_suggestMap) })
refreshSuggestMap()
})
}
function refreshSuggestMap () {
if (!_suggestMap || !_suggestMap.isStyleLoaded()) { setTimeout(refreshSuggestMap, 200); return }
const routes = suggestRoutes.value
for (const k in realRoutes) delete realRoutes[k] // repart des estimations ; les routes RÉELLES arrivent en async
const lines = []; const pts = []; const b = new window.mapboxgl.LngLatBounds()
for (const r of routes) {
const coords = []
if (r.home) { coords.push([r.home.lon, r.home.lat]); pts.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [r.home.lon, r.home.lat] }, properties: { kind: 'home', color: r.color } }); b.extend([r.home.lon, r.home.lat]) }
for (const s of r.stops) { coords.push([s.lon, s.lat]); pts.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [s.lon, s.lat] }, properties: { kind: 'stop', color: r.color, seq: String(s.seq), tech: r.techName, subject: s.subject } }); b.extend([s.lon, s.lat]) }
if (coords.length >= 2) lines.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color } })
}
const ls = _suggestMap.getSource('sr-line'); if (ls) ls.setData({ type: 'FeatureCollection', features: lines })
const ps = _suggestMap.getSource('sr-pt'); if (ps) ps.setData({ type: 'FeatureCollection', features: pts })
try { if (pts.length && !b.isEmpty()) _suggestMap.fitBounds(b, { padding: 50, maxZoom: 13, duration: 400 }) } catch (e) {}
loadRealRoutes(routes) // D v2 : remplace les segments droits par les routes ROUTIÈRES réelles (Mapbox Directions) + km/temps réels
}
// D v2 — routes RÉELLES : Mapbox Directions par tech (domicile→arrêts) → géométrie routière + distance/durée réelles. Cache par signature.
const _routeCache = new Map()
const realRoutes = reactive({}) // techId → { km, mins } (la légende préfère ces valeurs réelles quand présentes)
async function loadRealRoutes (routes) {
if (!MAPBOX_TOKEN || !routes.length || !_suggestMap) return
const results = await Promise.all(routes.map(async r => {
const pts = []; if (r.home) pts.push([r.home.lon, r.home.lat]); for (const s of r.stops) pts.push([s.lon, s.lat])
if (pts.length < 2 || pts.length > 25) return null // Directions = 25 points max
const sig = pts.map(p => p[0].toFixed(5) + ',' + p[1].toFixed(5)).join(';')
if (_routeCache.has(sig)) return { techId: r.techId, ..._routeCache.get(sig) }
try {
const url = `https://api.mapbox.com/directions/v5/mapbox/driving/${pts.map(p => p[0].toFixed(6) + ',' + p[1].toFixed(6)).join(';')}?geometries=geojson&overview=full&access_token=${MAPBOX_TOKEN}`
const d = await fetch(url).then(x => x.json()); const rt = d && d.routes && d.routes[0]; if (!rt) return null
const info = { geometry: rt.geometry, km: Math.round(rt.distance / 100) / 10, mins: Math.round(rt.duration / 60) }
_routeCache.set(sig, info); return { techId: r.techId, ...info }
} catch (e) { return null }
}))
if (!_suggestMap || !_suggestMap.getSource('sr-line')) return
const byTech = {}; for (const x of results) { if (x) { byTech[x.techId] = x; realRoutes[x.techId] = { km: x.km, mins: x.mins } } }
const lines = []
for (const r of routes) {
const real = byTech[r.techId]
if (real && real.geometry) { lines.push({ type: 'Feature', geometry: real.geometry, properties: { color: r.color } }); continue }
const coords = []; if (r.home) coords.push([r.home.lon, r.home.lat]); for (const s of r.stops) coords.push([s.lon, s.lat])
if (coords.length >= 2) lines.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color } })
}
_suggestMap.getSource('sr-line').setData({ type: 'FeatureCollection', features: lines })
}
function destroySuggestMap () { if (_suggestMapRO) { try { _suggestMapRO.disconnect() } catch (e) {} _suggestMapRO = null } if (_suggestMap) { try { _suggestMap.remove() } catch (e) {} _suggestMap = 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(selectedJobs, () => { if (assignPanel.showMap && _assignMap) refreshAssignMap() }) // surlignage doré des pins/amas suit la sélection
watch(() => assignPanel.open, (o) => { if (!o) destroyAssignMap() })
// ── Timeline contextuelle d'une RESSOURCE (dispatch des jobs de la semaine visible) ──
// Réutilise les helpers de cellule (cellBands/cellBlocks/cellJobs/cellPct) → 0 nouvel appel réseau.
const timelineDlg = reactive({ open: false, tech: null })
function openTimeline (t) { timelineDlg.tech = t; timelineDlg.open = true }
// (Clic sur le progressbar → gotoDispatch : on ouvre le timeline ÉDITABLE du tableau Dispatch, drag-drop + suppression,
// plutôt qu'un popup maison — réutilisation max + cohérence. Le réordonnancement/priorité se fait là-bas.)
// Deep-link vers le tableau Dispatch focalisé sur la ressource + le jour cliqué (sinon 1er jour de la semaine).
function gotoDispatch (t, dateIso) {
const q = {}
if (t) q.tech = t.id
q.date = dateIso || (timelineDays.value[0] && timelineDays.value[0].iso) || start.value
router.push({ path: '/dispatch', query: q })
}
// ── Éditeur de JOURNÉE (fenêtre contextuelle ciblée — clic sur le progressbar) ──
// Garde le contexte de la grille derrière. Timeline + réordonnancement DRAG-DROP + retrait d'un job.
const dayEditor = reactive({ open: false, tech: null, day: null, list: [], saving: false, dragIdx: null, travelMap: {}, routeReady: false })
const dayShift = ref({ min: 8, max: 16 }) // quart de l'éditeur de jour (q-range, révélé par « Modifier »)
const dayShiftEdit = ref(false) // false = affichage statique ; true = curseur d'édition révélé
// Jobs LEGACY (osTicket F) datés ce jour, PAS encore importés en Dispatch Job → comptés dans l'occupation mais absents
// de cellJobs (roster). On les liste en LECTURE SEULE dans l'éditeur de jour (sinon « Aucun job » alors que 88% occupé).
const dayLegacyJobs = computed(() => {
if (!showLegacyLoad.value || !dayEditor.tech || !dayEditor.day) return []
const lg = legacyLoad.value[dayEditor.tech.id + '|' + dayEditor.day.iso]
return lg && Array.isArray(lg.jobs) ? lg.jobs.filter(j => j.field) : []
})
function openDayEditor (t, d) {
dayEditor.tech = t; dayEditor.day = d
const _w = winOf(t.id, d.iso, false); dayShift.value = _w ? { min: _w.s, max: _w.e } : { min: 8, max: 16 } // init la bande de quart depuis le shift régulier (sinon 8-16 par défaut)
dayShiftEdit.value = false // toujours rouvrir en mode STATIQUE
// RDV confirmé (ou heure légacy précise) = heure FIXE → verrouillé ; sinon flexible (replanifiable par la tournée).
// Roster (Dispatch Jobs, ÉDITABLES) + jobs LEGACY (osTicket F, géocodés, LECTURE SEULE) dans la MÊME liste → pins sur la
// carte + tournée optimisable + legs de transport, via la logique existante. Le legacy ne se sauvegarde pas (F autoritaire).
const rosterItems = cellJobs(t.id, d.iso).map(j => ({ ...j, locked: j.booking_status === 'Confirmé', showDetail: false }))
const legacyItems = dayLegacyJobs.value.map(j => ({ name: 'LEG:' + j.id, legacy: true, readonly: true, legacy_id: j.id, lid: j.id, subject: j.subject, dept: j.dept, skill: j.skill, dur: Number(j.est_h) || 1, lat: j.lat, lon: j.lon, address: j.address || '', customer: j.customer || '', locked: false, showDetail: false }))
dayEditor.list = [...rosterItems, ...legacyItems]
dayOriginChoice.value = null // départ = auto (politique) à chaque ouverture
dayEditor.dragIdx = null; dayEditor.travelMap = {}; dayEditor.routeReady = false; dayEditor.open = true; dayShowTrack.value = false
loadDayRoute() // charge la matrice de temps routiers RÉELS (Mapbox) → packedDay les utilise dès l'arrivée (réactif)
loadTraccarDevices() // liste des appareils GPS (pour associer le tech, inline) — chargée une fois
}
// Quart MODIFIABLE depuis l'éditeur de jour : applique un gabarit d'heures (réutilise ensureWindowTpl + setCellReplace,
// comme la saisie rapide « 8-17 ») sur le tech/jour courant. Édition LOCALE → « Publier » pour enregistrer.
async function saveDayShift () {
const min = dayShift.value.min; const max = dayShift.value.max
if (!dayEditor.tech || !dayEditor.day || !(max > min)) return
const tpl = await ensureWindowTpl(min, max)
if (tpl) { pushHistory(); setCellReplace(dayEditor.tech.id, dayEditor.tech.name, dayEditor.day.iso, tpl); $q.notify({ type: 'positive', message: `Quart ${fmtH(min)}h${fmtH(max)}h — publier pour enregistrer`, timeout: 2500 }) }
}
function dayShiftLabel () { // affichage statique du quart (lecture seule) dans l'éditeur de jour
const reg = dayEditor.tech && dayEditor.day && hasReg(dayEditor.tech.id, dayEditor.day.iso)
if (!reg) return 'Aucun quart planifié'
return `${fmtH(dayShift.value.min)}h${fmtH(dayShift.value.max)}h · ${Math.round((dayShift.value.max - dayShift.value.min) * 10) / 10} h`
}
// ── Tracé GPS réel (Traccar) SUPERPOSÉ sur la carte de l'éditeur de jour (jobs + itinéraire planifié déjà présents) ──
// → permet de voir si le tech est réellement passé par ses jobs. Toggle dispo AUJOURD'HUI/HIER seulement (Traccar lourd).
const dayShowTrack = ref(false); const dayTrackMsg = ref('')
const dayCanTrack = computed(() => {
const iso = dayEditor.day && dayEditor.day.iso; const n = nowET.value.iso; if (!iso || !n) return false
const yest = new Date(Date.parse(n + 'T12:00:00Z') - 86400000).toISOString().slice(0, 10)
return iso === n || iso === yest
})
async function loadDayTrack () {
dayTrackMsg.value = '…'
const iso = dayEditor.day && dayEditor.day.iso; const tech = dayEditor.tech && dayEditor.tech.id
const src = _dayMap && _dayMap.getSource('day-track')
if (!iso || !tech || !src) { dayTrackMsg.value = ''; return }
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(tech, from, to); const coords = (r && r.coords) || []
src.setData(coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} } : { type: 'FeatureCollection', features: [] })
if (coords.length < 2) { dayTrackMsg.value = 'aucun tracé / appareil'; return }
// Comparaison RÉEL (GPS) vs PRÉVU (route Mapbox) — km + temps de conduite. Contextuel : seulement quand le tracé est affiché → pas de surcharge.
let pkm = 0, pmin = 0, hasPlan = false
for (let i = 0; i < dayEditor.list.length; i++) { const l = dayLeg(i); if (l) { if (l.km != null) { pkm += l.km; hasPlan = true } pmin += (l.min || 0) } }
const rkm = (r && r.km != null) ? r.km : null; const rmin = (r && r.moving_min != null) ? r.moving_min : null
const span = (r && r.span_min != null) ? r.span_min : null
if (rkm == null) { dayTrackMsg.value = coords.length + ' pts GPS'; return }
const onsite = (span != null && rmin != null) ? Math.max(0, span - rmin) : null // sur place (arrêté) = durée GPS totale conduite
let msg = 'réel ' + rkm + ' km' + (rmin != null ? ' · route ' + fmtMin(rmin) : '') + (onsite != null ? ' · sur place ' + fmtMin(onsite) : '')
if (hasPlan) {
const dkm = Math.round((rkm - pkm) * 10) / 10
const flag = (pkm > 0 && rkm > pkm * 1.25) ? '⚠ ' : '✓ ' // ⚠ = beaucoup plus de km que prévu (détour/trafic/route à revoir)
msg = flag + msg + ' · prévu ' + (Math.round(pkm * 10) / 10) + ' km / ' + Math.round(pmin) + ' min (' + (dkm >= 0 ? '+' : '') + dkm + ' km)'
}
dayTrackMsg.value = msg
} catch (e) { dayTrackMsg.value = 'indispo' }
}
watch(dayShowTrack, (on) => { if (!_dayMap) return; if (on) loadDayTrack(); else { const s = _dayMap.getSource('day-track'); if (s) s.setData({ type: 'FeatureCollection', features: [] }); dayTrackMsg.value = '' } })
// Matrice des temps de trajet ROUTIERS RÉELS entre tous les jobs du jour (Mapbox Matrix, 1 requête).
// Indépendante de l'ordre → le réordonnancement réutilise la matrice SANS nouvelle requête (recalcul instantané).
// Repli silencieux sur l'haversine si Mapbox indispo ou coords manquantes.
async function loadDayRoute () {
const key = (dayEditor.tech && dayEditor.tech.id) + '|' + (dayEditor.day && dayEditor.day.iso)
const jobsLL = dayEditor.list.filter(j => j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon))
const origin = dayOrigin.value // l'origine (dépôt/domicile) entre dans la matrice → trajet origine→1er job RÉEL
const pts = ((origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? [origin, ...jobsLL] : jobsLL).slice(0, 25) // Matrix = 25 coords max
if (pts.length < 2 || !MAPBOX_TOKEN) { dayEditor.travelMap = {}; dayEditor.routeReady = false; return }
const coords = pts.map(j => `${(+j.lon).toFixed(6)},${(+j.lat).toFixed(6)}`).join(';')
const url = `https://api.mapbox.com/directions-matrix/v1/mapbox/driving/${coords}?annotations=duration,distance&access_token=${MAPBOX_TOKEN}`
try {
const r = await fetch(url); if (!r.ok) throw new Error('matrix ' + r.status)
const d = await r.json(); const dur = d.durations || [], dist = d.distances || []
if (key !== ((dayEditor.tech && dayEditor.tech.id) + '|' + (dayEditor.day && dayEditor.day.iso))) return // l'éditeur a changé de cible entre-temps
const map = {}
for (let i = 0; i < pts.length; i++) for (let k = 0; k < pts.length; k++) {
if (i === k) continue
const sec = dur[i] && dur[i][k]; const m = dist[i] && dist[i][k]
if (sec == null) continue
map[pts[i].name + '>' + pts[k].name] = { min: Math.max(2, Math.round(sec / 60)), km: m != null ? Math.round(m / 100) / 10 : null, real: true }
}
dayEditor.travelMap = map; dayEditor.routeReady = true
} catch (e) { dayEditor.travelMap = {}; dayEditor.routeReady = false } // repli haversine
}
const dayOcc = () => (dayEditor.tech && dayEditor.day) ? cellOcc(dayEditor.tech.id, dayEditor.day.iso) : null
const dayBands = () => (dayEditor.tech && dayEditor.day) ? cellBands(dayEditor.tech.id, dayEditor.day.iso) : []
// Blocs RECALCULÉS depuis la SÉQUENCE éditée (packedDay) → l'ordre + les durées + le transport se reflètent + plus d'overlap.
const dayBlocks = () => packedDay.value.map(p => ({ s: p.startMin, e: p.endMin, skill: p.skill, legacy: p.legacy })) // packedDay inclut déjà les jobs legacy fusionnés
// Réordonnancement : flèches ↑↓ (fiable) + drag-drop basé sur le DROP (robuste, pas de splice live jittery)
function moveDayJob (i, dir) { const j = i + dir; const l = dayEditor.list; if (j < 0 || j >= l.length) return; const [x] = l.splice(i, 1); l.splice(j, 0, x) }
// Figer une job en 1re / dernière position (ou détacher). Réordonne pour honorer les positions figées.
function setPin (j, pin) { j.pin = (j.pin === pin ? null : pin); reorderByPins() }
function reorderByPins () {
const l = dayEditor.list
const first = l.filter(x => x.pin === 'first')
const last = l.filter(x => x.pin === 'last')
const mid = l.filter(x => x.pin !== 'first' && x.pin !== 'last')
dayEditor.list = [...first, ...mid, ...last]
}
function dayDragStart (i, ev) { dayEditor.dragIdx = i; try { ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.setData('text/plain', String(i)) } catch (e) {} }
function dayDropOn (i) { const from = dayEditor.dragIdx; if (from == null || from === i) { dayEditor.dragIdx = null; return } const l = dayEditor.list; const [x] = l.splice(from, 1); l.splice(i, 0, x); dayEditor.dragIdx = null }
function dayDragEnd () { dayEditor.dragIdx = null }
// Durée éditable en MINUTES (pas de 5) — best practice de précision
function jobMinutes (j) { return Math.round((Number(j.dur) || 0) * 60) }
// Couleur du « réel/estimé » sur la timeline : vert si dans l'estimé, rouge si dépassé > 10 %, neutre entre.
function ratioStyle (j) {
const est = jobMinutes(j); const act = j.actual_min
if (act == null || !est) return {}
if (act > est * 1.1) return { color: '#c62828', fontWeight: 700 }
if (act <= est) return { color: '#2e7d32', fontWeight: 700 }
return {}
}
function setJobMinutes (j, min) { const m = Math.max(5, Math.round((Number(min) || 0) / 5) * 5); j.dur = Math.round(m / 60 * 100) / 100 }
// Temps de transport estimé entre 2 jobs (haversine via coords Service Location) — provisoire, en attendant la géoloc live (Capacitor)
function haversineKm (la1, lo1, la2, lo2) { if ([la1, lo1, la2, lo2].some(v => v == null)) return null; const R = 6371; const r = x => x * Math.PI / 180; const dLa = r(la2 - la1); const dLo = r(lo2 - lo1); const s = Math.sin(dLa / 2) ** 2 + Math.cos(r(la1)) * Math.cos(r(la2)) * Math.sin(dLo / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(s)) }
function travelBetween (a, b) {
if (!a || !b) return null
const hit = dayEditor.travelMap && dayEditor.travelMap[a.name + '>' + b.name]
if (hit) return hit // temps routier RÉEL (Mapbox Matrix)
const km = haversineKm(a.lat, a.lon, b.lat, b.lon); if (km == null) return null
return { km: Math.round(km * 10) / 10, min: Math.max(5, Math.round(km / 40 * 60) + 5), real: false } // repli : 40 km/h + 5 min tampon (vol d'oiseau)
}
function dayLeg (i) { if (i === 0) { const o = dayOrigin.value; return (o && dayEditor.list[0]) ? travelBetween(o, dayEditor.list[0]) : null } return travelBetween(dayEditor.list[i - 1], dayEditor.list[i]) } // i=0 = trajet origine (dépôt/domicile) → 1er job ; sinon job précédent → courant
const fmtHM = (h) => { if (h == null) return '—'; const m = Math.round(h * 60); const hh = Math.floor(m / 60), mm = m % 60; return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') } // heure décimale → HH:MM (padded, pour start_time)
function dayShiftStartH () { const t = dayEditor.tech, d = dayEditor.day; if (!t || !d) return 8; const w = winOf(t.id, d.iso, false); return w ? w.s : 8 }
// PLANIFICATEUR DE TOURNÉE : recalcule les heures depuis l'ordre de la liste + durées + transport.
// Job verrouillé (RDV fixe) → garde son heure ; flexible → enchaîné après le précédent (+ transport). Plus d'overlap.
const packedDay = computed(() => {
const list = dayEditor.list; const out = []; let cursor = dayShiftStartH()
const origin = dayOrigin.value
if (origin && list.length) { const l0 = (travelBetween(origin, list[0]) || {}).min || 0; cursor += l0 / 60 } // départ origine → 1er job (le tech quitte l'origine à l'heure du shift)
for (let i = 0; i < list.length; i++) {
const j = list[i]; const dur = Number(j.dur) || 1
const start = (j.locked && j.start_h != null) ? j.start_h : cursor
const end = start + dur
out.push({ ...j, startMin: start, endMin: end })
const trH = (i < list.length - 1 ? (travelBetween(j, list[i + 1]) || {}).min || 0 : 0) / 60
cursor = Math.max(cursor, end) + trH
}
return out
})
// Coords valides : non nulles, finies, ET pas ~0,0 (placeholder « pas de coords » — golfe de Guinée).
const hasLL = (j) => j && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon) && (Math.abs(+j.lat) > 0.01 || Math.abs(+j.lon) > 0.01)
const dayNoCoord = computed(() => dayEditor.list.filter(j => !hasLL(j)).length)
// ── Origine de tournée : point de départ = DÉPÔT par défaut, OU DOMICILE du tech s'il est plus proche du travail ──
// (politique dispatch, lue côté client). La 1re job devient la plus proche de cette origine via « Optimiser depuis le départ ».
const depot = ref(null) // { label, address, lat, lon }
const techHomes = ref({}) // { techId: { address, lat, lon } }
async function loadDispatchPolicy () {
try { const d = await roster.getPolicy(); depot.value = (d.policy && d.policy.depot) || null; techHomes.value = (d.policy && d.policy.tech_homes) || {} } catch (e) { /* non bloquant */ }
}
// Point de départ de la tournée : choix MANUEL dans le dialogue (null = auto/politique). Permet de basculer Bureau↔Domicile
// pour un tech « dispatch maison » même sans job géolocalisé (sinon la carte n'aurait aucun pin de départ à afficher).
const dayOriginChoice = ref(null)
const dayHasDepot = computed(() => { const dp = depot.value; return !!(dp && hasLL(dp)) })
const dayHasHome = computed(() => { const t = dayEditor.tech; const hm = t && techHomes.value[t.id]; return !!(hm && hasLL(hm)) })
const dayOrigin = computed(() => {
const t = dayEditor.tech; if (!t) return null
const dp = depot.value; const dep = (dp && hasLL(dp)) ? { name: '__origin__', lat: +dp.lat, lon: +dp.lon, label: dp.label || 'Dépôt', address: dp.address || '', kind: 'depot' } : null
const hm = techHomes.value[t.id]; const home = (hm && hasLL(hm)) ? { name: '__origin__', lat: +hm.lat, lon: +hm.lon, label: 'Domicile', address: hm.address || '', kind: 'home' } : null
if (dayOriginChoice.value === 'depot' && dep) return dep // départ forcé manuellement (toggle du dialogue) — prioritaire sur l'auto
if (dayOriginChoice.value === 'home' && home) return home
const jobs = dayEditor.list.filter(hasLL)
if (!jobs.length) return dep || home || null
if (dep && home) { const nd = (o) => Math.min(...jobs.map(j => haversineKm(o.lat, o.lon, +j.lat, +j.lon) ?? 9e9)); return nd(home) < nd(dep) ? home : dep } // domicile gagne s'il est plus proche
return dep || home || null
})
// Affiche la carte même sans job géolocalisé → au moins le point de départ (dépôt/domicile) reste visible.
const dayShowMap = computed(() => dayEditor.list.length > 0 || !!dayOrigin.value)
// Bascule manuelle du départ (dépôt ↔ domicile) depuis le dialogue → recalcule pins + itinéraire.
function setDayOrigin (kind) { dayOriginChoice.value = kind; if (_dayMap) refreshDayMap(); loadDayRoute() }
// ── Carte INTERACTIVE de la journée (Mapbox GL) : itinéraire ROUTIER réel (API Directions) + pins
// numérotés dans l'ordre de tournée. Navigable : zoom molette + boutons (NavigationControl), déplacement. ──
const dayMapEl = ref(null)
let _dayMap = null; let _dayMapRO = null; let _dirTimer = null; let _dayStopMarkers = []
function ensureMapbox () {
return new Promise((resolve) => {
if (!document.getElementById('mapbox-css')) { const l = document.createElement('link'); l.id = 'mapbox-css'; l.rel = 'stylesheet'; l.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l) }
if (window.mapboxgl) return resolve(window.mapboxgl)
let s = document.getElementById('mapbox-js')
if (!s) { s = document.createElement('script'); s.id = 'mapbox-js'; s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'; document.head.appendChild(s) }
s.addEventListener('load', () => resolve(window.mapboxgl))
const iv = setInterval(() => { if (window.mapboxgl) { clearInterval(iv); resolve(window.mapboxgl) } }, 150)
})
}
const _esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
function dayStops () { // arrêts géolocalisés, dans l'ordre de tournée (packedDay) + infos pour le popup
return packedDay.value.filter(hasLL).map((j, i) => ({
lon: +j.lon, lat: +j.lat, label: String(i + 1), color: j.skill ? getTagColor(j.skill) : '#1976d2', skill: j.skill || '',
subject: j.subject || '', customer: j.customer || '', address: j.address || '', time: fmtHM(j.startMin) + '' + fmtHM(j.endMin),
}))
}
// Segments de DÉPLACEMENT (pointillés) = l'espace entre 2 jobs dans la barre timeline.
const dayTravelSegs = () => { const p = packedDay.value; const out = []; const o = dayOrigin.value; if (o && p.length) { const s = dayShiftStartH(), e = p[0].startMin; if (e - s > 0.02) out.push({ s, e }) } for (let i = 0; i < p.length - 1; i++) { const s = p[i].endMin, e = p[i + 1].startMin; if (e - s > 0.02) out.push({ s, e }) } return out }
// Centre la carte sur un job (clic sur la ligne de la liste).
function focusDayJob (j) { if (_dayMap && hasLL(j)) _dayMap.easeTo({ center: [+j.lon, +j.lat], zoom: 14, duration: 500 }) }
// Fil legacy (messages + réponses du ticket osTicket) chargé à la demande, mis en cache sur le job (j._thread).
async function loadThread (j) {
const id = j && (j.legacy_id || j.legacy_ticket_id); if (!id || j._thread) return
j._thread = { loading: true, messages: [] }
try { const r = await roster.ticketThread(id); j._thread = { loading: false, messages: r.messages || [], count: r.count || 0, status: r.status || '' } }
catch (e) { j._thread = { loading: false, error: true, messages: [] } }
}
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.
function closeLegacyTicket (j) {
if (!j || !j.legacy_ticket_id) 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 ».
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 }
$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) {
const done = new Set((r.results || []).filter(x => x.ok).map(x => String(x.t)))
assignPanel.jobs = assignPanel.jobs.filter(j => !done.has(String(j.legacy_ticket_id)))
for (const k in selectedJobs) delete selectedJobs[k]
$q.notify({ type: 'positive', message: r.closed + ' ticket(s) fermé(s)' + (r.locked ? ' · ' + r.locked + ' verrouillé(s)' : '') + (r.skipped ? ' · ' + r.skipped + ' déjà fermé(s)' : '') })
} else $q.notify({ type: 'warning', message: 'Échec : ' + ((r && r.error) || '?') })
} catch (e) { err(e) }
})
}
function fmtDT (iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString('fr-CA', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' } }
async function initDayMap () {
if (!MAPBOX_TOKEN || !dayMapEl.value || _dayMap) return
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !dayMapEl.value) return
mapboxgl.accessToken = MAPBOX_TOKEN
_dayMap = new mapboxgl.Map({ container: dayMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.6756, 45.1599], zoom: 9 })
_dayMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right') // boutons zoom +/-
_dayMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
_dayMapRO = new ResizeObserver(() => { if (_dayMap) _dayMap.resize() }); _dayMapRO.observe(dayMapEl.value)
_dayMap.on('load', () => {
_dayMap.resize()
addSatLayer(_dayMap) // couche satellite (sous l'itinéraire et les pins), masquée par défaut
_dayMap.addSource('day-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
_dayMap.addLayer({ id: 'day-route-halo', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 9, 'line-opacity': 0.2 } })
_dayMap.addLayer({ id: 'day-route', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 4, 'line-opacity': 0.85 } })
// Tracé GPS RÉEL (Traccar) — orange semi-transparent, par-dessus l'itinéraire planifié (bleu) ; sous les pins.
_dayMap.addSource('day-track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
_dayMap.addLayer({ id: 'day-track-halo', type: 'line', source: 'day-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ff6f00', 'line-width': 9, 'line-opacity': 0.2 } })
_dayMap.addLayer({ id: 'day-track-l', type: 'line', source: 'day-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ef6c00', 'line-width': 3.5, 'line-opacity': 0.8 } })
_dayMap.addSource('day-stops', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
_dayMap.addLayer({ id: 'day-stops-c', type: 'circle', source: 'day-stops', paint: { 'circle-radius': ['case', ['>', ['get', 'count'], 1], 12, 9], 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' } }) // groupe même adresse = un peu plus gros ; rayon réduit pour moins de chevauchement
_dayMap.addLayer({ id: 'day-stops-l', type: 'symbol', source: 'day-stops', layout: { 'text-field': ['get', 'label'], 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-size': 12, 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
// Clic sur un pin → popup avec les détails du job ; curseur main au survol.
_dayMap.on('mouseenter', 'day-stops-c', () => { _dayMap.getCanvas().style.cursor = 'pointer' })
_dayMap.on('mouseleave', 'day-stops-c', () => { _dayMap.getCanvas().style.cursor = '' })
_dayMap.on('click', 'day-stops-c', (e) => {
const f = e.features[0]; const p = f.properties
new window.mapboxgl.Popup({ offset: 14 }).setLngLat(f.geometry.coordinates)
.setHTML(`<div style="font-size:12px;line-height:1.5">${p.popup || ('<b>' + _esc(p.label) + '</b>')}${p.address ? '<br><span style="color:#888">📍 ' + _esc(p.address) + '</span>' : ''}</div>`)
.addTo(_dayMap)
})
refreshDayMap()
})
}
function refreshDayMap () {
if (!_dayMap || !_dayMap.isStyleLoaded()) { setTimeout(refreshDayMap, 200); return }
const stops = dayStops()
const origin = dayOrigin.value
// Regroupe les arrêts à la MÊME adresse (coords arrondies) → 1 pin « style Gaiia » : icône vectorielle du TYPE de la job
// PRINCIPALE (1re en ordre de tournée) + son n°, et — si plusieurs jobs à l'adresse — une mini barre segmentée au-dessus
// du n° (1 segment coloré par job) qui distingue la principale (l'icône) des secondaires. Le popup liste tous les jobs.
const grp = {}; const gord = []
for (const s of stops) { const k = s.lon.toFixed(5) + ',' + s.lat.toFixed(5); if (!grp[k]) { grp[k] = { lon: s.lon, lat: s.lat, items: [] }; gord.push(k) } grp[k].items.push(s) }
// Marqueurs DOM custom (styles inline car ils sortent du scope CSS du composant) ; les couches circle/symbol restent vides.
_dayStopMarkers.forEach(m => { try { m.remove() } catch (e) {} }); _dayStopMarkers = []
const sSrc = _dayMap.getSource('day-stops'); if (sSrc) sSrc.setData({ type: 'FeatureCollection', features: [] })
const seg = (items) => items.length > 1 ? `<div style="display:flex;gap:1.5px;margin-bottom:2px;padding:1.5px;background:#fff;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,.3)">${items.map(i => `<span style="display:block;width:7px;height:3px;border-radius:1px;background:${i.color}"></span>`).join('')}</div>` : ''
// L'icône peut être une ligature Material OU un chemin SVG custom (format q-icon « d@@style&&…|viewBox », ex. BUCKET_TRUCK) :
// sur un pin (HTML brut), on rend alors un <svg> inline (blanc) au lieu de la ligature.
const iconMarkup = (icon) => /^[Mm][\s\d.-]/.test(String(icon))
? (() => { const [def, vb = '0 0 24 24'] = String(icon).split('|'); const paths = def.split('&&').map(p => { const [d, style = ''] = p.split('@@'); return `<path d="${d}" style="${style.replace(/currentColor/g, '#fff')}"/>` }).join(''); return `<svg viewBox="${vb}" width="14" height="14" style="display:block;fill:#fff">${paths}</svg>` })()
: `<i class="material-icons" style="font-size:14px;line-height:1">${icon}</i>`
const pinHtml = (color, icon, num, items, round) => `${seg(items || [])}<div style="display:flex;align-items:center;gap:1px;color:#fff;background:${color};border:2px solid #fff;border-radius:${round ? '50%' : '11px'};padding:${round ? '3px' : '1px 5px 1px 3px'};box-shadow:0 1px 4px rgba(0,0,0,.45)">${iconMarkup(icon)}${num ? `<span style="font-size:10px;font-weight:800;line-height:1;padding:0 1px">${num}</span>` : ''}</div><div style="width:0;height:0;margin:-1px auto 0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid ${color}"></div>`
const addPin = (lon, lat, html, popup, addr) => {
const el = document.createElement('div'); el.style.cssText = 'display:flex;flex-direction:column;align-items:center;cursor:pointer;line-height:0'; el.innerHTML = html
el.addEventListener('click', (ev) => { ev.stopPropagation(); new window.mapboxgl.Popup({ offset: 16 }).setLngLat([lon, lat]).setHTML(`<div style="font-size:12px;line-height:1.5">${popup}${addr ? '<br><span style="color:#888">📍 ' + _esc(addr) + '</span>' : ''}</div>`).addTo(_dayMap) })
_dayStopMarkers.push(new window.mapboxgl.Marker({ element: el, anchor: 'bottom' }).setLngLat([lon, lat]).addTo(_dayMap))
}
for (const k of gord) {
const g = grp[k]; const f = g.items[0]
const popup = g.items.map(i => `<b>${_esc(i.label)}.</b> ${_esc(i.subject)}${i.time ? ' · ' + _esc(i.time) : ''}${i.customer ? ' · ' + _esc(i.customer) : ''}`).join('<br>')
addPin(g.lon, g.lat, pinHtml(f.color, skillIcon(f.skill), f.label, g.items, false), popup, f.address) // n° = job principale ; barre = N jobs à l'adresse
}
if (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) { // pin de départ (dépôt/domicile) : icône maison/entrepôt
const oc = origin.kind === 'home' ? '#00897b' : '#37474f'
addPin(+origin.lon, +origin.lat, pinHtml(oc, origin.kind === 'home' ? 'home' : 'warehouse', '', null, true), '<b>Départ</b> — ' + _esc(origin.label || ''), origin.address || '')
}
const all = (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? [...stops, { lon: +origin.lon, lat: +origin.lat }] : stops
if (all.length === 1) _dayMap.easeTo({ center: [all[0].lon, all[0].lat], zoom: 13, duration: 400 })
else if (all.length > 1) {
const b = new window.mapboxgl.LngLatBounds([all[0].lon, all[0].lat], [all[0].lon, all[0].lat])
all.forEach(s => b.extend([s.lon, s.lat]))
_dayMap.fitBounds(b, { padding: 45, maxZoom: 14, duration: 400 })
}
fetchDayRouteGeom(stops, origin)
}
async function fetchDayRouteGeom (stops, origin) { // itinéraire ROUTIER réel (Directions) → tracé sur la carte, DEPUIS l'origine
const rSrc = _dayMap && _dayMap.getSource('day-route'); if (!rSrc) return
const ordered = (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? [{ lon: +origin.lon, lat: +origin.lat }, ...(stops || [])] : (stops || [])
if (ordered.length < 2) { rSrc.setData({ type: 'FeatureCollection', features: [] }); return }
try {
const pts = ordered.slice(0, 25).map(s => `${(+s.lon).toFixed(6)},${(+s.lat).toFixed(6)}`).join(';')
// continue_straight=false : autorise le demi-tour aux points intermédiaires (sinon Mapbox interdit le U-turn au
// waypoint → grande boucle parasite quand le job suivant est « derrière » ; un tech PEUT faire demi-tour).
const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${pts}?overview=full&geometries=geojson&continue_straight=false&access_token=${MAPBOX_TOKEN}`
const r = await fetch(url); if (!r.ok) throw new Error('dir ' + r.status)
const d = await r.json(); const geom = d.routes && d.routes[0] && d.routes[0].geometry
const src = _dayMap && _dayMap.getSource('day-route')
if (geom && src) src.setData({ type: 'Feature', geometry: geom, properties: {} })
} catch (e) { /* repli : pas de tracé routier (les pins restent visibles) */ }
}
function destroyDayMap () {
if (_dirTimer) { clearTimeout(_dirTimer); _dirTimer = null }
if (_dayMapRO) { _dayMapRO.disconnect(); _dayMapRO = null }
_dayStopMarkers.forEach(m => { try { m.remove() } catch (e) {} }); _dayStopMarkers = []
if (_dayMap) { try { _dayMap.remove() } catch (e) {} _dayMap = null }
}
// (ré)init à l'ouverture du dialogue (après l'anim) ; refresh débouncé au réordonnancement ; destruction à la fermeture.
watch(() => dayEditor.open, (open) => { if (open) nextTick(() => setTimeout(initDayMap, 250)); else destroyDayMap() })
watch(() => dayEditor.list.map(j => j.name).join(','), () => { if (_dayMap) { clearTimeout(_dirTimer); _dirTimer = setTimeout(refreshDayMap, 500) } })
const dayTotalH = () => Math.round(dayEditor.list.reduce((s, j) => s + (Number(j.dur) || 0), 0) * 10) / 10
// Chrono (boucle de capture) : début/fin réels → durée mesurée (alimente l'apprentissage des durées par type×tech)
async function startJobChrono (j) { try { const r = await roster.startJob(j.name); j.actual_start = r.actual_start || '1'; j.actual_end = ''; $q.notify({ type: 'positive', icon: 'play_arrow', message: 'Job démarré (chrono)', timeout: 1500 }) } catch (e) { err(e) } }
async function finishJobChrono (j) { try { const r = await roster.finishJob(j.name); j.actual_end = '1'; j.actual_min = r.actual_minutes; $q.notify({ type: 'positive', icon: 'check', message: `Terminé · ${r.actual_minutes} min réels`, timeout: 2500 }); reloadOccupancy() } catch (e) { err(e) } }
async function removeFromDay (j) {
// TAMPON : on désassigne SEULEMENT dans OPS (ERPNext), comme le clic-droit « Renvoyer au pool ». AUCUNE écriture dans F ici.
// Le retour au pool dans F (legacy) ne part qu'à « Publier au legacy » (action explicite). Évite tout write-back surprise.
try {
await roster.unassignJobRoster(j.name)
dayEditor.list = dayEditor.list.filter(x => x.name !== j.name); await loadWeek()
$q.notify({ type: 'info', message: 'Retiré du tech (retour au pool OPS). À publier dans F via « Publier au legacy ».', timeout: 2800 })
} catch (e) { err(e) }
}
// « Optimiser depuis le départ » : ordonne par PLUS-PROCHE-VOISIN à partir de l'origine (dépôt/domicile)
// → la 1re job devient la plus proche de l'origine. Non destructif : l'utilisateur Enregistre ensuite.
function optimizeFromOrigin () {
const origin = dayOrigin.value
// Positions FIGÉES respectées : les « 1er » restent en tête (dans leur ordre), les « dernier » en queue ; on optimise le MILIEU.
const first = dayEditor.list.filter(j => j.pin === 'first')
const last = dayEditor.list.filter(j => j.pin === 'last')
const middle = dayEditor.list.filter(j => j.pin !== 'first' && j.pin !== 'last')
const withLL = middle.filter(hasLL)
const noLL = middle.filter(j => !hasLL(j))
if (withLL.length < 2 && !first.length && !last.length) { $q.notify({ type: 'info', message: 'Pas assez darrêts géolocalisés à optimiser' }); return }
const remaining = withLL.slice(); const ordered = []
// Point de départ de l'optim : dernière job figée-en-1er (si géoloc), sinon l'origine (domicile/dépôt), sinon le 1er arrêt du milieu.
let cur = (first.length && hasLL(first[first.length - 1])) ? first[first.length - 1]
: (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? origin
: remaining.shift()
if (cur && !first.includes(cur) && cur !== origin) ordered.push(cur)
while (remaining.length) {
let bi = 0, bd = Infinity
for (let i = 0; i < remaining.length; i++) { const lg = travelBetween(cur, remaining[i]); const d = lg ? lg.min : 9e9; if (d < bd) { bd = d; bi = i } }
cur = remaining[bi]; ordered.push(remaining.splice(bi, 1)[0])
}
dayEditor.list = [...first, ...ordered, ...noLL, ...last]
const pinNote = (first.length || last.length) ? ' (positions figées respectées)' : ''
$q.notify({ type: 'positive', icon: 'route', message: 'Tournée optimisée' + pinNote + (origin ? ' depuis ' + (origin.kind === 'home' ? 'le domicile' : 'le dépôt') : ''), timeout: 2000 })
}
async function saveDayOrder () {
dayEditor.saving = true
const packed = packedDay.value // heures recalculées par la tournée → on les persiste (start_time)
// Les jobs LEGACY (F autoritaire) ne sont PAS écrits — on ne persiste que les Dispatch Jobs roster (route_order séquentiel).
const updates = dayEditor.list.map((j, i) => ({ j, i })).filter(x => !x.j.legacy).map(({ j, i }, k) => ({ job: j.name, route_order: k + 1, priority: j.priority, duration_h: Number(j.dur) || 1, start_time: fmtHM(packed[i].startMin) }))
try { const r = await roster.reorderJobs(updates); dayEditor.open = false; await loadWeek(); $q.notify({ type: 'positive', message: 'Tournée enregistrée — ordre · heures · durées (' + (r.updated || 0) + ')', timeout: 2400 }) } catch (e) { err(e) } finally { dayEditor.saving = false }
}
const timelineDays = computed(() => {
const t = timelineDlg.tech; if (!t) return []
const out = []
for (const d of dayList.value) {
const shift = hasReg(t.id, d.iso) || onGarde(t.id, d.iso)
const jobs = shift ? cellJobs(t.id, d.iso) : rawCellJobs(t.id, d.iso) // hors quart : jobs bruts
if (!jobs.length && !shift) continue // on saute les jours vides
const o = cellOcc(t.id, d.iso)
const usedH = shift ? (o ? o.usedH : 0) : Math.round(jobs.reduce((s, j) => s + (j.dur || 0), 0) * 10) / 10
out.push({ iso: d.iso, label: d.dow + ' ' + d.dnum, weekend: d.weekend, bands: cellBands(t.id, d.iso), blocks: cellBlocks(t.id, d.iso), jobs, pct: shift ? cellPct(t.id, d.iso) : null, usedH, offShift: !shift && jobs.length > 0 })
}
return out
})
// Sauvegarde DEBOUNCÉE + silencieuse (succès) : coalesce les clics rapides → 1 seul appel (évite « load fail » concurrents).
let _skillSaveTimer = null
function queueSkillSave (t) { if (_skillSaveTimer) clearTimeout(_skillSaveTimer); _skillSaveTimer = setTimeout(() => { _skillSaveTimer = null; doSaveSkillData(t) }, 500) }
async function doSaveSkillData (t) {
const sset = new Set(t.skills || [])
const lv = {}; for (const k in (t.skill_levels || {})) if (sset.has(k)) lv[k] = t.skill_levels[k]
const ef = {}; for (const k in (t.skill_eff || {})) if (sset.has(k)) ef[k] = t.skill_eff[k]
t.skill_levels = lv; t.skill_eff = ef
try { await roster.setTechSkills(t.id, (t.skills || []).join(','), lv, ef) } catch (e) { $q.notify({ type: 'negative', message: 'Échec sauvegarde compétences — réessaie', timeout: 1800 }) }
}
// ── Catalogue de compétences (réutilise TagEditor) : couleurs persistées + création/suppression ──
// Palette élargie (incl. roses/magentas) + sélecteur HTML natif pour toute couleur.
const TAG_PALETTE = [
'#6366f1', '#3b82f6', '#0ea5e9', '#06b6d4', '#14b8a6', '#10b981', '#22c55e', '#84cc16',
'#eab308', '#f59e0b', '#f97316', '#ef4444', '#f43f5e', '#fb7185', '#ec4899', '#f472b6',
'#db2777', '#d946ef', '#a855f7', '#8b5cf6', '#78716c', '#64748b', '#94a3b8', '#111827',
]
function hashColor (label) { let h = 0; for (const c of String(label)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return TAG_PALETTE[h % TAG_PALETTE.length] }
const customTags = ref([]) // [{label,color}] créés à la volée (localStorage)
function saveCustomTags () { localStorage.setItem('roster-skill-tags-v1', JSON.stringify(customTags.value)) }
function getTagColor (label) { const ct = customTags.value.find(x => x.label === label); return (ct && ct.color) || hashColor(label) }
// Teinte PASTEL (≈80 % blanc + 20 % couleur) → fond doux des blocs (moins chargé), avec contour/icône en couleur pleine.
function pastelColor (hex) {
const h = String(hex || '').replace('#', ''); if (h.length < 6) return '#eef1f4'
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16)
const mix = (c) => Math.round(255 * 0.80 + (isNaN(c) ? 200 : c) * 0.20)
return `rgb(${mix(r)},${mix(g)},${mix(b)})`
}
function hexA (hex, a) { const h = String(hex || '').replace('#', ''); if (h.length < 6) return `rgba(120,130,145,${a})`; return `rgba(${parseInt(h.slice(0, 2), 16)},${parseInt(h.slice(2, 4), 16)},${parseInt(h.slice(4, 6), 16)},${a})` }
// Fond HACHURÉ (renfort/assistant) : rayures diagonales de la couleur de compétence sur blanc → « réservé sur une autre job ».
function hatchBg (hex) { const c = hexA(hex, 0.22); return `repeating-linear-gradient(45deg, ${c} 0, ${c} 5px, #ffffff 5px, #ffffff 11px)` }
// Couleur d'une carte job = COULEUR DE SA COMPÉTENCE (éditable via le gestionnaire de tags → cohérent + simple).
// required_skill est renseigné côté hub (skill explicite, sinon déduit du type legacy). Repli : couleur du type.
function panelJobColor (j) { return j.required_skill ? getTagColor(j.required_skill) : (legacyDeptColor(j.legacy_dept) || '#90a4ae') }
const tagCatalog = computed(() => {
const m = new Map()
for (const ct of customTags.value) m.set(ct.label, { name: ct.label, label: ct.label, color: ct.color || hashColor(ct.label), category: 'Custom' })
for (const s of [...allSkills.value, ...jobTypes.value, 'installation', 'réparation', 'support', 'fibre', 'aérien', 'épissure']) if (s && !m.has(s)) m.set(s, { name: s, label: s, color: getTagColor(s), category: jobTypes.value.includes(s) ? 'Type de job' : 'Compétence' })
return [...m.values()]
})
function onCreateRosterTag ({ label, color }) { if (label && !customTags.value.some(x => x.label === label)) { customTags.value.push({ label, color: color || hashColor(label) }); saveCustomTags() } }
function onUpdateRosterTag ({ name, color }) { const ct = customTags.value.find(x => x.label === name); if (ct) ct.color = color; else customTags.value.push({ label: name, color }); saveCustomTags() }
// (onDeleteRosterTag retiré : la suppression de tag passe par deleteTagGlobal du gestionnaire ci-dessous)
// ── Gestionnaire global de compétences (tags) : renommer / supprimer PARTOUT, recolorer, voir l'usage ──
const showTagManager = ref(false)
const managedTags = computed(() => {
const labels = new Set([...techs.value.flatMap(t => t.skills || []), ...customTags.value.map(c => c.label)])
return [...labels].filter(Boolean).sort((a, b) => a.localeCompare(b)).map(l => ({ label: l, color: getTagColor(l), count: techs.value.filter(t => (t.skills || []).includes(l)).length }))
})
async function renameTagGlobal (oldL, newL) { // remplace le label sur TOUS les techs (skills + niveaux + eff) + catalogue
newL = String(newL || '').trim(); if (!newL || newL === oldL) return
for (const t of techs.value) { // SÉQUENTIEL (pas de Promise.all sur erp)
if (!(t.skills || []).includes(oldL)) continue
t.skills = t.skills.map(s => s === oldL ? newL : s)
if (t.skill_levels && t.skill_levels[oldL] != null) { const m = { ...t.skill_levels, [newL]: t.skill_levels[oldL] }; delete m[oldL]; t.skill_levels = m }
if (t.skill_eff && t.skill_eff[oldL] != null) { const m = { ...t.skill_eff, [newL]: t.skill_eff[oldL] }; delete m[oldL]; t.skill_eff = m }
try { await roster.setTechSkills(t.id, t.skills.join(','), t.skill_levels || {}, t.skill_eff || {}) } catch (e) { err(e) }
}
const ct = customTags.value.find(c => c.label === oldL); if (ct) ct.label = newL; else customTags.value.push({ label: newL, color: getTagColor(oldL) })
saveCustomTags(); await syncSkillByType({ [oldL]: newL }, null) // cohérence avec la table type de job → compétence (booking)
$q.notify({ type: 'positive', message: '« ' + oldL +' » renommée « ' + newL + ' » partout', timeout: 2500 })
}
// Propage rename/delete d'un tag vers la table booking skill_by_type (Copilote #56) → cohérence globale du filtre.
async function syncSkillByType (renames, deletes) {
try {
const d = await roster.getPolicy(); const map = { ...((d.policy && d.policy.booking && d.policy.booking.skill_by_type) || {}) }; let changed = false
for (const k in map) { if (deletes && deletes.includes(map[k])) { delete map[k]; changed = true } else if (renames && renames[map[k]]) { map[k] = renames[map[k]]; changed = true } }
if (changed) await roster.savePolicy({ booking: { skill_by_type: map } })
} catch (e) { /* non bloquant */ }
}
async function deleteTagGlobal (tg) {
if (tg.count && !window.confirm('Supprimer « ' + tg.label + ' » de ' + tg.count + ' technicien(s) ?')) return
for (const t of techs.value) {
if (!(t.skills || []).includes(tg.label)) continue
t.skills = t.skills.filter(s => s !== tg.label)
if (t.skill_levels) { const m = { ...t.skill_levels }; delete m[tg.label]; t.skill_levels = m }
if (t.skill_eff) { const m = { ...t.skill_eff }; delete m[tg.label]; t.skill_eff = m }
try { await roster.setTechSkills(t.id, t.skills.join(','), t.skill_levels || {}, t.skill_eff || {}) } catch (e) { err(e) }
}
customTags.value = customTags.value.filter(c => c.label !== tg.label); saveCustomTags()
await syncSkillByType(null, [tg.label]) // retire aussi le tag de la table type de job → compétence (booking)
$q.notify({ type: 'info', message: '« ' + tg.label + ' » supprimée partout', timeout: 2000 })
}
function toggleSkill (sk) { const i = skillFilter.value.indexOf(sk); if (i >= 0) skillFilter.value.splice(i, 1); else skillFilter.value.push(sk) }
function techHasSkill (t) { const sk = (t.skills || []); return !skillFilter.value.length || skillFilter.value.every(f => sk.includes(f)) } // ET : toutes les compétences cochées requises
// Score de priorité (0 = meilleur) sur les techs qualifiés. DEUX dimensions DISTINCTES + coût :
// • COMPÉTENCE = niveau 15 dans la/les compétence(s) filtrée(s) (qualité ; haut = mieux)
// • VITESSE = efficiency (<1 = plus rapide = mieux) — un tech peut être très compétent MAIS lent
// • COÛT = coût chargé $/h. Pondéré compétence 0,4 · vitesse 0,3 · coût 0,3 (sans filtre : vitesse/coût 50/50).
function techCompetence (t) { const f = skillFilter.value; if (!f.length) return null; const ls = f.map(s => (t.skill_levels && t.skill_levels[s]) || 1); return ls.reduce((a, b) => a + b, 0) / ls.length }
// Vitesse retenue = efficacité PAR compétence filtrée (skillEffOf, fallback global) ; sinon efficacité globale.
function techSpeed (t) { const f = skillFilter.value; if (!f.length) return Number(t.efficiency) || 1; const es = f.map(s => skillEffOf(t, s)); return es.reduce((a, b) => a + b, 0) / es.length }
// PROXIMITÉ (à venir) : on gère le secteur MANUELLEMENT pour l'instant (zones). Ce hook renverra
// plus tard une distance normalisée 0..1 (0 = sur place) job↔tech (lat/lng de la Service Location vs
// secteur/base du tech). Tant qu'il retourne null, le score reste inchangé (poids redistribué).
// eslint-disable-next-line no-unused-vars
function techProximity (t /*, job */) { return null } // TODO proximité : brancher une fois lat/lng dispo
const priorityScores = computed(() => {
const cands = techs.value.filter(t => !isHidden(t.id) && techHasSkill(t)); const m = {}
if (!cands.length) return m
const effs = cands.map(techSpeed); const costs = cands.map(t => Number(t.cost_h) || 0)
const eMin = Math.min(...effs); const eMax = Math.max(...effs); const cMin = Math.min(...costs); const cMax = Math.max(...costs)
const norm = (v, lo, hi) => (hi > lo ? (v - lo) / (hi - lo) : 0)
for (const t of cands) {
const eff = norm(techSpeed(t), eMin, eMax); const cost = norm(Number(t.cost_h) || 0, cMin, cMax)
const comp = techCompetence(t)
const prox = techProximity(t) // null pour l'instant → ignoré (secteur manuel)
let s
if (comp == null) s = 0.5 * eff + 0.5 * cost
else { const compN = (Math.min(5, Math.max(1, comp)) - 1) / 4; s = 0.4 * (1 - compN) + 0.3 * eff + 0.3 * cost } // compétence ⊕ vitesse(par-skill) ⊕ coût
if (prox != null) s = 0.8 * s + 0.2 * prox // quand la proximité arrivera : 20% du score (réservé)
m[t.id] = s
}
return m
})
function techScore (t) { const v = priorityScores.value[t.id]; return v == null ? 0 : v }
function techRank (t) { if (!skillFilter.value.length) return null; const i = visibleTechs.value.findIndex(x => x.id === t.id); return i >= 0 ? i + 1 : null }
const visibleTechs = computed(() => {
const q = search.value.trim().toLowerCase()
const list = techs.value.filter(t => (showHidden.value || !isHidden(t.id)) && (!groupFilter.value || t.group === groupFilter.value) && techHasSkill(t) && (!q || (t.name || '').toLowerCase().includes(q) || (t.group || '').toLowerCase().includes(q)))
// Filtre par compétence actif → on TRIE par priorité (meilleur score d'abord) ; sinon ordre équipe/nom.
if (skillFilter.value.length) return list.slice().sort((a, b) => techScore(a) - techScore(b) || (a.name || '').localeCompare(b.name || ''))
return list.slice().sort((a, b) => (a.group || '~').localeCompare(b.group || '~') || (a.name || '').localeCompare(b.name || ''))
})
const cellsByTechDay = computed(() => { const m = {}; for (const a of assignments.value) { const t = (m[a.tech] || (m[a.tech] = {})); (t[a.date] || (t[a.date] = [])).push(a) } return m })
function cellsOf (techId, iso) { return (cellsByTechDay.value[techId] && cellsByTechDay.value[techId][iso]) || [] }
function isPaused (t) { return t.status === 'En pause' }
function hoursOf (techId) { let h = 0; for (const a of assignments.value) { if (a.tech !== techId) continue; const t = tplByName.value[a.shift]; if (t && t.on_call) continue; h += Number(a.hours) || 0 } return h } // garde exclue (mise en dispo, pas travaillée)
const serverSet = ref(new Set())
const currentSet = computed(() => new Set(assignments.value.map(a => a.tech + '|' + a.date + '|' + a.shift)))
const diffKeys = computed(() => { const cur = currentSet.value; const srv = serverSet.value; const d = []; for (const k of cur) if (!srv.has(k)) d.push(k); for (const k of srv) if (!cur.has(k)) d.push(k); return d })
const dirty = computed(() => diffKeys.value.length > 0)
const dirtyCount = computed(() => diffKeys.value.length)
const dirtyCells = computed(() => new Set(diffKeys.value.map(k => k.slice(0, k.lastIndexOf('|')))))
function isCellDirty (techId, iso) { return dirtyCells.value.has(techId + '|' + iso) }
const holSet = computed(() => new Set(holidays.value))
const statHolSet = computed(() => new Set(statHolidays.value.map(h => h.date)))
const statHolName = computed(() => { const m = {}; for (const h of statHolidays.value) m[h.date] = h.name; return m })
function isHoliday (iso) { return holSet.value.has(iso) || statHolSet.value.has(iso) } // manuel OU férié QC déterministe
function holidayLabel (iso) { return statHolName.value[iso] || (holSet.value.has(iso) ? 'Congé' : '') }
// toggle = ajoute/retire un congé MANUEL (les fériés QC restent auto, non togglables ici)
function toggleHoliday (iso) { holidays.value = holSet.value.has(iso) ? holidays.value.filter(x => x !== iso) : [...holidays.value, iso]; localStorage.setItem(LS_HOL, JSON.stringify(holidays.value)) }
// Préavis de disponibilité avant un férié (rec 2) : aperçu + envoi SMS aux techs non encore prévenus (confirmation requise).
const holNoticePreview = ref([])
const nextHolidayNotify = computed(() => (holNoticePreview.value || []).find(h => h.to_notify > 0) || null)
function sendHolidayPreavis (h) {
if (!h) return
$q.dialog({ title: 'Préavis de férié', message: `Envoyer un SMS à ${h.to_notify} technicien(s) pour ${h.name} (${h.date}) — leur demander s'ils travaillent et de proposer des dates de remplacement ?`, cancel: true, ok: { label: 'Envoyer', color: 'deep-orange' } }).onOk(async () => {
try { const r = await roster.sendHolidayNotice(h.date); $q.notify({ type: r && r.ok ? 'positive' : 'negative', message: r && r.ok ? `Préavis envoyé à ${r.sent} tech(s)` : ((r && r.error) || 'Échec'), timeout: 2600 }); const p = await roster.holidayNotice(40); holNoticePreview.value = p.holidays || [] } catch (e) { err(e) }
})
}
const selSet = computed(() => new Set(selection.value))
function isSelected (techId, iso) { return selSet.value.has(techId + '|' + iso) }
const statByDate = computed(() => Object.fromEntries(dailyStats.value.map(s => [s.date, s])))
function stat (iso) { const s = statByDate.value[iso] || {}; const g = gardeCountByDate.value[iso]; return g != null ? { ...s, on_call: g } : s } // on_call = calque live (cohérent avec la grille)
const hasOnCall = computed(() => Object.keys(gardeEffective.value).length > 0 || dailyStats.value.some(s => s.on_call > 0))
// Micro-timeline 24 h par cellule : fenêtre(s) du shift = bande neutre, jobs pris = trait coloré.
const occByTechDay = ref({})
// Déclencheur matrice Mapbox du Kanban (défini ICI, après occByTechDay/visibleTechs, pour éviter la zone morte temporelle).
watch([boardView, kanbanDay, occByTechDay], () => {
if (boardView.value !== 'kanban') return
clearTimeout(_kbMatT); _kbMatT = setTimeout(() => { for (const t of visibleTechs.value) fetchKbMatrix(t.id) }, 350) // 1 call/tournée visible, débouncé + caché
})
const absByTechDay = ref({}) // tech|date → type d'absence (En pause / Congé / Maladie…) → hachuré
function isAbsent (techId, iso) { return !!absByTechDay.value[techId + '|' + iso] }
function absenceLabel (techId, iso) { return absByTechDay.value[techId + '|' + iso] || 'Absent' }
async function reloadAbsences () { try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) {} }
// Bascule absence d'1 jour sur des cases (clic + « A » ou menu). Si toutes absentes → retire ; sinon marque.
async function toggleAbsentCells (targets) {
if (!targets || !targets.length) return
const allAbsent = targets.every(k => { const [tid, iso] = k.split('|'); return isAbsent(tid, iso) })
for (const k of targets) { const [tid, iso] = k.split('|'); try { await roster.setAbsence(tid, iso, 'Congé', allAbsent) } catch (e) { err(e) } }
await reloadAbsences()
$q.notify({ type: 'info', message: allAbsent ? 'Absence retirée' : (targets.length + ' absence(s) marquée(s)') })
if (!allAbsent) await checkAbsenceImpact(targets) // marquage → vérifier les jobs assignés impactés (IROPS)
}
function saveManualGarde () { localStorage.setItem(LS_GARDE_MANUAL, JSON.stringify(manualGarde.value)) }
// Override manuel : ne stocke QUE les écarts vs règles (want===défaut-règle → on retire l'override) → carte minimale.
function setGardeCell (m, key, want) { const ruleHas = !!gardeOverlay.value[key]; if (want === ruleHas) delete m[key]; else m[key] = want ? 'on' : 'off' }
// Bascule la garde sur des cases (clic + « G » ou menu). Toutes déjà de garde → retire ; sinon ajoute.
function toggleGardeCells (targets) {
if (!targets || !targets.length) return
const allG = targets.every(k => { const [tid, iso] = k.split('|'); return onGarde(tid, iso) })
const want = !allG // tout en garde ⇒ on retire ; sinon on place
if (want && !templates.value.some(t => t.on_call)) { $q.notify({ type: 'warning', message: 'Aucun modèle de garde (🛡️) — créez-en un dans « Types de shift »' }); return }
const m = { ...manualGarde.value }; let n = 0
for (const k of targets) { const [tid, iso] = k.split('|'); if (onGarde(tid, iso) !== want) { setGardeCell(m, k, want); n++ } }
manualGarde.value = m; saveManualGarde()
$q.notify({ type: 'info', message: want ? (n + ' garde(s) placée(s)') : (n + ' garde(s) retirée(s)') })
}
function hToNum (t) { if (!t) return null; const p = String(t).split(':'); return Number(p[0]) + (Number(p[1]) || 0) / 60 }
function fmtH (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return mm ? (hh + ':' + String(mm).padStart(2, '0')) : ('' + hh) }
// Axe ADAPTATIF : se cale sur l'amplitude réelle des shifts réguliers + la garde AFFICHÉE (calque),
// pour que les quarts de garde de soir/nuit (17hminuit, 8hminuit) soient visibles sur l'échelle.
const axisBounds = computed(() => {
let lo = Infinity; let hi = -Infinity
const grow = (t) => { if (!t) return; const s = hToNum(t.start_time); const e = hToNum(t.end_time); if (s != null) lo = Math.min(lo, s); if (e != null) hi = Math.max(hi, e <= s ? 24 : e) }
for (const techId in cellsByTechDay.value) { const day = cellsByTechDay.value[techId]; for (const iso in day) for (const a of day[iso]) { const t = tplByName.value[a.shift]; if (!t || t.on_call) continue; grow(t) } }
// Garde NON incluse dans l'axe (rendue comme bande « sur appel ») → l'axe reste FOCALISÉ sur les heures de travail.
if (!isFinite(lo) || !isFinite(hi)) return { min: 6, max: 19 }
lo = Math.max(0, Math.min(6, Math.floor(lo))); hi = Math.min(24, Math.max(19, Math.ceil(hi))) // focus 619, étendu seulement si un quart régulier déborde
return { min: lo, max: hi }
})
// Graduations horaires pour la règle d'en-tête (alignées sur l'axe adaptatif)
const axisTicks = computed(() => {
const b = axisBounds.value; const span = (b.max - b.min) || 24
const step = span > 13 ? 4 : (span > 7 ? 3 : 2); const out = []
for (let h = Math.ceil(b.min / step) * step; h <= b.max; h += step) out.push({ h, left: ((h - b.min) / span * 100) + '%' })
return out
})
function pos (s, e) { const b = axisBounds.value; const span = (b.max - b.min) || 24; const L = Math.max(0, (s - b.min) / span * 100); const R = Math.min(100, (e - b.min) / span * 100); return { left: L + '%', width: Math.max(1.5, R - L) + '%' } }
// Barre de temps PÂLE : bleu très pâle le matin → violet pâle le soir (repère discret du « quand »)
function todColor (h) { const t = Math.max(0, Math.min(1, (h - 6) / 15)); return 'hsl(' + Math.round(210 + t * 60) + ',45%,' + Math.round(91 - t * 8) + '%)' }
function bandGradient (s, e) { return 'linear-gradient(to right, ' + todColor(s) + ', ' + todColor(e) + ')' }
// Bandes = shifts réguliers (dégradé) + GARDE en CALQUE LIVE (calculée depuis les règles, pointillé ambre).
// La garde n'est PAS stockée : on l'ignore dans cellsOf (on_call) et on la recalcule → temps réel, pas de désync.
function pushBand (out, s, e, opt) { if (s == null || e == null) return; if (e <= s) { out.push({ ...pos(s, 24), ...opt }); out.push({ ...pos(0, e), ...opt }) } else out.push({ ...pos(s, e), ...opt }) }
function cellBands (techId, iso) {
const out = []
for (const a of cellsOf(techId, iso)) {
const t = tplByName.value[a.shift]; if (!t || t.on_call) continue // garde stockée ignorée → gérée par le calque live
const s = hToNum(t.start_time); const e = hToNum(t.end_time); if (s == null || e == null) continue
pushBand(out, s, e, { oncall: false, bg: bandGradient(s, e <= s ? 24 : e) })
}
const gShift = gardeEffective.value[techId + '|' + iso] // garde EFFECTIVE (règles + overrides manuels « G »)
if (gShift) { const t = tplByName.value[gShift]; if (t) pushBand(out, hToNum(t.start_time), hToNum(t.end_time), { oncall: true }) }
return out
}
// Barre de statut OPAQUE selon l'occupation : vert (peu) → orange (plein) → rouge (surbooké).
function occColor (pct) { if (pct == null) return '#9e9e9e'; if (pct >= 100) return '#e53935'; const t = Math.max(0, Math.min(1, pct / 100)); return 'hsl(' + Math.round(122 - t * 90) + ',68%,44%)' }
// Bloc = 1 job, coloré par la COULEUR DE SA COMPÉTENCE (palette skills éditable). Repli : couleur d'occupation.
// OPS-only = job dispatché dans OPS mais PAS encore dans F (autoritaire) : aucun legacy_id et pas un bloc synthétique F.
function blkOpsOnly (b) { return !!(b && !b.legacy && !b.legacy_id) }
// Couleur d'un bloc : dépt F (legacyDeptColor) si connu, sinon compétence OPS. PARTAGÉ grille + kanban (bordure, icône, remplissage).
function blkColor (b) { if (b && b.done) return '#6b7280'; return (b && (b.dept || b.legacy_dept) ? legacyDeptColor(b.dept || b.legacy_dept) : null) || (b && b.skill ? getTagColor(b.skill) : '#90a4ae') }
// Remplissage d'un bloc. F autoritaire (synthétique legacy OU job lié à un ticket F) = couleur PLEINE du dépt.
// OPS-only (pas encore publié dans F) = PÂLE + contour POINTILLÉ. assist = hachuré.
function blkFill (blk) {
const c = blkColor(blk)
if (blk && blk.done) return { background: '#e4e7eb', borderColor: '#aab2bd' } // COMPLÉTÉ = gris neutre (se distingue nettement des blocs colorés actifs) + ✓
if (blk && blk.assist) return { background: hatchBg(c), borderColor: c, borderStyle: 'dashed' }
if (blkOpsOnly(blk)) return { background: pastelColor(c), borderColor: c, borderStyle: 'dotted', borderWidth: '2px', opacity: '.72' }
return { background: pastelColor(c), borderColor: c }
}
function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), ...blkFill(blk) } } // F = couleur dépt pleine ; OPS-non-publié = pâle pointillé ; assist = hachuré
// Fenêtre des shifts (garde=true → seulement les quarts de garde ; garde=false → réguliers)
function winOf (techId, iso, garde) { let s = Infinity; let e = -Infinity; for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || (!!t.on_call) !== garde) continue; const st = hToNum(t.start_time); const en = hToNum(t.end_time); if (st != null) s = Math.min(s, st); if (en != null) e = Math.max(e, en) } return isFinite(s) ? { s, e } : null }
const occCells = computed(() => {
const m = {}; const ct = cellsByTechDay.value
for (const techId in ct) for (const iso in ct[techId]) {
const cells = ct[techId][iso]; if (!cells.length) continue
let bookableH = 0; let hasGarde = false; let hasReg = false
for (const a of cells) { const t = tplByName.value[a.shift]; if (t && t.on_call) hasGarde = true; else { hasReg = true; bookableH += Number(a.hours) || 0 } }
const o = occByTechDay.value[techId + '|' + iso] || { h: 0, blocks: [], jobs: [] }
let usedH = o.h || 0; const jobs = o.jobs || []
// Fenêtre du quart (début/fin). Les jobs HORODATÉS (o.blocks) restent à leur heure = « lock ». Les jobs sans heure
// (ingérés du legacy) + legacy résiduel coulent EN ORDRE (route_order, o.jobs déjà trié) depuis la fin des horodatés,
// MIS À L'ÉCHELLE pour TENIR dans la fenêtre du quart → plus de débordement ; la SURCHARGE reste signalée par le %.
let winS = Infinity, winE = -Infinity
for (const a of cells) { const t = tplByName.value[a.shift]; if (t && !t.on_call) { const s = hToNum(t.start_time), e = hToNum(t.end_time); if (s != null) winS = Math.min(winS, s); if (e != null) winE = Math.max(winE, e <= s ? 24 : e) } }
if (!isFinite(winS)) { winS = 8; winE = 17 } else if (!(winE > winS)) winE = winS + 8
// VERROUILLÉ = RDV CONFIRMÉS + miroirs assistant (à leur heure réelle). Tout le reste COULE — même un job
// « à planifier » qui porte une heure incidente (héritée du legacy) — pour remplir le quart sans trou.
// (Cohérent avec la vue Jour/Kanban : avant, la grille verrouillait TOUT job ayant un start_time → trous + faux débordements.)
const dur1 = (j) => Number(j.dur) || (j.est_min ? j.est_min / 60 : 1)
const ordered = (jobs || []).slice().sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
const locked = []
for (const j of ordered) { if (j.cancelled) continue; if ((j.booking_status === 'Confirmé' || j.assist) && j.start_h != null) { const d = dur1(j); locked.push({ s: j.start_h, e: j.start_h + d, skill: j.skill, job: j.name, assist: !!j.assist, done: !!j.done, subject: j.subject, legacy_id: j.legacy_id, legacy_dept: j.legacy_dept }) } }
const flow = [] // jobs « à planifier » (heure ignorée) + legacy résiduel, dans l'ordre de tournée. (Annulés exclus du timeline : bruit.)
for (const j of ordered) { if (j.cancelled) continue; if ((j.booking_status === 'Confirmé' && j.start_h != null) || j.assist) continue; const d = dur1(j); if (d > 0) flow.push({ dur: d, skill: j.skill, job: j.name, subject: j.subject, done: !!j.done, legacy_id: j.legacy_id, legacy_dept: j.legacy_dept }) }
const lg = showLegacyLoad.value ? legacyLoad.value[techId + '|' + iso] : null
// Seuls les jobs TERRAIN (j.field) deviennent des blocs horaires + comptent dans la charge. L'admin/bureau (officeN) est listé en pastille, pas en bloc → pas de fausse surcharge.
if (lg) { for (const j of (lg.jobs || [])) { if (!j.field) continue; const d = Number(j.est_h) || 0; if (d > 0) flow.push({ dur: d, legacy: true, subject: j.subject, dept: j.dept, skill: j.skill, lid: j.id }) }; usedH += (lg.h || 0) }
const scale = 1 // PAS de compression : les blocs gardent leur taille réelle et on ALLONGE la zone (shiftE) pour les couvrir ; surcharge = triangle
const synth = []
let cursor = winS // les jobs souples coulent depuis le DÉBUT du quart et remplissent les trous AUTOUR des RDV confirmés
for (const f of flow) {
const w = f.dur * scale
let guard = 0; while (guard++ < 80) { const c = locked.find(b => cursor < b.e - 0.001 && (cursor + w) > b.s + 0.001); if (c) cursor = c.e; else break } // saute par-dessus un RDV qu'il chevaucherait
synth.push({ s: cursor, e: cursor + w, skill: f.skill, legacy: f.legacy, subject: f.subject, dept: f.dept, lid: f.lid, job: f.job, done: f.done, legacy_id: f.legacy_id, legacy_dept: f.legacy_dept }); cursor += w
}
const blocks = [...locked, ...synth]
usedH = Math.round(usedH * 10) / 10
// Zone de quart ÉTENDUE (blanche) pour COUVRIR les blocs qui débordent ; surcharge = blocs hors quart OU usedH > dispo.
const _maxE = blocks.length ? Math.max(...blocks.map(b => b.e)) : winE
const _minS = blocks.length ? Math.min(...blocks.map(b => b.s)) : winS
const shiftS = Math.min(winS, _minS); const shiftE = Math.max(winE, _maxE)
const over = (_maxE > winE + 0.01) || (_minS < winS - 0.01) || (bookableH > 0 && usedH > bookableH + 0.01)
m[techId + '|' + iso] = { bookableH, usedH, hasGarde, hasReg, pct: bookableH > 0 ? Math.round(usedH / bookableH * 100) : null, blocks, jobs, legacyH: lg ? lg.h : 0, officeN: lg ? (lg.officeN || 0) : 0, shiftS, shiftE, winS, winE, over }
}
// Tickets assignés dans F à des techs SANS quart planifié ce jour → on les affiche quand même (sinon invisibles).
if (showLegacyLoad.value) {
for (const key in legacyLoad.value) {
if (m[key]) continue // déjà couvert par un quart
const _r = occByTechDay.value[key]; if (_r && ((_r.jobs || []).some(j => !j.cancelled) || _r.h > 0.01)) continue // jobs RÉELS présents → laissés à la branche C (blocs + noShift) ; pas de doublon legacy qui masque le réel
const lg = legacyLoad.value[key]; if (!lg || (!(lg.h > 0) && !(lg.officeN > 0))) continue
const winS = 8, winE = 17; const synth = []; let cursor = winS
for (const j of (lg.jobs || [])) { if (!j.field) continue; const d = Number(j.est_h) || 0; if (d <= 0) continue; synth.push({ s: cursor, e: cursor + d, skill: j.skill, legacy: true, subject: j.subject, dept: j.dept, lid: j.id }); cursor += d }
const maxE = synth.length ? Math.max(...synth.map(b => b.e)) : winE
// noShift (amber) seulement s'il y a du TERRAIN sans quart (autoritaire pour dispatcher). Office-only → pas d'amber (aucun quart requis), juste la pastille admin.
m[key] = { bookableH: 0, usedH: Math.round((lg.h || 0) * 10) / 10, hasGarde: false, hasReg: false, pct: null, blocks: synth, jobs: [], legacyH: lg.h || 0, noShift: synth.length > 0, officeN: lg.officeN || 0, shiftS: winS, shiftE: Math.max(winE, maxE), winS, winE, over: false }
}
}
// Jobs RÉELS (Dispatch Job) assignés à un tech SANS quart publié ce jour (ex. Louis-Paul Bourdon) → on les affiche QUAND MÊME :
// sinon ils comptent dans la charge (occByTechDay) mais sont INVISIBLES sur le timeline ET la carte. Fenêtre synthétique 8-17, drapeau noShift.
const visIds = new Set(visibleTechs.value.map(t => t.id))
for (const key in occByTechDay.value) {
if (m[key]) continue // déjà couvert par un quart (ou la branche legacy)
if (!visIds.has(key.split('|')[0])) continue // respecte les filtres équipe/compétence
const o = occByTechDay.value[key]; const jobs = (o && o.jobs) || []
const usedH = Math.round(((o && o.h) || 0) * 10) / 10
if (!(usedH > 0.01) && !jobs.some(j => !j.cancelled)) continue // aucun travail réel (que des annulés) → cellule vide, pas de faux « sans quart »
const winS = 8, winE = 17
const dur1 = (j) => Number(j.dur) || (j.est_min ? j.est_min / 60 : 1)
const ordered = jobs.slice().sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
const synth = []; let cursor = winS
for (const j of ordered) { if (j.cancelled) continue; const d = dur1(j); if (d <= 0) continue; synth.push({ s: cursor, e: cursor + d, skill: j.skill, job: j.name, subject: j.subject, done: !!j.done, legacy_id: j.legacy_id, legacy_dept: j.legacy_dept }); cursor += d }
const maxE = synth.length ? Math.max(...synth.map(b => b.e)) : winE
m[key] = { bookableH: 0, usedH, hasGarde: false, hasReg: false, pct: null, blocks: synth, jobs, legacyH: 0, noShift: true, officeN: 0, shiftS: winS, shiftE: Math.max(winE, maxE), winS, winE, over: false }
}
return m
})
function cellOcc (techId, iso) { return occCells.value[techId + '|' + iso] || null }
// Occupation tech-jour pour les vues MOBILES : occCells (avec quart) SINON occupation brute (jobs assignés SANS quart publié) —
// sinon un tech dont le quart auto a été perdu « disparaît » de la charge alors qu'il a des jobs (ex. Louis-Paul Bourdon).
function techDayOcc (techId, iso) {
const c = occCells.value[techId + '|' + iso]
if (c) return c
const raw = occByTechDay.value[techId + '|' + iso]
if (raw && ((+raw.h) || 0) > 0.01) return { usedH: +raw.h || 0, bookableH: 0, over: false, jobs: raw.jobs || [] }
return null
}
// Le tech a-t-il un quart RÉGULIER (non sur-appel) ce jour ? Sert au signalement « sans quart » (▲) ET au dialogue de création.
// Même logique que la garde de onCellDrop — un seul endroit (hoisté → utilisable plus haut).
function hasShiftDay (techId, iso) {
return cellsOf(techId, iso).some(a => { const tp = tplByName.value[a.shift]; return tp && !tp.on_call })
}
// ── Vue mobile « bande de jours + heat » (téléphone) : charge agrégée par jour + par tech, lecture rapide. ──
const todayIso = computed(() => (nowET.value && nowET.value.iso) || new Date().toISOString().slice(0, 10))
function heatColor (ratio) { const r = +ratio || 0; if (r >= 1) return '#ef4444'; if (r >= 0.75) return '#f59e0b'; if (r >= 0.45) return '#84cc16'; return '#22c55e' }
const _CAP_H = 8 // capacité indicative par tech-jour (h) pour normaliser les barres
const mobileSelIso = ref(null)
// Bande de jours mobile = fenêtre LONGUE (4 semaines) → carrousel horizontal CONTINU (sans flèches).
// L'occupation est chargée sur la même fenêtre (voir getOccupancy mobile) pour que tous ces jours aient des données.
const MOBILE_STRIP_DAYS = 28
const mobileStripDays = computed(() => {
const [y, m, dd] = start.value.split('-').map(Number); const base = new Date(Date.UTC(y, m - 1, dd)); const out = []
for (let i = 0; i < MOBILE_STRIP_DAYS; i++) { const d = new Date(base); d.setUTCDate(d.getUTCDate() + i); const iso = d.toISOString().slice(0, 10); const dow = d.getUTCDay(); out.push({ iso, dow: FR_DOW[dow], dnum: iso.slice(8) + '/' + iso.slice(5, 7), weekend: dow === 0 || dow === 6 }) }
return out
})
const mobileSelDay = computed(() => {
const isos = mobileStripDays.value.map(d => d.iso)
if (mobileSelIso.value && isos.includes(mobileSelIso.value)) return mobileSelIso.value
if (isos.includes(todayIso.value)) return todayIso.value
return isos[0] || todayIso.value
})
const mobileSelDayObj = computed(() => mobileStripDays.value.find(d => d.iso === mobileSelDay.value) || { iso: mobileSelDay.value })
const selDayLabel = computed(() => { const d = mobileSelDayObj.value; return d && d.dow ? d.dow + ' ' + d.dnum : mobileSelDay.value })
// Charge d'un tech : heures occupées / capacité. Dénominateur = heures du QUART RÉEL s'il est planifié ;
// SINON journée nominale ESTIMÉE à 8h (le tech a du travail mais aucun quart → on le signale avec noShift=▲).
// 3h sans quart → 3/8 ≈ 37% (vert) ; rouge seulement si réellement au-dessus de la capacité.
function techLoad (o, hasShift) {
const h = +(o && o.usedH) || 0
const book = +(o && o.bookableH) || 0
const cap = (hasShift && book > 0) ? book : 8
return { h: Math.round(h * 10) / 10, cap: Math.round(cap * 10) / 10, ratio: cap > 0 ? h / cap : 0, over: h > cap + 0.01, noShift: !hasShift }
}
// Barre globale du jour = Σ durées jobs estimées (numérateur) / Σ durées des quarts (dénominateur) = couverture du jour.
const mobileDayLoads = computed(() => {
const ts = visibleTechs.value || []
const pool = assignPanel.jobs || []
return mobileStripDays.value.map(d => {
let assignedH = 0, capH = 0, over = 0, noShift = 0
for (const t of ts) {
const o = techDayOcc(t.id, d.iso); if (!o) continue
const u = +o.usedH || 0; const bk = +o.bookableH || 0; const hasSh = hasShiftDay(t.id, d.iso)
const cap = (hasSh && bk > 0) ? bk : 8 // quart réel sinon estimation 8h (cohérent avec les barres par tech)
assignedH += u; capH += cap; if (u > cap + 0.01) over++
if (!hasSh && u > 0.01) noShift++ // tech avec du travail mais aucun quart planifié
}
// Numérateur = TOUT le travail DÛ ce jour à répartir = jobs déjà assignés ce jour (assignedH) + jobs du POOL (non assignés) dont la date prévue est ce jour.
let pendingH = 0
for (const j of pool) { if (j.scheduled_date !== d.iso) continue; pendingH += (j.est_min ? j.est_min / 60 : Number(j.duration_h || 1)) }
const jobsH = assignedH + pendingH
const ratio = capH > 0 ? jobsH / capH : (jobsH > 0 ? 1 : 0)
return { iso: d.iso, dow: d.dow, dnum: d.dnum, weekend: d.weekend, h: Math.round(jobsH * 10) / 10, cap: Math.round(capH * 10) / 10, pending: Math.round(pendingH * 10) / 10, ratio, over, noShift }
})
})
const mobileTechLoads = computed(() => {
const iso = mobileSelDay.value
return (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name, _t: t, ...techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso)) })).sort((a, b) => b.h - a.h)
})
const pmShowIdle = ref(false) // mobile : replier les techniciens à 0 h (sinon on scrolle 50+ barres vides)
const mobileTechBusy = computed(() => mobileTechLoads.value.filter(t => t.h > 0))
const mobileTechIdle = computed(() => mobileTechLoads.value.filter(t => !(t.h > 0)))
// Compétences requises par la sélection (pour auto-filtrer les techs candidats).
const assignSkills = computed(() => {
const set = new Set()
for (const n of selectedNames.value) { const j = (assignPanel.jobs || []).find(x => x.name === n); if (j && j.required_skill) set.add(j.required_skill) }
return [...set]
})
// Filtre compétence ACTIF dans la feuille d'assignation : démarre = compétences des jobs sélectionnés (auto-coché),
// mais chaque chip est DÉCOCHABLE → décocher relâche le filtre ; tout décoché ⇒ TOUS les techs s'affichent.
const assignSkillActive = ref(new Set())
watch(assignSkills, (sk) => { assignSkillActive.value = new Set(sk) }, { immediate: true }) // reset à chaque changement de sélection
function toggleAssignSkill (s) { const set = new Set(assignSkillActive.value); if (set.has(s)) set.delete(s); else set.add(s); assignSkillActive.value = set }
// Mode assignation : techs CAPABLES selon les chips compétence ACTIVES (décochables) ; aucune active ⇒ tous. Libres/avec-quart d'abord.
const mobileTechAssign = computed(() => {
const iso = mobileSelDay.value; const act = assignSkillActive.value
const capable = (t) => !act.size || (t.skills || []).some(s => act.has(s))
// Candidats CAPABLES : ceux qui ONT déjà un quart d'abord (prêts, pas de création requise), puis les moins chargés.
// Les « sans quart » (▲) restent listés en dessous — le dispatcher peut quand même les toucher et accepter la création d'un quart.
return (visibleTechs.value || []).filter(capable).map(t => ({ id: t.id, name: t.name, _t: t, ...techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso)) })).sort((a, b) => (a.noShift ? 1 : 0) - (b.noShift ? 1 : 0) || a.h - b.h)
})
// ── TAP-TO-ASSIGNER (desktop) : clic sur une job du pool → techs CLASSÉS par pertinence pour CETTE job, 1 clic assigne ──
// Jour cible = date prévue de la job (sinon 1er jour visible). Classement : capable (compétence) → a un quart → plus proche → moins chargé.
function jobTargetDay (job) {
const d = job && job.scheduled_date
const t = todayISO(); const isos = (dayList.value || []).map(x => x.iso)
// date valide ET pas dans le passé → respectée. En retard/sans date → aujourd'hui (jamais le passé).
if (d && d !== 'Sans date' && /^\d{4}-\d{2}-\d{2}$/.test(d) && d >= t) return d
if (isos.includes(t)) return t
return isos.find(iso => iso >= t) || isos[0] || t
}
function tomorrowISO () { const d = new Date(todayISO() + 'T12:00:00'); d.setDate(d.getDate() + 1); return d.toISOString().slice(0, 10) }
// Replanifie EN LOT la sélection à une date (reprogrammation réelle optimiste + updateJob). setJobDate/applyJobDate (unitaires + undo) plus bas.
function setSelDate (iso) {
const names = selectedNames.value.slice(); let n = 0
for (const nm of names) { const j = (assignPanel.jobs || []).find(x => x.name === nm); if (j && iso !== j.scheduled_date) { j.scheduled_date = iso; roster.updateJob(j.name, { scheduled_date: iso }).catch(e => err(e)); n++ } }
if (n) $q.notify({ type: 'info', icon: 'event', message: n + ' job(s) reportés au ' + fmtDueLabel(iso), timeout: 3000 })
}
// Niveau requis PERSISTANT d'un job (store hub → survit aux rechargements/sessions) — piloté par la pastille « niv ».
function setJobReqLevel (job, n) { const lv = Math.max(1, Math.min(5, Number(n) || 1)); job.required_level = lv; roster.setJobLevel(job.name, lv).catch(e => err(e)) }
function techsForJob (job) {
const iso = jobTargetDay(job)
const reqSkill = job && job.required_skill
const jlat = +(job && job.lat); const jlon = +(job && job.lon)
const hasJC = isFinite(jlat) && isFinite(jlon) && (jlat || jlon)
return (visibleTechs.value || []).map(t => {
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso))
const capable = !reqSkill || (t.skills || []).includes(reqSkill)
const home = techHomes.value[t.id]
const distKm = (hasJC && home && isFinite(+home.lat) && isFinite(+home.lon)) ? haversineKm(+home.lat, +home.lon, jlat, jlon) : null
return { id: t.id, name: t.name, _t: t, capable, distKm, ...load }
}).sort((a, b) =>
(Number(b.capable) - Number(a.capable)) || // capables d'abord
((a.noShift ? 1 : 0) - (b.noShift ? 1 : 0)) || // avec quart d'abord
((a.distKm == null ? 9e9 : a.distKm) - (b.distKm == null ? 9e9 : b.distKm)) || // plus proche
(a.h - b.h) // moins chargé
)
}
async function quickAssign (job, tech) {
const iso = jobTargetDay(job)
const dObj = (dayList.value || []).find(d => d.iso === iso) || { iso }
draggingJobName.value = job.name
await onCellDrop({}, tech._t || tech, dObj) // réutilise le chemin d'assignation (garde On Hold + dialogue sans-quart + toast Annuler)
}
// ── B : assignation EN LOT de la sélection (bouton « Assigner à un tech ») ──
// Techs classés pour la SÉLECTION : capable de TOUTES les compétences requises → avec quart → proche du centroïde → moins chargé.
function techsForSelection () {
const jobs = (assignPanel.jobs || []).filter(j => selectedJobs[j.name])
if (!jobs.length) return []
const reqSkills = [...new Set(jobs.map(j => j.required_skill).filter(Boolean))]
const ll = jobs.filter(j => isFinite(+j.lat) && isFinite(+j.lon) && (+j.lat || +j.lon))
const cLat = ll.length ? ll.reduce((s, j) => s + (+j.lat), 0) / ll.length : null
const cLon = ll.length ? ll.reduce((s, j) => s + (+j.lon), 0) / ll.length : null
const iso = jobTargetDay(jobs[0]) // jour de référence pour la charge (approx.)
return (visibleTechs.value || []).map(t => {
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso))
const capable = !reqSkills.some(s => !(t.skills || []).includes(s)) // capable de TOUTES les compétences de la sélection
const home = techHomes.value[t.id]
const distKm = (cLat != null && home && isFinite(+home.lat) && isFinite(+home.lon)) ? haversineKm(+home.lat, +home.lon, cLat, cLon) : null
return { id: t.id, name: t.name, _t: t, capable, distKm, ...load }
}).sort((a, b) =>
(Number(b.capable) - Number(a.capable)) ||
((a.noShift ? 1 : 0) - (b.noShift ? 1 : 0)) ||
((a.distKm == null ? 9e9 : a.distKm) - (b.distKm == null ? 9e9 : b.distKm)) ||
(a.h - b.h)
)
}
// Assigne TOUTE la sélection au tech, chaque job gardant SA date cible (regroupés par jour → 1 passage/jour, garde-fous réutilisés).
async function quickAssignSelection (tech) {
const names = selectedNames.value.slice(); if (!names.length) return
const t = tech._t || tech
const byDay = {}
for (const jn of names) { const job = (assignPanel.jobs || []).find(x => x.name === jn); if (!job) continue; const iso = jobTargetDay(job); (byDay[iso] = byDay[iso] || []).push(jn) }
for (const iso of Object.keys(byDay)) {
const dObj = (dayList.value || []).find(d => d.iso === iso) || { iso, dnum: iso.slice(8) }
draggingJobName.value = byDay[iso].join(',')
await onCellDrop({}, t, dObj) // On Hold + dialogue sans-quart + toast Annuler réutilisés
}
if (assignPanel.showMap) refreshAssignMap() // rafraîchit le surlignage carte
}
// 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 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()
}
// ── D : SUGGESTION de répartition (glouton proximité + charge + compétence), REVUE en surcouche avant d'appliquer ──
const suggestDlg = reactive({ open: false, mode: 'config', plan: [], building: false, applying: false, fromSel: false, techSel: {}, makeShifts: true, strategy: 'smart', view: 'list' }) // strategy: smart|best|balance|enough · view: list|map
function jobDur (j) { return (j && (j.est_min ? j.est_min / 60 : Number(j.duration_h || 1))) || 1 }
// Construit un plan job→tech×jour SANS rien écrire (proposition). Chaque job garde sa date si elle est dans la fenêtre.
function buildSuggestion () {
const useSel = selectedNames.value.length > 0
suggestDlg.fromSel = useSel
let jobs = (assignPanel.jobs || []).filter(j => j.status !== 'On Hold')
if (useSel) jobs = jobs.filter(j => selectedJobs[j.name])
const days = (dayList.value || []); const dayIsos = days.map(d => d.iso)
const techs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id]) // seulement les techs choisis (disponibles ce jour)
if (!jobs.length || !dayIsos.length || !techs.length) { suggestDlg.plan = []; return }
const pr = j => ({ high: 0, urgent: 0, élevée: 0, medium: 1, moyenne: 1, normal: 1, low: 2, basse: 2 }[String(j.priority || '').toLowerCase()] ?? 1)
const today = todayISO()
const win = suggestWindow.value; const winSet = new Set(win); const winMax = win.length ? win[win.length - 1] : today // fenêtre : dates sélectionnées ou auj.+demain
const isOver = j => (j.scheduled_date && j.scheduled_date !== 'Sans date' && j.scheduled_date < today) ? 0 : 1
const sorted = [...jobs].sort((a, b) => pr(a) - pr(b) || isOver(a) - isOver(b) || String(a.scheduled_date || '9').localeCompare(String(b.scheduled_date || '9')) || jobDur(b) - jobDur(a))
const proj = {} // techId|iso → { h, cap, pts:[[lat,lon]] } — charge PROJETÉE, initialisée depuis l'occupation actuelle
const cell = (tid, iso) => { const k = tid + '|' + iso; if (!proj[k]) { const load = techLoad(techDayOcc(tid, iso), hasShiftDay(tid, iso)); proj[k] = { h: load.h || 0, cap: load.cap || 8, pts: [], cities: new Set() } } return proj[k] }
// Poids selon la STRATÉGIE : smart · best (meilleurs d'abord, cascade) · balance (round-robin) · enough (juste ce qu'il faut → réserve les experts)
const strat = suggestDlg.strategy || 'smart'
const W = strat === 'best' ? { dist: 0.4, skill: 15, eff: 65, cost: 20, load: -0.25, overqual: 0 }
: strat === 'balance' ? { dist: 0.6, skill: 6, eff: 30, cost: 8, load: 24, overqual: 0 }
: strat === 'enough' ? { dist: 1.4, skill: 0, eff: 25, cost: 10, load: 2, overqual: 22 } // « juste ce qu'il faut » : capable mais le moins sur-qualifié possible
: { dist: 1.6, skill: 6, eff: 30, cost: 7, load: 0.6, overqual: 0 }
const costs = techs.map(t => Number(t.cost_h) || 0); const cMin = Math.min(...costs); const cRange = (Math.max(...costs) - cMin) || 1 // normalisation coût 0..1
const plan = []
const holdByDay = {} // jobs SANS tech de quart → regroupés par jour puis découpés en tournées PLACEHOLDER (pass 2)
for (const j of sorted) {
const reqSkill = j.required_skill || skillByType.value[j.service_type] || '' // compétence explicite, sinon déduite du TYPE de job
const reqLevel = Number(j.required_level) || 1 // niveau imposé PAR LE JOB (persistant) — plus de défaut global par compétence
const jlat = +(j.lat != null ? j.lat : j.latitude), jlon = +(j.lon != null ? j.lon : j.longitude); const hasLL = isFinite(jlat) && isFinite(jlon) && (jlat || jlon) // pool hub = latitude/longitude (bruts ERP) ; grille = lat/lon
const jcity = jobCity(j) || '' // municipalité — sert de repli de proximité quand le job n'a pas de coordonnées
const dur = jobDur(j)
const sched = (j.scheduled_date && j.scheduled_date !== 'Sans date' && /^\d{4}-\d{2}-\d{2}$/.test(j.scheduled_date)) ? j.scheduled_date : null
let cand
if (j._reDate && winSet.has(j._reDate)) cand = [j._reDate] // date re-choisie (dans la fenêtre)
else if (sched && winSet.has(sched)) cand = [sched] // daté DANS la fenêtre → ce jour
else if (sched && sched > winMax) continue // daté APRÈS la fenêtre → PAS dispatché maintenant (on ne planifie pas loin)
else cand = win // en retard / sans date / avant la fenêtre → placé dans la fenêtre
if (!cand.length) continue
let best = null
for (const t of techs) {
// SKILL = filtre DUR : un tech sans la compétence requise n'est JAMAIS candidat (ex. Josée-Anne ne fait ni réparation ni installation).
if (reqSkill && !(t.skills || []).includes(reqSkill)) continue
const capable = true
const lvl = reqSkill ? (skillLevelOf(t, reqSkill) || 0) : 3 // maîtrise 0-5 (3 = neutre sans compétence requise)
const eff = reqSkill ? skillEffOf(t, reqSkill) : (Number(t.efficiency) || 1) // vitesse : <1 = plus rapide pour cette compétence
const effDur = dur * (eff > 0 ? eff : 1) // durée EFFECTIVE = un tech rapide prend moins de capacité pour le même job
const costN = ((Number(t.cost_h) || 0) - cMin) / cRange
const home = techHomes.value[t.id]
for (const iso of cand) {
const c = cell(t.id, iso)
const noShift = !hasShiftDay(t.id, iso)
const over = Math.max(0, (c.h + effDur) - c.cap)
// PROXIMITÉ : vraie distance km si géolocalisé ; SINON repli par VILLE (regroupe la même municipalité, pénalise le mélange lointain type Ste-Barbe + Napierville)
let prox
if (hasLL && c.pts.length) prox = Math.min(...c.pts.map(p => haversineKm(p[0], p[1], jlat, jlon))) // proche des jobs déjà planifiés (route serrée)
else if (hasLL && home && isFinite(+home.lat) && isFinite(+home.lon)) prox = haversineKm(+home.lat, +home.lon, jlat, jlon) // 1er job → depuis le domicile
else if (jcity && c.cities.has(jcity)) prox = 0 // même ville qu'un job déjà planifié ce jour = même tournée
else if (c.cities.size) prox = 40 // le tech a déjà une AUTRE ville ce jour → forte pénalité (évite de mélanger des villes éloignées)
else prox = 15 // 1er job du tech ce jour, sans coords → neutre
const under = Math.max(0, reqLevel - lvl) // sous le niveau requis pour ce job (maîtrise insuffisante — compétence déjà garantie par le filtre dur)
let score = over * 120 + (noShift ? 60 : 0) + under * 200 // surcharge / hors-quart / SOUS-qualifié
score += prox * W.dist // proximité (km réels OU regroupement par ville)
score += c.h * W.load // load>0 = étale (round-robin) · load<0 = remplit les 1ers (cascade)
score += (5 - lvl) * W.skill // (best/smart) maîtrise faible pénalisée → avantage aux experts
score += Math.max(0, lvl - reqLevel) * (W.overqual || 0) // (« juste ce qu'il faut ») sur-qualification pénalisée → garde les experts pour les jobs exigeants
score += (eff - 1) * W.eff // rapide (eff<1) récompensé · lent (eff>1) pénalisé
score += costN * W.cost // moins cher préféré
if (!best || score < best.score) best = { t, iso, score, noShift, capable, over, lvl, eff, effDur }
}
}
// Commit sur un VRAI tech seulement s'il a un quart ce jour-là. Sinon → réservé pour une file PLACEHOLDER (pass 2), à assigner ensuite à un vrai tech.
if (best && !best.noShift) {
const c = cell(best.t.id, best.iso); c.h += best.effDur; if (hasLL) c.pts.push([jlat, jlon]); if (jcity) c.cities.add(jcity) // capacité + ville pour la proximité des suivants
plan.push({ jobName: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || j.location_label || '', techId: best.t.id, techName: best.t.name, iso: best.iso, dur, skill: reqSkill || '', capable: best.capable, noShift: false, over: best.over > 0, lat: hasLL ? jlat : null, lon: hasLL ? jlon : null, lvl: best.lvl, eff: best.eff, reqLevel })
} else {
const holdIso = best ? best.iso : (sched && winSet.has(sched) ? sched : (cand.find(d => !isWeekendIso(d)) || cand[0]))
;(holdByDay[holdIso] = holdByDay[holdIso] || []).push({ j, dur, reqSkill, reqLevel, jlat, jlon, hasLL, jcity, capable: !best || best.capable })
}
}
// PASS 2 — jobs sans tech de quart → files PLACEHOLDER par jour, groupées PAR COMPÉTENCE puis découpées en tournées ~8 h par PROXIMITÉ
// (km réels, sinon même ville ; >60 km = nouvelle tournée). Une tournée = 1 compétence + 1 secteur → assignable à un tech qui a CETTE compétence.
const HOLD_CAP = 8
for (const iso of Object.keys(holdByDay)) {
const bySkill = {}; for (const it of holdByDay[iso]) { const k = it.reqSkill || '__any__'; (bySkill[k] = bySkill[k] || []).push(it) } // 1 groupe par compétence
const dLbl = (FR_DOW[dowOf(iso)] || '') + ' ' + iso.slice(8) // ex. « ven 03 »
let ti = 0
for (const sk of Object.keys(bySkill)) {
const items = bySkill[sk].slice().sort((a, b) => (a.jcity || '').localeCompare(b.jcity || '')) // départ groupé par ville → tournées cohérentes
const queues = []
for (const it of items) {
let bq = null, bd = Infinity
for (const q of queues) {
if (q.h + it.dur > HOLD_CAP + 0.01) continue // tournée pleine (~8 h)
let d
if (it.hasLL && q.pts.length) d = Math.min(...q.pts.map(p => haversineKm(p[0], p[1], it.jlat, it.jlon)))
else if (it.jcity && q.cities.has(it.jcity)) d = 0
else if (q.cities.size || q.pts.length) d = 40
else d = 15
if (d < bd) { bd = d; bq = q }
}
if (!bq || bd > 60) { bq = { h: 0, pts: [], cities: new Set(), jobs: [] }; queues.push(bq) } // trop loin ou tout plein → nouvelle tournée
bq.h += it.dur; if (it.hasLL) bq.pts.push([it.jlat, it.jlon]); if (it.jcity) bq.cities.add(it.jcity); bq.jobs.push(it)
}
const skLbl = sk === '__any__' ? '' : ' ' + sk
queues.forEach((q) => {
ti++
const hid = `__hold__|${iso}|${ti}`
const label = `${dLbl}${skLbl} — tournée ${ti} (à assigner)`
for (const it of q.jobs) plan.push({ jobName: it.j.name, subject: it.j.subject || it.j.service_type || it.j.name, customer: it.j.customer_name || it.j.location_label || '', techId: hid, techName: label, placeholder: true, iso, dur: it.dur, skill: it.reqSkill || '', capable: it.capable, noShift: false, over: false, lat: it.hasLL ? it.jlat : null, lon: it.hasLL ? it.jlon : null, lvl: 0, eff: 1, reqLevel: it.reqLevel })
})
}
}
suggestDlg.plan = plan
}
function openSuggest () {
suggestDlg.fromSel = selectedNames.value.length > 0
// Étape « disponibilité » d'abord : défaut = techs AVEC un quart dans la fenêtre (= disponibles) ; sinon tous les visibles.
const techs = visibleTechs.value || []
const withShift = techs.filter(t => (dayList.value || []).some(d => hasShiftDay(t.id, d.iso)))
const base = withShift.length ? withShift : techs
const sel = {}; base.forEach(t => { sel[t.id] = true }); suggestDlg.techSel = sel
suggestDlg.plan = []; suggestDlg.mode = 'config'; suggestDlg.open = true
}
const suggestSelCount = computed(() => Object.values(suggestDlg.techSel).filter(Boolean).length)
// Fenêtre du dispatch auto : dates SÉLECTIONNÉES (chips) sinon aujourd'hui + demain (max) — on ne planifie pas loin (techs malades imprévus).
const suggestWindow = computed(() => {
const isos = (dayList.value || []).map(d => d.iso); const t = todayISO(); const tmrw = tomorrowISO()
const sel = (assignDateFilter.value || []).filter(iso => isos.includes(iso))
let win = sel.length ? [...sel].sort() : isos.filter(iso => iso === t || iso === tmrw)
if (!win.length) win = isos.filter(iso => iso >= t).slice(0, 2) // repli : 2 premiers jours futurs de la grille
return win
})
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(', ')
return w.length === 1 ? windowDayLabel(w[0]) : (windowDayLabel(w[0]) + ' + ' + windowDayLabel(w[w.length - 1]))
})
function suggestSelectTechs (which) { // 'all' | 'shift' | 'none'
const techs = visibleTechs.value || []; const sel = {}
if (which === 'all') techs.forEach(t => { sel[t.id] = true })
else if (which === 'shift') techs.forEach(t => { if ((dayList.value || []).some(d => hasShiftDay(t.id, d.iso))) sel[t.id] = true })
suggestDlg.techSel = sel
}
// « Groupe de techs par compétence » : sélectionner d'un coup tous les techs d'une compétence (ex : tous les monteurs).
const suggestSkillGroups = computed(() => {
const m = {}
for (const t of (visibleTechs.value || [])) for (const s of (t.skills || [])) m[s] = (m[s] || 0) + 1
return Object.entries(m).map(([skill, n]) => ({ skill, n })).sort((a, b) => b.n - a.n)
})
function suggestSkillOn (skill) { const techs = (visibleTechs.value || []).filter(t => (t.skills || []).includes(skill)); return !!techs.length && techs.every(t => suggestDlg.techSel[t.id]) }
function suggestSelectSkill (skill) { // (dé)sélectionne TOUT le groupe de techs ayant cette compétence
const sel = { ...suggestDlg.techSel }
const techs = (visibleTechs.value || []).filter(t => (t.skills || []).includes(skill))
const allOn = techs.length && techs.every(t => sel[t.id])
techs.forEach(t => { if (allOn) delete sel[t.id]; else sel[t.id] = true })
suggestDlg.techSel = sel
}
// Tri des techs par QUALITÉ (maîtrise ↑ · vitesse ↑ · coût ↓) → « les meilleurs en premier » dans la liste de dispo.
function techQuality (t) {
const lvls = Object.values(t.skill_levels || {}); const avgLvl = lvls.length ? lvls.reduce((a, b) => a + b, 0) / lvls.length : 0
const eff = Number(t.efficiency) || 1
return (5 - avgLvl) * 2 + (eff - 1) * 5 + ((Number(t.cost_h) || 0) / 100) // plus BAS = meilleur
}
const suggestTechsRanked = computed(() => [...(visibleTechs.value || [])].sort((a, b) => techQuality(a) - techQuality(b)))
function runSuggestion () {
if (!suggestSelCount.value) { $q.notify({ type: 'warning', message: 'Choisis au moins un technicien disponible.', timeout: 2500 }); return }
suggestDlg.building = true; buildSuggestion(); suggestDlg.building = false
suggestDlg.view = 'list'; suggestMapDay.value = suggestWindow.value[0] || '' // carte des tournées : 1er jour de la fenêtre par défaut
suggestDlg.mode = 'review'
}
// D v2 — ordonne une tournée par PLUS-PROCHE-VOISIN depuis le domicile du tech + distance totale (haversine, sans appel réseau).
function nnOrder (entries, home) {
const pts = entries.filter(e => e.lat != null && e.lon != null)
const noLL = entries.filter(e => e.lat == null || e.lon == null)
if (pts.length <= 1) return { ordered: [...pts, ...noLL], km: 0 }
const rem = [...pts]; const out = []
let cur = (home && isFinite(+home.lat) && isFinite(+home.lon)) ? { lat: +home.lat, lon: +home.lon } : rem[0]
let km = 0
while (rem.length) { let bi = 0, bd = Infinity; for (let i = 0; i < rem.length; i++) { const d = haversineKm(cur.lat, cur.lon, rem[i].lat, rem[i].lon); if (d < bd) { bd = d; bi = i } } if (isFinite(bd)) km += bd; cur = rem[bi]; out.push(rem.splice(bi, 1)[0]) }
return { ordered: [...out, ...noLL], km: Math.round(km * 10) / 10 }
}
const estTravelMin = km => Math.round(km / 45 * 60) // ~45 km/h moyen (route + arrêts) → estimation « ≈ »
// Regroupé par tech puis par jour, chaque jour ordonné en tournée (D v2) + km/temps de route estimés.
const suggestGroups = computed(() => {
const byTech = {}
for (const e of suggestDlg.plan) {
const g = byTech[e.techId] || (byTech[e.techId] = { techId: e.techId, techName: e.techName, days: {}, hours: 0, jobs: 0 })
const d = g.days[e.iso] || (g.days[e.iso] = { iso: e.iso, entries: [], hours: 0 })
d.entries.push(e); d.hours += e.dur; g.hours += e.dur; g.jobs++
}
const dnumOf = iso => { const d = (dayList.value || []).find(x => x.iso === iso); return d ? (d.dow + ' ' + d.dnum) : iso.slice(5) }
return Object.values(byTech).map(g => {
const home = techHomes.value[g.techId]; let tkm = 0
const days = Object.values(g.days).sort((a, b) => a.iso.localeCompare(b.iso)).map(d => {
const r = nnOrder(d.entries, home); tkm += r.km
return { ...d, entries: r.ordered, km: r.km, mins: estTravelMin(r.km), label: dnumOf(d.iso) }
})
// Occupation : capacité (quarts réels ou 8h) + charge DÉJÀ présente sur ces jours, + les heures proposées → barre projetée.
let cap = 0, existing = 0, placeholder = isHoldId(g.techId)
if (!placeholder) for (const d of days) { const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(g.techId, d.iso)); cap += (l.cap || 8); existing += (l.h || 0) }
const projected = existing + g.hours
const noShiftDays = placeholder ? [] : days.filter(d => !isWeekendIso(d.iso) && !hasShiftDay(g.techId, d.iso)).map(d => d.iso) // jours (hors WE) sans quart → alerte + créer quart
const skills = [...new Set(days.flatMap(d => d.entries).map(e => e.skill).filter(Boolean))] // compétences requises par cette file → cible d'assignation doit les couvrir
return {
...g, dayList: days, km: Math.round(tkm * 10) / 10, mins: estTravelMin(tkm),
cap: Math.round(cap * 10) / 10, existing: Math.round(existing * 10) / 10, projected: Math.round(projected * 10) / 10,
fillPct: cap ? Math.min(100, Math.round(projected / cap * 100)) : 0, over: !placeholder && projected > cap + 0.1, placeholder, noShiftDays, skills
}
}).sort((a, b) => b.jobs - a.jobs)
})
// Carte des tournées (grande carte dans la revue) : 1 couleur par tech, domicile + arrêts ordonnés, POUR UN SEUL JOUR.
const ROUTE_COLORS = ['#e11d48', '#2563eb', '#16a34a', '#d97706', '#7c3aed', '#0891b2', '#db2777', '#65a30d', '#0d9488', '#4f46e5', '#dc2626', '#ca8a04']
const suggestMapDay = ref('')
const suggestRoutes = computed(() => {
const day = suggestMapDay.value || (suggestWindow.value[0] || '')
const out = []; let ci = 0
for (const g of suggestGroups.value) {
if (g.placeholder) continue
const d = (g.dayList || []).find(x => x.iso === day); if (!d || !d.entries.length) continue
const stops = d.entries.filter(e => e.lat != null && e.lon != null).map((e, i) => ({ lat: +e.lat, lon: +e.lon, seq: i + 1, subject: e.subject }))
if (!stops.length) continue
const home = techHomes.value[g.techId]
out.push({ techId: g.techId, techName: g.techName, color: ROUTE_COLORS[ci % ROUTE_COLORS.length], km: d.km, mins: d.mins, home: (home && isFinite(+home.lat) && isFinite(+home.lon)) ? { lat: +home.lat, lon: +home.lon } : null, stops })
ci++
}
return out
})
// Watches de la carte des tournées — DÉFINIS ICI (après suggestDlg/suggestMapDay/suggestGroups) pour éviter le piège TDZ (watch-avant-const).
watch(() => suggestDlg.view, (v) => { if (v === 'map') { if (!suggestMapDay.value) suggestMapDay.value = suggestWindow.value[0] || ''; nextTick(() => { _suggestMap ? refreshSuggestMap() : initSuggestMap() }) } })
watch([suggestMapDay, suggestGroups], () => { if (suggestDlg.view === 'map' && _suggestMap) refreshSuggestMap() })
watch(() => suggestDlg.open, (o) => { if (!o) destroySuggestMap() })
// 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))
// Existe-t-il AU MOINS un tech capable ? Sinon (données de compétences incomplètes) on ne bloque personne (évite de piéger une file inassignable).
const anyCovers = (skills) => !((skills || []).filter(Boolean).length) || (visibleTechs.value || []).some(t => covers(t, skills))
// Liste de techs triée CAPABLES d'abord (couvrent la/les compétence(s)), puis alphabétique — pour les menus d'assignation.
function capableTechsFirst (skills) { return [...(visibleTechs.value || [])].sort((a, b) => (covers(b, skills) - covers(a, skills)) || String(a.name || '').localeCompare(String(b.name || ''))) }
const techById = computed(() => { const m = {}; for (const t of (visibleTechs.value || [])) m[t.id] = t; return m })
// Réassigne UNE entrée : quand la cible est un VRAI tech, on sort du placeholder + on (re)calcule quart manquant ET compétence.
function reassignEntry (e, techId, techName) {
e.techId = techId; e.techName = techName
const hold = isHoldId(techId)
e.placeholder = hold
e.noShift = hold ? false : !hasShiftDay(techId, e.iso)
e.capable = hold ? true : covers(techById.value[techId] || {}, [e.skill]) // hors compétence → marqué (icône ⚠)
}
function moveSuggestEntry (entry, techId, techName) { reassignEntry(entry, techId, techName) } // déplacer 1 job vers un autre tech (ou une file placeholder)
function moveSuggestGroup (g, techId, techName) { // déplace/fusionne TOUTE la file d'un tech (ou d'un placeholder) vers un autre tech
if (!techId || techId === g.techId) return
const targetHad = suggestDlg.plan.some(e => e.techId === techId) // la cible avait déjà une file → c'est une FUSION
let n = 0; for (const e of suggestDlg.plan) if (e.techId === g.techId) { reassignEntry(e, techId, techName); n++ }
$q.notify({ type: 'info', icon: targetHad ? 'merge' : 'drive_file_move', message: `${n} job(s) ${targetHad ? 'fusionnés dans' : '→'} ${techName}`, timeout: 2800 })
}
// Files déjà présentes dans le plan (pour signaler « fusionner » dans le menu de déplacement).
const planCountByTech = computed(() => { const m = {}; for (const e of suggestDlg.plan) m[e.techId] = (m[e.techId] || 0) + 1; return m })
// Crée LOCALEMENT le quart 8-16 pour les jours sans quart de cette file (persisté via Publier, comme l'option makeShifts). PAS d'écriture immédiate.
async function createShiftForGroup (g) {
const days = g.noShiftDays || []; if (!days.length) return
const tpl = await ensureWindowTpl(8, 16); if (!tpl) { $q.notify({ type: 'negative', message: 'Modèle de quart 8-16 introuvable' }); return }
pushHistory()
for (const iso of days) setCellReplace(g.techId, g.techName, iso, tpl) // local → recalcule hasShiftDay/occupation ; Publier pour enregistrer
$q.notify({ type: 'positive', icon: 'event_available', message: `${days.length} quart(s) 8-16 créé(s) pour ${g.techName} — Publier pour enregistrer`, timeout: 3500 })
}
function removeSuggestEntry (entry) { suggestDlg.plan = suggestDlg.plan.filter(e => e !== entry) } // retiré du plan → reste au pool
// Applique : regroupe par tech×jour → réutilise assignNames (assignation serveur + occupation optimiste + retrait du pool).
async function applySuggestion () {
suggestDlg.applying = true
const groups = {}
for (const e of suggestDlg.plan) { const k = e.techId + '|' + e.iso; (groups[k] = groups[k] || { techId: e.techId, iso: e.iso, names: [] }).names.push(e.jobName) }
// Quart 816 pour les tech×jour SANS quart (si demandé) → capacité + fini le « hors quart ». Local → Publier pour enregistrer.
let shiftsMade = 0
if (suggestDlg.makeShifts) {
const tpl = await ensureWindowTpl(8, 16)
if (tpl) { let first = true; for (const k of Object.keys(groups)) { const g = groups[k]; if (isHoldId(g.techId) || isWeekendIso(g.iso)) continue; if (!hasShiftDay(g.techId, g.iso)) { const t = (visibleTechs.value || []).find(x => x.id === g.techId); if (t) { if (first) { pushHistory(); first = false } setCellReplace(g.techId, t.name, g.iso, tpl); shiftsMade++ } } } } // JAMAIS de quart auto le week-end
}
let total = 0
for (const k of Object.keys(groups)) {
const g = groups[k]; const t = (visibleTechs.value || []).find(x => x.id === g.techId); if (!t) continue
const d = (dayList.value || []).find(x => x.iso === g.iso) || { iso: g.iso, dnum: g.iso.slice(8) }
try { const done = await assignNames(g.names, t, d); total += (done || []).length } catch (e) { err(e) }
}
suggestDlg.applying = false; suggestDlg.open = false
const pending = suggestDlg.plan.filter(e => e.placeholder).length // jobs en file PLACEHOLDER (pas assignés — à confier à un vrai tech avant d'appliquer)
if (total || pending) $q.notify({ type: pending ? 'warning' : 'positive', message: (total ? `${total} job(s) répartis` : '') + (shiftsMade ? ` · ${shiftsMade} quart(s) 816 créé(s)` : '') + (pending ? ` · ⏳ ${pending} job(s) en file à assigner — assigne les tournées placeholder à un tech (bouton 👤+) puis applique` : (total ? ' — Publier pour enregistrer' : '')), timeout: 7000 })
await reloadPool()
}
// 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 })
function initials (name) { return String(name || '').split(/\s+/).filter(Boolean).map(w => w[0]).slice(0, 2).join('').toUpperCase() || '?' }
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 }
if (selectedJobs[j.name]) delete selectedJobs[j.name]; else selectedJobs[j.name] = true
}
// ── Actions rapides du pool (mobile, façon Gmail : glisser + icônes directes ★/📝) ──
// ⚠ Le champ Dispatch Job.priority = Select { low | medium | high } UNIQUEMENT (pas de « urgent » → 417). L'étoile = « high » (le plus prioritaire dispo).
const POOL_PRIOS = [{ value: 'high', label: 'Élevée', color: '#ef4444' }, { value: 'medium', label: 'Moyenne', color: '#f59e0b' }, { value: 'low', label: 'Basse', color: '#9e9e9e' }]
const POOL_STATUSES = [{ value: 'open', label: 'Ouvert', icon: 'inbox', color: 'grey-7' }, { value: 'On Hold', label: 'En attente', icon: 'pause_circle', color: 'orange-7' }, { value: 'Cancelled', label: 'Annulé', icon: 'cancel', color: 'red-6' }]
const jobSheet = reactive({ open: false, job: null })
const noteDialog = reactive({ open: false, job: null, text: '' })
const snoozeDate = ref('') // date du sélecteur « Reporter », pré-réglée à +1 semaine à l'ouverture de la feuille
const sheetNoteText = ref('') // brouillon de la note importante, éditée INLINE dans la feuille (input + bouton Enregistrer)
// Patch optimiste : applique localement, persiste côté hub, réconcilie le pool sur échec.
async function patchJob (j, patch, okMsg) {
if (!j) return
Object.assign(j, patch)
try { await roster.updateJob(j.name, patch); if (okMsg) $q.notify({ type: 'positive', message: okMsg, timeout: 1600 }) }
catch (e) { err(e); await reloadPool() }
}
function toggleUrgent (j) { const on = j.priority === 'high'; patchJob(j, { priority: on ? 'medium' : 'high' }, on ? 'Priorité normale' : '⭐ Prioritaire') }
function setJobPriority (j, p) { patchJob(j, { priority: p }, 'Priorité : ' + ((POOL_PRIOS.find(x => x.value === p) || {}).label || p)) }
function setJobStatus (j, s) { patchJob(j, { status: s }, 'Statut : ' + ((POOL_STATUSES.find(x => x.value === s) || {}).label || s)) }
function setJobSkill (j, sk) { patchJob(j, { required_skill: sk }, 'Compétence : ' + (sk || '—')) }
// Reporter : +N jours (base = date actuelle sinon aujourd'hui).
function snoozeJob (j, days) {
if (!j) return
const base = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : todayISO()
applyJobDate(j, addDaysISO(base, days), j.scheduled_date || '')
}
// Reporter à une date PRÉCISE (depuis le sélecteur).
function setJobDate (j, iso) { if (!j || !iso || iso === j.scheduled_date) return; applyJobDate(j, iso, j.scheduled_date || '') }
// Pose la date prévue (optimiste) + toast Annuler (rétablit l'ancienne).
function applyJobDate (j, next, prev) {
j.scheduled_date = next
roster.updateJob(j.name, { scheduled_date: next })
.then(() => $q.notify({ type: 'info', icon: 'event', message: 'Reporté au ' + fmtDueLabel(next), timeout: 4500, actions: [{ label: 'Annuler', color: 'white', handler: () => patchJob(j, { scheduled_date: prev || null }) }] }))
.catch(e => { err(e); reloadPool() })
}
// Ouvre la feuille d'options ; pré-règle le sélecteur de date à AUJOURD'HUI + 1 semaine (pas date due + 1 sem.).
// Fil complet du ticket affiché dans les détails (le plus récent EN PREMIER). Chargé à l'ouverture via legacy_ticket_id.
const sheetThread = reactive({ loading: false, ticket: null, messages: [], contact: null })
// Validation « service livré » (à la demande) : statut service+modem du client/adresse du job → croise tech-présent (geofence) + service en ligne.
const serviceCheck = reactive({ loading: false, done: false, online: null, found: null, label: '', rxPower: null })
async function checkJobService (j) {
if (!j || !j.name) return
serviceCheck.loading = true; serviceCheck.done = false
try {
const r = await roster.jobServiceStatus(j.name)
if (!r || r.ok === false) { serviceCheck.found = null; serviceCheck.online = null; serviceCheck.label = (r && r.error) || 'Indisponible' }
else if (r.found === false) { serviceCheck.found = false; serviceCheck.online = null; serviceCheck.label = r.ambiguous ? 'Client ambigu — préciser' : 'Aucun service trouvé' }
else {
const s = r.summary || {}
serviceCheck.found = true
serviceCheck.online = s.online === true ? true : (s.online === false ? false : null)
serviceCheck.rxPower = s.rxPower != null ? s.rxPower : null
serviceCheck.label = (s.label || (serviceCheck.online ? 'En ligne' : 'Statut inconnu')) + (serviceCheck.rxPower != null ? ' · ' + serviceCheck.rxPower + ' dBm' : '')
}
} catch (e) { serviceCheck.found = null; serviceCheck.online = null; serviceCheck.label = 'Erreur' }
finally { serviceCheck.loading = false; serviceCheck.done = true }
}
// Mappe un message de ticket osTicket {author, fromClient} vers le resolver comms partagé (nom/initiales/couleur cohérents Boîte↔feuille).
function pmIdentity (m) {
const generic = !m || !m.author || m.author === 'Système / client'
if (m && m.fromClient) return messageIdentity({ from: 'customer', fromName: generic ? '' : m.author }, { customerName: sheetThread.contact && sheetThread.contact.name })
return messageIdentity({ fromName: (m && m.author) || '' }, {})
}
const postDraft = ref('') // brouillon de RÉPONSE au client
const noteMode = ref(false) // bascule 📌 « Note importante » (true = note épinglée au job j.notes ; false = réponse client via Outbox)
const postBusy = ref(false)
// CHAMP UNIQUE du composeur : édite la note épinglée (sheetNoteText → j.notes) en mode note, sinon la réponse (postDraft).
const composerText = computed({
get: () => noteMode.value ? sheetNoteText.value : postDraft.value,
set: (v) => { if (noteMode.value) sheetNoteText.value = v; else postDraft.value = v },
})
// Canal de réponse client déduit du contact (courriel prioritaire, sinon SMS) — gouverne l'option « Réponse ».
const replyChannel = computed(() => {
const c = sheetThread.contact
if (c && c.email) return { label: 'courriel', ok: true }
if (c && c.phone) return { label: 'SMS', ok: true }
return { label: '', ok: false }
})
async function submitComposer () {
const j = jobSheet.job; if (!j) return
if (noteMode.value) { // 📌 Note importante → épinglée au job (j.notes) ; pour un ticket legacy, miroir en note interne osTicket
const txt = (sheetNoteText.value || '')
patchJob(j, { notes: txt }, 'Note importante enregistrée')
if (j.legacy_ticket_id && txt.trim()) { try { await roster.postTicket(j.legacy_ticket_id, txt.trim(), false) } catch (e) { /* miroir best-effort */ } }
return
}
const txt = (postDraft.value || '').trim(); if (!txt) return
postBusy.value = true
try {
// Réponse AU CLIENT : résout la conversation du JOB (osTicket OU ERPNext-natif) puis envoie par le MÊME chemin que la Boîte → Outbox + miroir.
const c = await roster.jobConversation(j.name)
if (!c || !c.ok || !c.convToken) { $q.notify({ type: 'negative', message: (c && c.error) || 'Contact client introuvable' }); postBusy.value = false; return }
const email = (authStore.user && authStore.user !== 'authenticated') ? authStore.user : ''
await roster.sendConvMessage(c.convToken, txt, email)
const via = c.channel === 'email' ? 'courriel' : (c.channel === 'sms' ? 'SMS' : 'message')
postDraft.value = ''
$q.notify({ type: 'positive', icon: 'send', message: 'Réponse envoyée au client par ' + via, timeout: 2400 })
await loadSheetThread(j)
} catch (e) { err(e) }
postBusy.value = false
}
// « Fil complet » : ouvre la conversation comms (composeur riche : canned ::, signatures, Unlayer) en pleine page — réutilise ConversationPanel.
async function openFullThread () {
const j = jobSheet.job; if (!j || !j.name) return
try {
const c = await roster.jobConversation(j.name)
if (c && c.ok && c.convToken) { jobSheet.open = false; router.push('/communications/c/' + c.convToken) }
else $q.notify({ type: 'negative', message: (c && c.error) || 'Conversation introuvable' })
} catch (e) { err(e) }
}
async function loadSheetThread (j) {
const tok = j && j.name
sheetThread.messages = []; sheetThread.contact = null; sheetThread.ticket = (j && j.legacy_ticket_id) || null; sheetThread.loading = !!(j && j.name)
if (!j || !j.name) return
try {
const r = await roster.jobThread(j.name) // unifié : contact+fil depuis osTicket si legacy_ticket_id, sinon depuis la fiche client ERPNext
if (jobSheet.job && jobSheet.job.name === tok) {
sheetThread.messages = (r && r.ok && Array.isArray(r.messages)) ? [...r.messages].reverse() : [] // reverse = plus récent en haut
sheetThread.contact = (r && r.ok && r.contact) || null
if (!replyChannel.value.ok && !noteMode.value) noteMode.value = true // aucun canal pour répondre → bascule sur Note importante
}
} catch (e) { /* fil indisponible */ }
if (jobSheet.job && jobSheet.job.name === tok) sheetThread.loading = false
}
function openJobSheet (j, opts) { jobSheet.job = j; snoozeDate.value = addDaysISO(todayISO(), 7); sheetNoteText.value = j.notes || ''; postDraft.value = ''; noteMode.value = !!(opts && opts.note); serviceCheck.done = false; serviceCheck.loading = false; jobSheet.open = true; loadSheetThread(j) }
// Auto-save de la note à la FERMETURE de la feuille (X · clic extérieur · bouton Fermer · Échap) — uniquement si modifiée. Plus de bouton Enregistrer.
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)' } : {} }
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) {
const a = String(j.address || j.service_location || '').trim().toLowerCase()
const c = String(j.customer_name || j.customer || '').trim().toLowerCase()
const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : ''
return (a && c && d) ? (a + '|' + c + '|' + d) : null
}
function siblingJobs (names) {
const keys = new Set((assignPanel.jobs || []).filter(j => names.includes(j.name)).map(jobBatchKey).filter(Boolean))
if (!keys.size) return []
return (assignPanel.jobs || []).filter(j => !names.includes(j.name) && j.status !== 'On Hold' && keys.has(jobBatchKey(j)))
}
async function onTechTap (t) {
const tech = t._t || t
if (!selectedNames.value.length) { openDayFromCell(tech, mobileSelDayObj.value); return }
let names = selectedNames.value.slice()
// Même adresse/client/jour → proposer d'assigner tout le groupe au même tech (moins de clics).
const sibs = siblingJobs(names)
if (sibs.length) {
const ex = sibs[0]
const include = await new Promise(resolve => $q.dialog({
title: 'Assigner le groupe ?',
message: `${sibs.length} autre(s) tâche(s) à « ${String(ex.address || ex.service_location || '').slice(0, 60)} » (${ex.customer_name || ex.customer || ''} · ${fmtDueLabel(ex.scheduled_date)}) — les assigner aussi à ${tech.name} ?`,
cancel: { label: 'Non, juste la sélection', flat: true }, ok: { label: 'Oui, tout assigner', color: 'primary', unelevated: true }, persistent: true,
}).onOk(() => resolve(true)).onCancel(() => resolve(false)).onDismiss(() => resolve(false)))
if (include) names = [...new Set([...names, ...sibs.map(s => s.name)])]
}
draggingJobName.value = names.join(','); await onCellDrop({}, tech, mobileSelDayObj.value) // onCellDrop = garde On Hold + dialogue sans-quart + toast Annuler
}
function hasReg (techId, iso) { return cellsOf(techId, iso).some(a => { const t = tplByName.value[a.shift]; return t && !t.on_call }) } // a au moins un shift régulier (garde exclue)
function cellBlocks (techId, iso) { const o = cellOcc(techId, iso); return o ? o.blocks : [] }
function cellOfficeN (techId, iso) { const o = cellOcc(techId, iso); return o ? (o.officeN || 0) : 0 } // nb de tickets admin/bureau (non-terrain) assignés à ce tech ce jour
function officeJobs (techId, iso) { const lg = showLegacyLoad.value ? legacyLoad.value[techId + '|' + iso] : null; return lg ? (lg.jobs || []).filter(j => !j.field) : [] } // détail des tickets admin (pour la pastille)
// Bloc « sans déplacement » (vue semaine) : un SEUL bloc pointillé par jour. Largeur = somme des estimés
// des tickets office (est_min ou duration_h), PLANCHER 1h dès qu'il y a au moins un ticket, sur l'échelle du timeline.
function officeBlockH (techId, iso) {
const jobs = officeJobs(techId, iso); if (!jobs.length) return 0
let h = 0; for (const j of jobs) h += (j.est_min ? j.est_min / 60 : Number(j.duration_h || 0))
return Math.max(1, h)
}
function officeBlockStyle (techId, iso) {
const h = officeBlockH(techId, iso); if (!h) return { display: 'none' }
const occ = cellOcc(techId, iso); const startH = (occ && occ.shiftS != null) ? occ.shiftS : 8
return pos(startH, startH + h) // ancré au DÉBUT du quart (≈8h) → occupe le matin, pris en compte par le dispatch
}
function fmtOfficeH (techId, iso) { return fmtMin(Math.round(officeBlockH(techId, iso) * 60)) }
function cellGardeBands (techId, iso) { return cellBands(techId, iso).filter(b => b.oncall) } // garde seulement (la bande blanche du quart régulier vient de occCells.shiftS/E étendu)
function cellPct (techId, iso) { const o = cellOcc(techId, iso); return o ? o.pct : null }
// Explique PRÉCISÉMENT pourquoi le triangle de surcharge apparaît (heures vs quart + débordements) — pour le tooltip.
function overWhy (techId, iso) {
const o = cellOcc(techId, iso); if (!o || !o.over) return ''
const p = []
if (o.bookableH > 0 && o.usedH > o.bookableH + 0.01) p.push(`${o.usedH} h de travail prévu pour ${o.bookableH} h de quart${o.pct != null ? ' (' + o.pct + '%)' : ''}`)
if (o.winE != null && o.shiftE > o.winE + 0.01) p.push(`des jobs finissent après le quart (${fmtH(o.winE)}${fmtH(o.shiftE)})`)
if (o.winS != null && o.shiftS < o.winS - 0.01) p.push(`des jobs commencent avant le quart (dès ${fmtH(o.shiftS)})`)
if (!o.bookableH) p.push('jobs assignés sans quart régulier publié')
return p.length ? p.join(' · ') : 'jobs hors du quart prévu'
}
// Charge AGRÉGÉE par tech sur les JOURNÉES VISIBLES (dayList) : Σ occupé / Σ offrable → % d'occupation de la semaine affichée.
const techLoads = computed(() => {
const out = {}
for (const t of visibleTechs.value) {
let used = 0, book = 0, leg = 0
for (const d of dayList.value) { const o = cellOcc(t.id, d.iso); if (o) { used += o.usedH || 0; book += o.bookableH || 0; leg += o.legacyH || 0 } }
out[t.id] = { used: Math.round(used * 10) / 10, book: Math.round(book * 10) / 10, leg: Math.round(leg * 10) / 10, pct: book > 0 ? Math.round(used / book * 100) : null }
}
return out
})
function cellJobs (techId, iso) { const o = cellOcc(techId, iso); return o ? (o.jobs || []) : [] } // jobs du jour, déjà triés priorité→heure côté hub
function rawCellJobs (techId, iso) { const o = occByTechDay.value[techId + '|' + iso]; return o ? (o.jobs || []) : [] } // jobs BRUTS (inclut les jours SANS quart publié)
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' }
// 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) }
function cellTip (techId, iso) {
const parts = []
if (hasReg(techId, iso)) parts.push(cellInterval(techId, iso))
const o = cellOcc(techId, iso); if (o && o.bookableH) parts.push(o.usedH + ' h occupé / ' + o.bookableH + ' h offrable (' + o.pct + ' %)')
const g = gardeEffective.value[techId + '|' + iso]; if (g) { const t = tplByName.value[g]; const nm = (t && t.template_name) || 'Garde'; const hrs = t ? (' ' + (t.start_time || '').slice(0, 5) + '' + (t.end_time || '').slice(0, 5)) : ''; parts.push('🛡️ ' + nm + hrs) }
return parts.join(' · ')
}
function cellInterval (techId, iso) {
return cellsOf(techId, iso).filter(a => { const t = tplByName.value[a.shift]; return !(t && t.on_call) }).map(a => { const t = tplByName.value[a.shift]; const nm = (t && t.template_name) ? t.template_name.split(' ')[0] + ' ' : ''; return (t && t.start_time) ? (nm + t.start_time.slice(0, 5) + '' + (t.end_time || '').slice(0, 5)) : (a.shift_name || a.shift) }).join(' + ')
}
// coût de main-d'œuvre (coût chargé × heures)
const costByTech = computed(() => Object.fromEntries(techs.value.map(t => [t.id, t.cost_h || 0])))
const costByDate = computed(() => { const m = {}; for (const a of assignments.value) { const t = tplByName.value[a.shift]; if (t && t.on_call) continue; m[a.date] = (m[a.date] || 0) + (Number(a.hours) || 0) * (costByTech.value[a.tech] || 0) } return m }) // garde exclue du coût de main-d'œuvre
const weekCost = computed(() => Object.values(costByDate.value).reduce((s, v) => s + v, 0))
function dayCost (iso) { return Math.round(costByDate.value[iso] || 0) }
const shiftName = (s) => { const t = tplByName.value[s]; return t ? (t.template_name || s) : s }
const covRows = computed(() => { const seen = {}; const rows = []; for (const c of coverageData.value) { const key = c.shift + '|' + c.zone; if (!seen[key]) { seen[key] = true; rows.push({ key, label: shiftName(c.shift) + ' · ' + c.zone }) } } return rows })
const covByKeyDay = computed(() => { const m = {}; for (const c of coverageData.value) m[c.shift + '|' + c.zone + '|' + c.date] = c; return m })
const gapByDay = computed(() => { const m = {}; for (const c of coverageData.value) m[c.date] = (m[c.date] || 0) + (c.shortfall || 0); return m })
// Détail du manque de couverture d'un jour (par quart · zone) pour l'infobulle du badge rouge.
function gapDetail (iso) { return coverageData.value.filter(c => c.date === iso && c.shortfall > 0).map(c => `${shiftName(c.shift)} · ${c.zone || '—'} : ${c.assigned}/${c.required} (${c.shortfall})`).join('\n') }
function covCell (key, iso) { return covByKeyDay.value[key + '|' + iso] }
function covText (key, iso) { const c = covCell(key, iso); return c ? (c.assigned + '/' + c.required) : '' }
function covStyle (key, iso) { const c = covCell(key, iso); if (!c) return {}; return c.shortfall > 0 ? { background: '#ffcdd2', color: '#b71c1c', fontWeight: 700 } : { background: '#c8e6c9', color: '#1b5e20' } }
// undo / redo
function snap () { return JSON.parse(JSON.stringify(assignments.value)) }
function pushHistory () { history.value.push(snap()); if (history.value.length > 40) history.value.shift(); future.value = [] }
function undo () { if (!history.value.length) return; future.value.push(snap()); assignments.value = history.value.pop() }
function redo () { if (!future.value.length) return; history.value.push(snap()); assignments.value = future.value.pop() }
// garde anti-perte
function guard (fn) { if (dirty.value && !window.confirm(DIRTY_MSG)) return; fn() }
function onWeekChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { start.value = lastWeek.start; return } loadWeek() }
function onDaysChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { days.value = lastWeek.days; return } loadWeek() }
function navWeek (dir) { guard(() => { start.value = addDaysISO(start.value, dir * days.value); loadWeek() }) }
function navDay (dir) { guard(() => {
if (boardView.value === 'kanban') { // mode Jour : on déplace le JOUR sélectionné (pas la fenêtre)
const cur = (kanbanDay.value && kanbanDay.value.iso) || nowET.value.iso
const next = addDaysISO(cur, dir); kbSelIso.value = next
if (!dayList.value.find(d => d.iso === next)) { start.value = next; loadWeek() } // hors fenêtre → recentre
} else { start.value = addDaysISO(start.value, dir); loadWeek() }
}) }
function navToday () { guard(() => { kbSelIso.value = nowET.value.iso; start.value = dayMode.value ? todayISO() : thisMonday(); loadWeek() }) } // mode Jour → aujourd'hui ; sinon lundi courant
// chargement
async function loadBase () { const tr = await roster.listTechnicians(); techs.value = tr.technicians || []; const tp = await roster.listTemplates(); templates.value = tp.templates || [] }
async function refreshTemplates () { const tp = await roster.listTemplates(); templates.value = tp.templates || [] }
// cadence / efficacité par tech
// Libellé orienté PERFORMANCE/vitesse (≠ libellé "facteur temps") : facteur 0,9 = plus rapide, 1,2 = plus lent.
function effSuffix (e) { const d = Math.round((1 - Number(e)) * 100); if (!d) return 'normal'; return d > 0 ? ('+' + d + '% vite') : ('' + Math.abs(d) + '% lent') }
// RÔLE dérivé des compétences (tags) : support→casque · installation→échelle · réparation/terrain→handyman.
const ROLE_ICON = { support: symOutlinedHeadsetMic, install: symOutlinedToolsLadder, repair: symOutlinedHandyman }
const ROLE_LABEL = { support: 'Support / service client', install: 'Installation', repair: 'Réparation / terrain' }
function techRole (t) {
const s = (t.skills || []).map(x => String(x).toLowerCase())
if (s.some(x => x.includes('support') || x.includes('service'))) return 'support'
if (s.some(x => x.includes('install'))) return 'install'
if (s.some(x => x.includes('répar') || x.includes('repar') || x.includes('fusion') || x.includes('terrain'))) return 'repair'
return null
}
function roleIcon (t) { const r = techRole(t); return r ? ROLE_ICON[r] : null }
function roleLabel (t) { const r = techRole(t); return r ? ROLE_LABEL[r] : '' }
function openTeamEditor () { editTechs.value = techs.value.map(t => ({ id: t.id, name: t.name, group: t.group, skills: (t.skills || []).join(', '), efficiency: t.efficiency || 1, salary: t.cost_salary_h || 0, charges: t.cost_charges_pct || 0, other: t.cost_other_h || 0 })); showTeamEditor.value = true }
async function saveSkills (t) { try { await roster.setTechSkills(t.id, t.skills || ''); const tt = techs.value.find(x => x.id === t.id); if (tt) tt.skills = (t.skills || '').split(',').map(s => s.trim()).filter(Boolean); $q.notify({ type: 'positive', message: t.name + ' : compétences enregistrées' }) } catch (e) { err(e) } }
// congés / disponibilités
async function openLeave () { showLeave.value = true; await loadLeave() }
async function loadLeave () { try { leaveRows.value = (await roster.listAvailability(leaveFilter.value)).availability || [] } catch (e) { err(e) } }
async function approveLeave (l, reject) { try { await roster.approveAvailability(l.name, { reject, approver: 'ops' }); $q.notify({ type: reject ? 'info' : 'positive', message: (l.technician_name || l.technician) + (reject ? ' refusé' : ' approuvé') }); await loadLeave() } catch (e) { err(e) } }
async function createLeave () {
if (!newLeave.technician || !newLeave.from_date || !newLeave.to_date) { $q.notify({ type: 'warning', message: 'Technicien + dates requis' }); return }
const t = techs.value.find(x => x.id === newLeave.technician)
try { await roster.requestAvailability({ technician: newLeave.technician, technician_name: t ? t.name : '', availability_type: newLeave.availability_type, from_date: newLeave.from_date, to_date: newLeave.to_date, reason: newLeave.reason, long_term: newLeave.long_term }); $q.notify({ type: 'positive', message: 'Demande créée' }); newLeave.from_date = ''; newLeave.to_date = ''; newLeave.reason = ''; newLeave.long_term = 0; await loadLeave() } catch (e) { err(e) }
}
async function saveEff (t) { const eff = Number(t.efficiency) || 1; try { await roster.setTechEfficiency(t.id, eff); const tt = techs.value.find(x => x.id === t.id); if (tt) tt.efficiency = eff; $q.notify({ type: 'positive', message: t.name + ' : cadence ' + eff }) } catch (e) { err(e) } }
function loadedCost (t) { return Math.round(((Number(t.salary) || 0) * (1 + (Number(t.charges) || 0) / 100) + (Number(t.other) || 0)) * 100) / 100 }
async function saveCost (t) { try { await roster.setTechCost(t.id, { salary: t.salary, charges: t.charges, other: t.other }); const tt = techs.value.find(x => x.id === t.id); if (tt) { tt.cost_salary_h = Number(t.salary) || 0; tt.cost_charges_pct = Number(t.charges) || 0; tt.cost_other_h = Number(t.other) || 0; tt.cost_h = loadedCost(t) } $q.notify({ type: 'positive', message: t.name + ' : ' + loadedCost(t) + ' $/h chargé' }) } catch (e) { err(e) } }
// éditeur de types de shift (intervalle d'heures)
function calcHours (st, et) { if (!st || !et) return 0; const [h1, m1] = st.split(':').map(Number); const [h2, m2] = et.split(':').map(Number); let mins = (h2 * 60 + m2) - (h1 * 60 + m1); if (mins < 0) mins += 1440; return Math.round(mins / 60 * 100) / 100 }
function openShiftEditor () { editTpls.value = templates.value.map(t => ({ name: t.name, template_name: t.template_name, start: (t.start_time || '08:00:00').slice(0, 5), end: (t.end_time || '16:00:00').slice(0, 5), color: t.color || '#1976d2', on_call: t.on_call ? 1 : 0 })); showShiftEditor.value = true }
async function saveShiftTpl (t) { try { await roster.updateTemplate(t.name, { start_time: t.start + ':00', end_time: t.end + ':00', hours: calcHours(t.start, t.end), color: t.color, on_call: t.on_call ? 1 : 0 }); await refreshTemplates(); $q.notify({ type: 'positive', message: t.template_name + ' enregistré (' + calcHours(t.start, t.end) + ' h)' }) } catch (e) { err(e) } }
async function addShiftTpl () { const nm = (newTpl.template_name || '').trim() || (fmtH(hToNum(newTpl.start)) + 'h' + fmtH(hToNum(newTpl.end)) + 'h'); try { await roster.createTemplate({ template_name: nm, start_time: newTpl.start + ':00', end_time: newTpl.end + ':00', hours: calcHours(newTpl.start, newTpl.end), color: newTpl.color, default_required: 1, on_call: newTpl.on_call ? 1 : 0 }); newTpl.template_name = ''; newTpl.on_call = 0; await refreshTemplates(); openShiftEditor(); $q.notify({ type: 'positive', message: 'Type « ' + nm + ' » ajouté' }) } catch (e) { err(e) } }
async function delShiftTpl (t) { if (!window.confirm('Supprimer le type « ' + t.template_name + ' » ?')) return; try { await roster.deleteShiftTemplate(t.name); await refreshTemplates(); editTpls.value = editTpls.value.filter(x => x.name !== t.name); $q.notify({ type: 'info', message: 'Type supprimé' }) } catch (e) { err(e) } }
function snapshotServer (list) { serverSet.value = new Set(list.map(a => a.tech + '|' + a.date + '|' + a.shift)) }
async function loadWeek () {
loading.value = true
try {
const a = await roster.listAssignments(start.value, days.value); assignments.value = a.assignments || []
snapshotServer(assignments.value); history.value = []; future.value = []; solverStats.value = null
lastWeek.start = start.value; lastWeek.days = days.value
await loadStats()
} catch (e) { err(e) } finally { loading.value = false }
}
async function loadStats () {
try { const s = await roster.getStats(start.value, days.value); dailyStats.value = s.stats || [] } catch (e) { /* non bloquant */ }
try { const o = await roster.getOccupancy(start.value, $q.screen.lt.md ? MOBILE_STRIP_DAYS : days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ }
try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) { /* non bloquant */ }
loadLegacyWindow() // charge legacy datée de la fenêtre (durées estimées appliquées aux timelines) — non bloquant
}
// Refresh CIBLÉ + rapide de l'occupation (recharge les blocs/jobs des cellules → timeline à jour) après une assignation,
// sans le loadWeek complet (assignations + couverture + stats), bien plus lent.
async function reloadOccupancy () { try { const o = await roster.getOccupancy(start.value, $q.screen.lt.md ? MOBILE_STRIP_DAYS : days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ } }
async function doGenerate () {
generating.value = true
try {
const res = await roster.generate(start.value, days.value)
if (res.status !== 'OPTIMAL' && res.status !== 'FEASIBLE') { err(new Error(res.error || res.message || ('solveur: ' + res.status))); return }
pushHistory(); assignments.value = res.assignments || []; coverageData.value = res.coverage_report || []
solverStats.value = { assignments: (res.assignments || []).length, shortfall: res.total_shortfall || 0, spread: res.spread_hours || 0, ms: res.solve_ms || 0 }
$q.notify({ type: 'positive', message: 'Horaire généré : ' + solverStats.value.assignments + ' assignations (non publié)' })
} catch (e) { err(e) } finally { generating.value = false }
}
async function doPublish () {
publishing.value = true
try {
// Réécriture de semaine : efface la période + recrée la grille (anti-doublons).
const r = await roster.publishWeek(start.value, days.value, assignments.value, notifySms.value)
$q.notify({ type: r.errors ? 'warning' : 'positive', message: `Publié : ${r.created} assignations` + (r.deleted ? ` (${r.deleted} remplacées)` : '') + (r.errors ? ` · ${r.errors} erreurs` : '') + (r.notified ? ` · ${r.notified} SMS` : '') })
await loadWeek()
} catch (e) { err(e) } finally { publishing.value = false }
}
// demande
function loadLS () { try { demand.value = JSON.parse(localStorage.getItem(LS_DEMAND) || '[]') } catch { demand.value = [] } try { holidays.value = JSON.parse(localStorage.getItem(LS_HOL) || '[]') } catch { holidays.value = [] } try { weekTemplates.value = JSON.parse(localStorage.getItem(LS_TPL) || '[]') } catch { weekTemplates.value = [] } try { gardeRules.value = JSON.parse(localStorage.getItem(LS_GARDE) || '[]') } catch { gardeRules.value = [] } try { manualGarde.value = JSON.parse(localStorage.getItem(LS_GARDE_MANUAL) || '{}') } catch { manualGarde.value = {} } try { customTags.value = JSON.parse(localStorage.getItem('roster-skill-tags-v1') || '[]') } catch { customTags.value = [] } try { hiddenTechs.value = JSON.parse(localStorage.getItem('roster-hidden-techs-v1') || '[]') } catch { hiddenTechs.value = [] } }
// ── Rotation de garde par département (récurrence + rotation) ────────────────
const GARDE_EPOCH = '2026-01-05' // lundi de référence pour l'index de semaine
const gardeTemplateOptions = computed(() => templates.value.slice().sort((a, b) => (b.on_call ? 1 : 0) - (a.on_call ? 1 : 0)).map(t => ({ label: t.template_name + (t.on_call ? ' 🛡️' : ''), value: t.name })))
const groupNames = computed(() => [...new Set(techs.value.map(t => t.group).filter(Boolean))].sort())
const editingGardeId = ref(null); const gardePick = ref(null)
function d2ms (iso) { const a = iso.split('-').map(Number); return Date.UTC(a[0], a[1] - 1, a[2]) }
function mondayISO (iso) { return addDaysISO(iso, -((dowOf(iso) + 6) % 7)) }
function weekNo (iso) { return Math.round((d2ms(mondayISO(iso)) - d2ms(GARDE_EPOCH)) / (7 * 86400000)) } // n° de semaine absolu (réf. lundi)
function openGarde () { if (!newGardeRule.anchor) newGardeRule.anchor = mondayISO(start.value); showGarde.value = true }
// Séquence = étapes {tech, weeks}. Ajouter à la suite (doublons OK), réordonner, retirer.
function addTechToSeq () { if (gardePick.value) { newGardeRule.steps.push({ tech: gardePick.value, weeks: 1 }); gardePick.value = null } }
function moveTech (i, dir) { const a = newGardeRule.steps; const j = i + dir; if (j < 0 || j >= a.length) return; const x = a[i]; a.splice(i, 1); a.splice(j, 0, x) }
function editGardeRule (r) {
Object.assign(newGardeRule, { dept: r.dept || '', shift: r.shift, shiftWeekend: r.shiftWeekend || '', weekdays: [...(r.weekdays || [])], anchor: r.anchor || mondayISO(start.value), steps: ruleSteps(r) })
editingGardeId.value = r.id
}
const WD_SEMAINE = [1, 2, 3, 4, 5]; const WD_FINSEM = [6, 0]
function isSetActive (set) { return set.length && set.every(v => newGardeRule.weekdays.includes(v)) }
function toggleWeekdaysSet (set) { if (isSetActive(set)) newGardeRule.weekdays = newGardeRule.weekdays.filter(v => !set.includes(v)); else newGardeRule.weekdays = [...new Set([...newGardeRule.weekdays, ...set])] }
function toggleGardeDow (v) { const i = newGardeRule.weekdays.indexOf(v); if (i >= 0) newGardeRule.weekdays.splice(i, 1); else newGardeRule.weekdays.push(v) }
// ── Moteur de rotation : on PARCOURT la séquence semaine par semaine depuis l'ANCRAGE ──
function cycleWeeks (steps) { return (steps || []).reduce((s, x) => s + (Number(x.weeks) || 1), 0) }
function stepTechAt (steps, w) { for (const s of steps) { const n = Number(s.weeks) || 1; if (w < n) return s.tech; w -= n } return steps[0] && steps[0].tech }
// Rétrocompat : règle au nouveau format (steps) OU à l'ancien (techs[]+periodWeeks). Collapse les doublons consécutifs.
function ruleSteps (r) {
if (r.steps && r.steps.length) return r.steps.map(s => ({ tech: s.tech, weeks: Number(s.weeks) || 1 }))
const p = r.periodWeeks || 1; const out = []
for (const t of (r.techs || [])) { const last = out[out.length - 1]; if (last && last.tech === t) last.weeks += p; else out.push({ tech: t, weeks: p }) }
return out
}
function ruleAnchor (r) { return r.anchor || GARDE_EPOCH } // ancrage stable si non défini (vieilles règles)
function rotationTech (rule, iso) {
const steps = ruleSteps(rule); const cyc = cycleWeeks(steps); if (!cyc) return null
const w0 = (((weekNo(iso) - weekNo(ruleAnchor(rule))) % cyc) + cyc) % cyc
for (let k = 0; k < cyc; k++) { const id = stepTechAt(steps, (w0 + k) % cyc); if (id && !isAbsent(id, iso)) return id } // saut d'absent
return stepTechAt(steps, w0)
}
// Aperçu : qui est de garde, semaine par semaine, depuis l'ancrage — reflète la file en cours d'édition (ignore absences)
const gardePreview = computed(() => {
const rule = newGardeRule; const cyc = cycleWeeks(rule.steps); if (!cyc || !rule.weekdays.length) return []
const anchor = rule.anchor || mondayISO(start.value); const out = []
for (let i = 0; i < Math.min(14, cyc + 4); i++) {
const ws = addDaysISO(mondayISO(anchor), i * 7); const w0 = (((weekNo(ws) - weekNo(anchor)) % cyc) + cyc) % cyc
const id = stepTechAt(rule.steps, w0)
out.push({ week: ws, name: (techs.value.find(t => t.id === id) || {}).name || id })
}
return out
})
function saveGarde () { localStorage.setItem(LS_GARDE, JSON.stringify(gardeRules.value)) }
function addGardeRule () {
if (!newGardeRule.shift || !newGardeRule.steps.length || !newGardeRule.weekdays.length) { $q.notify({ type: 'warning', message: 'Shift, jours et au moins un tech requis' }); return }
const rule = { id: editingGardeId.value || Date.now(), dept: newGardeRule.dept || '—', shift: newGardeRule.shift, shiftWeekend: newGardeRule.shiftWeekend || '', weekdays: [...newGardeRule.weekdays], anchor: newGardeRule.anchor || mondayISO(start.value), steps: newGardeRule.steps.map(s => ({ tech: s.tech, weeks: Number(s.weeks) || 1 })) }
if (editingGardeId.value) gardeRules.value = gardeRules.value.map(r => r.id === editingGardeId.value ? rule : r)
else gardeRules.value = [...gardeRules.value, rule]
saveGarde(); editingGardeId.value = null; newGardeRule.steps = []; newGardeRule.weekdays = []
$q.notify({ type: 'positive', message: 'Règle enregistrée — clique « Générer la garde » pour l\'appliquer' })
}
function removeGardeRule (i) { gardeRules.value = gardeRules.value.filter((_, j) => j !== i); saveGarde(); if (editingGardeId.value && !gardeRules.value.some(r => r.id === editingGardeId.value)) editingGardeId.value = null }
function gardeDowLabel (r) { return (r.weekdays || []).map(w => (GARDE_DOW.find(x => x.v === w) || {}).l).join('') }
function gardeSeqLabel (r) { return ruleSteps(r).map(s => ((techs.value.find(t => t.id === s.tech) || {}).name || s.tech) + (s.weeks > 1 ? ' ×' + s.weeks : '')).join(' → ') }
// Génère les gardes de la semaine affichée selon les règles (rotation par département)
const gardeHorizon = ref(8) // nb de semaines à matérialiser (évènement récurrent)
// Génère la garde sur un HORIZON (plusieurs semaines) et l'écrit directement (publié) → navigable semaine par semaine.
async function applyGardeRules () {
if (!gardeRules.value.length && !Object.keys(manualGarde.value).length) { $q.notify({ type: 'info', message: 'Aucune règle — ajoute-en une' }); return }
if (dirty.value && !window.confirm('Les modifications non publiées de la grille seront rechargées. Continuer ?')) return
const weeks = gardeHorizon.value || 8; const wk0 = mondayISO(start.value)
// Construit la même garde EFFECTIVE que l'affichage, mais sur tout l'horizon : rotation par règles…
const horizon = new Set(); for (let i = 0; i < weeks * 7; i++) horizon.add(addDaysISO(wk0, i))
const map = {}
for (const iso of horizon) {
const dow = dowOf(iso); const weekend = (dow === 0 || dow === 6)
for (const rule of gardeRules.value) {
if (!rule.weekdays.includes(dow)) continue
const sh = (weekend && rule.shiftWeekend) ? rule.shiftWeekend : rule.shift
if (!tplByName.value[sh]) continue
const id = rotationTech(rule, iso); if (!id) continue
map[id + '|' + iso] = sh
}
}
// … + overrides MANUELS « G » dans l'horizon (déplacements faits à la main) → publiés aussi.
for (const key in manualGarde.value) { const iso = key.split('|')[1]; if (!horizon.has(iso)) continue; const v = manualGarde.value[key]; if (v === 'off') delete map[key]; else if (v === 'on') { const sh = gardeShiftForDay(iso); if (sh) map[key] = sh } }
const list = []
for (const key in map) { const [id, iso] = key.split('|'); const sh = map[key]; const tpl = tplByName.value[sh]; const t = techs.value.find(x => x.id === id); list.push({ tech: id, tech_name: t ? t.name : id, date: iso, shift: sh, hours: (tpl && tpl.hours) || 8, zone: (tpl && tpl.zone) || '' }) }
const shifts = [...new Set([...gardeRules.value.flatMap(r => [r.shift, r.shiftWeekend].filter(Boolean)), ...Object.values(map)])]
try {
const r = await roster.applyGardeHorizon(wk0, weeks, list, shifts)
showGarde.value = false; await loadWeek()
$q.notify({ type: 'positive', message: `Garde publiée sur ${weeks} sem. : ${r.created} assignations` + (r.deleted ? ` (${r.deleted} remplacées)` : '') + '. La grille la montrait déjà en direct ; c\'est maintenant visible par dispatch et les techs.', timeout: 6000 })
} catch (e) { err(e) }
}
function saveDemand () { localStorage.setItem(LS_DEMAND, JSON.stringify(demand.value)) }
function addDemand () { demand.value = [...demand.value, { shift: templates.value[0] && templates.value[0].name, zone: 'Montréal', skills: '', job_h: 0, weekday: 1, weekend: 0, holiday: 0 }]; saveDemand() }
function removeDemand (i) { demand.value = demand.value.filter((_, j) => j !== i); saveDemand() }
async function applyDemand () {
if (!demand.value.length) { $q.notify({ type: 'warning', message: 'Aucune ligne de demande' }); return }
applying.value = true
try {
await roster.clearRequirements(start.value, days.value)
const reqs = []
for (const d of dayList.value) {
const slot = isHoliday(d.iso) ? 'holiday' : (d.weekend ? 'weekend' : 'weekday')
for (const row of demand.value) {
const n = Number(row[slot]) || 0; if (n <= 0 || !row.shift) continue
const jobH = Number(row.job_h) || 0
const sh = (tplByName.value[row.shift] && tplByName.value[row.shift].hours) || 8
const count = jobH > 0 ? Math.max(1, Math.ceil(n * jobH / sh)) : n // mode jobs → effectif
reqs.push({ requirement_date: d.iso, shift_template: row.shift, zone: row.zone || '', required_count: count, required_skills: row.skills || '' })
}
}
if (reqs.length) await roster.bulkRequirements(reqs)
await loadWeek(); $q.notify({ type: 'positive', message: 'Demande appliquée : ' + reqs.length + ' besoins' })
} catch (e) { err(e) } finally { applying.value = false }
}
// modèles de semaine
function saveTemplate () {
$q.dialog({ title: 'Nouveau modèle', message: "Nom du modèle d'horaire", prompt: { model: '', type: 'text' }, cancel: true }).onOk(name => {
if (!name) return; const byDow = {}
for (const a of assignments.value) { const dow = dowOf(a.date); (byDow[dow] || (byDow[dow] = {}))[a.tech] = a.shift }
weekTemplates.value = [...weekTemplates.value, { name, byDow }]; localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value))
$q.notify({ type: 'positive', message: 'Modèle « ' + name + ' » enregistré' })
})
}
function deleteTemplate (i) { weekTemplates.value = weekTemplates.value.filter((_, j) => j !== i); localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value)) }
// Modèle par défaut (★) — un seul à la fois, appliqué en 1 clic
const defaultTemplate = computed(() => weekTemplates.value.find(t => t.default) || null)
function setDefaultTemplate (i) { weekTemplates.value = weekTemplates.value.map((t, j) => ({ ...t, default: j === i ? !t.default : false })); localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value)) }
function applyDefault () { const d = defaultTemplate.value; if (!d) { $q.notify({ type: 'info', message: 'Aucun modèle par défaut — marque-en un avec ★ dans Modèles' }); return } applyTemplate(d) }
function countPatternDays (tm, techId) { let n = 0; for (const d of dayList.value) { const map = tm.byDow[dowOf(d.iso)]; if (map && map[techId]) n++ } return n }
// Application « consciente des absences » : on n'assigne pas un tech absent ce jour-là.
// Absent toute la semaine (≈ congé permanent: maternité/blessure) → flag « à remplacer ».
// Absent quelques jours (≈ vacances) → on saute juste ces jours, le reste du patron tient.
function applyTemplate (tm) {
pushHistory()
const skipped = {}; let applied = 0
for (const d of dayList.value) {
const map = tm.byDow[dowOf(d.iso)]; if (!map) continue
for (const techId in map) {
if (isAbsent(techId, d.iso)) { skipped[techId] = (skipped[techId] || 0) + 1; continue }
const tpl = tplByName.value[map[techId]]; if (!tpl) continue
const t = techs.value.find(x => x.id === techId); setCellReplace(techId, t ? t.name : techId, d.iso, tpl); applied++
}
}
const fullOut = []; const partial = []
for (const techId in skipped) {
const t = techs.value.find(x => x.id === techId); const name = (t ? t.name : techId)
const type = absByTechDay.value[techId + '|' + (dayList.value.find(d => isAbsent(techId, d.iso)) || {}).iso] || ''
const lbl = name + (type ? ' (' + type + ')' : '')
if (/longue durée/i.test(type) || skipped[techId] >= countPatternDays(tm, techId)) fullOut.push(lbl); else partial.push(lbl)
}
let msg = 'Modèle « ' + tm.name + ' » appliqué (' + applied + ' assignations)'
if (partial.length) msg += ' · absence partielle ignorée : ' + partial.join(', ')
if (fullOut.length) msg += ' · ABSENT toute la semaine — à remplacer : ' + fullOut.join(', ')
$q.notify({ type: fullOut.length ? 'warning' : 'positive', message: msg, timeout: fullOut.length ? 9000 : 4500, multiLine: true })
}
// édition + sélection
const menu = reactive({ show: false, target: null, tech: null, day: null, x: 0, y: 0 })
const menuAnchorEl = ref(null) // ancre 1px positionnée au CURSEUR → le menu s'ouvre là où on clique
const menuRange = ref({ min: 8, max: 16 }); const quickEntry = ref('')
function rect (sti, sdi, eti, edi) {
const t0 = Math.min(sti, eti), t1 = Math.max(sti, eti), d0 = Math.min(sdi, edi), d1 = Math.max(sdi, edi)
const out = []
for (let i = t0; i <= t1; i++) for (let j = d0; j <= d1; j++) out.push(visibleTechs.value[i].id + '|' + dayList.value[j].iso)
return out
}
function onDown (ti, di, ev) { if (ev.button !== 0 || ev.shiftKey || ev.ctrlKey || ev.metaKey) return; drag.on = true; drag.ti = ti; drag.di = di; drag.moved = false; drag.base = [] }
function onEnter (ti, di) { if (!drag.on) return; drag.moved = true; selection.value = [...new Set([...drag.base, ...rect(drag.ti, drag.di, ti, di)])] }
function onUp () { if (drag.on) { drag.on = false; if (drag.moved) justDragged.value = true } }
function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a => a.shift === tpl.name)) return; assignments.value = [...assignments.value, { tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }] }
function setCellReplace (techId, techName, iso, tpl) { const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept }
function removeShift (techId, iso, shift) { assignments.value = assignments.value.filter(a => !(a.tech === techId && a.date === iso && a.shift === shift)) }
function clearLocal (techId, iso) { assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)) }
// Ouvre le menu d'horaire (Jour/Soir/Garde/Absent + heures) ancré sur la cellule.
function openShiftMenu (t, d, ev, ti, di) {
selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d
if (ev && ev.clientX != null) { menu.x = ev.clientX; menu.y = ev.clientY; menu.target = menuAnchorEl.value } else { menu.target = ev && ev.currentTarget } // ancre au curseur si dispo
const wr = winOf(t.id, d.iso, false); quickEntry.value = ''
menuRange.value = wr ? { min: wr.s, max: wr.e } : { min: 8, max: 16 }
menu.show = true
}
// Y a-t-il une bande timeline cliquable sur cette case ? (shift régulier OU garde, et pas absent/pause)
// Clic gauche sur le FOND de cellule (zone sans quart ni job) → menu d'horaire (POSER / modifier un quart).
// Le clic sur la BANDE de quart/garde ou un BLOC de job ouvre la tournée (openDayFromCell, via @click.stop).
function onCellClick (t, d, ev, ti, di) {
if (justDragged.value) { justDragged.value = false; return }
activeCell.value = { id: t.id, name: t.name, iso: d.iso } // mémorise la case pour Cmd+C/V
if (ev.shiftKey && anchor.value) { selectBlock(ti, di); return }
if (ev.ctrlKey || ev.metaKey) { const k = t.id + '|' + d.iso; selection.value = selSet.value.has(k) ? selection.value.filter(x => x !== k) : [...selection.value, k]; anchor.value = { ti, di }; return }
// Clic = LISTE DE TÂCHES du tech (tournée du jour) dès que la cellule a du contenu (quart, jobs terrain, ou tickets sans déplacement).
// Cellule vide = menu d'horaire (on garde la pose de quart au clic). La pose en lot reste : glisser-sélectionner + saisie rapide / clic droit.
const occ = cellOcc(t.id, d.iso)
if (hasReg(t.id, d.iso) || onGarde(t.id, d.iso) || (occ && (occ.noShift || occ.officeN))) openDayFromCell(t, d)
else openShiftMenu(t, d, ev, ti, di)
}
// Clic sur la BANDE de quart/garde ou un BLOC de job → ouvre la tournée du jour. Guardé contre les fins de drag.
function openDayFromCell (t, d) { if (justDragged.value) { justDragged.value = false; return } activeCell.value = { id: t.id, name: t.name, iso: d.iso }; openDayEditor(t, d) }
// Clic DROIT sur une cellule → menu d'horaire aussi (bonus / accessibilité).
function onCellContext (t, d, ev, ti, di) {
if (justDragged.value) { justDragged.value = false; return }
activeCell.value = { id: t.id, name: t.name, iso: d.iso }
openShiftMenu(t, d, ev, ti, di)
}
function selectBlock (ti, di) { const a = anchor.value; const t0 = Math.min(a.ti, ti); const t1 = Math.max(a.ti, ti); const d0 = Math.min(a.di, di); const d1 = Math.max(a.di, di); const add = []; for (let i = t0; i <= t1; i++) for (let j = d0; j <= d1; j++) add.push(visibleTechs.value[i].id + '|' + dayList.value[j].iso); selection.value = [...new Set([...selection.value, ...add])] }
function maybeSelectCol (di) { const ks = visibleTechs.value.map(t => t.id + '|' + dayList.value[di].iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] }
function maybeSelectRow (ti) { const ks = dayList.value.map(d => visibleTechs.value[ti].id + '|' + d.iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] }
function isRowSelected (ti) { const t = visibleTechs.value[ti]; if (!t || !dayList.value.length) return false; return dayList.value.every(d => selSet.value.has(t.id + '|' + d.iso)) } // rangée entière sélectionnée ?
const menuCellShifts = computed(() => (menu.tech && menu.day) ? cellsOf(menu.tech.id, menu.day.iso) : [])
const menuIsAbsent = computed(() => (menu.tech && menu.day) ? isAbsent(menu.tech.id, menu.day.iso) : false)
function toggleAbsentMenu () { if (menu.tech && menu.day) { toggleAbsentCells([menu.tech.id + '|' + menu.day.iso]); menu.show = false } }
const menuIsGarde = computed(() => (menu.tech && menu.day) ? onGarde(menu.tech.id, menu.day.iso) : false)
function toggleGardeMenu () { if (menu.tech && menu.day) { toggleGardeCells([menu.tech.id + '|' + menu.day.iso]); menu.show = false } }
function removeShiftFromMenu (a) { pushHistory(); removeShift(a.tech, a.date, a.shift) }
function clearOne () { if (menu.tech && menu.day) { pushHistory(); clearLocal(menu.tech.id, menu.day.iso); menu.show = false } }
// Menu : copier / coller (marche au clic, sans Cmd+clic) + ajuster l'horaire au slider
function copyFromMenu () { if (!menu.tech || !menu.day) return; cellClipboard.value = cellsOf(menu.tech.id, menu.day.iso).map(a => a.shift); $q.notify({ type: 'positive', message: cellClipboard.value.length ? (cellClipboard.value.length + ' shift(s) copié(s) — ouvre une autre case puis Coller') : 'Case vide copiée (Coller la videra)' }) }
function pasteFromMenu () { if (!menu.tech || !menu.day) return; pushHistory(); if (!cellClipboard.value.length) { clearLocal(menu.tech.id, menu.day.iso) } else { for (const name of cellClipboard.value) { const tpl = tplByName.value[name]; if (tpl) addShift(menu.tech.id, menu.tech.name, menu.day.iso, tpl) } } menu.show = false }
// Applique une fenêtre [min,max] à la case du menu : trouve/crée un modèle auto-nommé puis remplace.
// Trouve OU crée un modèle de shift régulier pour une plage horaire (réutilisé par menu + barre de sélection).
async function ensureWindowTpl (min, max) {
const s = numToTime(min); const e = numToTime(max); const nm = fmtH(min) + 'h' + fmtH(max) + 'h'
let tpl = templates.value.find(t => t.template_name === nm)
if (!tpl) { try { await roster.createTemplate({ template_name: nm, start_time: s + ':00', end_time: e + ':00', hours: calcHours(s, e), color: '#1976d2', default_required: 1, on_call: 0 }); await refreshTemplates(); tpl = templates.value.find(t => t.template_name === nm) } catch (e2) { err(e2); return null } }
return tpl
}
async function applyWindow (min, max) {
if (!menu.tech || !menu.day || max <= min) return
const tpl = await ensureWindowTpl(min, max)
if (tpl) { pushHistory(); setCellReplace(menu.tech.id, menu.tech.name, menu.day.iso, tpl); menu.show = false }
}
function quickShift (min, max) { return applyWindow(min, max) }
async function applyMenuRange () { return applyWindow(menuRange.value.min, menuRange.value.max) }
// Saisie rapide d'heures : « 8-17 » · « 8:30-16 » · « 830-16 » · « 85 » (=8→17, dernier chiffre en pm si ≤ début).
function parseHM (tok) { tok = String(tok).trim().toLowerCase().replace(/h/g, ':').replace(/[^\d:]/g, ''); if (!tok) return null; if (tok.includes(':')) { const [h, m] = tok.split(':'); return Number(h) + (Number(m || 0)) / 60 } if (tok.length >= 3) return Number(tok.slice(0, -2)) + Number(tok.slice(-2)) / 60; return Number(tok) }
function parseQuickShift (str) {
const s = (str || '').trim().toLowerCase(); if (!s) return null
if (/[-–—]|to|→|\s/.test(s)) { const p = s.split(/[-–—]|to|→|\s+/).filter(Boolean); if (p.length < 2) return null; const a = parseHM(p[0]); const b = parseHM(p[1]); return (a == null || b == null || b <= a || b > 24) ? null : { min: a, max: b } }
if (/^\d{2}$/.test(s)) { const a = Number(s[0]); let b = Number(s[1]); if (b <= a) b += 12; return (b <= a || b > 24) ? null : { min: a, max: b } } // « 85 » = 8→17
return null
}
function applyQuick () { const r = parseQuickShift(quickEntry.value); if (!r) { $q.notify({ type: 'warning', message: 'Format : 8-17 · 8:30-16 · 85' }); return } quickEntry.value = ''; applyWindow(r.min, r.max) }
function assignBulk (tpl) { pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); const t = techs.value.find(x => x.id === tid); addShift(tid, t ? t.name : tid, iso, tpl) } selection.value = [] }
// ── Barre de sélection : mêmes 4 actions que le menu de cellule ──
async function bulkWindow (min, max) { if (!selection.value.length) return; const tpl = await ensureWindowTpl(min, max); if (tpl) assignBulk(tpl) }
function bulkQuick () { const r = parseQuickShift(quickEntry.value); if (!r) { $q.notify({ type: 'warning', message: 'Format : 8-17 · 8:30-16 · 85' }); return } quickEntry.value = ''; bulkWindow(r.min, r.max) }
function bulkGarde () { const t = selection.value.slice(); toggleGardeCells(t); selection.value = [] }
function bulkAbsent () { const t = selection.value.slice(); toggleAbsentCells(t); selection.value = [] }
function clearBulk () { pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); clearLocal(tid, iso) } selection.value = [] }
// Copier-coller une case (bâtir l'horaire vite) : copie les shifts de la 1re case sélectionnée → colle dans les autres
const cellClipboard = ref([])
function copyCell () {
const k = selection.value[0] || (activeCell.value && (activeCell.value.id + '|' + activeCell.value.iso))
if (!k) { $q.notify({ type: 'warning', message: 'Clique ou sélectionne une case d\'abord' }); return }
const [tid, iso] = k.split('|'); cellClipboard.value = cellsOf(tid, iso).map(a => a.shift)
$q.notify({ type: 'positive', message: cellClipboard.value.length ? (cellClipboard.value.length + ' shift(s) copié(s) — sélectionne des cases puis Coller (ou Cmd+V)') : 'Case vide copiée (Coller videra les cases)' })
}
function pasteCells () {
const targets = selection.value.length ? selection.value.slice() : (activeCell.value ? [activeCell.value.id + '|' + activeCell.value.iso] : [])
if (!targets.length) { $q.notify({ type: 'warning', message: 'Sélectionne les cases cibles' }); return }
pushHistory()
for (const k of targets) { const [tid, iso] = k.split('|'); const t = techs.value.find(x => x.id === tid); if (!cellClipboard.value.length) { clearLocal(tid, iso); continue } for (const name of cellClipboard.value) { const tpl = tplByName.value[name]; if (tpl) addShift(tid, t ? t.name : tid, iso, tpl) } }
if (selection.value.length) selection.value = []
}
async function togglePause (t) { try { const paused = !isPaused(t); await roster.pauseTechnician(t.id, paused); t.status = paused ? 'En pause' : 'Disponible'; $q.notify({ type: 'info', message: t.name + (paused ? ' en pause' : ' réactivé') }) } catch (e) { err(e) } }
function err (e) { $q.notify({ type: 'negative', message: '' + (e.message || e) }) }
function onKey (e) {
const tag = (e.target && e.target.tagName) || ''
if (/INPUT|TEXTAREA|SELECT/.test(tag) || (e.target && e.target.isContentEditable)) return // ne pas intercepter quand on tape dans un champ
const k = e.key.toLowerCase()
if ((e.ctrlKey || e.metaKey) && k === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return }
if ((e.ctrlKey || e.metaKey) && k === 'c' && (selection.value.length || activeCell.value)) { e.preventDefault(); menu.show = false; copyCell(); return }
if ((e.ctrlKey || e.metaKey) && k === 'v' && (selection.value.length || activeCell.value)) { e.preventDefault(); menu.show = false; pasteCells(); return }
if ((k === 'delete' || k === 'backspace') && (selection.value.length || activeCell.value)) {
e.preventDefault(); menu.show = false
const targets = selection.value.length ? selection.value.slice() : [activeCell.value.id + '|' + activeCell.value.iso]
pushHistory()
for (const key of targets) { const [tid, iso] = key.split('|'); clearLocal(tid, iso) }
if (selection.value.length) selection.value = []
}
if (k === 'a' && !e.altKey && (selection.value.length || activeCell.value)) { // « A » = bascule absent
e.preventDefault(); menu.show = false
const targets = selection.value.length ? selection.value.slice() : [activeCell.value.id + '|' + activeCell.value.iso]
toggleAbsentCells(targets); if (selection.value.length) selection.value = []
}
if (k === 'g' && !e.altKey && !e.ctrlKey && !e.metaKey && (selection.value.length || activeCell.value)) { // « G » = bascule garde (manuel)
e.preventDefault(); menu.show = false
const targets = selection.value.length ? selection.value.slice() : [activeCell.value.id + '|' + activeCell.value.iso]
toggleGardeCells(targets); if (selection.value.length) selection.value = []
}
}
function onUnload (e) { if (dirty.value) { e.preventDefault(); e.returnValue = '' } }
// ── Veille Legacy → Ops (SSE topic 'dispatch') : reflète fermeture / réassignation hors-Ops / activité en direct ──
let _legacyRefreshT = null
function scheduleLegacyRefresh () {
clearTimeout(_legacyRefreshT)
_legacyRefreshT = setTimeout(async () => {
try { await reloadOccupancy(); if (assignPanel.open) { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } } catch (e) { /* non bloquant */ }
}, 700)
}
function onLegacyUpdate (data) {
const ch = (data && data.changes) || []
if (!ch.length) return
const closed = ch.filter(c => c.kind === 'closed').length
const taken = ch.filter(c => c.kind === 'taken')
const moved = ch.filter(c => c.kind === 'reassigned' && !c.pool)
const stat = ch.filter(c => c.kind === 'status').length
if (closed) $q.notify({ type: 'info', icon: 'task_alt', message: `Legacy : ${closed} ticket(s) fermé(s) — retiré(s) du planning`, timeout: 3500 })
if (taken.length) $q.notify({ type: 'info', icon: 'person_remove', message: `Legacy : ${taken.length} ticket(s) pris par un tech dans Legacy — retiré(s) du pool`, timeout: 4000 })
for (const m of moved) $q.notify({ type: 'warning', icon: 'swap_horiz', message: `Ticket #${m.ticket} réassigné HORS-Ops (staff ${m.to_staff}) — à vérifier`, timeout: 6000 })
if (stat) $q.notify({ type: 'info', icon: 'sync', message: `Legacy : ${stat} changement(s) de statut`, timeout: 3000 })
for (const c of ch) { if (c.job) flashJob.value = c.job }
scheduleLegacyRefresh()
}
const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } })
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } if ($q.screen.lt.md) { try { await reloadPool() } catch (e) { /* */ } } else openAssignPanel(); /* mobile : charge le pool pour la liste « À assigner » (assignation au toucher), pas de panneau flottant ; desktop : panneau ouvert */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ })
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload); if (_nowTimer) clearInterval(_nowTimer); if (_kbRO) _kbRO.disconnect() })
onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })
</script>
<style scoped>
/* Barre d'actions de sélection : flottante (fixed) → hors flux, aucune incidence sur la hauteur de la grille. */
.sel-actions { position: fixed; left: 50%; transform: translateX(-50%); bottom: 18px; z-index: 4000; display: flex; align-items: center; flex-wrap: wrap; gap: 2px; max-width: 94vw; padding: 6px 12px; background: #e0f2f1; color: #00695c; border: 1px solid #4db6ac; border-radius: 9px; box-shadow: 0 6px 22px rgba(0,0,0,.20); }
.garde-editor { background: #faf8f6; border: 1px solid #e8e0d8; } /* sous-panneau éditeur de garde */
/* Panneau flottant « jobs à assigner » (déplaçable, glisser-déposer) */
.assign-panel { position: fixed; z-index: 5000; width: 320px; max-height: 72vh; background: #fff; border: 1px solid #cfd8dc; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,.24); display: flex; flex-direction: column; }
.assign-hdr { display: flex; align-items: center; gap: 5px; padding: 6px 10px; background: #5e35b1; color: #fff; border-radius: 8px 8px 0 0; cursor: move; font-weight: 600; font-size: 13px; user-select: none; }
.assign-sortbar { display: flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 11px; color: #555; background: #f3f0fa; border-bottom: 1px solid #e0e0e0; }
.assign-sortbar select { font-size: 11px; border: 1px solid #cfc4e8; border-radius: 5px; padding: 1px 4px; background: #fff; color: #333; flex: 1; }
.assign-body { overflow: auto; padding: 5px; flex: 1 1 auto; min-height: 60px; }
.assign-map-wrap { flex: 0 0 auto; padding: 0 5px 4px; position: relative; }
.map-sat-btn { position: absolute; top: 7px; left: 11px; z-index: 3; background: rgba(255,255,255,.9); }
.map-lasso-btn { position: absolute; top: 44px; left: 11px; z-index: 3; background: rgba(255,255,255,.9); }
.lasso-box { position: absolute; top: 0; left: 0; background: rgba(99,102,241,.14); border: 1.5px dashed #6366f1; border-radius: 3px; pointer-events: none; z-index: 4; }
.loc-map-wrap { position: relative; }
.loc-map { height: 360px; border-radius: 6px; overflow: hidden; border: 1px solid #cfd8dc; }
.loc-map-hint { position: absolute; bottom: 8px; left: 8px; background: rgba(255,255,255,.88); font-size: 11px; padding: 2px 7px; border-radius: 4px; color: #555; pointer-events: none; }
.loc-results { max-height: 190px; overflow: auto; border-radius: 6px; }
.loc-pick-link { cursor: pointer; text-decoration: underline; font-weight: 600; }
/* Bande de capacité restante par jour (AM/PM/Soir) dans l'en-tête */
.cap-strip { display: flex; gap: 2px; justify-content: center; align-items: center; margin-top: 2px; }
.cap-seg { font-size: 9px; font-weight: 700; line-height: 1; min-width: 15px; padding: 2px 2px; border-radius: 3px; text-align: center; cursor: default; }
.cap-ok { background: #c8e6c9; color: #1b5e20; }
.cap-low { background: #ffe0b2; color: #e65100; }
.cap-full { background: #ffcdd2; color: #b71c1c; }
.cap-none { background: #eceff1; color: #b0bec5; }
.load-cell { min-width: 38px; display: inline-block; } /* pastille « est/dispo » au pied de tableau */
/* Liste des jobs prévus (non assignés) en en-tête de jour — glissables sur une case tech */
.dayjobs-hdr { width: 234px; max-width: 234px; margin: 3px auto 0; max-height: 132px; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; gap: 2px; text-align: left; }
.dayjobs-more { font-size: 10px; font-weight: 600; color: #6366f1; text-align: center; padding: 2px 0; cursor: pointer; border-radius: 4px; }
.dayjobs-more:hover { background: #eef2ff; }
.dayjob { width: 100%; box-sizing: border-box; font-size: 10px; line-height: 1.2; background: #fff; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 5px; cursor: grab; overflow: hidden; display: flex; align-items: center; gap: 4px; box-shadow: 0 1px 1px rgba(0,0,0,.05); }
.dayjob:hover { border-color: #90a4ae; background: #f5f7fa; }
.dayjob.dragging { opacity: .4; }
.dayjob.hold { opacity: .6; font-style: italic; }
.dj-est { font-weight: 700; color: #00695c; flex: 0 0 auto; font-size: 9px; }
.dj-sub { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* min-width:0 = indispensable pour l'ellipsis dans un flex */
.dayjobs-empty { font-size: 9px; color: #cfd8dc; text-align: center; padding: 2px 0; }
/* bloc LEGACY sur la timeline : coloré par type (comme les jobs importés) + anneau blanc pointillé pour le distinguer */
.tl-blk-legacy { box-shadow: inset 0 0 0 1.5px rgba(255,255,255,.9); } /* legacy = anneau blanc inset */
.tl-blk-done { opacity: 0.85; } /* job COMPLÉTÉ → fond gris neutre (blkFill) + ✓ ; léger estompage pour le recul visuel */
.ovl-warn { position: absolute; top: 0; right: 1px; z-index: 3; pointer-events: auto; filter: drop-shadow(0 0 1.5px #fff) drop-shadow(0 0 1px #fff); } /* triangle orange = surcharge ; halo blanc → ressort sur n'importe quel fond */
.cap-warn { margin-left: 1px; }
.de-actual { font-size: 11px; font-weight: 700; color: #00695c; background: #e0f2f1; border-radius: 4px; padding: 2px 5px; white-space: nowrap; }
.chrono-run { animation: chronopulse 1.4s ease-in-out infinite; }
@keyframes chronopulse { 0%, 100% { opacity: 1 } 50% { opacity: .4 } }
.jc-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.jc-table th { text-align: left; color: #607d8b; font-weight: 600; padding: 3px 4px; border-bottom: 1px solid #e0e0e0; }
.jc-table td { padding: 2px 4px; vertical-align: middle; }
.jc-table tr.jc-base td:first-child { border-left: 3px solid #1565c0; }
.jc-table tr.jc-addon td:first-child { border-left: 3px solid #00897b; }
.jc-table tr.jc-modifier td:first-child { border-left: 3px solid #8e24aa; }
.jc-learn { font-size: 10px; color: #2e7d32; white-space: nowrap; }
.assign-map { height: 230px; border-radius: 6px; overflow: hidden; border: 1px solid #cfd8dc; }
.assign-map-cap { font-size: 10px; color: #607d8b; padding: 2px 2px 0; line-height: 1.3; }
.assign-chips { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 6px; border-bottom: 1px solid #eee; flex: 0 0 auto; }
.assign-chip-f { font-size: 10px; border: 1.5px solid #cfd8dc; border-radius: 11px; padding: 1px 8px; cursor: pointer; user-select: none; line-height: 1.6; background: #fff; color: #455a64; }
.assign-chip-f.clear { border-color: #b0bec5; color: #607d8b; font-weight: 700; }
.assign-chip-f:hover { filter: brightness(0.95); }
.assign-resize { position: absolute; right: 1px; bottom: 1px; width: 16px; height: 16px; cursor: nwse-resize; background: linear-gradient(135deg, transparent 45%, #b0bec5 45%, #b0bec5 55%, transparent 55%, transparent 70%, #b0bec5 70%, #b0bec5 80%, transparent 80%); border-radius: 0 0 8px 0; z-index: 2; }
.assign-grp-badge { display: inline-block; background: #cfd8dc; color: #455a64; font-weight: 700; border-radius: 4px; padding: 0 5px; font-size: 10px; line-height: 16px; }
.assign-grp { margin-bottom: 6px; border-radius: 7px; padding: 2px; }
.assign-grp-lbl { font-size: 11px; font-weight: 700; color: #37474f; padding: 3px 6px 2px; border-bottom: 1px solid #eee; margin-bottom: 2px; position: sticky; top: 0; background: #fff; z-index: 1; }
.assign-grp.grp-hl { background: #ede7f6; box-shadow: inset 0 0 0 1px #b39ddb; } /* groupe lié surligné dès qu'un membre est coché */
.assign-grp-hdr { font-size: 10px; font-weight: 700; color: #5e35b1; padding: 2px 6px; cursor: pointer; display: flex; align-items: center; gap: 3px; }
.assign-grp-hdr:hover { text-decoration: underline; }
.assign-job { border: 1px solid #e0e0e0; border-radius: 6px; padding: 3px 7px; margin: 3px 0; cursor: grab; background: #fafafa; font-size: 12px; }
.assign-job:hover { border-color: #5e35b1; background: #f3e9fb; }
.assign-job:active { cursor: grabbing; }
.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-pick-hd { font-size: 11px; font-weight: 700; color: #455a64; padding: 6px 10px; border-bottom: 1px solid #eef2f7; background: #f7f5fc; }
.assign-pick-incap { opacity: 0.62; }
.assign-skill { display: inline-block; color: #fff; border-radius: 6px; padding: 0 5px; font-size: 9px; font-weight: 600; margin-right: 3px; }
.assign-lvl { display: inline-flex; align-items: center; gap: 1px; border: 1px solid #cfd8dc; border-radius: 6px; padding: 0 5px; font-size: 9px; font-weight: 600; color: #607d8b; margin-right: 3px; cursor: pointer; }
.assign-lvl.set { border-color: #6366f1; color: #4338ca; background: #eef2ff; }
.jtr-row { display: flex; align-items: center; gap: 10px; padding: 5px 6px; border-bottom: 1px solid #f0f2f5; }
.jtr-type { flex: 1; min-width: 0; font-size: 13px; font-weight: 600; color: #37474f; }
.jtr-stars { display: flex; align-items: center; gap: 1px; }
/* D — surcouche de revue de la répartition suggérée */
.suggest-tech { border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 8px; overflow: hidden; }
.suggest-wknd { border-color: #f59e0b; background: #fffbeb; } /* groupe placeholder « en attente du quart week-end » */
.suggest-map { height: 440px; border-radius: 8px; overflow: hidden; border: 1px solid #cfd8dc; }
.suggest-legend { display: flex; flex-wrap: wrap; gap: 4px 12px; padding: 6px 2px 2px; }
.suggest-leg { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: #37474f; }
.suggest-leg-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.suggest-tech-hd { display: flex; align-items: center; padding: 6px 10px; background: #f7f5fc; border-bottom: 1px solid #eef2f7; }
.suggest-occ { display: inline-flex; align-items: center; gap: 6px; margin-left: 12px; min-width: 130px; }
.suggest-occ-bar { flex: 1; height: 6px; border-radius: 3px; background: #e2e8f0; overflow: hidden; min-width: 60px; }
.suggest-occ-fill { height: 100%; background: #16a34a; border-radius: 3px; transition: width .2s; }
.suggest-occ-fill.high { background: #f59e0b; }
.suggest-occ-fill.over { background: #ef4444; }
.suggest-occ-txt { font-size: 11px; font-weight: 700; color: #475569; white-space: nowrap; }
.suggest-day { padding: 2px 8px 6px; }
.suggest-day-lbl { font-size: 11px; font-weight: 700; color: #475569; padding: 4px 2px 2px; }
.suggest-entry { display: flex; align-items: center; gap: 2px; padding: 4px 6px; border-radius: 6px; font-size: 12.5px; }
.suggest-entry:hover { background: #f8fafc; }
.suggest-entry-t { flex: 1; min-width: 0; }
.suggest-tech-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 6px; }
.suggest-tech-chip { display: flex; align-items: center; gap: 6px; padding: 5px 8px; border: 1px solid #e2e8f0; border-radius: 8px; cursor: pointer; font-size: 12.5px; background: #fff; user-select: none; }
.suggest-tech-chip:hover { background: #f8fafc; }
.suggest-tech-chip.on { border-color: #6366f1; background: #eef2ff; }
.suggest-skill-chip { border: 1px solid; border-radius: 12px; padding: 1px 9px; font-size: 11px; cursor: pointer; user-select: none; background: #fff; }
.suggest-step { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; border-radius: 50%; background: #e0e7ff; color: #4338ca; font-size: 10px; font-weight: 700; margin-right: 2px; flex-shrink: 0; }
.suggest-mlvl { font-size: 10px; color: #b45309; font-weight: 700; white-space: nowrap; margin-right: 3px; }
.assign-foot { border-top: 1px solid #e0e0e0; padding: 6px 9px; font-size: 11px; color: #555; line-height: 1.45; background: #fafafa; border-radius: 0 0 8px 8px; }
.assign-job.dragging { opacity: .4; } /* source estompée pendant le glissé (le fantôme compact suit le curseur) */
/* Dialogue Timeline d'une ressource */
.tldlg-day { padding: 7px 0; border-bottom: 1px solid #eee; }
.tldlg-bar { position: relative; height: 18px; background: #f1f3f5; border-radius: 3px; overflow: hidden; margin-bottom: 4px; }
.tldlg-tick { position: absolute; top: 1px; font-size: 8px; color: #90a4ae; transform: translateX(-50%); pointer-events: none; }
.tldlg-job { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 2px 2px 2px 4px; border-radius: 4px; }
.tldlg-job:hover { background: #f5f5f5; }
.tldlg-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.tldlg-time { font-variant-numeric: tabular-nums; color: #555; flex: 0 0 auto; min-width: 34px; }
.cell.drop-hover { outline: 2px dashed #5e35b1; outline-offset: -2px; background: #f3e9fb; }
.tl-proj { position: absolute; top: 0; bottom: 0; left: 0; border-radius: 2px; opacity: .42; outline: 1px dashed rgba(0,0,0,.35); outline-offset: -1px; } /* aperçu occupation projetée (fantôme) */
.drop-badge { position: absolute; top: -8px; right: 2px; z-index: 6; background: #5e35b1; color: #fff; font-size: 9px; font-weight: 700; padding: 0 4px; border-radius: 5px; white-space: nowrap; box-shadow: 0 1px 4px rgba(0,0,0,.3); pointer-events: none; }
.drop-badge.over { background: #e53935; } /* projection ≥ 100% = surbooké */
.grid-wrap { overflow-x: auto; border: 1px solid #e0e0e0; border-radius: 6px; max-width: 100%; }
.roster-grid { border-collapse: collapse; font-size: 12px; width: 100%; user-select: none; -webkit-user-select: none; }
.roster-grid th, .roster-grid td { border: 1px solid #c7d0dc; text-align: center; padding: 2px; } /* séparateurs plus contrastés (était #eee, invisible sur le gris des cellules) */
.roster-grid th + th, .roster-grid td + td { border-left-color: #aab6c6; } /* séparateur de COLONNE (jour) plus marqué que les lignes — en-tête ET rangées */
.roster-grid thead th { position: sticky; top: 0; background: #fafafa; z-index: 1; }
.tech-col { position: sticky; left: 0; background: #fff; text-align: left !important; white-space: nowrap; padding: 2px 8px !important; min-width: 240px; max-width: 300px; z-index: 2; }
.roster-grid thead .tech-col { z-index: 3; }
.roster-grid tbody tr:hover td { background: #f0f7ff; }
.roster-grid tbody tr:hover .tech-col { background: #f0f7ff; }
th.weekend, td.weekend { background: #f5f5f5; }
th.holiday, td.holiday { background: #fff3e0; }
th.clk, td.clk { cursor: pointer; }
.dow { font-size: 10px; color: #999; text-transform: uppercase; }
.dnum { font-size: 11px; font-weight: 600; }
.grp { font-size: 9px; color: #999; background: #f0f0f0; border-radius: 3px; padding: 0 4px; margin-left: 2px; }
.role-ic { color: #546e7a; vertical-align: middle; margin-right: 3px; display: inline-flex; } /* icône de rôle monochrome (Lucide) */
.tech-row { display: flex; align-items: center; gap: 3px; flex-wrap: nowrap; min-width: 0; }
.tech-name { white-space: nowrap; flex-shrink: 0; }
.th { white-space: nowrap; flex-shrink: 0; }
.tech-row > .q-btn, .tech-row > .q-badge, .tech-row > .role-ic, .tech-row > .grp, .tech-row > .eff { flex-shrink: 0; }
.tech-skills { display: flex; align-items: center; gap: 2px; overflow: hidden; flex: 1 1 auto; min-width: 0; } /* chips inline, débordement clippé */
.skill-edit-btn { flex-shrink: 0; }
.skill-chip { font-size: 10px; line-height: 15px; height: 15px; padding: 0 5px; border-radius: 8px; color: #fff; font-weight: 600; white-space: nowrap; flex-shrink: 0; display: inline-flex; align-items: center; gap: 2px; }
.chip-lvl { display: inline-flex; align-items: center; justify-content: center; min-width: 13px; height: 13px; padding: 0 1px; border-radius: 50%; background: rgba(0,0,0,.32); font-size: 8px; font-weight: 800; box-shadow: 0 0 0 1.5px #fff; margin-left: 1px; } /* niveau 15 · contour blanc pour détacher la couleur (vitesse) */
.add-skill-hint { font-size: 10px; color: #9e9e9e; font-style: italic; } /* invite quand aucune compétence */
/* Compétence compacte = cercle de la couleur du skill + chiffre du niveau ; survol → nom complet + niveau + vitesse */
/* Compétence : cercle de couleur avec ICÔNE vectorielle (Material, offline) ; devient un rectangle arrondi (pill) icône+chiffre si un niveau est défini */
.skill-dot { display: inline-flex; align-items: center; justify-content: center; gap: 1px; min-width: 16px; height: 16px; padding: 0 1px; border-radius: 50%; color: #fff; flex-shrink: 0; cursor: pointer; box-shadow: inset 0 0 0 1px rgba(0,0,0,.16); }
.skill-dot.has-lvl { border-radius: 8px; padding: 0 4px 0 2px; }
.skill-dot .q-icon { color: #fff; }
.sd-lvl { font-size: 9px; font-weight: 800; line-height: 1; color: #fff; text-shadow: 0 1px 1px rgba(0,0,0,.4); }
.skill-more { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; font-size: 9px; font-weight: 800; color: #5a6678; background: #e8ebf0; box-shadow: inset 0 0 0 1px rgba(0,0,0,.10); cursor: pointer; flex-shrink: 0; } /* « +N » compétences restantes (détail au survol) */
.skill-more:hover { background: #dde1e8; }
/* Barre de charge AGRÉGÉE par tech sur les journées visibles (occupé / offrable) */
.tech-load { display: inline-flex; align-items: center; gap: 4px; margin-left: 4px; flex-shrink: 0; cursor: default; }
.tech-load-bar { width: 44px; height: 6px; border-radius: 3px; background: #e3e7ea; overflow: hidden; display: inline-block; }
.tech-load-fill { display: block; height: 100%; border-radius: 3px; }
.tech-load-pct { font-size: 9px; font-weight: 700; color: #607d8b; }
.tech-load-pct.over { color: #c62828; }
.hide-eye { flex-shrink: 0; opacity: .45; } .tech-row:hover .hide-eye { opacity: 1; } /* œil masquer (discret, visible au survol) */
tr.res-hidden { opacity: .5; background: repeating-linear-gradient(45deg, #fafafa, #fafafa 6px, #f0f0f0 6px, #f0f0f0 12px); } /* ressource masquée affichée en grisé */
tr.res-hidden .hide-eye { opacity: 1; }
.tech-name.clk:hover { text-decoration: underline; }
.hol-toggle { font-size: 9px; color: #ccc; cursor: pointer; border: 1px solid #eee; border-radius: 3px; width: 14px; margin: 1px auto 0; line-height: 12px; }
.hol-toggle.on { background: #ff9800; color: #fff; border-color: #ff9800; }
.cell { cursor: pointer; min-height: 24px; position: relative; background: #eef1f4; } /* gris pâle = INDISPONIBLE par défaut ; un quart crée une zone blanche (.tl-shift) */
.cell.over { background: #fbe2cf; } /* SURCHARGE : la cellule entière se teinte orange pâle (le triangle reste dans le coin) */
.cell.noshift { background: #fff3e0; box-shadow: inset 0 0 0 1.5px #ffb74d; } /* WARNING : ticket F assigné mais AUCUN quart planifié ce jour → cellule ambre */
.cell:hover { outline: 2px solid #1976d2; outline-offset: -2px; }
.cell.sel { outline: 2px solid #00897b; outline-offset: -2px; background: #e0f2f1; }
.cell.dirty { box-shadow: inset 0 0 0 2px #ff9800; }
.cell.cov { cursor: default; font-size: 11px; }
.code-chip { display: inline-block; min-width: 18px; padding: 1px 5px; border-radius: 4px; font-weight: 700; font-size: 11px; line-height: 16px; margin: 1px; }
.cell-dirty-demo { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 4px; font-weight: 700; font-size: 11px; background: #1976d2; color: #fff; box-shadow: inset 0 0 0 2px #ff9800; }
.ch-h { opacity: .7; font-weight: 400; font-size: 9px; margin-left: 1px; }
.free { color: #ccc; }
.offshift-warn { display: inline-flex; align-items: center; gap: 1px; font-size: 10px; font-weight: 700; color: #ef6c00; cursor: pointer; line-height: 1; } /* job assigné un jour sans quart publié */
.office-dots { position: absolute; right: 2px; top: 2px; bottom: 2px; z-index: 2; display: flex; flex-direction: column; flex-wrap: wrap; align-content: flex-end; gap: 1.5px; cursor: pointer; } /* tickets SANS déplacement (admin/support/NP) = points ronds empilés (≈3-4 de haut puis nouvelle colonne vers la gauche), colorés par dept ; clic = liste cliquable, survol = aperçu ; n'occupent pas la charge */
.office-dot { width: 5px; height: 5px; min-width: 5px; border-radius: 50%; opacity: .9; display: inline-block; box-shadow: 0 0 0 .5px rgba(255,255,255,.5); }
/* tickets SANS déplacement (vue semaine) = 1 SEUL bloc POINTILLÉ ; largeur ∝ somme des estimés (plancher 1h) ; survol/clic = liste des tâches */
.office-blk { position: absolute; top: auto; bottom: 2px; height: 11px; z-index: 3; min-width: 7px; box-sizing: border-box; border-radius: 4px; cursor: pointer; border: 1px solid rgba(90,99,140,.5); background: repeating-linear-gradient(45deg, rgba(99,102,241,.24) 0 3px, rgba(99,102,241,.06) 3px 6px); display: flex; align-items: center; justify-content: center; } /* admin/sans-déplacement = bloc RÉGULIER (aligné, rayures diagonales pour le distinguer) mais plus MINCE (11px) et FLUSH au bas (2px) — fini le demi-bloc flottant décollé qui désorganisait */
.office-blk:hover { border-color: rgba(79,70,229,.9); filter: brightness(1.05); }
.office-blk-ic { color: rgba(79,70,229,.7); }
.hdr-ruler { position: relative; height: 11px; margin-top: 3px; }
.hdr-ruler .tick { position: absolute; top: 2px; transform: translateX(-50%); font-size: 8px; color: #aab; line-height: 1; font-weight: 400; }
.hdr-ruler .tick::before { content: ''; position: absolute; top: -3px; left: 50%; width: 1px; height: 2px; background: #d0d0d8; }
.tl { position: relative; height: 24px; min-width: 110px; background: transparent; border-radius: 4px; margin: 2px 0; overflow: hidden; cursor: pointer; } /* track TRANSPARENT : toute la cellule est blanche (pas d'élément blanc séparé) */
/* ── Mode JOUR (style Gaiia) : 1 colonne pleine largeur → timelines hautes + règle d'heures alignée → dispo des jobs facile ── */
.roster-grid.day-mode .tl { height: 30px; }
.roster-grid.day-mode .cell { min-height: 34px; }
.grid-axis { position: relative; width: 100%; height: 13px; margin: 2px 0 1px; border-bottom: 1px dashed #e3e7ee; }
.grid-axis-tick { position: absolute; top: 0; transform: translateX(-50%); font-size: 9px; line-height: 13px; color: #90a4ae; font-weight: 700; white-space: nowrap; }
.grid-axis-tick::after { content: 'h'; font-weight: 400; opacity: .65; }
/* ── Vue BOARD (Kanban horizontal) : pool vertical (recherche/tri) + techs en lanes horizontales à échelle d'heures ── */
.kbb-wrap { display: flex; gap: 10px; align-items: flex-start; padding: 2px 2px 12px; }
.kbb-pool { flex: 0 0 268px; width: 268px; background: #eef2f7; border: 1px solid #d7deea; border-radius: 8px; display: flex; flex-direction: column; max-height: calc(100vh - 205px); }
.kbb-pool.drop-hover { outline: 2px dashed #5e35b1; outline-offset: -2px; background: #f3e9fb; }
.kbb-pool-hd { display: flex; align-items: center; font-size: 12px; font-weight: 700; color: #2b3445; padding: 7px 9px 4px; }
.kbb-pool-tools { display: flex; flex-direction: column; gap: 5px; padding: 0 7px 7px; border-bottom: 1px solid #e0e6ef; }
.kbb-pool-body { flex: 1 1 auto; overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 5px; }
.kbb-board { flex: 1 1 auto; min-width: 0; }
.kbb-daybar { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #455; padding: 2px 4px 6px; flex-wrap: wrap; }
.kbb-scroll { overflow-x: auto; padding-bottom: 8px; }
.kbb-axis-row, .kbb-row { display: flex; align-items: stretch; width: max-content; min-width: 100%; }
.kbb-name-sp, .kbb-name { flex: 0 0 150px; width: 150px; box-sizing: border-box; position: sticky; left: 0; z-index: 5; background: #fff; }
.kbb-name { display: flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 600; color: #2b3445; padding: 0 4px 0 6px; border-right: 1px solid #e3e7ee; }
.kbb-name .ellipsis { flex: 1 1 auto; min-width: 0; }
.kbb-axis { position: relative; flex: 0 0 auto; height: 16px; border-bottom: 1px dashed #e3e7ee; }
.kbb-row { border-bottom: 1px solid #e2e6ed; } /* hairline très mince, centrée entre 2 lanes (marges symétriques) */
.kbb-lane { position: relative; flex: 0 0 auto; height: 60px; margin: 2px 0; background: #fff; border-radius: 4px; overflow: hidden; }
.kbb-lane.drop-hover { outline: 2px dashed #5e35b1; outline-offset: -2px; background: #f3e9fb; }
.kbb-lane-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 10.5px; color: #aab2bd; pointer-events: none; }
/* trajet d'approche (km/min) avant le job : zone pâle hachurée, plus courte que le bloc */
.kbb-leg { position: absolute; top: 15px; bottom: 15px; background: repeating-linear-gradient(45deg, rgba(120,144,176,.10) 0 4px, rgba(120,144,176,.18) 4px 8px); border: 1px dashed rgba(120,144,176,.5); border-radius: 3px; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.kbb-leg-t { font-size: 9px; font-weight: 700; color: #5b6b80; white-space: nowrap; }
.kbb-blk { position: absolute; top: 7px; bottom: 7px; border-radius: 5px; background: #fff; color: #1c2533; border: 1.5px solid #cfd6e0; border-left-width: 4px; display: flex; flex-direction: column; justify-content: center; gap: 1px; padding: 2px 7px; cursor: grab; box-shadow: 0 1px 3px rgba(0,0,0,.12); overflow: hidden; } /* carte BLANCHE, contour + icône colorés (skill), texte noir — style Gaiia */
.kbb-blk:hover { box-shadow: 0 2px 8px rgba(0,0,0,.22); z-index: 2; }
.kbb-blk.assist { border-style: dashed; background: #faf8ff; cursor: default; }
.kbb-blk-l1 { display: flex; align-items: center; gap: 3px; font-size: 11px; font-weight: 700; line-height: 1.2; }
.kbb-blk-l2 { font-size: 9.5px; font-weight: 500; color: #6a7686; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kbb-blk-t { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Volet détails (double-clic) : grand panneau droit */
.jd-card { width: min(900px, 84vw); max-width: 94vw; height: 100vh; display: flex; flex-direction: column; }
.jd-body { flex: 1 1 auto; overflow: auto; }
.jd-meta { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: #455; }
.jd-meta > div { display: flex; align-items: center; gap: 6px; }
.jd-detail { margin-top: 10px; padding: 8px 10px; background: #f6f8fb; border-radius: 6px; white-space: pre-wrap; font-size: 12.5px; color: #333; }
.jd-team { margin-top: 12px; padding: 10px; border: 1px solid #e6e2f2; background: #faf9fe; border-radius: 8px; }
.jd-map { width: 100%; height: 240px; border-radius: 8px; overflow: hidden; margin-bottom: 8px; background: #eceff3; }
.jd-track-row { display: flex; align-items: center; gap: 6px; margin: -2px 0 8px; }
.dt-toggle { cursor: pointer; margin-left: 8px; padding: 0 7px; border-radius: 10px; border: 1px solid #ef6c00; color: #ef6c00; font-weight: 600; white-space: nowrap; }
.dt-toggle.on { background: #ef6c00; color: #fff; }
.kbb-rows { position: relative; width: max-content; min-width: 100%; }
.kbb-now-full { position: absolute; top: 0; bottom: 0; width: 2px; background: #e53935; z-index: 4; pointer-events: none; box-shadow: 0 0 3px rgba(229,57,53,.5); }
.kbb-shift { position: absolute; top: 2px; bottom: 2px; border: 1px solid #dfe4ea; border-radius: 4px; background: #fff; pointer-events: none; } /* quart = zone BLANCHE (lumière = dispo) sur le fond gris */
.kbb-shift.oncall { border-style: dotted; border-color: #f9a825; background: rgba(249,168,37,.06); }
/* garde = shift SUR APPEL : bande jaune pâle + téléphone rouge */
.kbb-garde { position: absolute; top: 7px; bottom: 7px; background: rgba(255,213,79,.32); border: 1px solid rgba(245,166,35,.6); border-radius: 5px; display: flex; align-items: center; padding: 0 5px; overflow: hidden; }
.kbb-now-lbl { position: absolute; top: 0; transform: translateX(-50%); background: #e53935; color: #fff; font-size: 9px; font-weight: 800; line-height: 13px; padding: 0 4px; border-radius: 3px; white-space: nowrap; z-index: 6; pointer-events: none; }
/* Cartes du pool (réutilisées) */
.kb-card { background: #fff; border: 1px solid #e6e9ef; border-left: 4px solid #90a4ae; border-radius: 6px; padding: 5px 7px; cursor: grab; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
.kb-card:hover { box-shadow: 0 2px 6px rgba(0,0,0,.13); }
.kb-card:active { cursor: grabbing; }
.kb-card.dragging { opacity: .4; }
.kb-card.hold { opacity: .62; cursor: not-allowed; background: repeating-linear-gradient(45deg, #fafafa 0 6px, #f0f0f0 6px 12px); }
.kb-card-t { font-size: 12px; font-weight: 600; color: #222; line-height: 1.25; display: flex; align-items: center; }
.kb-card-m { font-size: 10.5px; color: #67707e; display: flex; align-items: center; gap: 4px; margin-top: 2px; }
.kb-dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto; }
.kb-count { margin-left: auto; background: #d7deea; color: #3a4658; border-radius: 9px; padding: 0 7px; font-size: 11px; }
.kb-load { font-size: 11px; font-weight: 700; color: #2e7d32; }
.kb-load.over { color: #e53935; }
.kb-empty { font-size: 11px; color: #9aa3b0; text-align: center; padding: 10px 4px; border: 1px dashed #d7deea; border-radius: 6px; }
.tl-click { cursor: pointer; } /* clic sur le progressbar → menu jobs (détail + réordonner) */
.tl-click:hover { outline: 1px solid #1976d2; outline-offset: 1px; }
/* Éditeur de journée (clic progressbar) — lignes draggables */
.de-row { display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-bottom: 1px solid #eee; background: #fff; cursor: default; }
.de-row.de-drag { opacity: .5; background: #ede7f6; }
.de-row.de-pinned { box-shadow: inset 3px 0 0 #5c6bc0; } /* position FIGÉE (1er/dernier) → liseré indigo */
.de-row:hover { background: #f7f5fc; }
.de-handle { touch-action: none; } /* indispensable pour le glisser TACTILE (vuedraggable/SortableJS) */
.de-ghost { opacity: .45; background: #ede7f6; } /* placeholder pendant le glisser */
.de-ghost .de-row { background: #ede7f6; }
.de-ord { font-size: 12px; font-weight: 700; color: #607d8b; min-width: 16px; text-align: center; }
.de-dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; }
/* minimap du jour (territoire des arrêts) */
.de-map-wrap { margin: 8px 0 4px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; position: relative; }
.de-map-gl { width: 100%; height: 240px; }
.de-map-cap { font-size: 10px; color: #777; padding: 3px 6px; background: #fafafa; border-top: 1px solid #eee; }
.de-prio { font-size: 11px; border: 1px solid #ccc; border-left-width: 4px; border-radius: 4px; padding: 2px 4px; background: #fff; }
.de-dur { display: flex; align-items: center; gap: 2px; font-size: 10px; color: #888; }
.de-dur input { width: 46px; font-size: 11px; text-align: right; border: 1px solid #cfc4e8; border-radius: 4px; padding: 2px 3px; }
.de-travel { font-size: 10px; color: #8a6d3b; padding: 1px 0 1px 40px; opacity: .85; } /* espace entre 2 jobs = transport */
.de-depart { color: #3949ab; font-weight: 600; opacity: 1; } /* trajet origine (dépôt/domicile) → 1er job */
.de-detail { font-size: 11px; line-height: 1.4; color: #444; background: #f7f5fc; border-left: 3px solid #b39ddb; border-radius: 4px; margin: 0 4px 6px 40px; padding: 6px 8px; max-height: 280px; overflow: auto; }
.de-msg { border-top: 1px solid #e6e0f0; padding: 4px 0; }
.de-msg:first-of-type { border-top: none; }
.de-msg-hdr { font-size: 11px; font-weight: 700; color: #5e35b1; }
.de-msg-txt { font-size: 11px; white-space: pre-wrap; color: #37474f; }
.assign-thread { background: #f5f5f5; border-radius: 5px; padding: 4px 7px; margin-top: 4px; max-height: 220px; overflow: auto; }
.assign-msg { font-size: 11px; border-top: 1px solid #e0e0e0; padding: 3px 0; }
.assign-msg:first-child { border-top: none; }
.assign-msg-txt { white-space: pre-wrap; color: #37474f; }
.tl-shift { position: absolute; top: 0; bottom: 0; background: #fff; border-radius: 4px; border: 1px solid #dfe4ea; box-sizing: border-box; } /* quart = zone BLANCHE (lumière = dispo) sur le fond gris de la cellule */
.tl-shift.oncall { background: rgba(255,213,79,.30); border: 1px solid rgba(245,166,35,.55); } /* garde = sur appel (jaune pâle, harmonisé Kanban) */
.tl-shift.over { background: #fff1e0; border-color: #ffc183; } /* zone de quart en surcharge = teinte orange (au lieu du blanc) */
.tl-absent { position: absolute; inset: 0; border-radius: 2px; box-sizing: border-box; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg, #cfcfcf 0, #cfcfcf 3px, #f0f0f0 3px, #f0f0f0 6px); } /* absent = hachuré gris */
.tl-blk { position: absolute; top: 2px; bottom: 2px; border-radius: 4px; border: 1px solid; display: flex; align-items: center; justify-content: center; overflow: hidden; } /* fond PASTEL + contour couleur (via inline) + icône couleur centrée si elle rentre */
.tl-travel { position: absolute; top: 0; bottom: 0; background-image: repeating-linear-gradient(90deg, #78909c 0 3px, transparent 3px 7px); background-size: 100% 3px; background-repeat: no-repeat; background-position: 0 center; opacity: .85; } /* déplacement = pointillés */
.tl-shift-click { cursor: pointer; } /* bande de quart/garde → clic ouvre la tournée (voir les jobs) */
.tl-shift-click:hover { filter: brightness(0.92); outline: 1px solid rgba(55,71,79,.4); outline-offset: -1px; }
.tl-blk-click { cursor: pointer; }
.tl-blk-click:hover { outline: 1px solid rgba(25,118,210,.7); outline-offset: -1px; filter: brightness(1.08); }
.tod-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(210,45%,91%), hsl(270,45%,83%)); }
.occ-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(122,68%,44%), hsl(32,68%,44%)); }
.leg-absent { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg,#cfcfcf 0,#cfcfcf 3px,#f0f0f0 3px,#f0f0f0 6px); }
.leg-garde { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; background: rgba(255,179,0,.14); border: 1px dashed #f9a825; }
tr.paused .tech-col { color: #aaa; }
tfoot .sum td { background: #fafafa; font-size: 11px; color: #555; font-weight: 600; }
tfoot .sum .tech-col { background: #fafafa; }
.eff { font-size: 9px; border-radius: 3px; padding: 0 4px; margin-left: 3px; font-weight: 600; }
.eff.fast { color: #1b5e20; background: #c8e6c9; }
.eff.slow { color: #b71c1c; background: #ffe0b2; }
.demand-tbl { border-collapse: collapse; }
.demand-tbl th { font-size: 11px; color: #888; font-weight: 600; padding: 2px 6px; text-align: left; }
.demand-tbl td { padding: 2px 4px; }
/* ── Vue mobile « bande de jours + heat » (téléphone) ── */
.plan-mobile { padding: 8px; }
.pm-strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 6px; }
.pm-strip::-webkit-scrollbar { height: 0; }
.pm-day { position: relative; flex: 0 0 auto; width: 46px; border: 1px solid var(--ops-border, #e2e8f0); border-radius: 10px; background: #fff; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 6px 2px; cursor: pointer; }
.pm-day.active { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99, 102, 241, .18); }
.pm-day.today .pm-num { color: #6366f1; }
.pm-day.weekend { background: #f8fafc; }
.pm-day.holiday { background: #fff7ed; border-color: #fdba74; } /* férié QC (auto) ou congé manuel */
.pm-hol-banner { display: flex; align-items: center; gap: 8px; background: #fff7ed; border: 1px solid #fdba74; border-radius: 8px; padding: 6px 10px; margin-bottom: 8px; font-size: 12.5px; color: #9a3412; min-width: 0; }
.pm-day-hol { position: absolute; top: 1px; left: 3px; color: #ea580c; font-size: 9px; line-height: 1; }
.pm-day-warn { position: absolute; top: 2px; right: 3px; color: #ef4444; font-size: 9px; line-height: 1; pointer-events: none; } /* ▲ : la dispo du jour vient de quarts non créés (PAS de bord rouge → le contour de sélection reste visible) */
.pm-dow { font-size: 10px; font-weight: 700; text-transform: uppercase; color: #94a3b8; }
.pm-num { font-size: 15px; font-weight: 700; color: #1e293b; line-height: 1; }
.pm-bar { width: 10px; height: 44px; background: #eef2f7; border-radius: 5px; overflow: hidden; display: flex; align-items: flex-end; margin: 2px 0; }
.pm-fill { width: 100%; border-radius: 5px; transition: height .3s; }
.pm-h { font-size: 9.5px; font-weight: 700; color: #64748b; white-space: nowrap; }
.pm-over { color: #ef4444; margin-left: 1px; }
.pm-techs { margin-top: 10px; }
.pm-techs-hd { display: flex; align-items: center; font-size: 12px; font-weight: 600; color: #475569; margin-bottom: 6px; }
.pm-tech { display: flex; align-items: center; gap: 8px; width: 100%; border: none; background: #fff; border-bottom: 1px solid #f1f5f9; padding: 8px 4px; cursor: pointer; }
.pm-tech-name { flex: 0 0 34%; font-size: 13px; font-weight: 600; color: #1e293b; text-align: left; }
.pm-tbar { flex: 1; height: 8px; background: #eef2f7; border-radius: 4px; overflow: hidden; }
.pm-tfill { display: block; height: 100%; border-radius: 4px; transition: width .3s; }
.pm-th { flex: 0 0 auto; font-size: 12px; font-weight: 700; color: #1e293b; min-width: 46px; text-align: right; white-space: nowrap; }
.pm-th.over { color: #ef4444; }
.pm-cap { color: #94a3b8; font-weight: 600; }
.pm-cap.est { font-style: italic; } /* dénominateur ESTIMÉ (8h, pas de quart réel) */
.pm-warn { flex: 0 0 auto; color: #ef4444; font-size: 11px; line-height: 1; cursor: help; } /* ▲ aucun quart planifié ce jour */
.pm-tech.no-shift { background: #fff7f5; } /* fond ambré léger : tech sans quart (problème à régler) */
.pm-tech.assignable.no-shift { background: #fdf2f8; outline-color: #fbcfe8; }
.pm-empty { color: #94a3b8; font-size: 13px; text-align: center; padding: 12px; }
.pm-tip { font-size: 11px; color: #94a3b8; text-align: center; padding: 8px 0 2px; }
.pm-more { width: 100%; border: none; background: none; color: #6366f1; font-size: 12px; font-weight: 600; padding: 9px; cursor: pointer; }
.pm-tech-idle { opacity: .55; }
.pm-ava { flex: 0 0 auto; width: 26px; height: 26px; border-radius: 50%; background: #e0e7ff; color: #4338ca; font-size: 10px; font-weight: 800; display: flex; align-items: center; justify-content: center; }
.pm-tech.assignable { background: #f5f3ff; outline: 1px dashed #c4b5fd; }
.pm-tech.assignable:active { background: #ede9fe; }
.pm-assign-banner { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; background: #6366f1; color: #fff; font-size: 12px; font-weight: 600; padding: 8px 10px; border-radius: 8px; margin-bottom: 8px; }
.pm-pool { margin-top: 12px; border-top: 1px solid #e2e8f0; padding-top: 8px; }
.pm-pool-hd { display: flex; align-items: center; font-size: 12px; font-weight: 700; color: #475569; margin-bottom: 4px; }
.pm-pool-sub { font-size: 10px; color: #94a3b8; margin-bottom: 6px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.pm-prio-leg { display: inline-flex; align-items: center; gap: 4px; }
.pm-prio-leg i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; margin-left: 4px; }
.pm-count { margin-left: 6px; background: #e2e8f0; color: #475569; border-radius: 9px; font-size: 11px; padding: 0 7px; font-weight: 700; }
.pm-job { display: flex; align-items: center; gap: 6px; width: 100%; text-align: left; border: 1px solid #e2e8f0; background: #fff; border-radius: 8px; padding: 8px; margin-bottom: 6px; cursor: pointer; }
.pm-job.sel { border-color: #6366f1; background: #eef2ff; box-shadow: 0 0 0 2px rgba(99, 102, 241, .18); }
/* « En attente » : carte OPAQUE (sinon le fond de swipe transparaît derrière sur mobile) — hachuré + titre grisé + cursor bloqué. */
.pm-job.hold { background: repeating-linear-gradient(45deg, #f8fafc 0 7px, #eef2f7 7px 14px); cursor: not-allowed; }
.pm-job.hold .pm-job-t { color: #64748b; }
.pm-job-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.pm-job-t { font-size: 13px; font-weight: 600; color: #1e293b; }
.pm-job-s { font-size: 11px; color: #64748b; }
.pm-job-d { font-size: 10.5px; margin-top: 1px; }
.pm-job-note { font-size: 10.5px; margin-top: 1px; color: #c2410c; font-style: italic; max-width: 100%; } /* note importante en aperçu sur la carte */
.pm-job-icons { flex: 0 0 auto; display: flex; align-items: center; gap: 1px; } /* ★ urgent + 📝 note (icônes directes, façon Gmail) */
.pm-job-icons .q-icon { padding: 2px; cursor: pointer; }
.pm-job-h { flex: 0 0 auto; font-size: 12px; font-weight: 700; color: #0d9488; min-width: 30px; text-align: right; }
/* Swipe MAISON : la carte glisse au-dessus d'un fond révélant l'action (vert=+1 j à gauche · indigo=options à droite). Tap = clic natif. */
.pm-swipe { position: relative; margin-bottom: 6px; border-radius: 8px; overflow: hidden; }
.pm-swipe-bg { position: absolute; inset: 0; display: flex; align-items: center; justify-content: space-between; border-radius: 8px; background: linear-gradient(90deg, #bbf7d0 0 50%, #c7d2fe 50% 100%); }
.pm-swipe-hint { display: flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 800; padding: 0 14px; }
.pm-swipe-hint.l { color: #166534; }
.pm-swipe-hint.r { color: #3730a3; }
.pm-swipe .pm-job { margin-bottom: 0; position: relative; touch-action: pan-y; transition: transform .18s ease; }
.pm-job.swiping { transition: none; }
/* Feuille d'options (bottom sheet) */
.pm-opt-card { width: 100%; max-height: 86vh; overflow-y: auto; border-top-left-radius: 16px; border-top-right-radius: 16px; }
.pm-opt-actions { position: sticky; bottom: 0; background: #fff; border-top: 1px solid #eef2f7; } /* « Fermer » toujours visible (carte qui défile) */
.pm-opt-label { font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: .02em; margin: 10px 0 4px; }
.pm-tk-details { background: #f8fafc; border: 1px solid #eef2f7; border-radius: 8px; padding: 7px 9px; margin-top: 4px; }
.pm-tk-row { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: #334155; line-height: 1.5; min-width: 0; }
.pm-tk-row .q-icon { flex: 0 0 auto; }
.pm-tk-row .ellipsis { min-width: 0; }
.pm-tk-link { color: #0d9488; text-decoration: none; font-weight: 500; }
.pm-tk-link:active { opacity: .6; }
.pm-postbox { background: #f8fafc; border: 1px solid #eef2f7; border-radius: 8px; padding: 7px; margin-bottom: 6px; }
.pm-postbox-foot { gap: 4px; }
.pm-postbox-hint { font-size: 10.5px; color: #64748b; min-width: 0; }
.pm-thread { max-height: 34vh; overflow-y: auto; border: 1px solid #eef2f7; border-radius: 8px; background: #fff; padding: 2px 0; }
.pm-thread-empty { color: #94a3b8; font-size: 12.5px; padding: 10px; display: flex; align-items: center; }
.pm-post { padding: 7px 10px; border-bottom: 1px solid #f1f5f9; }
.pm-post:last-child { border-bottom: 0; }
.pm-post--client { background: #f6fbfa; } /* message du client (entrant) : léger fond teal, comme entrant↔sortant dans la Boîte */
.pm-post-hd { display: flex; align-items: center; gap: 7px; font-size: 12px; color: #334155; }
.pm-post-hd b { min-width: 0; }
.pm-post-av { font-size: 9px; font-weight: 600; color: #fff; flex: 0 0 auto; }
.pm-post-tag { font-size: 9.5px; background: #e0f2f1; color: #0d9488; padding: 0 5px; border-radius: 6px; flex: 0 0 auto; }
.pm-post-at { flex: 0 0 auto; font-size: 10.5px; color: #94a3b8; }
.pm-post-text { font-size: 12.5px; color: #475569; white-space: pre-wrap; word-break: break-word; margin-top: 3px; line-height: 1.4; }
/* Carrousel de jours (défilement horizontal continu, sans flèches — 28 jours, dimanche compris) + listes bornées (scroll interne, anti-étirement) + chips de filtre + feuille d'assignation (retour user 2026-06-24) */
.pm-techs-list { max-height: 32vh; overflow-y: auto; }
.pm-sort { font-size: 11px; min-height: 0; max-width: 120px; }
.pm-sort .q-field__control { min-height: 26px; }
.pm-sort .q-field__native { font-size: 11px; font-weight: 600; color: #475569; padding: 0; min-height: 26px; }
.pm-sort .q-field__prepend { padding-right: 2px; height: 26px; }
.pm-sort .q-field__marginal { height: 26px; }
.pm-chips { display: flex; gap: 5px; overflow-x: auto; padding-bottom: 6px; margin-bottom: 2px; }
.pm-chips::-webkit-scrollbar { height: 0; }
.pm-chip { flex: 0 0 auto; border: 1px solid #cbd5e1; background: #fff; color: #475569; border-radius: 999px; font-size: 11px; font-weight: 600; padding: 3px 10px; cursor: pointer; white-space: nowrap; }
.pm-chip.on { color: #fff; border-color: transparent; }
.pm-chip-x { border-color: #fca5a5; color: #dc2626; }
.pm-pool-list { max-height: 46vh; overflow-y: auto; }
.pm-leg { width: 7px; height: 7px; border-radius: 2px; display: inline-block; margin-left: 2px; }
.pm-sheet { position: fixed; left: 0; right: 0; bottom: 0; z-index: 4500; background: #fff; border-top: 1px solid #e2e8f0; box-shadow: 0 -8px 28px rgba(0, 0, 0, .18); border-radius: 14px 14px 0 0; max-height: 60vh; display: flex; flex-direction: column; }
.pm-sheet-hd { display: flex; align-items: center; background: #6366f1; color: #fff; font-size: 12.5px; font-weight: 600; padding: 9px 12px; border-radius: 14px 14px 0 0; }
.pm-sheet-skills { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 7px 12px; background: #eef2ff; border-bottom: 1px solid #e0e7ff; }
.pm-ss-lbl { font-size: 11px; font-weight: 700; color: #6366f1; }
.pm-ss-hint { font-size: 10.5px; color: #94a3b8; margin-left: auto; }
.pm-sheet-body { overflow-y: auto; padding: 4px 10px 10px; }
/* Mobile : le panneau flottant « Jobs à assigner » devient une feuille plein écran (sinon il flotte mal au doigt). */
@media (max-width: 640px) {
.assign-panel { left: 6px !important; right: 6px !important; top: 8px !important; bottom: 8px !important; width: auto !important; height: auto !important; max-height: calc(100vh - 16px) !important; z-index: 6000 !important; }
.assign-resize { display: none !important; }
}
</style>