gigafibre-fsm/apps/ops/src/pages/PlanificationPage.vue
louispaulb 4bed6bb9fd planif : extraction #4/2 — dialogue Synchroniser les techniciens → components/planif/TechSyncDialog.vue
2e extraction de la décomposition (couplage nul : ne lit AUCUN état partagé).
- components/planif/TechSyncDialog.vue (NOUVEAU) : reactive techSync + techSyncSelCount +
  applyTechSync déplacés ; rapport self-fetch à l'ouverture (watch modelValue) ; v-model
  pour l'ouverture ; émet `applied` après création → le parent appelle loadBase
- PlanificationPage : dialogue + reactive + 2 fonctions retirés ; menu Outils → `showTechSync = true` ;
  `<TechSyncDialog v-model="showTechSync" @applied="loadBase" />`
- Vérifié en aperçu : ouvre via le menu, rapport chargé (staff 53 · fiches 56 · tech 41), 0 erreur

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:02:10 -04:00

6632 lines
633 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="showLeave = true"><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-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="openCreateJob"><q-item-section avatar><q-icon name="add_task" color="teal-7" /></q-item-section><q-item-section>Créer un job</q-item-section></q-item>
<q-item clickable v-close-popup @click="showSuggestSlots = true"><q-item-section avatar><q-icon name="event_available" color="teal-8" /></q-item-section><q-item-section>Trouver un créneau</q-item-section></q-item>
<q-item clickable v-close-popup @click="openSchedGenBulk"><q-item-section avatar><q-icon name="event_note" color="primary" /></q-item-section><q-item-section>Générer les horaires (lot)</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">
<!-- Sélecteur de VUE à GAUCHE, le plus évident (le titre « Planification » est déjà au-dessus, inutile de le répéter) -->
<q-btn-toggle v-model="boardView" dense unelevated no-caps class="view-switch" :options="[{ value: 'grid', icon: 'calendar_view_week', label: 'Semaine' }, { value: 'kanban', icon: 'today', label: 'Jour' }, { value: 'routes', icon: 'route', label: 'Tournées' }]" toggle-color="primary" color="grey-2" text-color="grey-8"><q-tooltip>Semaine = grille (vue d'ensemble) · Jour = disposition d'une journée · Tournées = trajets réels de la journée, 1 couleur/tech (OSRM)</q-tooltip></q-btn-toggle>
<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-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>
<!-- #2 — Journal des changements + état auto-save (plus d'alerte « non publié ») -->
<q-btn dense flat round :icon="autosaving ? 'cloud_sync' : 'history'" :color="autosaving ? 'primary' : ''"><q-tooltip>Journal des changements · {{ autosaving ? 'sauvegarde en cours…' : 'auto-sauvegardé' }}</q-tooltip>
<q-menu anchor="bottom middle" self="top middle"><q-list dense style="min-width:250px;max-width:340px">
<q-item-label header class="q-py-xs">Journal — <span :class="autosaving ? 'text-primary' : 'text-positive'">{{ autosaving ? 'sauvegarde…' : '✓ auto-sauvegardé' }}</span></q-item-label>
<q-item v-if="!changeLog.length"><q-item-section class="text-grey-6 text-caption">Aucun changement récent.</q-item-section></q-item>
<q-item v-for="(c, i) in changeLog.slice(0, 30)" :key="i" dense><q-item-section><q-item-label class="text-caption">{{ c.text }}</q-item-label></q-item-section><q-item-section side><span class="text-grey-5" style="font-size:10px">{{ new Date(c.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) }}</span></q-item-section></q-item>
</q-list></q-menu>
</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>
<!-- Création UNIFIÉE : Tâche · Intervention (sur site → créneau / à distance → ticket support·vente). Remplace « Créer un job » + « Créneau ». -->
<q-btn dense unelevated no-caps color="teal-7" icon="add" label="Créer" @click="createChooser = true"><q-tooltip>Créer une tâche ou une intervention (sur site · à distance)</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:250px">
<!-- ⚙️ RÉGLAGES : config de planification (dépôt, compétences, cadence, durées, modèles, garde) -->
<q-item-label header class="q-py-xs text-weight-bold text-grey-8">⚙️&nbsp; Réglages planification</q-item-label>
<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="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="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="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>
<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 class="q-my-xs" />
<!-- 🗓 HORAIRES & PERSONNEL : génération de quarts, absences, synchro -->
<q-item-label header class="q-py-xs text-weight-bold text-grey-8">🗓&nbsp; Horaires &amp; personnel</q-item-label>
<q-item clickable v-close-popup @click="doGenerate"><q-item-section avatar><q-icon name="auto_awesome" color="indigo" /></q-item-section><q-item-section>Générer l'horaire (semaine)<q-item-label caption>solveur de quarts — la semaine affichée</q-item-label></q-item-section><q-item-section side><q-spinner v-if="generating" size="16px" color="indigo" /></q-item-section></q-item>
<q-item clickable v-close-popup @click="openSchedGenBulk"><q-item-section avatar><q-icon name="event_note" color="primary" /></q-item-section><q-item-section>Générer les horaires (lot)<q-item-label caption>modèle → plusieurs techs, N semaines</q-item-label></q-item-section></q-item>
<q-item clickable v-close-popup @click="showLeave = true"><q-item-section avatar><q-icon name="beach_access" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
<q-item clickable v-close-popup @click="showTechSync = true"><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-separator class="q-my-xs" />
<!-- 👁 AFFICHAGE : bascules de ce qui s'affiche sur le board -->
<q-item-label header class="q-py-xs text-weight-bold text-grey-8">👁&nbsp; Affichage</q-item-label>
<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="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 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-label caption>panneau besoins vs capacité</q-item-label></q-item-section>
<q-item-section side><q-icon v-if="showDemand" name="check" color="indigo" /></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="Suggérer" @click="openSuggest"><q-tooltip class="bg-grey-9">Répartition automatique des jobs du <b>jour sélectionné</b> (distance · compétence · priorité · taux d'occupation) — revue avant d'appliquer</q-tooltip></q-btn>
<!-- PUBLIER unifié : action principale + sous-options (SMS · publier au legacy) dans le même bouton -->
<q-btn-dropdown split :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" no-caps @click="doPublish">
<q-list dense style="min-width:250px">
<q-item tag="label" v-ripple>
<q-item-section avatar><q-checkbox v-model="notifySms" dense /></q-item-section>
<q-item-section>Notifier les techs par SMS<q-item-label caption>à la publication</q-item-label></q-item-section>
</q-item>
<q-separator />
<!-- #3 — étape d'approbation FACULTATIVE (maker-checker) : Soumettre → Approuver, sans SMS. « Publier » reste l'étape finale (SMS). -->
<q-item clickable v-close-popup @click="doWeekStatus('submit')" :disable="!dirty"><q-item-section avatar><q-icon name="outbox" color="indigo" /></q-item-section><q-item-section>Soumettre pour approbation<q-item-label caption>marque « Soumis » (sans SMS)</q-item-label></q-item-section></q-item>
<q-item clickable v-close-popup @click="doWeekStatus('approve')" :disable="!dirty"><q-item-section avatar><q-icon name="verified" color="teal" /></q-item-section><q-item-section>Approuver<q-item-label caption>marque « Approuvé » (sans SMS)</q-item-label></q-item-section></q-item>
<q-separator />
<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-label caption>réassigne les tickets dans osTicket (aperçu avant)</q-item-label></q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<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-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><TagEditor :model-value="Array.isArray(d.skills) ? d.skills : []" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" compact placeholder="+ compétence" style="min-width:150px" @update:model-value="items => onDemandSkills(d, items)" @create="onCreateRosterTag" /></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' ? 'Urgent — toucher pour retirer' : 'Marquer urgent (priorité, heure fixée / SLA)' }}</q-tooltip></q-icon>
<q-icon :name="j.notes ? 'sticky_note_2' : 'note_add'" :color="j.notes ? 'deep-orange-6' : 'grey-4'" size="19px" @click.stop="openJobSheet(j, { note: true })"><q-tooltip>Note importante / détails</q-tooltip></q-icon>
<q-icon name="more_vert" color="grey-6" size="19px" @click.stop="openJobSheet(j)"><q-tooltip>Options (priorité, statut, compétence, report)</q-tooltip></q-icon>
</span>
<span class="pm-job-h">{{ j.est_min ? fmtMin(j.est_min) : ((j.duration_h || 1) + 'h') }}</span>
</button>
</div></template>
<div v-if="!poolJobs.length" class="pm-empty">Rien à assigner 🎉</div>
</div>
</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-tooltip class="bg-grey-9" style="font-size:11px;max-width:230px">{{ p.desc }}</q-tooltip></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étences requises</div>
<TagEditor :model-value="(jobSheet.job.required_skills && jobSheet.job.required_skills.length) ? jobSheet.job.required_skills : (jobSheet.job.required_skill ? [jobSheet.job.required_skill] : [])" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" placeholder="Ajouter une compétence requise…" style="width:100%" @update:model-value="list => setPoolSkills(jobSheet.job, list)" @create="onCreateRosterTag" />
<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 === 'grid'">
<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="hdr-vbar"><span class="hdr-vfill" :style="{ height: hdrFillH(d.iso) + '%', background: heatColor(hdrRatio(d.iso)) }"></span></span>
<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>Réglages de {{ t.name }} — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée</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). ── -->
<!-- P3 — Onglet « Tournées » : trajets RÉELS (jobs assignés) de la journée sélectionnée, 1 couleur/tech (composant RouteMap partagé, OSRM) -->
<div v-if="boardView === 'routes'" class="routes-wrap q-pa-sm">
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
<span class="text-caption text-grey-7">Journée :</span>
<OccupancyStrip :days="routeStripDays" :selected="routesDay" style="flex:1 1 100%;min-width:0" @select="routesDay = $event" />
<q-space />
<q-btn dense unelevated no-caps size="sm" color="deep-purple-6" text-color="white" icon="forward_to_inbox" label="Notifier" :disable="!dayRoutes.length" @click="openNotifyDlg"><q-tooltip>Envoyer à chaque tech (SMS/courriel) le lien de sa tournée + les infos clés (adresse · AM/PM · urgent). Aperçu avant envoi.</q-tooltip></q-btn>
<q-btn dense unelevated no-caps size="sm" :color="showLivePos ? 'teal-6' : 'grey-4'" :text-color="showLivePos ? 'white' : 'grey-8'" icon="my_location" :label="'GPS live' + (dayLivePositions.length ? ' (' + dayLivePositions.length + ')' : '')" @click="showLivePos = !showLivePos"><q-tooltip>Positions GPS des techs (Traccar), rafraîchies ~25 s. Cliquer pour {{ showLivePos ? 'masquer' : 'afficher' }}.</q-tooltip></q-btn>
<span class="text-caption text-grey-6">{{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail</span>
</div>
<!-- Chips techs : clic = ISOLER la tournée de ce tech (masquer les autres) ; re-clic = tout réafficher. Pastille = nb de jobs, couleur du tech. -->
<div v-if="allDayRoutes.length" class="row items-center q-mb-xs" style="gap:5px;flex-wrap:wrap">
<span v-for="r in allDayRoutes" :key="'chip' + r.id" class="rt-chip" :class="{ off: hiddenRouteTechs.has(r.id) }" :style="hiddenRouteTechs.has(r.id) ? {} : { borderColor: r.color, background: r.color + '14' }" @click="toggleRouteTech(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)">
<span class="rt-dot" :style="{ background: hiddenRouteTechs.has(r.id) ? '#cbd5e1' : r.color }"></span>{{ r.name }}
<span class="rt-n" :style="{ background: hiddenRouteTechs.has(r.id) ? '#94a3b8' : r.color }">{{ r.stops.length }}</span>
<q-tooltip>{{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour n\'afficher QUE cette tournée' : 'Cliquer pour isoler cette tournée (masquer les autres)' }}</q-tooltip>
</span>
<button v-if="hiddenRouteTechs.size" type="button" class="rt-chip rt-chip-all" @click="showAllRouteTechs"><q-icon name="done_all" size="13px" />Tout afficher</button>
</div>
<!-- Jobs DUS ce jour, NON assignés (mis en évidence) — chips secteur filtrent liste+carte · clic = détail · « Suggérer » = dispatch auto -->
<div v-if="routeDayUnassigned.length" class="rt-unassigned">
<div class="rt-ua-top">
<button type="button" class="rt-ua-lbl rt-ua-open" @click="openDayInPanel(routesDay)"><q-icon name="inbox" size="15px" color="deep-orange-7" /> <b>{{ routeDayUnassignedView.length }}</b> à répartir<span v-if="routeSectorSel.size" class="text-grey-6"> / {{ routeDayUnassigned.length }}</span><q-icon name="open_in_full" size="12px" class="q-ml-xs" /><q-tooltip>Voir la liste des jobs de ce jour (recherche · ville · compétence)</q-tooltip></button>
<template v-if="routeSectors.length > 1">
<button v-for="s in routeSectors" :key="s.sector" type="button" class="rt-sect" :class="{ on: routeSectorSel.has(s.sector) }" @click="toggleRouteSector(s.sector)"><q-icon name="place" size="11px" />{{ s.sector }}<span class="rt-sect-n">{{ s.n }}</span></button>
<button v-if="routeSectorSel.size" type="button" class="rt-sect rt-sect-x" @click="routeSectorSel = new Set()">✕ tout</button>
</template>
<template v-if="routePrios.length > 1">
<span class="rt-chip-sep" />
<button v-for="p in routePrios" :key="p.value" type="button" class="rt-sect rt-prio" :class="{ on: routePrioSel.has(p.value) }"
:style="routePrioSel.has(p.value) ? { background: p.color, borderColor: p.color, color: '#fff' } : { borderColor: p.color, color: p.color }"
@click="toggleRoutePrio(p.value)"><q-icon name="flag" size="11px" />{{ p.label }}<span class="rt-sect-n">{{ p.n }}</span></button>
</template>
<q-space />
<q-btn dense unelevated no-caps size="sm" color="primary" icon="auto_awesome" label="Suggérer" @click="openSuggest"><q-tooltip>Répartir automatiquement les jobs {{ routeSectorSel.size ? 'du secteur' : 'de ce jour' }}</q-tooltip></q-btn>
</div>
<!-- Carrousel horizontal (1 rangée, défile latéralement) : compact au-dessus de la carte, tactile/tablette, montre TOUS les jobs (pas de plafond à 16). -->
<div class="rt-ua-jobs">
<span v-for="j in routeDayUnassignedView" :key="j.name" class="rt-ua-chip" :style="{ borderLeftColor: prioColor(j.priority) }" @click="openJobDetail(j)">
<q-icon :name="skillSym(j.required_skill || j.service_type)" size="14px" :style="{ color: getTagColor(j.required_skill || j.service_type) }" />{{ j.subject || j.service_type || j.name }}<template v-if="jobCity(j)"> · {{ jobCity(j) }}</template>
<q-tooltip class="bg-grey-9">{{ j.required_skill || '—' }}<template v-if="j.customer_name"> · {{ j.customer_name }}</template><template v-if="j.address"><br>📍 {{ j.address }}</template></q-tooltip>
</span>
</div>
</div>
<RouteMap ref="routesMapRef" :routes="dayRoutes" :live="showLivePos ? dayLivePositions : []" :pins="routeUnassignedPins" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" @pin-click="p => openJobDetail(p.job)" />
<div class="suggest-legend q-mt-xs">
<span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)">
<span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.name }}
<q-icon :name="techHomes[r.id] ? 'home' : 'business'" size="13px" :color="techHomes[r.id] ? 'teal-7' : 'blue-grey-4'" style="cursor:pointer" @click.stop="openHomePicker({ id: r.id, name: r.name })"><q-tooltip>Départ : {{ techHomes[r.id] ? 'domicile' : 'bureau TARGO (défaut)' }} — cliquer pour définir/modifier</q-tooltip></q-icon>
· {{ r.stops.length }} arrêt(s)<span v-if="dayRouteMetrics[r.id]"> · 🚗 {{ dayRouteMetrics[r.id].km }} km / {{ dayRouteMetrics[r.id].mins }} min</span>
</span>
<span v-if="!dayRoutes.length" class="text-grey-6" style="font-size:12px">Aucune tournée géolocalisée ce jour — les jobs assignés n'ont pas de coordonnées, ou la journée est vide.</span>
</div>
</div>
<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 clk" style="cursor:pointer" @click.stop="openSkillEditor(t, $event)">{{ t.name }}<q-tooltip>Réglages de {{ t.name }} — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée</q-tooltip></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><q-icon name="tune" size="14px" color="grey-5" class="cursor-pointer" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Réglages du tech (compétences · GPS · domicile · tournée)</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>
<!-- Congés & disponibilités — extrait dans components/planif/LeaveDialog.vue (décomposition #4) -->
<LeaveDialog v-model="showLeave" :techs="techs" :tech-options="techOptions" />
<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>Efficacité&nbsp;%</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><TagEditor :model-value="Array.isArray(t.skills) ? t.skills : []" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" compact placeholder="+ compétence" style="min-width:160px" @update:model-value="items => onTeamSkills(t, items)" @create="onCreateRosterTag" /></td>
<td><q-input dense outlined type="number" step="10" min="20" max="1000" :model-value="(t.id in gEffBuf) ? gEffBuf[t.id] : effPctOf(t.efficiency)" @update:model-value="v => gEffBuf[t.id] = v" @blur="commitGEff(t)" @keyup.enter="commitGEff(t)" style="width:92px" suffix="%"><q-tooltip>100 % = cadence normale · PLUS HAUT = plus rapide (fait plus de jobs). Ex. 300 % = 3× plus rapide · 60 % = plus lent.</q-tooltip></q-input></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" sortable placeholder="Cliquez pour ajouter une compétence…"
@update:model-value="items => onTagsChange(skillDialog, items)"
@create="onCreateRosterTag" />
<div v-if="(skillDialog.skills || []).length > 1" class="text-caption text-grey-6 q-mt-xs" style="line-height:1.3">↔ <b>Glisse les chips</b> pour l'ordre de priorité — la <b>1<sup>re</sup> (★)</b> = fonction principale : le dispatch auto envoie ces jobs à ce tech <b>en premier</b> (épargne les moins spécialisés).</div>
<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">Cadence</div>
</div>
<div v-for="(sk, si) 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">
<span class="skill-rank" :class="{ 'skill-rank-1': si === 0 }" :title="si === 0 ? 'Priorité 1 — compétence principale' : 'Priorité ' + (si + 1)">{{ si + 1 }}</span>
<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="25" min="20" max="1000" :model-value="(effBufKey(skillDialog, sk) in effBuf) ? effBuf[effBufKey(skillDialog, sk)] : skillEffPct(skillDialog, sk)" @update:model-value="v => { effBuf[effBufKey(skillDialog, sk)] = v }" @blur="commitSkillEff(skillDialog, sk)" @keyup.enter="commitSkillEff(skillDialog, sk)" 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>Cadence</b> : <b>100&nbsp;%</b> = normale · <b>200&nbsp;%</b> = deux fois plus de jobs · &lt;&nbsp;100&nbsp;% = plus lent · vide = hérite du global · × sur le chip = retirer.</div>
<div class="text-caption text-green-7 q-mt-xs row items-center no-wrap" style="gap:3px"><q-icon name="cloud_done" size="13px" />Enregistré automatiquement — pas de bouton à cliquer.</div>
</div>
<q-separator class="q-my-sm" />
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🗓 Horaire</div>
<div class="row items-center q-gutter-xs">
<q-btn dense outline size="sm" no-caps color="indigo-7" icon="event_note" label="Générer l'horaire…" @click="openSchedGen(skillDialog)"><q-tooltip>Ouvre l'assistant de quarts (modèles 5×8h · 4×10h · 3×12h + heures/jour + N semaines) → publie les quarts. Ce n'est PAS un bouton « enregistrer ».</q-tooltip></q-btn>
<q-btn dense outline size="sm" no-caps color="primary" label="5×8h rapide" @click="applyWeekPreset(skillDialog, [1,2,3,4,5], 8, 16)"><q-tooltip>Applique 5×8h à la semaine affichée (à publier)</q-tooltip></q-btn>
<q-btn dense outline size="sm" no-caps color="deep-orange-7" icon="event_busy" label="Réserver…" @click="openReserve(skillDialog)"><q-tooltip>Réserver du temps pour une tâche/projet (bloc priorité moyenne) → dé-priorise ce tech au dispatch auto. Réparations/urgences passent quand même (créneau en mode urgence).</q-tooltip></q-btn>
</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>
<q-separator class="q-my-sm" />
<!-- 📍 Appareil GPS (Traccar) — même contrôle que l'éditeur de jour, ici accessible directement depuis le tech -->
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">📍 Appareil GPS <span class="text-grey-5 text-weight-regular">(Traccar — suivi live + tracé réel)</span></div>
<div class="row items-center no-wrap q-gutter-xs">
<template v-if="deviceOf(skillDialog)">
<q-chip dense :color="deviceOf(skillDialog).missing ? 'orange-1' : 'green-1'" :text-color="deviceOf(skillDialog).missing ? 'orange-9' : 'green-9'" icon="place" class="text-weight-medium">
<q-icon name="circle" size="8px" :color="deviceOf(skillDialog).online ? 'positive' : 'grey-5'" class="q-mr-xs" />{{ deviceOf(skillDialog).label }}
<q-tooltip>{{ deviceOf(skillDialog).online ? 'En ligne' : 'Hors ligne' }}{{ deviceOf(skillDialog).uniqueId ? ' · ' + deviceOf(skillDialog).uniqueId : '' }}</q-tooltip>
</q-chip>
<q-btn flat dense no-caps size="sm" color="indigo" icon="swap_horiz" label="Changer"><q-tooltip>Relier à un autre véhicule</q-tooltip>
<q-menu anchor="bottom left" self="top left"><div class="q-pa-xs" style="min-width:250px"><q-input dense outlined v-model="deviceFilter" placeholder="Filtrer les véhicules…" clearable autofocus class="q-mb-xs" /><q-list dense style="max-height:40vh;overflow:auto"><q-item v-for="o in deviceOptions" :key="o.value" clickable v-close-popup @click="onPickDevice(skillDialog, o.value)" :active="deviceOf(skillDialog) && deviceOf(skillDialog).value === o.value" active-class="bg-green-1"><q-item-section avatar style="min-width:22px"><q-icon name="circle" size="9px" :color="o.online ? 'positive' : 'grey-4'" /></q-item-section><q-item-section><q-item-label>{{ o.label }}</q-item-label><q-item-label v-if="o.uniqueId" caption>{{ o.uniqueId }}</q-item-label></q-item-section></q-item><q-item v-if="!deviceOptions.length"><q-item-section class="text-grey-6">{{ devicesLoading ? 'Chargement…' : 'Aucun appareil' }}</q-item-section></q-item></q-list></div></q-menu>
</q-btn>
<q-btn flat dense round size="sm" icon="link_off" color="grey-6" @click="onPickDevice(skillDialog, null)"><q-tooltip>Dissocier l'appareil GPS</q-tooltip></q-btn>
</template>
<template v-else>
<span class="text-caption text-grey-6">non associé</span>
<q-btn v-if="deviceSuggestOf(skillDialog)" dense unelevated size="sm" no-caps color="primary" icon="link" :label="'Associer ' + deviceSuggestOf(skillDialog).label" @click="onPickDevice(skillDialog, deviceSuggestOf(skillDialog).value)"><q-tooltip>Appareil probable (nom similaire au tech)</q-tooltip></q-btn>
<q-btn flat dense no-caps size="sm" color="indigo" icon="add_location_alt" label="Choisir"><q-tooltip>Relier un véhicule Traccar</q-tooltip>
<q-menu anchor="bottom left" self="top left"><div class="q-pa-xs" style="min-width:250px"><q-input dense outlined v-model="deviceFilter" placeholder="Filtrer les véhicules…" clearable autofocus class="q-mb-xs" /><q-list dense style="max-height:40vh;overflow:auto"><q-item v-for="o in deviceOptions" :key="o.value" clickable v-close-popup @click="onPickDevice(skillDialog, o.value)"><q-item-section avatar style="min-width:22px"><q-icon name="circle" size="9px" :color="o.online ? 'positive' : 'grey-4'" /></q-item-section><q-item-section><q-item-label>{{ o.label }}</q-item-label><q-item-label v-if="o.uniqueId" caption>{{ o.uniqueId }}</q-item-label></q-item-section></q-item><q-item v-if="!deviceOptions.length"><q-item-section class="text-grey-6">{{ devicesLoading ? 'Chargement…' : 'Aucun appareil' }}</q-item-section></q-item></q-list></div></q-menu>
</q-btn>
</template>
</div>
<q-separator class="q-my-sm" />
<q-btn dense unelevated size="sm" no-caps color="deep-purple" icon="route" label="Voir sa tournée" class="full-width" @click="viewTechTournee(skillDialog)"><q-tooltip>Passe en vue Tournées et isole la tournée de ce tech sur la carte</q-tooltip></q-btn>
</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="suggestFiltered ? 'Suggérer (' + suggestJobCount + ')' : 'Suggérer'" :loading="suggestDlg.building" class="q-mr-xs" @click="openSuggest"><q-tooltip>Proposer une répartition optimisée (proximité · charge · compétence){{ suggestFiltered ? ' — ' + suggestJobCount + ' job(s) ' + suggestScope : '' }} — tu revois et ajustes avant d'appliquer</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="map" :color="assignPanel.showMap ? 'amber-4' : 'white'" @click="toggleAssignMap"><q-tooltip>Carte des jobs (regrouper par région)</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="refresh" color="white" :loading="assignPanel.loading" @click="openAssignPanel" />
<q-btn flat dense round size="sm" icon="close" color="white" @click="assignPanel.open = false" />
</div>
<div class="assign-sortbar" @mousedown.stop>
<span>Trier :</span>
<select v-model="assignSort" @mousedown.stop>
<option value="group">Groupe (parent-enfant)</option>
<option value="skill">Compétence</option>
<option value="date">Date</option>
<option value="city">Ville</option>
<option value="priority">Priorité</option>
</select>
<q-btn dense flat size="sm" :icon="assignSortDir === 'asc' ? 'arrow_upward' : 'arrow_downward'" @click="assignSortDir = assignSortDir === 'asc' ? 'desc' : 'asc'" @mousedown.stop><q-tooltip>{{ assignSortDir === 'asc' ? 'Croissant (ancien → récent)' : 'Décroissant (récent → ancien)' }}</q-tooltip></q-btn>
<q-btn dense flat size="sm" :icon="allCollapsed ? 'unfold_more' : 'unfold_less'" no-caps @click="toggleCollapseAll" @mousedown.stop><q-tooltip>{{ allCollapsed ? 'Tout déplier' : 'Tout replier (voir un seul groupe)' }}</q-tooltip></q-btn>
<q-space />
<q-btn v-if="selectedNames.length" dense flat size="sm" icon="deselect" :label="'✕ ' + selectedNames.length" no-caps color="grey-7" @click="clearSel" @mousedown.stop><q-tooltip>Tout désélectionner</q-tooltip></q-btn>
</div>
<div v-if="assignTypes.length > 1" class="assign-chips" @mousedown.stop>
<span v-for="t in assignTypes" :key="t.k" class="assign-chip-f" :style="assignTypeFilter.includes(t.k) ? { background: getTagColor(t.k), color: '#fff', borderColor: getTagColor(t.k) } : { borderColor: getTagColor(t.k) }" @click="toggleAssignType(t.k)">{{ t.k }} <b>{{ t.n }}</b></span>
<span v-if="assignTypeFilter.length" class="assign-chip-f clear" @click="assignTypeFilter = []"><q-tooltip>Tout afficher</q-tooltip>✕</span>
</div>
<div v-if="assignDates.length > 1" class="assign-chips" @mousedown.stop>
<q-icon name="event" size="14px" color="grey-6" class="q-mr-xs" />
<span v-for="d in assignDates" :key="d.iso" class="assign-chip-f" :style="assignDateFilter.includes(d.iso) ? { background: d.overdue ? '#f59e0b' : '#6366f1', color: '#fff', borderColor: d.overdue ? '#f59e0b' : '#6366f1' } : (d.overdue ? { borderColor: '#f59e0b', color: '#b45309' } : {})" @click="toggleAssignDate(d.iso)">{{ d.label }} <b>{{ d.n }}</b></span>
<span v-if="assignDateFilter.length" class="assign-chip-f clear" @click="assignDateFilter = []"><q-tooltip>Toutes les dates</q-tooltip>✕</span>
</div>
<!-- Carte des jobs à assigner : 1 pin par adresse (lettre = groupe de ≥2 jobs à la même adresse) → voir les régions, clic = sélectionner le groupe local -->
<div v-show="assignPanel.showMap" class="assign-map-wrap" @mousedown.stop>
<div ref="assignMapEl" class="assign-map"></div>
<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>
<!-- SLA : pastille rouge (dépassé) / orange (proche) — réutilise les politiques SLA des tickets -->
<span v-if="jobSlaBadge(j)" class="assign-sla" :class="'sla-' + jobSlaBadge(j).state"><q-icon :name="jobSlaBadge(j).icon" size="11px" />{{ jobSlaBadge(j).short }}<q-tooltip class="bg-grey-9">{{ jobSlaBadge(j).label }}</q-tooltip></span>
<q-space />
<q-btn v-if="j.status !== 'On Hold'" flat dense round size="sm" icon="person_add" color="primary" class="q-ml-xs" @click.stop @mousedown.stop>
<q-tooltip>Assigner à un technicien (classés par pertinence : compétence · dispo · distance · charge)</q-tooltip>
<q-menu anchor="bottom right" self="top right" @click.stop>
<div class="assign-pick-hd">Assigner « {{ (j.subject || j.name).slice(0, 30) }} » · 📅 {{ fmtDueLabel(jobTargetDay(j)) }}</div>
<q-list dense style="min-width:270px;max-height:44vh;overflow:auto">
<q-item v-for="tk in techsForJob(j)" :key="tk.id" clickable v-close-popup @click="quickAssign(j, tk)" :class="{ 'assign-pick-incap': !tk.capable }">
<q-item-section avatar style="min-width:32px"><q-avatar size="26px" :color="tk.capable ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:10px;font-weight:700">{{ initials(tk.name) }}</q-avatar></q-item-section>
<q-item-section>
<q-item-label>{{ tk.name }}<q-icon v-if="!tk.capable && j.required_skill" name="warning" size="12px" color="orange" class="q-ml-xs"><q-tooltip>Compétence « {{ j.required_skill }} » absente</q-tooltip></q-icon></q-item-label>
<q-item-label caption><span v-if="tk.distKm != null">📍 {{ Math.round(tk.distKm) }} km</span><span v-else class="text-grey-5">dist. ?</span> · {{ tk.h }}/{{ tk.cap }}h<span v-if="tk.noShift" class="text-warning"> · ▲ sans quart</span><span v-else-if="tk.over" class="text-negative"> · surchargé</span></q-item-label>
</q-item-section>
<q-item-section side><q-icon name="chevron_right" size="16px" color="grey-5" /></q-item-section>
</q-item>
<q-item v-if="!techsForJob(j).length"><q-item-section class="text-grey-6 text-caption">Aucun technicien visible.</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
<q-icon v-if="j.legacy_ticket_id" name="forum" size="14px" :color="j._showThread ? 'indigo' : 'grey-5'" class="q-ml-xs" style="cursor:pointer" @click.stop="toggleAssignThread(j)" @mousedown.stop><q-tooltip>Voir le fil du ticket #{{ j.legacy_ticket_id }}</q-tooltip></q-icon>
<q-icon v-if="j.status === 'On Hold'" name="lock" size="13px" color="orange"><q-tooltip>En attente de {{ j.depends_on || 'la tâche précédente' }}</q-tooltip></q-icon>
</div>
<div class="assign-sub">
<span v-if="j.required_skill" class="assign-skill" :style="{ background: getTagColor(j.required_skill) }">{{ j.required_skill }}</span>
<span v-if="j.required_skill" class="assign-lvl" :class="{ set: j.required_level > 1 }" @click.stop @mousedown.stop><q-icon name="military_tech" size="11px" />niv&nbsp;{{ j.required_level || 1 }}<q-tooltip class="bg-grey-9">Niveau min requis pour ce job (persistant · mode « juste ce qu'il faut » → réserve les experts)</q-tooltip><q-menu anchor="bottom left" self="top left" @click.stop><q-list dense style="min-width:150px"><q-item v-for="n in [1,2,3,4,5]" :key="n" clickable v-close-popup @click="setJobReqLevel(j, n)" :active="(j.required_level||1)===n"><q-item-section>Niveau {{ n }}{{ n===1 ? ' — de base' : (n===5 ? ' — expert' : '') }}</q-item-section></q-item></q-list></q-menu></span>
{{ j.customer_name || j.location_label || j.service_location || '' }}<span v-if="j.depends_on"> · après {{ j.depends_on }}</span> · <span class="text-positive text-weight-medium">≈ {{ j.est_min ? fmtMin(j.est_min) : (j.duration_h || 1) + 'h' }}<q-tooltip v-if="j.est_labels && j.est_labels.length" class="bg-grey-9" style="font-size:11px">Estimé (auto) : {{ j.est_labels.join(' + ') }}</q-tooltip></span><span v-if="j.scheduled_date"> · 📅 <span :class="isOverdue(j.scheduled_date) ? 'text-warning text-weight-bold' : 'text-grey-7'">{{ fmtDueLabel(j.scheduled_date) }}</span><q-icon name="edit_calendar" size="14px" :color="isOverdue(j.scheduled_date) ? 'warning' : 'grey-5'" class="q-ml-xs" style="cursor:pointer" @click.stop @mousedown.stop><q-tooltip>Replanifier (aujourd'hui / demain / date)</q-tooltip><q-menu anchor="bottom left" self="top left" @click.stop><q-list dense style="min-width:160px"><q-item clickable v-close-popup @click="setJobDate(j, todayISO())"><q-item-section avatar style="min-width:28px"><q-icon name="today" size="18px" color="primary" /></q-item-section><q-item-section>Aujourd'hui</q-item-section></q-item><q-item clickable v-close-popup @click="setJobDate(j, tomorrowISO())"><q-item-section avatar style="min-width:28px"><q-icon name="event" size="18px" color="primary" /></q-item-section><q-item-section>Demain</q-item-section></q-item><q-separator /><q-date :model-value="j.scheduled_date" @update:model-value="v => setJobDate(j, v)" mask="YYYY-MM-DD" minimal today-btn /></q-list></q-menu></q-icon></span>
</div>
<div v-if="j._showThread" class="assign-thread" @mousedown.stop @click.stop>
<div v-if="j._thread && j._thread.loading" class="text-grey-6" style="font-size:11px"><q-spinner size="12px" class="q-mr-xs" />Chargement du fil…</div>
<template v-else-if="j._thread && j._thread.messages && j._thread.messages.length">
<div v-for="(m, mi) in j._thread.messages" :key="mi" class="assign-msg"><b>{{ m.author }}</b><span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span><div class="assign-msg-txt">{{ m.text }}</div></div>
</template>
<div v-else class="text-grey-6" style="font-size:11px;white-space:pre-wrap">{{ j.legacy_detail || 'Aucun fil pour ce ticket.' }}</div>
<div class="text-right q-mt-xs"><q-btn dense flat size="sm" color="negative" icon="block" label="Fermer ce ticket (legacy)" @click="closeLegacyTicket(j)"><q-tooltip>Ticket inutile → status closed dans osTicket + sort du dispatch</q-tooltip></q-btn></div>
</div>
</div>
</template>
</div>
</div>
<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" :maximized="$q.screen.lt.sm" @hide="suggestDlg.building = false">
<q-card :style="$q.screen.lt.sm ? 'width:100%;max-width:100%;height:100%;display:flex;flex-direction:column' : '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-start q-mb-sm text-caption text-grey-7" style="gap:6px;background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:7px 10px">
<q-icon name="auto_awesome" color="amber-8" size="17px" />
<span>Répartition automatique — compétences · <b>efficacité par compétence</b> · distance (routes réelles) · fenêtres AM/PM · priorités. Les cas simples vont d'abord aux techs les <b>moins polyvalents</b> (réserve les experts). Tu revois et ajustes avant d'appliquer.</span>
</div>
<!-- Simulation « ré-optimiser l'existant » : inclut AUSSI les jobs déjà assignés de la fenêtre (le solveur les replace) → comparer/améliorer une tournée déjà dispatchée, avec les nouveaux jobs. Rien n'est écrit avant « Appliquer ». -->
<div class="row items-center q-mb-sm" style="gap:6px">
<q-toggle v-model="suggestDlg.includeAssigned" dense size="sm" color="deep-purple" label="Ré-optimiser aussi les jobs déjà assignés (simulation)" />
<q-icon name="info" size="15px" color="grey-5"><q-tooltip class="bg-grey-9" style="font-size:11px">Inclut les {{ assignedNamesForWindow().length }} job(s) déjà assigné(s) du/des jour(s) → le solveur repropose une répartition de TOUT (existant + nouveaux). Force le solveur VRP. Aperçu seulement — « Appliquer » réassigne.</q-tooltip></q-icon>
</div>
<!-- FENÊTRE de répartition = les CHIPS de date cochés dans le pool (ou la sélection lasso). Sinon → un jour choisi ici. -->
<div class="row items-center q-mb-sm" style="gap:6px;color:#475569;background:#eef2ff;border-radius:6px;padding:5px 8px">
<q-icon name="event" size="16px" color="primary" /><span class="text-caption text-weight-medium">Répartir :</span>
<template v-if="suggestFiltered">
<span class="text-caption text-weight-medium text-indigo-8">{{ suggestJobCount }} job(s) {{ suggestScope }}</span>
<q-icon name="arrow_forward" size="13px" color="grey-6" /><span class="text-caption text-grey-7">{{ suggestWindow.map(windowDayLabel).join(', ') }}</span>
<q-space /><span class="text-caption text-grey-6">{{ selectedNames.length ? 'sélection lasso' : 'ajuste les chips sous le pool' }}</span>
</template>
<template v-else>
<span class="text-caption">pour :</span>
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === todayISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === todayISO() ? 'white' : 'grey-8'" label="Aujourd'hui" @click="suggestDay = todayISO()" />
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === tomorrowISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === tomorrowISO() ? 'white' : 'grey-8'" label="Demain" @click="suggestDay = tomorrowISO()" />
<q-input dense outlined type="date" v-model="suggestDay" style="width:150px" />
<q-space /><span class="text-caption text-grey-6">{{ suggestJobCount }} job(s) de ce jour · retards exclus (chip « ⏰ en retard » pour les inclure)</span>
</template>
</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>
<!-- Groupes de techs enregistrés (sélection manuelle sauvée : Installateurs, Épisseurs…) — utile quand la compétence déduite du job est imprécise. -->
<div class="row items-center q-mb-sm" style="gap:5px;flex-wrap:wrap">
<span class="text-caption text-grey-7 q-mr-xs">Groupes :</span>
<span v-for="g in techGroups" :key="g.name" class="suggest-grp-chip" @click="applyTechGroup(g)"><q-icon name="groups" size="13px" />{{ g.name }} <b>{{ (g.ids || []).length }}</b><q-icon name="close" size="12px" class="suggest-grp-x" @click.stop="deleteTechGroup(g)"><q-tooltip>Supprimer ce groupe</q-tooltip></q-icon><q-tooltip>Sélectionner ce groupe de techniciens</q-tooltip></span>
<q-btn dense flat size="sm" no-caps icon="bookmark_add" label="Enregistrer" color="primary" @click="saveTechGroup"><q-tooltip>Enregistrer la sélection actuelle comme groupe réutilisable</q-tooltip></q-btn>
<span v-if="suggestSelCount" class="suggest-grp-chip suggest-grp-clear" @click="suggestSelectTechs('none')"><q-icon name="close" size="13px" />Tout désélectionner</span>
<span v-if="!techGroups.length" class="text-caption text-grey-5">— enregistre une sélection (ex. Installateurs, Épisseurs)</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="tune" size="14px" color="grey-6" class="cursor-pointer" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Réglages du tech — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée</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-if="suggestDlg.view === 'map'">
<RouteMap :routes="suggestMapRoutes" height="440px" @metrics="onRouteMetrics" @stop-click="onReviewStopClick" />
<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>
<!-- Compétences du tech DANS L'ORDRE de priorité (1re = ★ principale) — clic = éditer/réordonner (même éditeur) -->
<span v-if="!g.placeholder && techById[g.techId]" class="suggest-hd-skills q-ml-xs" @click.stop="openSkillEditorFor(g.techId, $event)">
<span v-for="(sk, si) in (techById[g.techId].skills || []).slice(0, 2)" :key="sk" class="assign-skill" :style="{ background: getTagColor(sk), opacity: si === 0 ? 1 : 0.75 }">{{ si === 0 ? '★ ' : '' }}{{ sk }}</span>
<span v-if="(techById[g.techId].skills || []).length > 2" class="suggest-hd-more">+{{ (techById[g.techId].skills || []).length - 2 }}</span>
<q-tooltip>Ordre de priorité : {{ (techById[g.techId].skills || []).join(' → ') || 'aucune compétence' }} — clic pour éditer/réordonner</q-tooltip>
</span>
<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.sectors && g.sectors.length" class="text-teal-8" title="Secteur(s) où ce tech a DÉJÀ des jobs assignés"> · 📍 {{ g.sectors.slice(0, 2).join(', ') }}<template v-if="g.sectors.length > 2"> +{{ g.sectors.length - 2 }}</template></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>
<!-- Point de DÉPART : 🏠 domicile défini ou 🏢 bureau TARGO (défaut) — clic = définir/modifier sur la carte -->
<q-btn v-if="!g.placeholder" flat dense round size="sm" :icon="techHomes[g.techId] ? 'home' : 'business'" :color="techHomes[g.techId] ? 'teal-7' : 'blue-grey-4'" @click="openHomePicker(techById[g.techId] || { id: g.techId, name: g.techName })"><q-tooltip>Départ : {{ techHomes[g.techId] ? ('domicile — ' + (techHomes[g.techId].address || 'défini')) : 'bureau TARGO (défaut)' }} — cliquer pour définir/modifier le domicile</q-tooltip></q-btn>
<!-- Éditer / réordonner les compétences du tech directement depuis l'optimiseur (même éditeur : chips ★ + glisser priorité) -->
<q-btn v-if="!g.placeholder" flat dense round size="sm" icon="edit" color="grey-6" @click="openSkillEditorFor(g.techId, $event)"><q-tooltip>Compétences & niveaux de {{ g.techName }} — glisser pour l'ordre de priorité</q-tooltip></q-btn>
<!-- Tech malade/absent → le retire ET redistribue ses jobs équitablement sur les autres (ré-optimise) -->
<q-btn v-if="!g.placeholder" flat dense round size="sm" icon="sick" color="deep-orange-4" @click="markTechSick(g)"><q-tooltip>{{ g.techName }} malade / absent — retirer et redistribuer ses {{ g.jobs }} job(s) sur les autres</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-separator v-if="!showAllMoveTechs" />
<q-item v-if="!showAllMoveTechs" clickable @click="showAllMoveTechs = true"><q-item-section class="text-primary" style="font-size:12px"><q-icon name="expand_more" size="14px" class="q-mr-xs" />Afficher tous les techniciens…</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>
<!-- Jobs DÉJÀ assignés (occupation) : cliquer pour voir — permet de s'ajuster au secteur existant du tech -->
<span v-if="d.prevJobs && d.prevJobs.length" class="suggest-prev-toggle" @click="prevOpen[g.techId + '|' + d.iso] = !prevOpen[g.techId + '|' + d.iso]">
🔒 {{ d.prevJobs.length }} déjà assigné(s)<template v-if="d.prevCities.length"> · {{ d.prevCities.slice(0, 2).join(', ') }}</template>
<q-icon :name="prevOpen[g.techId + '|' + d.iso] ? 'expand_less' : 'expand_more'" size="13px" />
</span>
</div>
<template v-if="prevOpen[g.techId + '|' + d.iso]">
<div v-for="pj in d.prevJobs" :key="'prev' + pj.name" class="suggest-entry suggest-prev">
<q-icon name="lock" size="11px" color="grey-5" class="q-mr-xs" />
<span v-if="pj.start" class="text-grey-6 q-mr-xs" style="font-size:11px">{{ pj.start }}</span>
<span v-if="pj.skill" class="assign-skill" :style="{ background: getTagColor(pj.skill) }">{{ pj.skill }}</span>
<span class="suggest-entry-t ellipsis text-grey-7">{{ pj.subject }}<span v-if="pj.customer" class="text-grey-5"> · {{ pj.customer }}</span></span>
<span class="text-grey-5 q-mx-xs" style="font-size:11px;white-space:nowrap">{{ Math.round((pj.dur || 0) * 10) / 10 }}h</span>
</div>
</template>
<template v-for="(e, ei) in d.entries" :key="e.jobName">
<div v-if="ei === 0 && legHome(g, d) != null" class="suggest-leg-time">🚗 {{ (legsByKey[g.techId + '|' + d.iso] || {}).kind === 'depot' ? '🏢 bureau TARGO' : '🏠 domicile' }} → 1er arrêt : {{ legHome(g, d) }} min</div>
<div 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 v-if="entrySlaBadge(e)" class="assign-sla q-mr-xs" :class="'sla-' + entrySlaBadge(e).state"><q-icon :name="entrySlaBadge(e).icon" size="11px" />{{ entrySlaBadge(e).short }}<q-tooltip class="bg-grey-9">{{ entrySlaBadge(e).label }}</q-tooltip></span>
<!-- Coordination d'adresse : 🔗 teal = épinglé sur le tech qui a DÉJÀ un job à cette adresse · 🔗 indigo = N jobs du pool fusionnés en 1 arrêt -->
<q-icon v-if="e.coordWith" name="link" size="14px" color="teal-7" class="q-mr-xs"><q-tooltip>Coordonné : même adresse que « {{ e.coordWith }} » déjà assigné à {{ e.techName }} — placé sur SON arrêt</q-tooltip></q-icon>
<q-btn v-else-if="e.sameAddrN > 1" flat dense round size="xs" :icon="e.grouped ? 'inventory_2' : 'link'" :color="e.grouped ? 'indigo-7' : 'indigo-4'" class="q-mr-xs">
<q-tooltip>{{ e.grouped ? 'Groupé en projet 📦' : e.sameAddrN + ' jobs à la MÊME adresse — 1 seul arrêt · clic pour grouper en projet' }}</q-tooltip>
<q-menu auto-close><q-list dense style="min-width:300px">
<q-item clickable @click="groupSameAddr(e, g, d)" :disable="!!e.grouped"><q-item-section avatar style="min-width:32px"><q-icon name="inventory_2" size="18px" color="indigo" /></q-item-section><q-item-section>📦 Grouper en « projet » ({{ e.sameAddrN }} tâches même adresse — parent = la plus longue)</q-item-section></q-item>
</q-list></q-menu>
</q-btn>
<span class="suggest-entry-t ellipsis suggest-clk" @click="openEntryDetail(e, g)" title="Voir le détail du ticket (adresse, description, fil)">{{ e.subject }}<span v-if="e.customer" class="text-grey-6"> · {{ e.customer }}</span></span>
<span class="suggest-dur q-mx-xs" title="Temps sur place — cliquer pour réviser">{{ Math.round(e.dur * 10) / 10 }}h
<q-popup-edit :model-value="e.dur" v-slot="scope" buttons label-set="OK" label-cancel="Annuler" @save="v => setEntryDur(e, v)">
<q-input type="number" v-model.number="scope.value" dense autofocus :step="0.25" :min="0.25" :max="24" suffix="h" label="Temps sur place" @keyup.enter="scope.set" />
</q-popup-edit>
</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>
<!-- Contraintes horaires (prises en compte par ⚡ Ré-optimiser) : AM/PM = fenêtre · ⚡ = urgent, servir en début de journée -->
<!-- PRIORITÉ persistante (MÊME drapeau que les conversations) : contour gris = non classé, coloré = niveau. Liste rapide. -->
<q-btn flat dense round size="sm" :icon="priorityMeta(jobPriorityOf(e.jobName)).icon" :color="priorityMeta(jobPriorityOf(e.jobName)).color">
<q-tooltip>Priorité : {{ priorityMeta(jobPriorityOf(e.jobName)).label }} (persistante — SLA/commercial). Ré-optimiser pour appliquer.</q-tooltip>
<q-menu auto-close anchor="bottom right" self="top right"><q-list dense style="min-width:180px">
<q-item-label header class="q-py-xs">Priorité</q-item-label>
<q-item v-for="lvl in PRIORITY_LEVELS" :key="lvl" clickable @click="setEntryPriority(e.jobName,lvl)" :active="jobPriorityOf(e.jobName) === lvl" active-class="bg-green-1">
<q-item-section avatar><q-icon :name="priorityMeta(lvl).icon" :color="priorityMeta(lvl).color" size="18px" /></q-item-section>
<q-item-section><q-item-label>{{ priorityMeta(lvl).label }}</q-item-label><q-item-label caption>{{ priorityMeta(lvl).desc }}</q-item-label></q-item-section>
<q-item-section side v-if="jobPriorityOf(e.jobName) === lvl"><q-icon name="check" color="primary" size="16px" /></q-item-section>
</q-item>
</q-list></q-menu>
</q-btn>
<q-icon v-if="jobTimeOf(e.jobName).auto" name="auto_awesome" size="11px" color="amber-7" class="q-mr-xs"><q-tooltip>AM/PM détecté automatiquement depuis le titre/note du ticket</q-tooltip></q-icon>
<span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'am' }" @click="toggleJobAmpm(e.jobName, 'am')"><q-tooltip>Avant-midi (8-12) — Ré-optimiser pour appliquer</q-tooltip>AM</span>
<span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'pm' }" @click="toggleJobAmpm(e.jobName, 'pm')"><q-tooltip>Après-midi (12-16) — Ré-optimiser pour appliquer</q-tooltip>PM</span>
<q-space />
<q-btn flat dense round size="sm" icon="swap_horiz" color="grey-7"><q-tooltip>Déplacer vers un autre tech (classés par proximité + charge)</q-tooltip>
<q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:250px;max-height:46vh;overflow:auto">
<q-item-label header class="q-py-xs">Techs par <b>proximité</b> + <b>charge</b><span v-if="e.skill" class="text-grey-6"> · {{ e.skill }}</span></q-item-label>
<q-item v-for="r in techsForEntry(e)" :key="r.id" clickable v-close-popup @click="moveSuggestEntry(e, r.id, r.name)" :active="r.id === e.techId" :disable="!r.capable && anyCovers([e.skill])">
<q-item-section avatar style="min-width:30px"><q-avatar size="22px" :color="r.capable ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:9px;font-weight:700">{{ initials(r.name) }}</q-avatar></q-item-section>
<q-item-section>{{ r.name }}<div class="text-grey-6" style="font-size:10px">{{ r.distKm != null ? '📍 ≈' + (Math.round(r.distKm * 10) / 10) + ' km' : 'sans domicile' }} · {{ r.h }}/{{ r.cap }}h<span v-if="r.noShift"> · sans quart</span></div></q-item-section>
<q-item-section side v-if="!r.capable"><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-separator v-if="!showAllMoveTechs" />
<q-item v-if="!showAllMoveTechs" clickable @click="showAllMoveTechs = true"><q-item-section class="text-primary" style="font-size:12px"><q-icon name="expand_more" size="14px" class="q-mr-xs" />Afficher tous les techniciens…</q-item-section></q-item>
</q-list></q-menu>
</q-btn>
<q-btn flat dense round size="sm" icon="computer" :color="isJobRemote(e.jobName) ? 'indigo-6' : 'grey-4'" @click="toggleJobRemote(e)"><q-tooltip>{{ isJobRemote(e.jobName) ? '🖥 SANS déplacement (à distance) — Ré-optimiser pour l\'exclure des tournées' : 'Marquer « sans déplacement » (configurable à distance, ex. boîtier tel via ACS)' }}</q-tooltip></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 v-if="legInfo(g, d, ei)" class="suggest-leg-time" :title="legInfo(g, d, ei).real ? 'Temps de route réel (OSRM)' : 'Estimation à vol d\'oiseau'">🚗 {{ legInfo(g, d, ei).mins }} min{{ legInfo(g, d, ei).real ? '' : ' ≈' }}</div>
</template>
<!-- Retour au point de départ (le tech revient chez lui / au bureau) — inclus dans la distance/temps de la tournée -->
<div v-if="legReturn(g, d) != null" class="suggest-leg-time suggest-leg-return">🔄 retour {{ (legsByKey[g.techId + '|' + d.iso] || {}).kind === 'depot' ? '🏢 bureau TARGO' : '🏠 domicile' }} : {{ legReturn(g, d) }} min</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 === 'review'" outline no-caps color="primary" icon="bolt" label="Ré-optimiser" :loading="suggestDlg.building" @click="reoptimize"><q-tooltip>Relance le solveur VRP en tenant compte des contraintes AM/PM/⚡ posées sur les jobs</q-tooltip></q-btn>
<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 -->
<!-- Notifier les techs : aperçu par tech (lien token + billets + flags) puis envoi SMS/courriel -->
<q-dialog v-model="notifyDlg.open" :maximized="$q.screen.lt.sm">
<q-card :style="$q.screen.lt.sm ? 'width:100%;max-width:100%;height:100%' : 'width:560px;max-width:96vw'" class="column no-wrap">
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid #e2e8f0">
<q-icon name="forward_to_inbox" color="deep-purple-6" size="22px" class="q-mr-sm" />
<div class="col" style="min-width:0"><div class="text-subtitle1 text-weight-bold">Notifier les techniciens</div><div class="text-caption text-grey-7">Lien personnel de la tournée + adresse & flags (AM/PM · urgent · appeler avant)</div></div>
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-py-sm text-caption text-grey-7"><q-icon name="info" size="14px" /> Coche le canal <b>par technicien</b> — <b>SMS préféré</b>. Décoche pour exclure quelqu'un (ex. envoi de test).</q-card-section>
<q-separator />
<q-card-section class="col scroll q-pa-none" style="overflow:auto">
<div v-if="notifyDlg.loading" class="text-center q-pa-lg"><q-spinner size="28px" color="primary" /><div class="text-caption text-grey-6 q-mt-sm">Préparation des messages…</div></div>
<div v-else-if="!notifyDlg.techs.length" class="text-center text-grey-6 q-pa-lg">Aucun technicien avec une tournée à notifier ce jour.</div>
<q-list v-else separator>
<q-expansion-item v-for="m in notifyDlg.techs" :key="m.techId" dense>
<template #header>
<q-item-section avatar><q-avatar size="30px" color="deep-purple-1" text-color="deep-purple-9" style="font-weight:700;font-size:11px">{{ initials(m.name) }}</q-avatar></q-item-section>
<q-item-section><q-item-label>{{ m.name }}</q-item-label><q-item-label caption>{{ m.jobCount }} intervention(s)<span v-if="!m.phone && !m.email" class="text-negative"> · ⚠ aucune coordonnée</span></q-item-label></q-item-section>
<q-item-section side @click.stop>
<div class="row items-center q-gutter-xs no-wrap">
<q-toggle v-model="m.wantSms" dense size="sm" icon="sms" color="teal-6" :disable="!m.phone"><q-tooltip>{{ m.phone ? 'SMS → ' + m.phone : 'Aucun téléphone (ajoute-le ci-dessous)' }}</q-tooltip></q-toggle>
<q-toggle v-model="m.wantEmail" dense size="sm" icon="email" color="purple-6" :disable="!m.email"><q-tooltip>{{ m.email ? 'Courriel → ' + m.email : 'Aucun courriel (ajoute-le ci-dessous)' }}</q-tooltip></q-toggle>
</div>
</q-item-section>
</template>
<q-card flat><q-card-section class="q-pt-none">
<!-- Édition INLINE si coordonnée manquante — sans quitter le dialogue -->
<div v-if="!m.phone || !m.email" class="row items-center q-gutter-sm q-mb-sm">
<q-input v-if="!m.phone" v-model="m.newPhone" dense outlined type="tel" placeholder="Ajouter un téléphone…" style="max-width:190px" @keyup.enter="saveNotifyContact(m)"><template #prepend><q-icon name="sms" size="16px" /></template></q-input>
<q-input v-if="!m.email" v-model="m.newEmail" dense outlined type="email" placeholder="Ajouter un courriel…" style="max-width:220px" @keyup.enter="saveNotifyContact(m)"><template #prepend><q-icon name="email" size="16px" /></template></q-input>
<q-btn dense unelevated size="sm" color="primary" icon="save" label="Enregistrer" no-caps :loading="m.saving" :disable="!m.newPhone && !m.newEmail" @click="saveNotifyContact(m)" />
</div>
<pre class="notify-preview">{{ m.sms }}</pre>
</q-card-section></q-card>
</q-expansion-item>
</q-list>
</q-card-section>
<q-separator />
<q-card-actions align="right" class="q-pa-md">
<div class="text-caption text-grey-6 col text-left" style="min-width:0">Aperçu — rien n'est envoyé avant « Envoyer ».</div>
<q-btn flat no-caps label="Annuler" v-close-popup />
<q-btn unelevated no-caps color="deep-purple-6" icon="send" :label="'Envoyer (' + notifyRecipientCount + ')'" :loading="notifyDlg.sending" :disable="!notifyRecipientCount" @click="sendNotify" />
</q-card-actions>
</q-card>
</q-dialog>
<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" style="line-height:1.25;word-break:break-word">{{ 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>
<!-- QUI fait la job : chip visible + assignation DIRECTE depuis le volet (avec regroupement même adresse) -->
<div class="q-mt-xs row items-center q-gutter-xs no-wrap">
<q-chip v-if="jobDetail.techName" dense color="indigo-6" text-color="white" class="text-weight-bold jd-tech-chip"><q-avatar color="indigo-9" text-color="white" size="20px">{{ initials(jobDetail.techName) }}</q-avatar>{{ jobDetail.techName }}</q-chip>
<q-chip v-else dense outline color="grey-7" text-color="grey-8" icon="person_off">Non assigné</q-chip>
<TechSelect v-model="jobDetail.assignTech" :options="jdTeamOptions" :label="jobDetail.techName ? 'Réassigner…' : 'Assigner à…'" style="min-width:170px;max-width:230px" @update:model-value="v => v && jdAssignTech(v)" />
<TicketStatusControl v-if="jobDetail.canTeam" doctype="Dispatch Job" :docname="jobDetail.name" :status="jobDetail.status" :scheduled-date="jobDetail.iso"
@changed="p => { if (p && p.status) jobDetail.status = p.status; reloadOccupancy(); reloadPool && reloadPool() }" />
</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">
<!-- Détails SIMPLES d'abord (carte/tracé GPS repliés : non requis pour lire le ticket) -->
<div class="jd-meta">
<div v-if="jobDetail.time"><q-icon name="schedule" size="16px" /> {{ jobDetail.time }}</div>
<div v-if="jobDetail.address"><q-icon name="place" size="16px" /> {{ jobDetail.address }}</div>
<div v-if="jdHasCoords" class="jd-geolinks">
<a :href="jdMapsUrl" target="_blank" rel="noopener" class="jd-geolink"><q-icon name="satellite_alt" size="14px" /> Carte / satellite</a>
<a :href="jdStreetViewUrl" target="_blank" rel="noopener" class="jd-geolink"><q-icon name="streetview" size="14px" /> Street View</a>
</div>
</div>
<!-- Ajuster la durée estimée (comme « Suggérer ») → persiste duration_h -->
<div class="jd-dur row items-center q-gutter-sm q-my-sm">
<q-icon name="timer" size="16px" color="grey-6" /><span class="text-caption text-grey-7">Durée estimée</span>
<q-input dense outlined type="number" step="0.25" min="0" v-model.number="jobDetail.durH" style="width:96px" suffix="h" @change="jdSetDuration(jobDetail.durH)" @keyup.enter="jdSetDuration(jobDetail.durH)" />
<span class="text-caption text-grey-5">≈ {{ fmtMin(Math.round((jobDetail.durH || 0) * 60)) }}</span>
</div>
<!-- Compétences REQUISES (dispatch auto) — LISTE : sélecteur/créateur en chips (autosuggest). Le tech doit les avoir TOUTES. 1re = principale. -->
<div class="jd-skill row items-center q-gutter-sm q-mb-xs">
<q-icon name="construction" size="16px" color="grey-6" /><span class="text-caption text-grey-7">Compétences requises</span>
<TagEditor :model-value="jobDetail.skills" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" placeholder="Ajouter une compétence requise…" style="min-width:220px;flex:1" @update:model-value="jdSetJobSkills" @create="onCreateRosterTag" />
</div>
<!-- Attribuer cette compétence à un tech précis → il sera matché au dispatch automatique. -->
<div v-if="jobDetail.skill" class="row items-center q-gutter-xs q-mb-sm no-wrap">
<span class="text-caption text-grey-6 no-wrap">Donner à :</span>
<TechSelect v-model="jdSkillTech" :options="jdAllTechOpts" label="un technicien…" style="min-width:180px;flex:1" />
<q-btn dense unelevated color="teal-7" icon="add" no-caps :disable="!jdSkillTech" :loading="jdSkillBusy" @click="jdGiveSkillToTech" label="Attribuer" />
</div>
<!-- Carte / tracé GPS : repliés par défaut -->
<div class="q-mb-sm">
<q-btn flat dense no-caps size="sm" color="grey-7" :icon="jdShowMap ? 'expand_less' : 'map'" :label="jdShowMap ? 'Masquer la carte' : 'Voir la carte / tracé GPS'" @click="jdShowMap = !jdShowMap" />
</div>
<div v-show="jdShowMap">
<div ref="jdMapEl" class="jd-map"></div>
<div v-if="kbCanTrack" class="jd-track-row"><q-toggle v-model="jdShowTrack" dense size="sm" color="warning" /><span class="text-caption text-weight-medium">Tracé GPS réel (Traccar)</span><span class="text-caption text-grey-6">{{ jdTrackMsg }}</span></div>
<div v-else class="text-caption text-grey-5 q-mb-sm">Tracé GPS réel : aujourd'hui / hier seulement (Traccar).</div>
</div>
<!-- Suivi terrain (géofencing GPS) : étapes façon suivi de colis — En route → Arrivé → Reparti, avec l'heure atteinte. -->
<div v-if="jobDetail.geofence && jobDetail.geofence.state" class="jd-geo">
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="my_location" size="18px" class="q-mr-xs" color="teal-7" /> Suivi terrain (GPS)</div>
<div class="jd-geo-track">
<div v-for="s in geoTimeline" :key="s.k" class="jd-geo-step" :class="{ done: s.done, current: s.current }">
<div class="jd-geo-ic"><q-icon :name="s.icon" size="15px" /></div>
<div class="jd-geo-lbl">{{ s.label }}<span v-if="s.at" class="jd-geo-at">{{ s.at }}</span></div>
</div>
</div>
</div>
<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">{{ jdAssistantName(a) }}</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">
<TechSelect v-model="jobDetail.teamAdd" :options="jdTeamOptions" label="Ajouter un assistant (nom · compétence)" style="flex:1;min-width:0" />
<q-btn dense unelevated color="deep-purple" icon="group_add" label="Ajouter" :disable="!jobDetail.teamAdd" @click="jdAddAssistant" no-caps />
</div>
</div>
<div class="text-subtitle2 q-mt-md q-mb-xs row items-center">Commentaires / fil du billet<span v-if="jdMessages.length" class="text-grey-5 q-ml-sm" style="font-size:10px">· plus récent en haut</span></div>
<div v-if="jobDetail.loading" class="text-center q-pa-md"><q-spinner size="26px" color="primary" /><div class="text-caption text-grey-6 q-mt-sm">Lecture du ticket…</div></div>
<template v-else-if="jdMessages.length">
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ jdMessages.length }} message(s){{ jobDetail.thread.status ? ' · ' + jobDetail.thread.status : '' }}</div>
<div v-for="(m, mi) in jdMessages" :key="mi" class="de-msg" :class="{ 'de-msg-latest': mi === 0 }">
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span><span v-if="mi === 0 && jdMessages.length > 1" class="de-msg-badge">dernier</span></div>
<div class="de-msg-txt">{{ m.text }}</div>
</div>
</template>
<!-- Repli : corps brut du billet UNIQUEMENT s'il n'y a aucun message dans le fil (sinon = doublon du 1er commentaire) -->
<div v-else-if="jobDetail.detail" class="jd-detail">{{ jobDetail.detail }}</div>
<div v-else class="text-grey-6 q-pa-md text-center">{{ jobDetail.lid ? ((jobDetail.thread && jobDetail.thread.error) ? 'Détail indisponible' : 'Aucun commentaire.') : 'Pas de billet Legacy lié à ce job.' }}</div>
<!-- Répondre au fil depuis la job — même geste que le panneau ticket + l'app terrain. Note INTERNE par défaut ; au client si coché. -->
<div v-if="jobDetail.lid || jobDetail.name" class="q-mt-sm">
<q-input v-model="jdReply" dense outlined type="textarea" autogrow placeholder="Écrire une note / réponse…" :input-style="{ minHeight: '48px' }" @keydown.ctrl.enter="sendJdReply" @keydown.meta.enter="sendJdReply" />
<div class="row items-center q-mt-xs">
<q-checkbox v-if="jobDetail.lid" v-model="jdReplyPublic" dense size="sm" label="Envoyer aussi au client" />
<q-space />
<q-btn dense unelevated color="primary" no-caps icon="send" :label="jdReplyPublic ? 'Répondre au client' : 'Note interne'" :disable="!jdReply.trim()" :loading="jdReplySending" @click="sendJdReply" />
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
<!-- #1 — Sommaire des changements AVANT de publier (ajouts/retraits par jour + nb SMS) -->
<q-dialog v-model="pubConfirm">
<q-card style="min-width:380px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="cloud_upload" color="positive" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Publier l'horaire</div>
<q-space /><q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-pt-sm">
<div class="text-body2 q-mb-sm"><b class="text-positive">{{ pubSummary.added }}</b> ajout(s) · <b class="text-negative">{{ pubSummary.removed }}</b> retrait(s) · <b>{{ notifySms ? pubSummary.added + ' SMS aux techs' : 'sans SMS' }}</b></div>
<q-list dense bordered style="border-radius:8px;max-height:44vh;overflow:auto">
<template v-for="d in pubSummary.days" :key="d.date">
<q-item-label header class="q-py-xs text-weight-medium">{{ fmtDueLabel(d.date) }}</q-item-label>
<q-item v-for="(a, i) in d.add" :key="'a' + i" dense><q-item-section avatar style="min-width:24px"><q-icon name="add" color="positive" size="16px" /></q-item-section><q-item-section>{{ a.name }}<span class="text-grey-6"> · {{ a.shift }}</span></q-item-section></q-item>
<q-item v-for="(r, i) in d.rem" :key="'r' + i" dense><q-item-section avatar style="min-width:24px"><q-icon name="remove" color="negative" size="16px" /></q-item-section><q-item-section>{{ r.name }}<span class="text-grey-6"> · {{ r.shift }}</span></q-item-section></q-item>
</template>
</q-list>
</q-card-section>
<q-card-actions align="right" class="q-px-md q-pb-md">
<q-btn flat no-caps label="Annuler" color="grey-7" v-close-popup />
<q-btn unelevated no-caps color="positive" icon="cloud_upload" :label="notifySms ? 'Publier + SMS' : 'Publier'" :loading="publishing" @click="doPublishConfirmed" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- #5 — Réserver du temps d'un tech (bloc « Réservation » priorité moyenne → dé-priorise au dispatch auto ; urgences passent) -->
<q-dialog v-model="resvDlg.open">
<q-card style="min-width:320px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="event_busy" color="deep-orange-7" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Réserver du temps</div>
<q-space /><q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-gutter-sm">
<div class="text-body2"><b>{{ resvDlg.tech && resvDlg.tech.name }}</b> <span class="text-grey-6">— indisponible au dispatch auto pendant ce bloc</span></div>
<div class="row q-col-gutter-sm">
<q-input class="col-6" dense outlined type="date" v-model="resvDlg.date" label="Date" />
<q-input class="col-3" dense outlined type="time" v-model="resvDlg.start" label="Début" />
<q-input class="col-3" dense outlined type="number" v-model.number="resvDlg.dur" label="Durée (h)" min="0.5" step="0.5" />
</div>
<q-input dense outlined v-model="resvDlg.reason" label="Motif (tâche / projet)" placeholder="Ex. Projet déploiement fibre secteur X" />
<div class="text-caption text-grey-6"><q-icon name="info" size="14px" /> Les <b>réparations & urgences</b> peuvent quand même utiliser ce tech (recherche de créneau en mode urgence).</div>
</q-card-section>
<q-card-actions align="right" class="q-px-md q-pb-md">
<q-btn flat no-caps label="Annuler" color="grey-7" v-close-popup />
<q-btn unelevated no-caps color="deep-orange-7" icon="event_busy" label="Réserver" :loading="resvDlg.busy" @click="doReserve" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Drop d'une job sur un tech SANS quart : créer un quart (durée) ou marquer absent (durée + retour) -->
<q-dialog v-model="dropAsk.open">
<q-card style="min-width:380px;max-width:440px">
<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 — extrait dans components/planif/TechSyncDialog.vue (décomposition #4) -->
<TechSyncDialog v-model="showTechSync" @applied="loadBase" />
<!-- 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 🟠 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 : chip clair de l'appareil relié (📍 + pastille en-ligne) OU « non associé » + relier facilement -->
<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="jdDevice ? 'primary' : 'grey-4'" />
<span class="text-grey-8 text-weight-medium">Appareil GPS&nbsp;:</span>
<!-- RELIÉ : appareil clairement affiché + changer de véhicule + dissocier -->
<template v-if="jdDevice">
<q-chip dense :color="jdDevice.missing ? 'orange-1' : 'green-1'" :text-color="jdDevice.missing ? 'orange-9' : 'green-9'" icon="place" class="text-weight-medium">
<q-icon name="circle" size="8px" :color="jdDevice.online ? 'positive' : 'grey-5'" class="q-mr-xs" />{{ jdDevice.label }}
<q-tooltip>{{ jdDevice.online ? 'En ligne' : 'Hors ligne' }}{{ jdDevice.uniqueId ? ' · ' + jdDevice.uniqueId : '' }}</q-tooltip>
</q-chip>
<q-btn flat dense no-caps size="sm" color="indigo" icon="swap_horiz" label="Changer"><q-tooltip>Relier à un autre véhicule</q-tooltip>
<q-menu anchor="bottom left" self="top left"><div class="q-pa-xs" style="min-width:270px"><q-input dense outlined v-model="deviceFilter" placeholder="Filtrer les véhicules…" clearable autofocus class="q-mb-xs" /><q-list dense style="max-height:44vh;overflow:auto"><q-item v-for="o in deviceOptions" :key="o.value" clickable v-close-popup @click="onPickDevice(dayEditor.tech, o.value)" :active="jdDevice && jdDevice.value === o.value" active-class="bg-green-1"><q-item-section avatar style="min-width:22px"><q-icon name="circle" size="9px" :color="o.online ? 'positive' : 'grey-4'" /></q-item-section><q-item-section><q-item-label>{{ o.label }}</q-item-label><q-item-label v-if="o.uniqueId" caption>{{ o.uniqueId }}</q-item-label></q-item-section></q-item><q-item v-if="!deviceOptions.length"><q-item-section class="text-grey-6">{{ devicesLoading ? 'Chargement…' : 'Aucun appareil' }}</q-item-section></q-item></q-list></div></q-menu>
</q-btn>
<q-btn flat dense round size="sm" icon="link_off" color="grey-6" @click="onPickDevice(dayEditor.tech, null)"><q-tooltip>Dissocier l'appareil GPS</q-tooltip></q-btn>
</template>
<!-- NON RELIÉ : suggestion auto (nom similaire) + choisir dans la liste -->
<template v-else>
<span class="text-grey-6">non associé</span>
<q-btn v-if="jdDeviceSuggest" dense unelevated size="sm" no-caps color="primary" icon="link" :label="'Associer ' + jdDeviceSuggest.label" @click="onPickDevice(dayEditor.tech, jdDeviceSuggest.value)"><q-tooltip>Appareil probable (nom similaire au tech) — cliquer pour relier</q-tooltip></q-btn>
<q-btn flat dense no-caps size="sm" color="indigo" icon="add_location_alt" label="Choisir"><q-tooltip>Relier un véhicule Traccar</q-tooltip>
<q-menu anchor="bottom left" self="top left"><div class="q-pa-xs" style="min-width:270px"><q-input dense outlined v-model="deviceFilter" placeholder="Filtrer les véhicules…" clearable autofocus class="q-mb-xs" /><q-list dense style="max-height:44vh;overflow:auto"><q-item v-for="o in deviceOptions" :key="o.value" clickable v-close-popup @click="onPickDevice(dayEditor.tech, o.value)"><q-item-section avatar style="min-width:22px"><q-icon name="circle" size="9px" :color="o.online ? 'positive' : 'grey-4'" /></q-item-section><q-item-section><q-item-label>{{ o.label }}</q-item-label><q-item-label v-if="o.uniqueId" caption>{{ o.uniqueId }}</q-item-label></q-item-section></q-item><q-item v-if="!deviceOptions.length"><q-item-section class="text-grey-6">{{ devicesLoading ? 'Chargement…' : 'Aucun appareil' }}</q-item-section></q-item></q-list></div></q-menu>
</q-btn>
</template>
</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>
<!-- #17 — Création de job NATIVE OPS (work-order) : réutilise le modal partagé. @created → rafraîchit le pool. -->
<UnifiedCreateModal v-model="createJobOpen" :mode="createMode" :context="createJobCtx"
:technicians="techs" :external-tags="tagCatalog" :external-get-color="getTagColor" @created="onJobCreated" />
<!-- Chooser « Créer » : Tâche · Intervention (sur site → créneau / à distance → ticket) — un seul point d'entrée -->
<q-dialog v-model="createChooser">
<q-card style="min-width:340px;max-width:440px">
<q-card-section class="row items-center q-pb-sm"><div class="text-h6">Créer…</div><q-space /><q-btn flat round dense icon="close" v-close-popup /></q-card-section>
<q-card-section class="q-pt-none column q-gutter-sm">
<q-btn no-caps unelevated align="left" color="grey-2" text-color="grey-9" class="cc-opt" @click="startCreate('task')">
<q-icon name="task_alt" color="indigo" size="24px" class="q-mr-md" /><div class="text-left"><div class="text-weight-medium">Tâche</div><div class="text-caption text-grey-7">Travail interne — sans client ni déplacement</div></div>
</q-btn>
<div class="text-caption text-weight-medium text-grey-7 q-mt-xs">Intervention</div>
<q-btn no-caps unelevated align="left" color="teal-1" text-color="teal-10" class="cc-opt" @click="startCreneau">
<q-icon name="construction" color="teal-8" size="24px" class="q-mr-md" /><div class="text-left"><div class="text-weight-medium">Sur site — trouver un créneau</div><div class="text-caption text-grey-7">Meilleur tech + date + heure (selon priorité &amp; compétences)</div></div>
</q-btn>
<q-btn no-caps flat dense align="left" color="teal-9" class="cc-sub" @click="startCreate('work-order')"><q-icon name="inbox" size="16px" class="q-mr-sm" />…ou ajouter au pool (planifier plus tard)</q-btn>
<q-btn no-caps unelevated align="left" color="blue-1" text-color="blue-10" class="cc-opt" @click="startCreate('ticket')">
<q-icon name="headset_mic" color="blue-8" size="24px" class="q-mr-md" /><div class="text-left"><div class="text-weight-medium">À distance — ticket</div><div class="text-caption text-grey-7">Support ou vente (selon priorité &amp; compétences), sans déplacement</div></div>
</q-btn>
<q-separator class="q-my-xs" />
<q-btn no-caps unelevated align="left" color="amber-1" text-color="amber-10" class="cc-opt" @click="createChooser = false; quoteWizardOpen = true">
<q-icon name="request_quote" color="amber-8" size="24px" class="q-mr-md" /><div class="text-left"><div class="text-weight-medium">Soumission</div><div class="text-caption text-grey-7">Vérifier l'adresse (fibre) → prospect → services &amp; devis — même pour un non-client</div></div>
</q-btn>
</q-card-section>
</q-card>
</q-dialog>
<!-- « Trouver un créneau » → chips compétence (durée par défaut) + adresse → choisit tech+date+heure puis pré-remplit la création -->
<SuggestSlotsDialog v-model="showSuggestSlots" :skills="slotSkills" @select="onSlotSelected" @plan-shifts="onPlanShifts" />
<!-- Soumission : wizard adresse+fibre+prospect → passe le relais à ProjectWizard (panier + rabais + devis + acceptation) -->
<QuoteWizard v-model="quoteWizardOpen" @qualified="onQuoteQualified" />
<ProjectWizard v-model="projectWizardOpen" :customer="quoteCustomer" :initial-tier="quoteTier" @created="onQuoteCreated" />
<!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) → écrit les Shift Assignment -->
<WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" />
</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'
import { useRoute } from 'vue-router' // deep-link ?day=&skill= (tableau de bord, slot-finder)
// Icônes de rôle monochromes outline (Material Symbols, style « une couleur » demandé) : échelle = installation.
import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
import { usePermissions } from 'src/composables/usePermissions'
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 { installMaplibre, basemapStyle, createPlanMap } from 'src/config/basemap' // fond de carte MapLibre/OSM auto-hébergé (plus de Mapbox, plus de logo) + init commune des cartes
import { legacyDeptColor, heatColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé) + heatColor (source unique, aussi <OccupancyStrip>)
import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur)
import { relTime, initials } from 'src/composables/useFormatters' // temps relatif + initiales (source unique, ex-locales dé-dupliquées)
import { messageIdentity, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' // resolvers partagés + priorité (drapeau) réutilisée des conversations
import { useSla, SLA_BADGE } from 'src/composables/useSla' // SLA existant (politiques éditables) — réutilisé sur les jobs (échéance = création + résolution)
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 LeaveDialog from 'src/components/planif/LeaveDialog.vue' // Congés & disponibilités (extrait — décomposition #4)
import TechSyncDialog from 'src/components/planif/TechSyncDialog.vue' // Synchroniser les techniciens (extrait — décomposition #4)
import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags/compétences PARTAGÉ (chips colorées, création + palette, niveaux) — SOURCE UNIQUE : jobs ET techs
import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' // bande d'occupation réutilisable (sélecteur de jour Tournées + tableau de bord)
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) — Dispatch déprécié, tout ici
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » → pré-remplit la création (tech+date+heure+adresse)
import QuoteWizard from 'src/components/shared/QuoteWizard.vue' // soumission : adresse+fibre+prospect (lead-first)
import { useCreateSignal } from 'src/composables/useCreateSignal' // FAB global « Créer » → ouvre le chooser ici
import ProjectWizard from 'src/components/shared/ProjectWizard.vue' // moteur soumission existant (panier + rabais + devis + acceptation) — réutilisé
import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) — repris/amélioré depuis Dispatch
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
// Barre verticale d'occupation de l'en-tête desktop (miroir de la bande mobile) : ratio charge estimée / heures de quart.
function hdrRatio (iso) { const cap = visStat(iso).hours || 0, load = estLoad(iso) || 0; return cap > 0 ? load / cap : (load > 0 ? 1 : 0) }
function hdrFillH (iso) { return (estLoad(iso) || 0) > 0 ? Math.max(12, Math.min(100, Math.round(hdrRatio(iso) * 100))) : 0 }
// 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(14) // 2 semaines par défaut (sélecteur retiré pour éviter les changements d'étendue accidentels)
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)
// ── Notifier les techs de leur tournée (SMS/Email + lien token) : aperçu d'abord (compose sans envoi), puis envoi. ──
const notifyDlg = reactive({ open: false, loading: false, sending: false, techs: [] })
const notifyRecipientCount = computed(() => (notifyDlg.techs || []).filter(m => (m.wantSms && m.phone) || (m.wantEmail && m.email)).length)
function notifyScope () { // jour(s) + techs concernés = vue Tournées : jour choisi + techs ayant une tournée ; sinon semaine + techs visibles
const dates = boardView.value === 'routes' && routesDay.value ? [routesDay.value] : (dayList.value || []).map(d => d.iso)
const techIds = boardView.value === 'routes' ? (allDayRoutes.value || []).map(r => r.id) : (visibleTechs.value || []).map(t => t.id)
return { dates, techIds: [...new Set(techIds)] }
}
async function openNotifyDlg () {
const { dates, techIds } = notifyScope()
if (!dates.length || !techIds.length) { $q.notify({ type: 'warning', message: 'Aucune tournée à notifier (sélectionne un jour avec des jobs assignés).' }); return }
Object.assign(notifyDlg, { open: true, loading: true, techs: [] })
// Par tech : SMS PRÉFÉRÉ (coché si téléphone), courriel seulement si pas de téléphone. Toggle par personne → exclure certains (ex. test).
try { const r = await roster.notifyDispatch({ dates, tech_ids: techIds, preview: true }); notifyDlg.techs = ((r && r.techs) || []).map(m => ({ ...m, wantSms: !!m.phone, wantEmail: !m.phone && !!m.email, newPhone: '', newEmail: '', saving: false })) }
catch (e) { err(e) } finally { notifyDlg.loading = false }
}
async function sendNotify () {
const smsIds = notifyDlg.techs.filter(m => m.wantSms && m.phone).map(m => m.techId)
const emailIds = notifyDlg.techs.filter(m => m.wantEmail && m.email).map(m => m.techId)
if (!smsIds.length && !emailIds.length) { $q.notify({ type: 'warning', message: 'Aucun destinataire coché (SMS ou courriel sur au moins un tech).' }); return }
const { dates } = notifyScope()
notifyDlg.sending = true
try {
let sms = 0, email = 0; const errs = []
if (smsIds.length) { const r = await roster.notifyDispatch({ dates, tech_ids: smsIds, sms: true, email: false }); sms = r.sms || 0; if (r.errors) errs.push(...r.errors) }
if (emailIds.length) { const r = await roster.notifyDispatch({ dates, tech_ids: emailIds, sms: false, email: true }); email = r.email || 0; if (r.errors) errs.push(...r.errors) }
$q.notify({ type: errs.length ? 'warning' : 'positive', message: `Notifié : ${sms} SMS · ${email} courriel(s)` + (errs.length ? ` · ${errs.length} échec(s)` : ''), timeout: 4000 })
if (errs.length) console.warn('notify errors', errs)
notifyDlg.open = false
} catch (e) { err(e) } finally { notifyDlg.sending = false }
}
// Édition INLINE du contact d'un tech sans coordonnées, sans quitter le dialogue (comme ailleurs dans l'app).
async function saveNotifyContact (m) {
const phone = String(m.newPhone || '').trim(); const email = String(m.newEmail || '').trim()
if (!phone && !email) return
m.saving = true
try {
const r = await roster.setTechContact({ tech_id: m.techId, phone: phone || undefined, email: email || undefined })
if (r && r.ok) { if (phone) { m.phone = phone; m.wantSms = true } if (email) { m.email = email; if (!phone) m.wantEmail = true } m.newPhone = ''; m.newEmail = ''; $q.notify({ type: 'positive', message: 'Coordonnées enregistrées', timeout: 1800 }) }
else $q.notify({ type: 'negative', message: (r && r.error) || 'Échec de lenregistrement' })
} catch (e) { err(e) } finally { m.saving = false }
}
const showLeave = ref(false) // ouverture de <LeaveDialog> (état leaveRows/leaveFilter/newLeave + fonctions déplacés dans le composant)
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__' || id === '__unassigned__' || id === '__remote__' || (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(['kanban', 'routes'].includes(planifPrefs.value.view) ? planifPrefs.value.view : 'grid') // 'grid' | 'kanban' | 'routes'
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 OSRM 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) 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 d = await roster.osrmTable(use.map(p => [p.lon, p.lat])) // OSRM auto-hébergé (via hub) — même forme que Mapbox Matrix
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 = capPickups((await roster.unassignedJobs()).jobs) } catch (e) { /* non bloquant */ } }
// ── Création de job NATIVE OPS (#17) — Dispatch déprécié → tout depuis Planification. Réutilise UnifiedCreateModal (work-order). ──
// Job OPS-natif = PAS de legacy_ticket_id → coexiste avec les jobs synchronisés de F (la sync F ne touche que les jobs à legacy_ticket_id). « Sync F transitoire » = coexistence, pas de push F pour l'instant.
const createJobOpen = ref(false)
const createJobCtx = ref({})
const showSuggestSlots = ref(false)
const createChooser = ref(false) // chooser « Créer » : Tâche / Intervention (sur site / à distance)
const createMode = ref('work-order') // mode passé à UnifiedCreateModal : 'task' | 'work-order' | 'ticket'
function openCreateJob () { createMode.value = 'work-order'; createJobCtx.value = { scheduled_date: '' }; createJobOpen.value = true } // pool (non planifié) par défaut
// Point d'entrée unifié « Créer ». startCreate ouvre le formulaire dans le bon mode ; startCreneau ouvre plutôt le solveur de créneau (sur site).
function startCreate (mode) { createMode.value = mode; createJobCtx.value = { scheduled_date: '' }; createChooser.value = false; createJobOpen.value = true }
function startCreneau () { createMode.value = 'work-order'; createChooser.value = false; showSuggestSlots.value = true }
// Soumission : QuoteWizard qualifie (adresse+fibre+prospect) → on ouvre ProjectWizard pré-rempli avec le client.
const quoteWizardOpen = ref(false)
const projectWizardOpen = ref(false)
const quoteCustomer = ref(null)
const quoteTier = ref(null) // forfait d'intérêt choisi dans la vitrine QuoteWizard → pré-remplit le panier ProjectWizard
function onQuoteQualified (payload) {
const cust = (payload && payload.customer) || null
// Rattache l'adresse qualifiée (+ zone tarifaire) au client passé à ProjectWizard → portée sur le devis (le prix dépend de l'adresse).
quoteCustomer.value = cust ? { ...cust, qualified_address: (payload && payload.address) || null } : null
quoteTier.value = (payload && payload.tier) || null
if (quoteCustomer.value) projectWizardOpen.value = true
}
function onQuoteCreated (job, meta) {
projectWizardOpen.value = false
// Pas de contrat signé requis (JWT/DocuSeal) → on enchaîne direct sur « Trouver un créneau » d'installation. Sinon : attendre l'approbation.
if (meta && !meta.needsAcceptance) {
$q.dialog({ title: 'Soumission créée', message: 'Planifier l\'installation maintenant (trouver un créneau) ?', cancel: 'Plus tard', ok: { label: 'Trouver un créneau', color: 'teal-8', noCaps: true }, persistent: false })
.onOk(() => { showSuggestSlots.value = true })
} else {
$q.notify({ type: 'positive', icon: 'request_quote', message: 'Soumission créée — en attente d\'approbation client avant de planifier', timeout: 3200 })
}
}
function onSlotSelected (slot) { // un créneau choisi → pré-remplit tech+date+heure+adresse+coords+compétence
createJobCtx.value = { assigned_tech: slot.tech_id, scheduled_date: slot.date, start_time: slot.start_time, address: slot._address || '', duration_h: slot._duration || 1, _latitude: slot._latitude, _longitude: slot._longitude, tags: slot._skill ? [slot._skill] : [] }
createJobOpen.value = true
}
async function onJobCreated () { createJobOpen.value = false; await reloadPool(); await reloadOccupancy(); $q.notify({ type: 'positive', icon: 'add_task', message: 'Job créé — visible dans « À assigner »', timeout: 2600 }) }
// 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: '', skills: [], time: '', durH: 1, detail: '', lid: null, iso: '', dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null, assignTech: null, geofence: null, status: '' })
// Décode les entités HTML (« d&#039;équipement » → « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés).
const _deEntEl = typeof document !== 'undefined' ? document.createElement('textarea') : null
function deEnt (s) { if (!s || String(s).indexOf('&') < 0 || !_deEntEl) return s || ''; _deEntEl.innerHTML = String(s); return _deEntEl.value }
async function openJobDetail (b, t) {
if (!b) return
jdShowTrack.value = false; jdShowMap.value = false
const durH0 = b.dur != null ? +b.dur : (b.est_min ? +b.est_min / 60 : (b.duration_h != null ? +b.duration_h : 1))
// b peut être un BLOC assigné (b.customer/skill/detail/legacy_id) OU un job BRUT du pool (customer_name/required_skill/
// legacy_detail/legacy_ticket_id) — ouvert depuis une goutte/carrousel. On mappe les DEUX formes pour toujours montrer
// client, compétence, description et le fil du billet.
const lid = b.legacy_id || b.legacy_ticket_id || null
Object.assign(jobDetail, { open: true, name: b.name || '', subject: deEnt(b.subject || b.name || 'Job'), customer: deEnt(b.customer || b.customer_name || ''), address: deEnt(b.address || b.service_location || ''), skill: b.skill || b.required_skill || '', skills: (Array.isArray(b.required_skills) && b.required_skills.length ? b.required_skills.filter(Boolean) : (b.skill || b.required_skill ? [b.skill || b.required_skill] : [])), time: (b.start ? (b.start + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : '')) : (b.scheduled_date ? fmtDueLabel(b.scheduled_date) : '')), durH: Math.round((durH0 || 1) * 100) / 100, detail: deEnt(b.detail || b.legacy_detail || ''), lid, iso: b.scheduled_date || b.iso || (boardView.value === 'routes' ? routesDay.value : '') || '', dept: b.dept || b.legacy_dept || '', techId: (t && t.id) || b.assigned_tech || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : (b.latitude != null ? +b.latitude : null), lon: b.lon != null ? +b.lon : (b.longitude != null ? +b.longitude : null), loading: !!lid, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null, assignTech: null, status: b.status || '' })
// Géofencing (suivi façon colis) : timeline En route → Arrivé → Reparti, non bloquant.
jobDetail.geofence = null
if (b.name) roster.jobGeofence(b.name).then(g => { if (jobDetail.name === b.name) jobDetail.geofence = g }).catch(() => {})
if (lid) { try { jobDetail.thread = await roster.ticketThread(lid) } 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 } }
}
// Timeline géofencing pour l'affichage (façon suivi de colis) : 4 étapes + heure atteinte.
const GEO_STEPS = [{ k: 'assigned', label: 'Assigné', icon: 'assignment_ind' }, { k: 'en_route', label: 'En route', icon: 'directions_car' }, { k: 'on_site', label: 'Arrivé sur place', icon: 'location_on' }, { k: 'departed', label: 'Reparti', icon: 'check_circle' }]
const geoTimeline = computed(() => {
const gf = jobDetail.geofence; const events = (gf && gf.events) || []
const atOf = (k) => { const e = [...events].reverse().find(x => x.status === k); return e ? new Date(e.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', timeZone: 'America/Toronto' }) : null }
const order = { assigned: 0, en_route: 1, on_site: 2, departed: 3 }
const curIdx = order[(gf && gf.state) || 'assigned'] ?? 0
return GEO_STEPS.map((s, i) => ({ ...s, at: s.k === 'assigned' ? null : atOf(s.k), done: i <= curIdx, current: i === curIdx }))
})
// Options du sélecteur d'assistant (TechSelect, recherche par nom OU compétence) : tous les techs sauf le lead + l'équipe déjà là.
// Label = nom · compétences (searchable) ; CAPABLES d'abord (possèdent la compétence du job) ; value = id (scalaire).
function techNameById (id) { const t = (techs.value || []).find(x => x.id === id); return t ? t.name : '' } // repli nom d'un assistant depuis l'id
// Nom d'assistant ROBUSTE : une vieille ligne a pu être persistée avec tech_name = la CHAÎNE « undefined »/« null » → on la traite comme vide et on résout par l'id.
function jdAssistantName (a) { const bad = v => !v || v === 'undefined' || v === 'null'; if (a && !bad(a.tech_name)) return a.tech_name; const n = techNameById(a && a.tech_id); return n || (a && !bad(a.tech_id) ? a.tech_id : '—') }
// Éditeur de COMPÉTENCES REQUISES du job (TagEditor partagé : chips colorées + création + palette) + attribution à un tech (dispatch auto).
const jdSkillTech = ref(null)
const jdSkillBusy = ref(false)
// Normalise la sortie d'un éditeur de compétences → libellés propres dédupliqués.
// TagEditor émet un tableau de libellés (ou de {tag}) ; on tolère aussi une CSV (rétro-compat).
function normSkillList (list) {
const raw = Array.isArray(list) ? list : String(list || '').split(',')
return [...new Set(raw.map(x => (typeof x === 'string' ? x : (x && x.tag) || '')).map(s => String(s).trim()).filter(Boolean))]
}
// LISTE de compétences requises du job → store hub /roster/job-skills (PAS un champ ERPNext). required_skill(principale)=1re. Le tech doit les avoir TOUTES.
async function jdSetJobSkills (list) {
const arr = normSkillList(list)
jobDetail.skills = arr; jobDetail.skill = arr[0] || ''
if (jobDetail.name) { try { await roster.setJobSkills(jobDetail.name, arr); if (typeof reloadPool === 'function') reloadPool() } catch (e) { err(e) } }
}
const jdAllTechOpts = computed(() => (techs.value || []).map(t => ({ label: t.name + ((t.skills || []).length ? ' · ' + t.skills.slice(0, 3).join(', ') : ''), value: t.id })))
async function jdGiveSkillToTech () { // attribue au tech TOUTES les compétences requises du job qu'il n'a pas encore
const req = (jobDetail.skills && jobDetail.skills.length) ? jobDetail.skills : (jobDetail.skill ? [jobDetail.skill] : [])
const tid = jdSkillTech.value; if (!req.length || !tid) return
const t = (techs.value || []).find(x => x.id === tid); if (!t) return
const missing = req.filter(s => !(t.skills || []).includes(s))
if (!missing.length) { $q.notify({ type: 'info', message: t.name + ' a déjà ces compétences' }); jdSkillTech.value = null; return }
jdSkillBusy.value = true
try { const skills = [...(t.skills || []), ...missing]; await roster.setTechSkills(t.id, skills.join(','), t.skill_levels || {}, t.skill_eff || {}); t.skills = skills; jdSkillTech.value = null; $q.notify({ type: 'positive', icon: 'construction', message: missing.join(', ') + ' → ' + t.name + ' (dispatch)' }) } catch (e) { err(e) } finally { jdSkillBusy.value = false }
}
const jdTeamOptions = computed(() => {
const taken = new Set([jobDetail.techId, ...(jobDetail.team || []).map(a => a.tech_id)])
const req = jobDetail.required_skill || jobDetail.skill || ''
return (techs.value || []).filter(t => !taken.has(t.id))
.map(t => { const sk = (t.skills || []); return { capable: !!(req && sk.includes(req)), label: t.name + (sk.length ? ' · ' + sk.slice(0, 4).join(', ') : ''), value: t.id } })
.sort((a, b) => (b.capable - a.capable) || a.label.localeCompare(b.label))
.map(({ label, value }) => ({ label, value }))
})
// Fil du billet, PLUS RÉCENT EN HAUT (le dernier commentaire porte souvent l'info la plus importante).
const jdMessages = computed(() => { const m = (jobDetail.thread && jobDetail.thread.messages) || []; return m.slice().reverse() })
// Répondre au fil depuis la job (note interne par défaut ; au client si coché). Attribution = agent connecté (prénom+nom).
const { userName: _opsUserName } = usePermissions()
const jdReply = ref('')
const jdReplyPublic = ref(false)
const jdReplySending = ref(false)
async function sendJdReply () {
const text = jdReply.value.trim(); if (!text || jdReplySending.value) return
jdReplySending.value = true
try {
const r = await roster.postJobComment({ job: jobDetail.name, lid: jobDetail.lid, text, isPublic: jdReplyPublic.value, agentName: _opsUserName.value || '' })
if (r && r.ok) {
const wasPublic = jdReplyPublic.value
jdReply.value = ''; jdReplyPublic.value = false
$q.notify({ type: 'positive', message: wasPublic ? 'Réponse envoyée au client' : 'Note interne ajoutée', timeout: 2200 })
if (jobDetail.lid) { try { jobDetail.thread = await roster.ticketThread(jobDetail.lid) } catch (e) {} } // recharge le fil
} else { $q.notify({ type: 'negative', message: (r && r.error) || 'Échec de l\'envoi', timeout: 3000 }) }
} catch (e) { $q.notify({ type: 'negative', message: 'Échec de l\'envoi', timeout: 3000 }) } finally { jdReplySending.value = false }
}
async function jdAddAssistant () {
const id = jobDetail.teamAdd; if (!id || !jobDetail.name) return
const tech = (techs.value || []).find(t => t.id === id)
// Nom robuste : tech chargé, sinon libellé de l'option choisie (« Nom · compétences »), sinon l'id — jamais « undefined ».
const opt = (jdTeamOptions.value || []).find(o => o.value === id)
const name = (tech && tech.name) || (opt && String(opt.label || '').split(' · ')[0].trim()) || String(id)
try {
await roster.addAssistant(jobDetail.name, { tech_id: String(id), tech_name: 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: 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 jdShowMap = ref(false) // carte masquée par défaut dans le volet détail (pas requise) — dépliable au besoin
const jdShowTrack = ref(false); const jdTrackMsg = ref('')
// Ajuste la durée du job depuis le volet détail (comme « Suggérer ») → persiste duration_h + override de session.
async function jdSetDuration (h) {
h = Math.round((Number(h) || 0) * 100) / 100
if (h <= 0 || !jobDetail.name) return
durOverride[jobDetail.name] = h
const pj = (assignPanel.jobs || []).find(x => x.name === jobDetail.name)
await patchJob(pj || { name: jobDetail.name }, { duration_h: h }, 'Durée révisée : ' + h + ' h — Ré-optimiser pour recalculer')
}
// Assigner ce job à un tech DEPUIS le volet détail ; si d'autres tâches à la MÊME adresse/client/jour → proposer de
// les assigner au même tech (moins de clics). Réutilise assignNames + siblingJobs (même logique que la grille).
async function jdAssignTech (techId) {
if (!techId || !jobDetail.name) return
const tech = techById[techId] || (visibleTechs.value || []).find(x => x.id === techId)
if (!tech) { jobDetail.assignTech = null; return }
const iso = jobDetail.iso || routesDay.value || todayISO()
let names = [jobDetail.name]
const sibs = siblingJobs(names)
if (sibs.length) {
const ex = sibs[0]
const yes = await new Promise(res => $q.dialog({ title: 'Assigner le groupe ?', message: `${sibs.length} autre(s) tâche(s) à « ${String(ex.address || ex.service_location || '').slice(0, 60)} » — les assigner aussi à ${tech.name} ?`, cancel: { label: 'Non, juste celle-ci', flat: true }, ok: { label: 'Oui, tout assigner', color: 'primary', unelevated: true } }).onOk(() => res(true)).onCancel(() => res(false)).onDismiss(() => res(false)))
if (yes) names = [...new Set([...names, ...sibs.map(s => s.name)])]
}
await assignNames(names, tech, { iso })
jobDetail.techId = tech.id; jobDetail.techName = tech.name; jobDetail.assignTech = null
$q.notify({ type: 'positive', message: names.length > 1 ? `${names.length} tâches assignées à ${tech.name}` : `Assigné à ${tech.name}`, timeout: 1800 })
}
// 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
})
// Autres jobs (pool + occupation) À PROXIMITÉ (~3 km) du job affiché → points PÂLES sur la carte miniature pour le contexte.
function jdNearby () {
const la = jobDetail.lat, lo = jobDetail.lon
if (!isFinite(la) || !isFinite(lo)) return []
const out = []; const seen = new Set()
const add = (lat, lon, subject, name) => {
lat = +lat; lon = +lon
if (!isFinite(lat) || !isFinite(lon) || Math.abs(lat) < 0.01) return
if (name) { if (seen.has(name)) return; seen.add(name) }
if (name && name === jobDetail.name) return
if (Math.abs(lat - la) < 1e-5 && Math.abs(lon - lo) < 1e-5) return // même point que le job affiché
if (haversineKm(la, lo, lat, lon) > 3) return
out.push([lon, lat, subject || ''])
}
for (const j of (assignPanel.jobs || [])) add(j.latitude, j.longitude, j.subject, j.name)
const occ = occByTechDay.value || {}
for (const k in occ) for (const j of ((occ[k] || {}).jobs || [])) if (!j.cancelled) add(j.lat, j.lon, j.subject, j.name)
return out.slice(0, 80)
}
async function initJdMap () {
if (!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 = createPlanMap(jdMapEl.value, { center: hasLL ? [lon, lat] : [-73.6756, 45.1599], zoom: hasLL ? 14 : 9 })
_jdMap.on('load', () => {
_jdMap.resize()
// Autres jobs à proximité = points PÂLES (contexte), ajoutés AVANT le marqueur du job → celui-ci reste au-dessus.
if (hasLL) {
_jdMap.addSource('jd-near', { type: 'geojson', data: { type: 'FeatureCollection', features: jdNearby().map(p => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [p[0], p[1]] }, properties: { t: p[2] } })) } })
_jdMap.addLayer({ id: 'jd-near', type: 'circle', source: 'jd-near', paint: { 'circle-radius': 5, 'circle-color': '#94a3b8', 'circle-opacity': 0.45, 'circle-stroke-color': '#fff', 'circle-stroke-width': 1 } })
_jdMap.on('mouseenter', 'jd-near', () => { _jdMap.getCanvas().style.cursor = 'pointer' })
_jdMap.on('mouseleave', 'jd-near', () => { _jdMap.getCanvas().style.cursor = '' })
_jdMap.on('click', 'jd-near', (e) => { const p = e.features[0].properties; new mapboxgl.Popup({ offset: 10 }).setLngLat(e.features[0].geometry.coordinates).setHTML('<div style="font-size:11px;color:#475569">' + String(p.t || '').replace(/[<>&]/g, '') + '</div>').addTo(_jdMap) })
}
if (hasLL) new mapboxgl.Marker({ color: '#1976d2' }).setLngLat([lon, lat]).addTo(_jdMap) // le point du job (au-dessus des points pâles)
_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 = '' } })
// Carte NON initialisée à l'ouverture (masquée par défaut) ; init seulement quand on déplie « Voir la carte ».
watch(() => jobDetail.open, (open) => { if (!open) { jdShowMap.value = false; if (_jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null } } })
watch(jdShowMap, (on) => { if (on && jobDetail.open) nextTick(() => setTimeout(initJdMap, 200)); else if (!on && _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; loadTraccarDevices() } // charge les appareils GPS pour la section « Appareil GPS » du volet
// #5 — Réserver du temps d'un tech (bloc « Réservation », priorité moyenne). Occupe l'occupation → dé-priorise au dispatch auto + soustrait des créneaux.
const resvDlg = reactive({ open: false, tech: null, date: '', start: '08:00', dur: 2, reason: '', busy: false })
function openReserve (t) {
if (!t) return
skillMenuShown.value = false
const iso = mobileSelIso.value || kbSelIso.value || start.value || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
Object.assign(resvDlg, { open: true, tech: { id: t.id, name: t.name }, date: iso, start: '08:00', dur: 2, reason: '', busy: false })
}
async function doReserve () {
if (!resvDlg.tech || !resvDlg.date || resvDlg.busy) return
resvDlg.busy = true
try {
const r = await roster.reserveTech({ tech: resvDlg.tech.id, date: resvDlg.date, start_time: resvDlg.start, duration_h: resvDlg.dur, reason: resvDlg.reason })
if (r && r.ok) { $q.notify({ type: 'positive', icon: 'event_busy', message: 'Temps réservé — ' + resvDlg.tech.name + ' · ' + resvDlg.dur + 'h' }); resvDlg.open = false; try { await reloadOccupancy() } catch (e) {} }
else $q.notify({ type: 'negative', message: (r && r.error) || 'Réservation impossible' })
} catch (e) { $q.notify({ type: 'negative', message: 'Réservation impossible' }) } finally { resvDlg.busy = false }
}
// « Voir sa tournée » depuis le volet réglages du tech : bascule en vue Tournées + isole ce tech sur la carte.
function viewTechTournee (tech) {
if (!tech || !tech.id) return
skillMenuShown.value = false
if (!routesDay.value) routesDay.value = todayISO()
boardView.value = 'routes'
nextTick(() => { const others = allDayRoutes.value.map(r => r.id).filter(x => x !== tech.id); hiddenRouteTechs.value = new Set(others) })
}
function openSkillEditorFor (techId, ev) { const t = (visibleTechs.value || []).find(x => x.id === techId); if (t) openSkillEditor(t, ev) } // depuis la revue de l'optimiseur
// 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 })
}
// ── Génération de quarts HEBDO par tech (modèles + N semaines) — écrit DIRECTEMENT les Shift Assignment (Publié). ──
const schedGenOpen = ref(false); const schedGenTechs = ref([])
function openSchedGen (t) { schedGenTechs.value = [{ id: t.id, name: t.name }]; skillMenuShown.value = false; schedGenOpen.value = true } // 1 tech
function openSchedGenBulk () { schedGenTechs.value = (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name, skills: t.skills || [] })); schedGenOpen.value = true } // LOT : tous les techs visibles (+ compétences pour filtrer)
const _hmNum = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 }
async function onScheduleApply ({ schedule, weeks, techIds, mode }) {
const targets = (schedGenTechs.value || []).filter(t => (techIds || []).includes(t.id)); if (!targets.length) return
// ── RÉCURRENT : le patron devient la SOURCE (weekly_schedule) → matérialisation auto (fériés/vacances sautés, manuels préservés) ──
if (mode === 'recurring') {
const patt = {}; for (const d of schedule) patt[d.dow] = d.on ? { start: d.start, end: d.end } : null
let okT = 0, failT = 0
for (const t of targets) { try { const r = await roster.setWeeklySchedule(t.id, patt); if (r && r.ok) okT++; else { failT++; err(new Error('weekly-schedule ' + t.name + ': ' + ((r && r.error) || 'échec'))) } } catch (e) { failT++; err(e) } }
let mat = null, matErr = ''; try { mat = await roster.materializeShifts({ weeks }) } catch (e) { matErr = (e && e.message) || 'erreur'; err(e) }
try { await loadWeek() } catch (e) {}
// #4c : on NE SWALLOW PLUS — les échecs (compte tech introuvable, création de quart refusée) remontent à l'écran.
const parts = [okT + ' tech']
if (failT) parts.push('⚠ ' + failT + ' non enregistré(s)')
if (mat) { parts.push(mat.created + ' quart(s)/' + weeks + ' sem.'); if (mat.create_fail) parts.push('⚠ ' + mat.create_fail + ' échec(s) de création'); if (mat.skipped_holiday) parts.push(mat.skipped_holiday + ' férié(s) sauté(s)') } else if (matErr) parts.push('⚠ matérialisation : ' + matErr)
$q.notify({ type: (failT || (mat && mat.create_fail) || matErr) ? 'warning' : 'positive', icon: 'event_repeat', message: 'Horaire récurrent · ' + parts.join(' · '), timeout: 5500 })
schedGenTechs.value = []
return
}
// ── EXCEPTION : override ponctuel (source='manuel', préservé par la matérialisation) sur N semaines dès la semaine affichée ──
let created = 0, failed = 0; const tplByKey = {}
const base = start.value // lundi de la semaine affichée
for (const t of targets) {
for (let w = 0; w < weeks; w++) {
for (let i = 0; i < 7; i++) {
const day = schedule[i]; if (!day || !day.on) continue
const sh = _hmNum(day.start), eh = _hmNum(day.end); if (!(eh > sh)) { failed++; continue }
const key = sh + '-' + eh; let tpl = tplByKey[key]
if (!tpl) { tpl = await ensureWindowTpl(sh, eh); tplByKey[key] = tpl }
if (!tpl) { failed++; continue }
const iso = addDaysISO(base, w * 7 + i)
try { const r = await roster.createShift({ tech: t.id, tech_name: t.name, date: iso, shift: tpl.name, hours: tpl.hours || calcHours(day.start, day.end) }); if (r && (r.ok || r.existed)) created++; else failed++ } catch (e) { failed++ }
}
}
}
try { await loadWeek() } catch (e) {} // rafraîchit la grille (semaine affichée)
$q.notify({ type: created ? 'positive' : 'warning', icon: 'event_available', message: created + ' quart(s) publié(s) · ' + targets.length + ' tech' + (weeks > 1 ? ' (' + weeks + ' sem.)' : '') + (failed ? ' · ' + failed + ' ignoré(s)' : ''), timeout: 4000 })
schedGenTechs.value = []
}
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.
// CADENCE en % : 100 = normal · 200 = deux fois plus de jobs (moitié du temps) · <100 = plus lent. Interne = FACTEUR de durée (200 %→0.5).
function effPctOf (factor) { const f = Number(factor) || 1; return Math.round(100 / (f > 0 ? f : 1)) }
function factorFromPct (pct) { const p = Number(pct) || 100; const f = 100 / (p > 0 ? p : 100); return Math.max(0.1, Math.min(5, Math.round(f * 100) / 100)) } // cap 20 %1000 %
// Efficacité GLOBALE saisie en % (haut = plus rapide, comme le per-skill) — tampon pour ne pas se battre avec la frappe.
const gEffBuf = reactive({})
function commitGEff (t) { if (!(t.id in gEffBuf)) return; const v = gEffBuf[t.id]; delete gEffBuf[t.id]; t.efficiency = factorFromPct(v); saveEff(t) }
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)
// Saisie efficacité : tampon local pendant l'édition (pas de debounce qui perdait la valeur au blur) → commit sur blur/Entrée.
const effBuf = reactive({})
function effBufKey (t, sk) { return (t && t.id) + '::' + sk }
function commitSkillEff (t, sk) { const k = effBufKey(t, sk); if (!(k in effBuf)) return; const v = effBuf[k]; delete effBuf[k]; setSkillEffPct(t, sk, v) }
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 === 100) return '#607d8b'; const a = Math.min(Math.abs(p - 100), 200) / 200; const hue = p > 100 ? 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 'router' // installation fibre = pose du routeur/ONT (icône unique carte + listes)
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 'handyman' // visite générique (type non reconnu) : technicien avec outils, plutôt que l'éclair peu parlant
}
// Icône de compétence pour un MARQUEUR carte (DOM, police material-icons) : UNIQUEMENT des ligatures material (pas les SVG
// custom de skillSym) — sinon le glyphe ne s'affiche pas dans un <span class="material-icons"> hors q-icon.
function markerIcon (skill) {
const s = String(skill || '').toLowerCase()
if (/t[ée]l[ée]vis|\btv\b|iptv/.test(s)) return 'live_tv'
if (/install/.test(s)) return 'router' // installation fibre = pose du routeur/ONT au domicile
if (/monteur|poteau|hauteur|nacelle|a[ée]rien|grimp/.test(s)) return 'local_shipping'
const ic = skillIcon(skill)
return (typeof ic === 'string' && /^[a-z0-9_]+$/.test(ic)) ? ic : 'build'
}
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 = capPickups((await roster.unassignedJobs()).jobs) } catch (e) { err(e) } finally { assignPanel.loading = false } }
// Write-back legacy : aperçu (dryRun, 0 écriture) → confirmation → écrit ticket.assign_to dans osTicket.
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) })
// Chips « Trouver un créneau » : 4 compétences principales (les + fréquentes du pool, repli liste cœur) + durée PAR DÉFAUT par skill.
const SLOT_SKILL_DUR = { installation: 2, 'réparation': 1, reparation: 1, tv: 1, 'télévision': 1, television: 1, 'téléphonie': 0.5, telephone: 0.5, 'téléphone': 0.5, 'sans-fil': 1.5, fibre: 1.5, support: 0.5 }
const slotSkills = computed(() => {
const top = (assignTypes.value || []).map(t => t.k).filter(k => k && k.toLowerCase() !== 'autre').slice(0, 4)
return (top.length ? top : ['installation', 'réparation', 'tv']).map(k => ({ skill: k, dur: SLOT_SKILL_DUR[k.toLowerCase()] || 1, color: getTagColor(k) }))
})
// Filtre par DATE DUE (chips) — « n'afficher que les jobs d'une date précise »
const assignDateFilter = ref([]) // isos retenus (vide = toutes les dates)
const assignDates = computed(() => {
const t = todayISO(); const m = {}
for (const j of assignPanel.jobs) { const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'; m[d] = (m[d] || 0) + 1 }
// Toutes les dates PASSÉES → UN SEUL chip « ⏰ en retard » (déclutter). Les jours futurs + « Sans date » restent séparés.
const out = []; let overdueN = 0
for (const [iso, n] of Object.entries(m)) {
if (iso !== 'Sans date' && iso < t) { overdueN += n; continue }
out.push({ iso, n, label: iso === 'Sans date' ? 'Sans date' : fmtDueLabel(iso), overdue: false })
}
out.sort((a, b) => String(a.iso).localeCompare(String(b.iso)))
if (overdueN) out.unshift({ iso: '__overdue__', n: overdueN, label: '⏰ en retard', overdue: true })
return out
})
function toggleAssignDate (iso) { const s = new Set(assignDateFilter.value); s.has(iso) ? s.delete(iso) : s.add(iso); assignDateFilter.value = [...s] }
// Depuis l'aperçu « jobs sur la colonne du jour » → ouvre le panneau « À assigner » filtré sur ce jour.
async function openDayInPanel (iso) { await openAssignPanel(); assignSort.value = 'date'; assignDateFilter.value = [iso] }
// Un job correspond-il aux CHIPS de date sélectionnées ? (vide = toutes). '__overdue__' = toutes dates passées.
// Partagé par le filtre d'affichage ET le dispatch auto (« Suggérer n'utilise que les jobs des jours cochés »).
function jobMatchesDateChips (j) {
const df = assignDateFilter.value; if (!df.length) return true
const ds = new Set(df); const t = todayISO()
const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'
return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t)
}
const assignJobsFiltered = computed(() => {
let arr = assignPanel.jobs
const f = assignTypeFilter.value; if (f.length) { const set = new Set(f); arr = arr.filter(j => set.has(j.required_skill || 'autre')) }
if (assignDateFilter.value.length) arr = arr.filter(jobMatchesDateChips)
return arr
})
function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) }
// 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 ESRI World Imagery — libre, sans jeton — superposée SOUS les pins) ──
// 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', tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'], tileSize: 256, attribution: '© Esri, Maxar, Earthstar Geographics' })
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') }
// Liens géo externes du volet détail job (évaluer la difficulté avant d'y aller) : Street View (sol) + Google Maps (satellite).
const jdHasCoords = computed(() => { const la = +jobDetail.lat, lo = +jobDetail.lon; return isFinite(la) && isFinite(lo) && (la !== 0 || lo !== 0) })
const jdMapsUrl = computed(() => `https://www.google.com/maps/search/?api=1&query=${jobDetail.lat},${jobDetail.lon}`)
const jdStreetViewUrl = computed(() => `https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${jobDetail.lat},${jobDetail.lon}`)
// ── 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 }
const address = await reverseGeocodeOSM(r.lon, r.lat)
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 }
}
const deviceOptions = computed(() => {
const q = deviceFilter.value.toLowerCase().trim()
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))
})
// Appareil GPS relié à N'IMPORTE QUEL tech (résout l'id → nom réel). Réutilisé par l'éditeur de jour ET le volet réglages du tech.
function deviceOf (tech) {
const id = tech && tech.traccar_device_id
if (!id) return null
const d = traccarDevices.value.find(x => String(x.id) === String(id))
if (d) return { value: String(d.id), label: d.name || d.uniqueId || ('#' + d.id), online: d.status === 'online', uniqueId: d.uniqueId || '' }
return { value: String(id), label: devicesLoading.value ? ('#' + id + '…') : ('#' + id + ' — introuvable'), online: false, missing: !devicesLoading.value }
}
const jdDevice = computed(() => deviceOf(dayEditor.tech))
// Suggestion AUTO quand non relié : appareil dont le nom/uniqueId contient un mot du nom du tech (≥3 lettres). Facilite « relier ».
const _normDev = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '')
function deviceSuggestOf (tech) {
if (!tech || deviceOf(tech)) return null
const parts = _normDev(tech.name).split(/[^a-z]+/).filter(p => p.length >= 3)
if (!parts.length) return null
let best = null
for (const d of traccarDevices.value) {
const dn = _normDev(d.name) + ' ' + _normDev(d.uniqueId)
const hit = parts.filter(p => dn.includes(p)).length
if (hit > (best ? best.hit : 0)) best = { hit, d }
}
if (best && best.hit >= 1) { const d = best.d; return { value: String(d.id), label: d.name || ('#' + d.id), online: d.status === 'online' } }
return null
}
const jdDeviceSuggest = computed(() => deviceSuggestOf(dayEditor.tech))
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 }
_locMap = createPlanMap(locMapEl.value, { center: [locPicker.lon || -73.95, locPicker.lat || 45.18], zoom: locPicker.lat != null ? 14 : 8.5, streetView: openStreetView })
_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)
}
// Géocodage INVERSE (coord → adresse). 1) NOTRE RQA locale (hub /address/reverse — pas de dépendance externe). 2) repli OSM/Nominatim.
async function reverseGeocodeOSM (lon, lat) {
try { const r = await addressApi.reverse(lat, lon); if (r && r.found && r.address) return r.address } catch (e) { /* RQA reverse indispo → repli */ }
try {
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&accept-language=fr&zoom=18`)
const j = await r.json(); const a = j.address || {}
const line1 = [a.house_number, a.road].filter(Boolean).join(' ')
const city = a.city || a.town || a.village || a.municipality || a.hamlet || ''
const parts = [line1, city, a.postcode].filter(Boolean)
return parts.length ? parts.join(', ') : (j.display_name ? String(j.display_name).replace(/, Canada$/, '') : '')
} catch (e) { return '' }
}
async function reverseGeocode (lon, lat) { const a = await reverseGeocodeOSM(lon, lat); if (a) locPicker.address = a }
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 → extrait dans components/planif/TechSyncDialog.vue (état + fonctions déplacés ; @applied → loadBase).
const showTechSync = ref(false)
const _hasJobLL = (j) => j && j.latitude != null && j.longitude != null && isFinite(+j.latitude) && isFinite(+j.longitude) && (Math.abs(+j.latitude) > 0.01 || Math.abs(+j.longitude) > 0.01)
const assignNoCoord = computed(() => assignJobsFiltered.value.filter(j => !_hasJobLL(j)).length) // « sans coords » = dans le filtre courant (cohérent avec la carte)
// 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 (!assignMapEl.value || _assignMap) return
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !assignMapEl.value) return
_assignMap = createPlanMap(assignMapEl.value, { center: [-73.55, 45.2], zoom: 8, streetView: openStreetView })
_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 ──
// Carte de la revue = composant partagé <RouteMap> (P2). Les métriques réelles (OSRM) remontent ici pour la légende.
function onRouteMetrics (m) {
// Idempotent : ne muter realRoutes QUE si les valeurs changent (sinon re-rendu inutile → risque de boucle avec RouteMap)
const keys = Object.keys(m || {})
let same = keys.length === Object.keys(realRoutes).length
if (same) for (const k of keys) { const a = realRoutes[k], b = m[k]; if (!a || a.km !== b.km || a.mins !== b.mins) { same = false; break } }
if (same) return
for (const k in realRoutes) delete realRoutes[k]
Object.assign(realRoutes, m)
}
// 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)
// Tournée routière RÉELLE d'un tech (OSRM auto-hébergé via hub, plus de Mapbox payant) : tracé + total + temps ENTRE arrêts. Cache par signature.
async function fetchTechRoute (home, stops) {
const pts = []; if (home) pts.push([+home.lon, +home.lat]); for (const s of stops) pts.push([+s.lon, +s.lat])
if (home) pts.push([+home.lon, +home.lat]) // RETOUR au point de départ : la tournée est une boucle domicile→arrêts→domicile
if (pts.length < 2 || pts.length > 52) return null
const sig = pts.map(p => p[0].toFixed(5) + ',' + p[1].toFixed(5)).join(';')
if (_routeCache.has(sig)) return _routeCache.get(sig)
try {
const d = await roster.osrmRoute(pts)
if (!d || !Array.isArray(d.geometry)) return null
const info = { geometry: { type: 'LineString', coordinates: d.geometry }, km: d.km, mins: d.mins, legs: d.legs || [], hasHome: !!home }
_routeCache.set(sig, info); return info
} catch (e) { return null }
}
watch(() => assignPanel.showMap, (on) => { if (on) { if (assignPanel.h < 600) assignPanel.h = 640; nextTick(initAssignMap) } else destroyAssignMap() })
watch(assignJobsFiltered, () => { if (assignPanel.showMap) nextTick(() => { _assignMap ? refreshAssignMap() : initAssignMap() }) }) // carte suit le filtre (date/compétence) + les jobs
watch(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: '/planification', query: q }) // page Dispatch retirée → reste sur Planification
}
// ── É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) { dayEditor.travelMap = {}; dayEditor.routeReady = false; return }
try {
const d = await roster.osrmTable(pts.map(j => [+j.lon, +j.lat])) // OSRM auto-hébergé (via hub) — même forme que Mapbox Matrix
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 } }
// Point de DÉPART d'un tech : son DOMICILE s'il est défini, SINON le bureau TARGO (dépôt) — politique par défaut.
function techOrigin (techId) {
const h = techHomes.value[techId]
if (h && isFinite(+h.lat) && isFinite(+h.lon) && (Math.abs(+h.lat) > 0.01)) return { lat: +h.lat, lon: +h.lon, kind: 'home', address: h.address || '' }
const d = depot.value
if (d && isFinite(+d.lat) && isFinite(+d.lon)) return { lat: +d.lat, lon: +d.lon, kind: 'depot', address: d.address || '' }
return null
}
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 Promise.resolve(installMaplibre()) } // MapLibre bundlé + protocole pmtiles:// (alias window.mapboxgl pour compat)
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 (!dayMapEl.value || _dayMap) return
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !dayMapEl.value) return
_dayMap = createPlanMap(dayMapEl.value, { center: [-73.6756, 45.1599], zoom: 9, streetView: openStreetView })
_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, maxWidth: '260px' }).setLngLat([lon, lat]).setHTML(`<div style="font-size:12px;line-height:1.5;max-width:236px;overflow-wrap:anywhere;max-height:44vh;overflow-y:auto">${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 d = await roster.osrmRoute(ordered.slice(0, 50).map(s => [+s.lon, +s.lat])) // OSRM auto-hébergé (via hub) — l'U-turn aux waypoints est permis par défaut
const src = _dayMap && _dayMap.getSource('day-route')
if (d && Array.isArray(d.geometry) && d.geometry.length && src) src.setData({ type: 'Feature', geometry: { type: 'LineString', coordinates: d.geometry }, 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() // clearable (X) met search=null → null-safe (sinon le computed plante et le champ « ne se vide pas »)
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 })
// #2/#3 — « dirty » = quarts NON PUBLIÉS (statut ≠ Publié). Tout est AUTO-SAUVÉ en brouillon → plus d'alerte « modifications non publiées » à la navigation.
const unpublished = computed(() => assignments.value.filter(a => a.status && a.status !== 'Publié'))
const dirty = computed(() => unpublished.value.length > 0)
const dirtyCount = computed(() => unpublished.value.length)
const dirtyCells = computed(() => new Set(unpublished.value.map(a => a.tech + '|' + a.date)))
function isCellDirty (techId, iso) { return dirtyCells.value.has(techId + '|' + iso) }
// Statut agrégé de la semaine (pour l'affichage #3) : le « plus bas » statut non publié présent.
const weekStatus = computed(() => { const st = new Set(unpublished.value.map(a => a.status)); if (st.has('Proposé')) return 'Proposé'; if (st.has('Soumis')) return 'Soumis'; if (st.has('Approuvé')) return 'Approuvé'; return 'Publié' })
// #1 — Sommaire AVANT publication : liste les quarts NON PUBLIÉS (à publier) par jour + nb SMS.
const pubConfirm = ref(false)
const pubSummary = computed(() => {
const nameOf = id => { const t = (techs.value || []).find(x => x.id === id); return (t && t.name) || id }
const byDate = {}
for (const a of unpublished.value) {
const d = (byDate[a.date] = byDate[a.date] || { date: a.date, add: [], rem: [] })
d.add.push({ tech: a.tech, date: a.date, shift: a.shift_name || a.shift, name: nameOf(a.tech), status: a.status })
}
return { added: unpublished.value.length, removed: 0, days: Object.values(byDate).sort((x, y) => String(x.date).localeCompare(String(y.date))) }
})
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))
// heatColor → composables/useHelpers.js (source unique, partagé avec <OccupancyStrip> + tableau de bord)
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 reqSkillList = (job && Array.isArray(job.required_skills) && job.required_skills.length) ? job.required_skills : (reqSkill ? [reqSkill] : [])
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 = !reqSkillList.length || reqSkillList.every(s => (t.skills || []).includes(s))
const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
const distKm = (hasJC && home) ? 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 = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
const distKm = (cLat != null && home) ? 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: 'optimize', view: 'list', jobTime: {}, includeAssigned: false }) // défaut = solveur VRP · strategy: optimize|smart|best|balance|enough · includeAssigned = simulation re-optimisant AUSSI les jobs déjà assignés
// Noms des jobs DÉJÀ assignés sur la fenêtre (pour la simulation « ré-optimiser l'existant ») — depuis l'occupation.
// Jobs DÉJÀ assignés dans la fenêtre (objets complets : subject/detail → détection AM/PM). occ.jobs = objets, pas des noms.
function assignedJobsForWindow () {
const win = new Set(suggestWindow.value); const out = []; const seen = new Set()
for (const key in occByTechDay.value) {
if (!win.has(key.split('|')[1])) continue
for (const j of (occByTechDay.value[key].jobs || [])) { const n = j && j.name; if (n && !seen.has(n)) { seen.add(n); out.push(j) } }
}
return out
}
function assignedNamesForWindow () { return assignedJobsForWindow().map(j => j.name).filter(Boolean) }
// AUTO AM/PM sur les jobs déjà assignés (ré-optimiser l'existant) : sinon la « Note AM » d'un job dispatché serait ignorée → placé en fin de tournée.
function seedAssignedAmpm () { for (const j of assignedJobsForWindow()) { if (!suggestDlg.jobTime[j.name]) { const ap = detectAmpm(j); if (ap) suggestDlg.jobTime[j.name] = { ampm: ap, auto: true } } } }
const prevOpen = reactive({}) // revue : sections « déjà assigné(s) » dépliées (clé techId|iso)
// ── Revue : détail du ticket au clic + temps révisable + temps de route ENTRE arrêts (OSRM) ──
const durOverride = reactive({}) // jobName → heures révisées par le dispatcher (survit aux Ré-optimiser de la session)
const legsByKey = reactive({}) // 'techId|iso' → { homeTo: min|null, after: { jobName: min } } — temps de route réels entre arrêts
function openEntryDetail (e, g) { // clic sur un job de la revue → même fiche détail que la grille (adresse, détail, fil du ticket F)
const j = (assignPanel.jobs || []).find(x => x.name === e.jobName) || {}
openJobDetail({
name: e.jobName, subject: e.subject || j.subject, customer: e.customer || j.customer_name || '',
address: j.address || j.location_label || '', skill: e.skill || j.required_skill || '', dur: e.dur,
detail: j.legacy_detail || '', legacy_id: j.legacy_ticket_id || null, dept: j.legacy_dept || '',
lat: e.lat, lon: e.lon,
}, (g && !g.placeholder) ? { id: g.techId, name: g.techName } : null)
}
// Clic sur une pastille de la carte → fiche détail COMPLÈTE du ticket (même dialog que la grille). rid = techId de la tournée.
function onReviewStopClick (stop, rid) { // carte de la REVUE : l'arrêt porte l'entrée du plan
if (!stop) return
const e = stop.entry || { jobName: stop.name, subject: stop.subject, skill: '', lat: stop.lat, lon: stop.lon, dur: 0 }
const g = suggestGroups.value.find(x => x.techId === rid) || { techId: rid, techName: (techById.value[rid] || {}).name || '', placeholder: false }
openEntryDetail(e, g)
}
function onRoutesStopClick (stop, rid) { // onglet TOURNÉES : l'arrêt porte le job d'occupation
if (!stop) return
const j = stop.job || {}
openJobDetail({
name: stop.name || j.name, subject: stop.subject || j.subject, customer: j.customer || j.customer_name || '',
address: j.address || j.location_label || '', skill: j.skill || '', dur: j.dur,
detail: j.legacy_detail || '', legacy_id: j.legacy_id || j.legacy_ticket_id || null, dept: j.legacy_dept || j.dept || '',
lat: stop.lat, lon: stop.lon,
}, { id: rid, name: (techById.value[rid] || {}).name || '' })
}
function setEntryDur (e, v) { // temps révisé : plan (réactif) + mémoire de session + persistance ERPNext duration_h
const h = Math.round(Math.max(0.25, Math.min(24, Number(v) || 0)) * 4) / 4
if (!h) return
e.dur = h; durOverride[e.jobName] = h
const j = (assignPanel.jobs || []).find(x => x.name === e.jobName)
if (j) patchJob(j, { duration_h: h }, 'Temps révisé : ' + h + ' h — Ré-optimiser pour recalculer les tournées')
}
// Temps de route réels entre les arrêts de chaque tournée (1 appel OSRM par tech×jour, cache par signature dans fetchTechRoute).
async function loadLegTimes () {
for (const g of suggestGroups.value) {
if (g.placeholder) continue
const h = techOrigin(g.techId) // domicile, sinon bureau TARGO
for (const d of g.dayList) {
const geo = d.entries.filter(e => e.lat != null && e.lon != null)
if (geo.length < (h ? 1 : 2)) continue
const info = await fetchTechRoute(h, geo.map(e => ({ lat: +e.lat, lon: +e.lon })))
if (!info || !info.legs || !info.legs.length) continue
const legs = info.legs; const out = { homeTo: null, after: {}, returnTo: null, kind: h ? h.kind : null }
let li = 0
if (h) { out.homeTo = legs[0] ? legs[0].mins : null; li = 1 }
for (let k = 0; k < geo.length - 1; k++) { const L = legs[li + k]; if (L) out.after[geo[k].jobName] = L.mins }
if (h) { const rl = legs[li + geo.length - 1]; if (rl) out.returnTo = rl.mins } // dernier segment = dernier arrêt → point de départ
legsByKey[g.techId + '|' + d.iso] = out
}
}
}
// Temps de route affiché ENTRE deux lignes : réel (OSRM) si connu, sinon estimation haversine, sinon rien.
function legInfo (g, d, ei) {
const e = d.entries[ei]; const nx = d.entries[ei + 1]
if (!e || !nx) return null
const real = (legsByKey[g.techId + '|' + d.iso] || {}).after
if (real && real[e.jobName] != null) return { mins: real[e.jobName], real: true }
if (e.lat != null && e.lon != null && nx.lat != null && nx.lon != null) return { mins: estTravelMin(haversineKm(+e.lat, +e.lon, +nx.lat, +nx.lon)), real: false }
return null
}
function legHome (g, d) { const v = (legsByKey[g.techId + '|' + d.iso] || {}).homeTo; return v != null ? v : null }
function legReturn (g, d) { const v = (legsByKey[g.techId + '|' + d.iso] || {}).returnTo; return v != null ? v : null } // temps de retour au départ
// Contraintes horaires par job (⚡ Optimiser) : AM/PM (fenêtre dure) + « tôt » (urgence → début de journée). Session (clé = jobName, survit aux ré-optimisations).
const AMPM_WIN = { am: [480, 720], pm: [720, 960] } // 8-12 / 12-16 (heure d'horloge)
// Détecte une contrainte AM/PM dans le titre/note du ticket (legacy « Note AM » / « Note PM » importé tel quel).
// Renvoie 'am' | 'pm' | null (null si absent ou ambigu = les deux présents).
function detectAmpm (job) {
const t = (String(job && job.subject || '') + ' ' + String(job && (job.legacy_detail || job.notes) || '')).toLowerCase()
const am = /\bam\b|avant.?midi|\bmatin\b/.test(t)
const pm = /\bpm\b|apr[eè]s.?midi/.test(t)
if (am && !pm) return 'am'
if (pm && !am) return 'pm'
return null
}
function jobTimeOf (name) { return suggestDlg.jobTime[name] || {} }
function toggleJobAmpm (name, v) { const c = suggestDlg.jobTime[name] || {}; suggestDlg.jobTime[name] = { ...c, ampm: c.ampm === v ? null : v } }
// 🖥 « SANS DÉPLACEMENT » (à distance, ex. config boîtier tel via ACS) : persistant (store hub), exclu des tournées par l'optimiseur.
const poolRemote = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = !!j.remote; return m })
function isJobRemote (name) { return !!poolRemote.value[name] }
async function toggleJobRemote (e) {
const j = (assignPanel.jobs || []).find(x => x.name === e.jobName); const next = !(j ? j.remote : e.remote)
if (j) j.remote = next; e.remote = next // optimiste
try { await roster.setJobRemote(e.jobName, next) } catch (er) { if (j) j.remote = !next; e.remote = !next; err(er); return }
$q.notify({ type: 'info', icon: 'computer', message: next ? '🖥 Sans déplacement — Ré-optimiser pour l\'exclure des tournées' : 'Déplacement requis rétabli — Ré-optimiser', timeout: 2600 })
}
// 📦 Grouper les tâches MÊME ADRESSE en « projet » (parent_job ERPNext) — parent = la plus longue, les autres deviennent sous-tâches.
async function groupSameAddr (e, g, d) {
const key = x => (x.lat != null && x.lon != null) ? (+x.lat).toFixed(4) + ',' + (+x.lon).toFixed(4) : null
const k = key(e); if (!k) return
const grp = d.entries.filter(x => key(x) === k)
if (grp.length < 2) { $q.notify({ type: 'info', message: 'Une seule tâche à cette adresse dans ce plan.' }); return }
const parent = grp.slice().sort((a, b) => (b.dur || 0) - (a.dur || 0))[0]
const children = grp.filter(x => x !== parent).map(x => x.jobName)
try {
const r = await roster.groupJobs(parent.jobName, children)
if (!r || !r.ok) throw new Error((r && r.error) || 'échec du groupement')
grp.forEach(x => { x.grouped = true })
$q.notify({ type: 'positive', icon: 'inventory_2', message: `📦 Projet créé : « ${(parent.subject || '').slice(0, 42)} » + ${children.length} sous-tâche(s)`, timeout: 3500 })
} catch (er) { err(er) }
}
// PRIORITÉ du job — MÊME drapeau que les conversations (priorityMeta) + champ ERPNext STANDARD (low/medium/high) via patchJob, comme la fiche du pool. Une seule source.
const poolPriority = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = String(j.priority || '').toLowerCase(); return m })
function jobPriorityOf (name) { return poolPriority.value[name] || '' } // medium → priorityMeta = contour gris (non classé)
// ── SLA sur les jobs (RÉUTILISE useSla des tickets) : échéance de RÉSOLUTION = création + politique[priorité]. Compute-on-read, aucune migration ERPNext. ──
const poolByName = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = j; return m })
const { slaFor: _slaForTicket } = useSla()
const PRIO_CAP = { high: 'High', 'élevée': 'High', elevee: 'High', urgent: 'Urgent', medium: 'Medium', moyenne: 'Medium', normal: 'Medium', low: 'Low', basse: 'Low' }
function jobSlaBadge (job) { // { state, color, text, icon, label } pour at_risk/breached SEULEMENT (états actionnables) ; null sinon
if (!job) return null
// ⚠️ Échéance = DATE DE VISITE PRÉVUE (scheduled_date), PAS la création : un job planifié dans le futur n'est PAS en retard
// même si l'incident est ancien (le client peut avoir demandé une visite plus tard). La création ne sert que faute de date.
const sched = job.scheduled_date
if (sched && sched !== 'Sans date' && /^\d{4}-\d{2}-\d{2}$/.test(sched)) {
// Badge « en retard » UNIQUEMENT si la date de visite est PASSÉE (aujourd'hui/futur = à l'heure, pas de badge).
if (sched < todayISO()) { const b = SLA_BADGE.breached; return { state: 'breached', color: b.color, text: b.text, icon: b.icon, label: 'Visite prévue en retard — ' + fmtDueLabel(sched), short: 'En retard' } }
return null
}
// Aucune date de visite planifiée : SLA classique (vieillissement du backlog depuis la création).
if (!job.creation) return null
const prio = PRIO_CAP[String(job.priority || jobPriorityOf(job.name) || 'medium').toLowerCase()] || 'Medium'
// job → ticket synthétique : first_responded_on renseigné → on SAUTE la bande « réponse » et on prend la bande RÉSOLUTION depuis la création.
const s = _slaForTicket({ issue_type: job.job_type || undefined, priority: prio, creation: job.creation, first_responded_on: job.creation, status: (String(job.status || '').toLowerCase() === 'completed' ? 'Closed' : job.status) })
if (!s || (s.state !== 'at_risk' && s.state !== 'breached')) return null
const b = SLA_BADGE[s.state]
return { state: s.state, color: b.color, text: b.text, icon: b.icon, label: s.label, short: s.state === 'breached' ? 'SLA dépassé' : 'SLA proche' }
}
function entrySlaBadge (e) { return jobSlaBadge(poolByName.value[e.jobName]) } // revue : retrouve le job du pool pour l'échéance
function setEntryPriority (name, lvl) { // écrit la priorité ERPNext du job (low/medium/high) — réutilise patchJob (optimiste + updateJob)
const j = (assignPanel.jobs || []).find(x => x.name === name); if (!j) return
patchJob(j, { priority: lvl || 'medium' }, 'Priorité : ' + priorityMeta(lvl).label)
}
// Réglages de l'optimiseur VRP (curseur distance↔spécialité, vitesse repli, budget de calcul) — persistés localement, ajustables sans redéploiement.
const solverOpts = reactive({ rankWeight: 2, overtimeCoef: 20, speedKmh: 45, maxSeconds: 8 }) // un COÛT par critère (pas de slider)
try { const s = JSON.parse(localStorage.getItem('ops.solverOpts') || 'null'); if (s && typeof s === 'object') Object.assign(solverOpts, s) } catch (e) { /* défauts */ }
watch(solverOpts, v => { try { localStorage.setItem('ops.solverOpts', JSON.stringify(v)) } catch (e) {} }, { deep: true })
// « Ramassage d'équipement » (récupération chez le client) = simple collecte → job court (~5 min).
// On ne surallocue pas le temps même si la classification/est_min du ticket dit plus long.
const PICKUP_RE = /ramm?ass|r[ée]cup[ée]ration/i
const PICKUP_MIN = 5
function isPickup (j) { return !!j && PICKUP_RE.test(String(j.subject || j.service_type || j.job_type || '')) }
function jobDur (j) { if (isPickup(j)) return PICKUP_MIN / 60; return (j && (j.est_min ? j.est_min / 60 : Number(j.duration_h || 1))) || 1 }
// Cap la durée AFFICHÉE des ramassages à 5 min (le temps affiché suit alors jobDur partout : pool, carte, occupation).
function capPickups (jobs) { return (jobs || []).map(j => isPickup(j) ? { ...j, est_min: PICKUP_MIN } : j) }
// 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
const jobs = suggestJobs() // lasso si actif, sinon pool filtré par les chips de date (jours cochés)
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'
// rank = ORDRE de la compétence chez le tech (0 = compétence PRINCIPALE) → un job va d'abord aux spécialistes de CETTE compétence, épargnant les polyvalents.
const W = strat === 'best' ? { dist: 0.4, skill: 15, eff: 65, cost: 20, load: -0.25, overqual: 0, rank: 10 }
: strat === 'balance' ? { dist: 0.6, skill: 6, eff: 30, cost: 8, load: 24, overqual: 0, rank: 8 }
: strat === 'enough' ? { dist: 1.4, skill: 0, eff: 25, cost: 10, load: 2, overqual: 22, rank: 16 } // « 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, rank: 14 }
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 PRINCIPALE (déduite du TYPE si absente)
const reqSkillList = (Array.isArray(j.required_skills) && j.required_skills.length) ? j.required_skills : (reqSkill ? [reqSkill] : []) // LISTE : le tech doit toutes les avoir
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 = Number(durOverride[j.name]) > 0 ? Number(durOverride[j.name]) : jobDur(j) // temps révisé par le dispatcher prioritaire
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 {
// En retard (date < fenêtre) ou sans date : NE PLUS balayer automatiquement sur le(s) jour(s) choisi(s).
// Seulement si EXPLICITEMENT demandé : sélection lasso, ou chip « ⏰ en retard » / « Sans date » coché.
// → sélectionner un seul jour ne dispatche QUE les jobs dus ce jour-là (retour terrain : les retards ne doivent pas s'inviter).
const wantBacklog = useSel || (assignDateFilter.value || []).includes('__overdue__') || (assignDateFilter.value || []).includes('Sans date')
if (!wantBacklog) continue
cand = win
}
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 (reqSkillList.length && !reqSkillList.every(s => (t.skills || []).includes(s))) continue
const capable = true
const skillRank = reqSkill ? Math.max(0, (t.skills || []).indexOf(reqSkill)) : 0 // ORDRE de la compétence chez le tech : 0 = principale (spécialiste) ; 1,2… = secondaire (polyvalent)
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 = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut) — départ de tournée
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 += skillRank * (W.rank || 0) // ORDRE de la compétence : le spécialiste (rang 0) passe avant le polyvalent → épargne les moins spécialisés
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, skillRank }
}
}
// 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 () {
// Jour de base = la sélection de la vue active (strip Tournées, Jour kanban, ou bande mobile) → « Suggérer se base sur la journée sélectionnée »
suggestDay.value = (boardView.value === 'routes' && routesDay.value) || (boardView.value === 'kanban' && kbSelIso.value) || mobileSelIso.value || routesDay.value || kbSelIso.value || todayISO()
suggestDlg.fromSel = selectedNames.value.length > 0
// Étape « disponibilité » d'abord : défaut = techs AVEC un quart sur un des jours de la fenêtre (= disponibles) ; sinon tous les visibles.
const win = suggestWindow.value // fenêtre = chips de date cochés, sinon le jour du sélecteur
const techs = visibleTechs.value || []
const withShift = techs.filter(t => win.some(iso => hasShiftDay(t.id, iso)))
const availPool = withShift.length ? withShift : techs
// Pré-sélection par COMPÉTENCE : parmi les dispos, ceux dont les compétences couvrent au moins un skill requis
// par les jobs du périmètre (jour + filtres). Toujours modifiable (le dialogue liste tous les techs, cochables) ;
// repli sur tous les dispos si aucun skill requis connu ou aucun match.
const scopeJobs = suggestJobs() || []
// AUTO AM/PM : pré-applique la contrainte horaire détectée dans le titre/note (« Note AM/PM » legacy) si non déjà posée.
for (const j of scopeJobs) { if (!suggestDlg.jobTime[j.name]) { const ap = detectAmpm(j); if (ap) suggestDlg.jobTime[j.name] = { ampm: ap, auto: true } } }
const needed = new Set(scopeJobs.map(j => j.required_skill).filter(Boolean))
const skilled = needed.size ? availPool.filter(t => (t.skills || []).some(s => needed.has(s))) : []
const base = skilled.length ? skilled : availPool
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)
// Dispatch auto : la FENÊTRE = les CHIPS de date cochées dans le pool (jours à répartir). Sinon (aucun chip) → le jour choisi dans le sélecteur.
// Les chips « ⏰ en retard » / « Sans date » ne sont pas des jours calendaires → les jobs correspondants sont placés AUJOURD'HUI. Non destructif (revue avant d'appliquer).
const suggestDay = ref('')
const suggestWindow = computed(() => {
// Vue Tournées : la fenêtre = EXACTEMENT le jour sélectionné (autorité = la bande de dates), jamais les chips de date du pool.
// → « Suggérer » ne balaie PAS les jobs en retard (date < jour choisi) sur ce jour ; seuls les jobs DUS ce jour-là sont répartis.
if (boardView.value === 'routes' && routesDay.value) return [routesDay.value]
const f = assignDateFilter.value || []
if (!f.length) return [suggestDay.value || todayISO()] // aucun chip coché → sélecteur de jour du dialogue
const days = new Set(f.filter(iso => iso !== '__overdue__' && iso !== 'Sans date')) // jours concrets cochés (auj./futur)
if (f.includes('__overdue__') || f.includes('Sans date') || !days.size) days.add(todayISO()) // en retard / sans date → placés aujourd'hui
return [...days].sort()
})
// Jobs que « Suggérer » doit répartir : la sélection lasso si active, sinon « ce que je vois » = le pool filtré
// par les CHIPS de TYPE **et** de DATE (assignJobsFiltered). Ex. décocher « téléphonie » → non réparti aux techs terrain.
const suggestJobs = () => {
if (selectedNames.value.length) return (assignPanel.jobs || []).filter(j => j.status !== 'On Hold' && selectedJobs[j.name]) // sélection lasso explicite
// Vue Tournées : périmètre = EXACTEMENT la bande filtrée (jour sélectionné + compétence + secteur + priorité) → « ce que je filtre = ce que Suggérer répartit ».
if (boardView.value === 'routes') return (routeDayUnassignedView.value || []).filter(j => j.status !== 'On Hold')
let jobs = (assignJobsFiltered.value || []).filter(j => j.status !== 'On Hold') // pool tel qu'affiché (type + date)
// Par DÉFAUT (aucun chip de date coché) : ne répartir QUE les jobs DATÉS sur le(s) jour(s) de la fenêtre — jamais les
// jobs en retard/sans date/d'autres jours (souvent faits mais pas encore fermés) qui pollueraient la simulation.
// (Pour dispatcher les retards, cocher explicitement le chip « ⏰ en retard » ou « Sans date ».)
// Filtre appliqué à la SOURCE (couvre aussi le solveur ⚡ Optimiser qui prend suggestJobs()) : seuls les jobs
// datés DANS la fenêtre passent, SAUF si l'utilisateur demande explicitement les retards/sans-date.
const wantBacklog = (assignDateFilter.value || []).includes('__overdue__') || (assignDateFilter.value || []).includes('Sans date')
if (!wantBacklog) {
const win = new Set(suggestWindow.value)
jobs = jobs.filter(j => j.scheduled_date && j.scheduled_date !== 'Sans date' && win.has(j.scheduled_date))
}
return jobs
}
const suggestJobCount = computed(() => suggestJobs().length)
// Y a-t-il un filtre restreignant le dispatch ? (lasso, chips de date ou de type)
const suggestFiltered = computed(() => !!(selectedNames.value.length || assignDateFilter.value.length || assignTypeFilter.value.length))
// Libellé du périmètre : « sélectionnés » (lasso) · « des jours/types cochés » (chips) — décrit ce qui limite le jeu de jobs.
const suggestScope = computed(() => {
if (selectedNames.value.length) return 'sélectionnés'
const parts = []
if (assignDateFilter.value.length) parts.push('jours')
if (assignTypeFilter.value.length) parts.push('types')
return parts.length ? ('des ' + parts.join(' + ') + ' cochés') : 'du pool'
})
const windowDayLabel = iso => iso === todayISO() ? "Aujourd'hui" : (iso === tomorrowISO() ? 'Demain' : (fmtDueLabel(iso) || iso))
const suggestWindowLabel = computed(() => {
const w = suggestWindow.value; if (!w.length) return '—'
if ((assignDateFilter.value || []).length) return w.map(windowDayLabel).join(', ')
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
}
// ── Groupes de techs ENREGISTRÉS (ex. « Installateurs », « Épisseurs ») — sélection manuelle sauvée par utilisateur,
// utile quand la compétence déduite du job est imprécise. Persisté serveur (useUserPrefs, suit l'usager). ──
const { prefs: techGroupPrefs, save: saveTechGroupPrefs } = useUserPrefs('techGroups', { groups: [] })
const techGroups = computed(() => techGroupPrefs.value.groups || [])
function applyTechGroup (g) { // remplace la sélection par les techs du groupe (présents/visibles seulement)
const ids = new Set(g.ids || []); const sel = {}
for (const t of (visibleTechs.value || [])) if (ids.has(t.id)) sel[t.id] = true
suggestDlg.techSel = sel
}
function saveTechGroup () {
const ids = Object.keys(suggestDlg.techSel).filter(id => suggestDlg.techSel[id])
if (!ids.length) { $q.notify({ type: 'warning', message: 'Sélectionne au moins un technicien avant denregistrer un groupe' }); return }
$q.dialog({ title: 'Enregistrer le groupe', message: 'Nom du groupe (ex. Installateurs, Épisseurs)', prompt: { model: '', type: 'text' }, cancel: true }).onOk(name => {
name = String(name || '').trim(); if (!name) return
const groups = [...(techGroupPrefs.value.groups || [])].filter(g => g.name.toLowerCase() !== name.toLowerCase())
groups.push({ name, ids }); groups.sort((a, b) => a.name.localeCompare(b.name))
saveTechGroupPrefs({ groups })
$q.notify({ type: 'positive', message: `Groupe « ${name} » enregistré (${ids.length} tech)` })
})
}
function deleteTechGroup (g) {
saveTechGroupPrefs({ groups: (techGroupPrefs.value.groups || []).filter(x => x.name !== g.name) })
}
// 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)))
async function runSuggestion () {
if (!suggestSelCount.value) { $q.notify({ type: 'warning', message: 'Choisis au moins un technicien disponible.', timeout: 2500 }); return }
suggestDlg.strategy = 'optimize' // UNE seule méthode : solveur VRP (routes réelles + compétences + efficacité + AM/PM + priorités)
showAllMoveTechs.value = false // picker « déplacer vers » : repart filtré aux techs sélectionnés+compétents
// « Ré-optimiser l'existant » : le solveur récupère les données des jobs assignés par leur nom (l'heuristique locale
// n'a que le pool en mémoire) → on force le solveur VRP.
if (suggestDlg.includeAssigned) suggestDlg.strategy = 'optimize'
suggestDlg.building = true
try {
if (suggestDlg.strategy === 'optimize') await optimizeSuggestion() // solveur VRP OR-Tools (routes + compétences + temps sur place)
else buildSuggestion() // heuristique gloutonne (rapide, local)
} catch (e) { err(e); buildSuggestion() } finally { 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'
loadLegTimes().catch(() => {}) // temps de route réels entre arrêts (OSRM, non bloquant)
}
// Ré-optimiser depuis la revue : relance le solveur VRP en tenant compte des contraintes AM/PM/⚡ posées sur les jobs.
// Tech malade/absent : le désélectionne puis RÉ-OPTIMISE → ses jobs sont répartis équitablement (le solveur équilibre la charge) sur les techs restants.
async function markTechSick (g) {
if (!g || isHoldId(g.techId)) return
suggestDlg.techSel[g.techId] = false
$q.notify({ type: 'info', icon: 'sick', message: g.techName + ' retiré (malade/absent) — redistribution de ses ' + g.jobs + ' job(s)…', timeout: 2200 })
await reoptimize()
}
async function reoptimize () {
suggestDlg.strategy = 'optimize'; suggestDlg.building = true
try { await optimizeSuggestion() } catch (e) { err(e) } finally { suggestDlg.building = false }
suggestDlg.view = 'list'; suggestMapDay.value = suggestWindow.value[0] || ''
loadLegTimes().catch(() => {}) // recalcule les temps entre arrêts après ré-optimisation
}
// Fenêtre de quart (min depuis minuit) d'un tech un jour donné, depuis ses cellules + le modèle. null si aucun quart régulier.
function shiftWindowMin (techId, iso) {
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) }
if (!isFinite(s) || !isFinite(e) || e <= s) return null
return { start: Math.round(s * 60), end: Math.round(e * 60) }
}
// « Optimiser » = P4 : l'ORCHESTRATION COMPLÈTE vit au hub (/roster/optimize-plan — pool, quarts, occupation, mêmes règles pour le cron
// auto-plan) avec règles d'adresse : même adresse = 1 arrêt ; job à l'adresse d'un job DÉJÀ assigné → épinglé sur CE tech (coordination).
// Repli : l'ancienne orchestration locale si le hub est indisponible.
async function optimizeSuggestion () {
suggestDlg.makeShifts = true // quarts 8-16 assumés (hub assumed_shift) → créés au Publier
const selTechs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id])
if (suggestDlg.includeAssigned) seedAssignedAmpm() // ré-optimiser l'existant : détecte AM/PM des jobs déjà dispatchés (fenêtre dure 8-12 / 12-16)
const job_windows = {}
for (const n in suggestDlg.jobTime) { const w = AMPM_WIN[(suggestDlg.jobTime[n] || {}).ampm]; if (w) job_windows[n] = { tw_start_min: w[0], tw_end_min: w[1] } }
try {
const r = await roster.optimizePlanHub({
dates: suggestWindow.value, tech_ids: selTechs.map(t => t.id),
// job_names = pool filtré (+ jobs DÉJÀ assignés de la fenêtre si « ré-optimiser l'existant ») → le solveur les replace.
job_names: [...new Set([...suggestJobs().map(j => j.name), ...(suggestDlg.includeAssigned ? assignedNamesForWindow() : [])])],
opts: { rank_weight: 20, versatility_weight: 3, speed_kmh: solverOpts.speedKmh, max_seconds: solverOpts.maxSeconds, overtime_coef: solverOpts.overtimeCoef }, // rang (compétence principale) DOMINE ; polyvalence = léger départage
dur_overrides: { ...durOverride }, job_windows,
})
if (!r || !r.ok || !Array.isArray(r.plan)) throw new Error((r && r.error) || 'réponse invalide')
const out = []
for (const e of r.plan) {
const t = techById.value[e.techId] || {}
out.push({
...e, techName: e.techName || t.name || e.techId, placeholder: false, over: false,
capable: e.capable !== false, noShift: !!(r.assumed_shift && r.assumed_shift[e.techId + '|' + e.iso]),
lvl: e.skill ? (skillLevelOf(t, e.skill) || 0) : 0, eff: e.skill ? skillEffOf(t, e.skill) : (Number(t.efficiency) || 1),
})
}
for (const e of (r.unassigned || [])) out.push({ ...e, techId: '__unassigned__', techName: '⚠️ Non assignés — ajoute des techs ou assigne à la main', placeholder: true, noShift: false, capable: true, lvl: 0, eff: 1 })
for (const e of (r.remote || [])) out.push({ ...e, techId: '__remote__', techName: '🖥 Sans déplacement — à faire à distance (assigner à un agent)', placeholder: true, noShift: false, capable: true, lvl: 0, eff: 1 })
suggestDlg.plan = out
return
} catch (e) {
$q.notify({ type: 'warning', message: 'Optimisation serveur indisponible — repli local', timeout: 2500 })
}
await optimizeSuggestionLocal()
}
// Ancienne orchestration CLIENT (repli d'urgence si le hub est injoignable) — même solveur, sans les règles d'adresse.
async function optimizeSuggestionLocal () {
buildSuggestion() // pose les jours + skill/dur/coords/reqLevel sur chaque entrée + les placeholders
suggestDlg.makeShifts = true // on SUPPOSE un quart 8-16 pour chaque tech sélectionné → à créer au moment du Publier
const plan = suggestDlg.plan
const byDay = {}; const unassigned = [] // PAS de faux « tech placeholder » : on n'utilise QUE les techs sélectionnés ; le reste = 1 seule liste « non assignés »
for (const e of plan) {
if (e.placeholder && isWeekendIso(e.iso)) { unassigned.push(e); continue } // week-end : pas de quart auto → non assigné
;(byDay[e.iso] = byDay[e.iso] || []).push(e)
}
const selTechs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id])
// Priorité du job → « priority_boost » (pénalité de drop) : URGENT jamais laissé de côté ; BASSE lâchée en 1er si la capacité manque.
const poolPr = Object.fromEntries((assignPanel.jobs || []).map(j => [j.name, String(j.priority || '').toLowerCase()]))
const prBoost = p => ({ high: 400000, 'élevée': 400000, urgent: 400000, medium: 0, moyenne: 0, normal: 0, low: -60000, basse: -60000 }[p] ?? 0) // HAUTE jamais lâchée ; basse lâchée en 1er
const prEarly = p => ({ high: 10, 'élevée': 10, urgent: 10 }[p] || 0) // HAUTE = servie tôt (début de journée)
const out = []
for (const iso of Object.keys(byDay)) {
const entries = byDay[iso]
const byName = Object.fromEntries(entries.map(e => [e.jobName, e]))
const jobs = entries.map(e => {
const jt = suggestDlg.jobTime[e.jobName] || {}
const win = AMPM_WIN[jt.ampm] // AM/PM → fenêtre horaire dure
const p = poolPr[e.jobName] // priorité (surcharge persistante incluse via buildUnassigned) : urgent/high jamais lâché + servi tôt
return { id: e.jobName, lat: e.lat, lon: e.lon, service_min: Math.max(5, Math.round((e.dur || 1) * 60)), skill: e.skill || '', priority_boost: prBoost(p), urgent_weight: prEarly(p), ...(win ? { tw_start_min: win[0], tw_end_min: win[1] } : {}) }
})
const vehicles = []
for (const t of selTechs) {
// Fenêtre = quart réel ; SINON quart 8-16 supposé (créé au Publier). Jamais de quart auto le week-end → pas de véhicule.
const w = shiftWindowMin(t.id, iso) || (isWeekendIso(iso) ? null : { start: 480, end: 960 })
if (!w) continue
// RÉDUIT la capacité par la charge DÉJÀ assignée ce jour (sinon surcharge, ex. « déjà 8.8h » + 12h → 20/16h).
const occ = techDayOcc(t.id, iso); const usedMin = occ ? Math.round((occ.usedH || 0) * 60) : 0
const startMin = Math.min(w.end - 15, w.start + usedMin)
const overtimeMin = Math.round(0.2 * (w.end - w.start)) // plafond ~120 % du quart nominal ; au-delà de 100 % = pénalisé (équilibrage)
const o = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
vehicles.push({ id: t.id, name: t.name, skills: t.skills || [], home_lat: o ? o.lat : null, home_lon: o ? o.lon : null, shift_start_min: startMin, shift_end_min: w.end, overtime_min: overtimeMin })
}
if (!vehicles.length) { unassigned.push(...entries); continue } // aucun tech éligible ce jour → non assignés
let res
try { res = await roster.optimizeRoutes({ jobs, vehicles, max_seconds: solverOpts.maxSeconds, rank_weight: 20, versatility_weight: 3, speed_kmh: solverOpts.speedKmh, overtime_coef: solverOpts.overtimeCoef }) } catch (e) { unassigned.push(...entries); continue }
if (!res || res.status !== 'OK') { unassigned.push(...entries); continue } // solveur absent/erreur → non assignés (ne jamais perdre de jobs)
const placed = new Set()
for (const rt of (res.routes || [])) {
const t = selTechs.find(x => x.id === rt.vehicle) || { id: rt.vehicle, name: rt.vehicle_name }
for (const st of (rt.stops || [])) { const e0 = byName[st.job_id]; if (!e0) continue; placed.add(st.job_id); out.push({ ...e0, techId: t.id, techName: t.name, placeholder: false, noShift: !hasShiftDay(t.id, iso), capable: true }) } // noShift → pastille « Créer quart »
}
for (const jid of (res.unassigned || [])) { const e0 = byName[jid]; if (e0 && !placed.has(jid)) unassigned.push(e0) }
}
// UN SEUL groupe « Non assignés » (aucun faux tech) : capacité/compétence insuffisante avec les techs sélectionnés → en ajouter, ou assigner à la main via le menu ⇄ (classé proximité + charge).
for (const e of unassigned) out.push({ ...e, techId: '__unassigned__', techName: '⚠️ Non assignés — ajoute des techs ou assigne à la main', placeholder: true, noShift: false })
suggestDlg.plan = out
}
// 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) {
// ORDRE = fenêtre horaire D'ABORD (AM 8-12 → sans-fenêtre → PM 12-16), plus-proche-voisin DANS chaque bloc,
// en chaînant la position d'un bloc au suivant. Respecte « faire en AM / PM » (toggle explicite OU détecté du texte
// ex. « **Passer en premier en AM** », « PM Fin de journée »). AVANT : NN pur → une job PM pouvait finir en 1er.
const ampmKey = e => { const a = jobTimeOf(e.jobName).ampm || detectAmpm(e); return a === 'am' ? 'am' : a === 'pm' ? 'pm' : 'none' }
const blocks = { am: [], none: [], pm: [] }
for (const e of entries) blocks[ampmKey(e)].push(e)
const out = []; let km = 0
let cur = (home && isFinite(+home.lat) && isFinite(+home.lon)) ? { lat: +home.lat, lon: +home.lon } : null
for (const key of ['am', 'none', 'pm']) {
const rem = blocks[key].filter(e => e.lat != null && e.lon != null)
const noLL = blocks[key].filter(e => e.lat == null || e.lon == null)
while (rem.length) {
let bi = 0, bd = Infinity
if (cur) 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 (cur && isFinite(bd)) km += bd
cur = rem[bi]; out.push(rem.splice(bi, 1)[0])
}
out.push(...noLL)
}
return { ordered: out, 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) }
// Ville d'un job d'occupation/legacy : sujets hétérogènes (« Ville | Client » OU « Type | Ville | Client ») → on prend le 1er segment
// qui RESSEMBLE à une ville (≤4 mots, sans chiffres, sans mots-métier), sinon la fin d'adresse, sinon le suffixe « … - Ville ».
const OCC_STOP = /bris|fibre|install|r[ée]par|modem|internet|panne|coupure|remplac|ramass|retrait|d[ée]mant|note|t[ée]l[ée]|\btv\b|wifi|borne|activation|transfert|[ée]quipement|technicien|est\.|time|urgent|\bsca\b|drop|bo[iî]tier|d[ée]roulage|registre|camping/i
const cityLike = s => { s = String(s || '').trim(); if (!s || /\d/.test(s)) return ''; if (s.split(/\s+/).length > 4) return ''; if (OCC_STOP.test(s)) return ''; return s }
const cityOfOcc = j => {
const parts = String(j.address || '').split(',').map(x => x.trim()).filter(Boolean)
if (parts.length >= 2) { const c = cityLike(parts[parts.length - 1]); if (c) return c }
for (const seg of String(j.subject || '').split('|')) {
const c = cityLike(seg); if (c) return c
if (seg.includes(' - ')) { const c2 = cityLike(seg.split(' - ').pop()); if (c2) return c2 }
}
return ''
}
return Object.values(byTech).map(g => {
const home = isHoldId(g.techId) ? null : techOrigin(g.techId); const isPh = isHoldId(g.techId); let tkm = 0 // départ = domicile, sinon bureau TARGO
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
// Jobs DÉJÀ assignés à ce tech ce jour (occupation OPS + tickets legacy F terrain) → visibles + secteur où il est déjà
let prevJobs = []
if (!isPh) {
prevJobs = (((techDayOcc(g.techId, d.iso) || {}).jobs) || []).filter(j => !j.cancelled)
const lg = legacyLoad.value[g.techId + '|' + d.iso]
for (const j of ((lg && lg.jobs) || [])) if (j.field) prevJobs.push({ name: 'LEG:' + j.id, subject: j.subject, skill: j.skill || '', dur: Number(j.est_h) || 0, legacy: true })
}
const prevCities = [...new Set(prevJobs.map(cityOfOcc).filter(c => c && c !== 'Sans ville'))]
return { ...d, entries: r.ordered, km: r.km, mins: estTravelMin(r.km), label: dnumOf(d.iso), prevJobs, prevCities }
})
const sectors = [...new Set(days.flatMap(d => d.prevCities))] // secteur(s) où le tech a DÉJÀ des jobs → s'ajuster en conséquence
// Occupation : capacité (quarts réels ou 8h) + charge DÉJÀ présente sur ces jours, + les heures proposées → barre projetée.
// Occupation PAR JOUR (le vrai plafond = 8h/jour, PAS la somme des 2 jours) : on affiche le JOUR le plus chargé (contrainte qui lie).
const placeholder = isPh
let worstPct = 0, worstProj = 0, worstCap = 8, existing = 0, over = false
if (!placeholder) for (const d of days) {
const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(g.techId, d.iso))
const dcap = l.cap || 8; const dproj = (l.h || 0) + (d.hours || 0); existing += (l.h || 0)
const pct = dcap ? dproj / dcap * 100 : 0
if (pct > worstPct) { worstPct = pct; worstProj = dproj; worstCap = dcap }
if (dproj > dcap * 1.001) over = true
}
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(worstCap * 10) / 10, existing: Math.round(existing * 10) / 10, projected: Math.round(worstProj * 10) / 10,
fillPct: Math.min(100, Math.round(worstPct)), over: !placeholder && over, placeholder, noShiftDays, skills, sectors
}
}).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.
// Couleur de tournée par index : ANGLE D'OR (137,5°) sur la roue HSL → couleurs consécutives toujours très éloignées,
// nombre illimité de techs sans collision visuelle (remplace la palette fixe de 12 qui se ressemblait).
const routeColor = (i) => `hsl(${Math.round((i * 137.508) % 360)}, 70%, 40%)`
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
// MÊME ADRESSE = 1 ARRÊT sur la carte : les entrées aux mêmes coordonnées (~10 m) partagent un seul pin numéroté.
const stops = []; const seen = new Map()
for (const e of d.entries) {
if (e.lat == null || e.lon == null || !isFinite(+e.lat) || !isFinite(+e.lon) || Math.abs(+e.lat) < 0.01) continue // 0,0 / invalides exclus (sinon la carte dézoome jusqu'au golfe de Guinée)
const k = (+e.lat).toFixed(4) + ',' + (+e.lon).toFixed(4)
const ex = seen.get(k)
if (ex) { ex.n++; ex.subject = ex.n + ' jobs — ' + ex.first } else { const s = { lat: +e.lat, lon: +e.lon, seq: stops.length + 1, subject: e.subject, first: e.subject, n: 1, name: e.jobName, entry: e, icon: markerIcon(e.skill) }; seen.set(k, s); stops.push(s) }
}
if (!stops.length) continue
const home = techOrigin(g.techId) // domicile, sinon bureau TARGO
out.push({ techId: g.techId, techName: g.techName, color: routeColor(ci), km: d.km, mins: d.mins, home: home ? { lat: home.lat, lon: home.lon } : null, stops })
ci++
}
return out
})
// RÉFÉRENCE STABLE pour <RouteMap :routes> — computed CACHÉ (ne change QUE si suggestRoutes change).
// AVANT : `:routes="suggestRoutes.map(...)"` inline créait un NOUVEAU tableau à CHAQUE rendu → le watch deep de RouteMap
// redessinait → emit('metrics') → onRouteMetrics muta realRoutes → re-rendu → nouveau tableau → BOUCLE INFINIE (figeait la carte).
const suggestMapRoutes = computed(() => suggestRoutes.value.map(r => ({ id: r.techId, name: r.techName, color: r.color, home: r.home, stops: r.stops })))
// Carte de la revue = <RouteMap> (réactif sur suggestRoutes) — il ne reste qu'à défauter le jour affiché à l'ouverture de la vue carte.
watch(() => suggestDlg.view, (v) => { if (v === 'map' && !suggestMapDay.value) suggestMapDay.value = suggestWindow.value[0] || '' })
// ── P3 — Onglet « Tournées » : tournées RÉELLES (jobs assignés, occupation) de la journée choisie, 1 couleur/tech, tracés OSRM (RouteMap). ──
const routesDay = ref(null)
// Sélecteur de jour (Tournées) = bande d'occupation sur 2 SEMAINES (14 j depuis le lundi affiché) par défaut.
// Données via /roster/capacity (14 j) — DÉCOUPLÉ de la fenêtre de charge du grid (7 j) → barres réelles même en semaine 2.
const routeCapacity = ref({})
async function loadRouteCapacity () { try { const r = await roster.getCapacity(start.value, 14); routeCapacity.value = r.capacity || {} } catch (e) { routeCapacity.value = {} } }
watch([boardView, start], () => { if (boardView.value === 'routes') loadRouteCapacity() }, { immediate: true })
const routeStripDays = computed(() => {
const out = []
for (let i = 0; i < 14; i++) {
const iso = addDaysISO(start.value, i); const c = routeCapacity.value[iso] || {}
const h = +c.due_h || 0; const cap = +c.cap_h || 0; const dw = dowOf(iso)
out.push({ iso, dow: FR_DOW[dw], dnum: iso.slice(8) + '/' + iso.slice(5, 7), weekend: dw === 0 || dw === 6, holiday: isHoliday(iso), h, cap, ratio: cap > 0 ? h / cap : (h > 0 ? 1 : 0), over: !!c.overbooked, today: iso === todayISO(), unassigned: (unassignedByDay.value[iso] || []).length })
}
return out
})
// Jobs DUS le jour sélectionné (Tournées) mais NON assignés — mis en évidence + « Suggérer ». Respecte le filtre de compétence (unassignedByDay).
const routeDayUnassigned = computed(() => unassignedByDay.value[routesDay.value] || [])
// Chips SECTEUR (par ville) des non-assignés du jour → filtre la liste ET la carte. Reset au changement de jour.
const routeSectorSel = ref(new Set())
const routePrioSel = ref(new Set())
watch(routesDay, () => { routeSectorSel.value = new Set(); routePrioSel.value = new Set() })
const routeSectors = computed(() => { const m = {}; for (const j of routeDayUnassigned.value) { const c = jobCity(j) || 'Sans secteur'; m[c] = (m[c] || 0) + 1 } return Object.entries(m).map(([sector, n]) => ({ sector, n })).sort((a, b) => b.n - a.n) })
function toggleRouteSector (s) { const set = new Set(routeSectorSel.value); if (set.has(s)) set.delete(s); else set.add(s); routeSectorSel.value = set }
// Chips PRIORITÉ (normalisée high/medium/low) — filtrent la liste + la carte comme les secteurs, et (avec eux) le périmètre de « Suggérer ».
const NORM_PRIO = { high: 'high', urgent: 'high', 'élevée': 'high', elevee: 'high', medium: 'medium', moyenne: 'medium', normal: 'medium', low: 'low', basse: 'low' }
const normPrio = j => NORM_PRIO[String(j.priority || 'medium').toLowerCase()] || 'medium'
const routePrios = computed(() => { const m = {}; for (const j of routeDayUnassigned.value) { const p = normPrio(j); m[p] = (m[p] || 0) + 1 } return POOL_PRIOS.filter(pp => m[pp.value]).map(pp => ({ ...pp, n: m[pp.value] })) })
function toggleRoutePrio (p) { const set = new Set(routePrioSel.value); if (set.has(p)) set.delete(p); else set.add(p); routePrioSel.value = set }
const routeDayUnassignedView = computed(() => {
const sel = routeSectorSel.value; const ps = routePrioSel.value
if (!sel.size && !ps.size) return routeDayUnassigned.value
return routeDayUnassigned.value.filter(j => (!sel.size || sel.has(jobCity(j) || 'Sans secteur')) && (!ps.size || ps.has(normPrio(j))))
})
const _jlat = j => (j.latitude != null ? +j.latitude : (j.lat != null ? +j.lat : null))
const _jlon = j => (j.longitude != null ? +j.longitude : (j.lon != null ? +j.lon : null))
// Gouttes carte = non-assignés du jour (filtrés secteur) qui ont des coordonnées ; couleur = priorité
// Gouttes carte des non-assignés — GROUPÉES PAR ADRESSE : une pastille par adresse portant les icônes des types de
// jobs + le temps TOTAL (évite de cliquer chaque ticket ; détail au clic via popup). Couleur = priorité la plus haute.
const routeUnassignedPins = computed(() => {
const valid = routeDayUnassignedView.value.filter(j => { const la = _jlat(j); const lo = _jlon(j); return la != null && lo != null && isFinite(la) && isFinite(lo) && Math.abs(la) > 0.01 })
const prioRank = p => ({ urgent: 0, high: 1, 'élevée': 1, moyenne: 2, medium: 2, normal: 2, low: 3, basse: 3 }[String(p || '').toLowerCase()] ?? 2)
const groups = new Map()
for (const j of valid) {
const la = _jlat(j), lo = _jlon(j)
const key = String(j.address || '').trim().toLowerCase() || (la.toFixed(5) + ',' + lo.toFixed(5))
let g = groups.get(key); if (!g) { g = { lat: la, lon: lo, jobs: [], city: jobCity(j), address: j.address || '' }; groups.set(key, g) }
g.jobs.push(j)
}
return [...groups.values()].map(g => {
const icons = [...new Set(g.jobs.map(j => markerIcon(j.required_skill || j.service_type)))].slice(0, 3)
const totalMin = g.jobs.reduce((s, j) => s + Math.round(jobDur(j) * 60), 0)
const topPrio = g.jobs.map(j => j.priority).sort((a, b) => prioRank(a) - prioRank(b))[0]
return {
lat: g.lat, lon: g.lon, icons, count: g.jobs.length, totalLabel: fmtMin(totalMin),
color: prioColor(topPrio), city: g.city, address: g.address,
jobs: g.jobs.map(j => ({ subject: j.subject || j.service_type || j.name, est: fmtMin(Math.round(jobDur(j) * 60)), job: j })),
job: g.jobs[0], // clic simple (1 job) → détail direct
}
})
})
// ── Deep-link (?day=YYYY-MM-DD & ?skill=…) + action « planifier des quarts » du slot-finder ──
const route = useRoute()
const createSignal = useCreateSignal()
// FAB global « Créer » → signal partagé (useCreateSignal) → ouvre le chooser unifié. Ref module-level = survit à la nav
// (fresh mount ET même page). nextTick → posé après le rendu (fiable, contrairement au router.replace-au-setup).
watch(() => createSignal.pending.value, (v) => { if (v) { createSignal.pending.value = false; nextTick(() => { createChooser.value = true }) } }, { immediate: true })
function matchSkill (skill) { return skill ? (allSkills.value || []).find(s => String(s).toLowerCase() === String(skill).toLowerCase()) : null }
function focusDay (iso, skill) { // cadre la semaine sur le jour, le sélectionne (grille/mobile/tournées), filtre compétence si connue
if (iso && /^\d{4}-\d{2}-\d{2}$/.test(iso)) { start.value = mondayISO(iso); mobileSelIso.value = iso; routesDay.value = iso }
const m = matchSkill(skill); if (m) skillFilter.value = [m]
}
// « Aucun créneau » → « Planifier des quarts » : ouvre la timeline des techs (filtrée compétence) au bon jour pour créer des quarts.
function onPlanShifts ({ skill, date } = {}) {
showSuggestSlots.value = false
boardView.value = 'grid' // vue timeline = voir/créer les quarts
focusDay(date, skill)
const m = matchSkill(skill)
$q.notify({ type: 'info', icon: 'event_busy', timeout: 4500, message: m ? `Techs « ${m} » — crée un quart ce jour pour ouvrir des créneaux` : (skill ? `Aucun tech n'a « ${skill} » comme compétence — ajoute-la à un tech ou crée un quart` : 'Crée un quart pour ouvrir des créneaux') })
}
const routesMapRef = ref(null)
const dayRouteMetrics = reactive({})
const hiddenRouteTechs = ref(new Set()) // chips : techs RETIRÉS de la carte (couleur/badge conservés, réactivables d'un clic)
function toggleRouteTech (id) {
// Clic sur un tech = ISOLER sa tournée sur la carte (masquer toutes les autres) → voir une tournée précise.
// Re-clic sur le tech déjà seul = tout réafficher.
const others = allDayRoutes.value.map(r => r.id).filter(x => x !== id)
const soloed = others.length > 0 && !hiddenRouteTechs.value.has(id) && others.every(x => hiddenRouteTechs.value.has(x))
hiddenRouteTechs.value = soloed ? new Set() : new Set(others)
}
function showAllRouteTechs () { hiddenRouteTechs.value = new Set() }
const allDayRoutes = computed(() => { // TOUTES les tournées du jour (couleurs STABLES par index, même quand on masque)
const iso = routesDay.value; if (!iso) return []
const out = []; let ci = 0
for (const t of (visibleTechs.value || [])) {
const occ = techDayOcc(t.id, iso)
const jobs = ((occ && occ.jobs) || []).filter(j => !j.cancelled && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon) && Math.abs(+j.lat) > 0.01)
if (!jobs.length) continue
jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // ordre de tournée réel
const h = techOrigin(t.id) // domicile, sinon bureau TARGO
out.push({ id: t.id, name: t.name, color: routeColor(ci++), home: h ? { lat: h.lat, lon: h.lon } : null, stops: jobs.map((j, i) => ({ lat: +j.lat, lon: +j.lon, seq: i + 1, subject: j.subject, name: j.name, job: j, icon: markerIcon(j.skill) })) })
}
return out
})
const dayRoutes = computed(() => allDayRoutes.value.filter(r => !hiddenRouteTechs.value.has(r.id))) // ce que la carte affiche
// ── Positions GPS LIVE des techs (onglet Tournées) : polling doux (25 s) tant que l'onglet est ouvert ; couleur = celle de la tournée du tech ──
const livePosRaw = ref([]) // [{ techId, techName, lat, lon, time, speed }]
const showLivePos = ref(true) // afficher/masquer les positions GPS live sur la carte des tournées
let _livePosTimer = null
async function refreshLivePositions () { try { const r = await roster.techPositions(); livePosRaw.value = (r && r.positions) || [] } catch (e) { /* non bloquant */ } }
function startLivePositions () { if (_livePosTimer) return; refreshLivePositions(); _livePosTimer = setInterval(refreshLivePositions, 25000) }
function stopLivePositions () { if (_livePosTimer) { clearInterval(_livePosTimer); _livePosTimer = null } }
// Positions pour la carte : masque les techs cachés (chips), colore selon leur tournée du jour (sinon gris neutre).
const dayLivePositions = computed(() => {
const colorByTech = {}; for (const r of (allDayRoutes.value || [])) colorByTech[r.id] = r.color
const visIds = new Set((visibleTechs.value || []).map(t => t.id)) // filtre de compétence : seules les positions des techs capables (+ non masqués) sur la carte
return (livePosRaw.value || [])
.filter(p => visIds.has(p.techId) && !hiddenRouteTechs.value.has(p.techId))
.map(p => ({ techId: p.techId, name: p.techName, lat: p.lat, lon: p.lon, time: p.time, speed: p.speed, color: colorByTech[p.techId] || '#455a64' }))
})
watch(boardView, (v) => { if (v === 'routes') startLivePositions(); else stopLivePositions() }, { immediate: true })
watch(routesDay, () => { for (const k in dayRouteMetrics) delete dayRouteMetrics[k]; hiddenRouteTechs.value = new Set() })
watch(boardView, (v) => { if (v === 'routes' && (!routesDay.value || !dayList.value.some(d => d.iso === routesDay.value))) { const t = todayISO(); routesDay.value = ((dayList.value || []).find(d => d.iso >= t) || (dayList.value || [])[0] || {}).iso || t } }, { immediate: true })
// Un tech « couvre » une file s'il possède TOUTES les compétences requises par ses jobs (compétences vides ignorées).
const covers = (t, skills) => (skills || []).filter(Boolean).every(s => (t.skills || []).includes(s))
// 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.
// Par défaut, le picker « déplacer vers » ne montre QUE les techs SÉLECTIONNÉS pour cette simulation ET compétents ;
// « Afficher tous » (showAllMoveTechs) lève le filtre. Repli sur la liste complète si aucun sélectionné-compétent (jamais vide).
const showAllMoveTechs = ref(false)
function moveTechFilter (list, skills) {
if (showAllMoveTechs.value) return list
const cap = (t) => (t.capable != null) ? !!t.capable : covers(t._t || t, skills) // entrée = projection (.capable) ; groupe = tech complet (.skills)
const sel = list.filter(t => suggestDlg.techSel[t.id] && cap(t))
return sel.length ? sel : list // jamais vide : repli sur tous si aucun sélectionné-compétent
}
function capableTechsFirst (skills) { return moveTechFilter([...(visibleTechs.value || [])].sort((a, b) => (covers(b, skills) - covers(a, skills)) || String(a.name || '').localeCompare(String(b.name || ''))), skills) }
// Une entrée de la revue → objet job-like pour réutiliser techsForJob (classement proximité + occupation).
function techsForEntry (e) { return moveTechFilter(techsForJob({ required_skill: e.skill, lat: e.lat, lon: e.lon, scheduled_date: e.iso }), e.skill ? [e.skill] : []) }
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 })
// initials → composables/useFormatters (source unique ; 2 premiers mots · '?')
function toggleSel (j) {
if (j.status === 'On Hold') { $q.notify({ type: 'warning', message: 'En attente d\'une tâche précédente — non assignable', timeout: 2500 }); return }
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 ».
// POOL_PRIOS = priorités Dispatch Job — SOURCE UNIQUE (config/dispatch-priority).
import { DISPATCH_PRIORITIES as POOL_PRIOS } from 'src/config/dispatch-priority'
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é moyenne' : '⚡ Urgent') }
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)) }
// Compétences requises (LISTE) du job depuis la feuille du pool → store hub /roster/job-skills (PAS un champ ERPNext). Optimiste + réconcilie sur échec.
async function setPoolSkills (j, list) {
if (!j) return
const arr = normSkillList(list)
j.required_skills = arr; j.required_skill = arr[0] || '' // affichage/couleur/icône immédiats
if (!j.name) return
try { await roster.setJobSkills(j.name, arr); $q.notify({ type: 'positive', message: 'Compétences : ' + (arr.join(', ') || '—'), timeout: 1600 }) }
catch (e) { err(e); await reloadPool() }
}
// 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(); logChange('↶ Annulé'); scheduleDraftSave() }
function redo () { if (!future.value.length) return; history.value.push(snap()); assignments.value = future.value.pop(); logChange('↷ Rétabli'); scheduleDraftSave() }
// garde anti-perte
// #2 — plus de blocage : tout est auto-sauvé en brouillon. On ATTEND le flush (semaine courante) AVANT de naviguer (évite d'écrire sur la nouvelle semaine).
async function guard (fn) { await autosaveDraft(); 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 p = Math.round(100 / (Number(e) || 1)); if (p === 100) return 'cadence normale'; return 'cadence ' + p + '% (' + (p > 100 ? 'plus de jobs' : 'moins de jobs') + ')' }
// 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 || [])], 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 }
// Éditeur d'équipe (table) : TagEditor émet un tableau → t.skills en tableau, puis persiste (l'API attend une CSV).
function onTeamSkills (t, items) { t.skills = normSkillList(items); saveSkills(t) }
async function saveSkills (t) { const csv = normSkillList(t.skills).join(','); try { await roster.setTechSkills(t.id, csv); const tt = techs.value.find(x => x.id === t.id); if (tt) tt.skills = csv ? csv.split(',') : []; $q.notify({ type: 'positive', message: t.name + ' : compétences enregistrées' }) } catch (e) { err(e) } }
// congés / disponibilités → extrait dans components/planif/LeaveDialog.vue (état + fonctions déplacés).
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 }
}
// #1 — « Publier » ouvre d'abord le sommaire des changements ; la publication réelle se fait sur confirmation.
function doPublish () { if (!dirty.value) return; pubConfirm.value = true }
async function doPublishConfirmed () {
publishing.value = true
try {
// Mode 'publish' : promeut les brouillons (Proposé/Soumis/Approuvé) → Publié + SMS. Les éditions étaient déjà auto-sauvées.
const r = await roster.publishWeek(start.value, days.value, assignments.value, notifySms.value, 'publish')
const done = (r.created || 0) + (r.promoted || 0)
$q.notify({ type: r.errors ? 'warning' : 'positive', message: `Publié : ${done} quart(s)` + (r.deleted ? ` (${r.deleted} retirés)` : '') + (r.errors ? ` · ${r.errors} erreurs` : '') + (r.notified ? ` · ${r.notified} SMS` : '') })
pubConfirm.value = false
await loadWeek()
} catch (e) { err(e) } finally { publishing.value = false }
}
// #3 — étape d'approbation FACULTATIVE : Soumettre (→ Soumis) / Approuver (→ Approuvé), sans SMS. « Publier » reste l'étape finale (SMS).
async function doWeekStatus (mode) {
publishing.value = true
try {
const r = await roster.publishWeek(start.value, days.value, assignments.value, false, mode)
const n = (r.created || 0) + (r.promoted || 0)
$q.notify({ type: r.errors ? 'warning' : 'positive', message: (mode === 'submit' ? 'Soumis pour approbation' : 'Approuvé') + ' · ' + n + ' quart(s)' + (r.errors ? ' · ' + r.errors + ' err.' : '') })
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 = [] } demand.value.forEach(d => { if (!Array.isArray(d.skills)) d.skills = String(d.skills || '').split(',').map(s => s.trim()).filter(Boolean) }); /* migration CSV→tableau (ancien SkillSelect) */ 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 onDemandSkills (d, items) { d.skills = normSkillList(items); saveDemand() } // TagEditor émet un tableau ; d.skills reste un tableau (→ CSV à la génération)
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: normSkillList(row.skills).join(',') })
}
}
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 } }
// #2 — AUTO-SAVE : chaque édition de la grille est persistée en BROUILLON (statut 'Proposé') après un court debounce. Plus rien n'est « non sauvegardé ».
const autosaving = ref(false)
const changeLog = ref([]) // journal de session : { at, text }
let _draftT = null
function logChange (text) { changeLog.value.unshift({ at: Date.now(), text }); if (changeLog.value.length > 60) changeLog.value.pop() }
function scheduleDraftSave () { if (_draftT) clearTimeout(_draftT); _draftT = setTimeout(autosaveDraft, 800) }
async function autosaveDraft () {
if (_draftT) { clearTimeout(_draftT); _draftT = null }
if (autosaving.value) { scheduleDraftSave(); return } // une sauvegarde en cours → replanifie
autosaving.value = true
try { await roster.publishWeek(start.value, days.value, assignments.value, false, 'draft') } catch (e) { /* non bloquant : réessaie au prochain edit */ } finally { autosaving.value = false }
}
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 }]; logChange('Quart ajouté · ' + techName + ' · ' + iso); scheduleDraftSave() }
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; logChange('Quart défini · ' + techName + ' · ' + iso); scheduleDraftSave() }
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)); logChange('Quart retiré · ' + iso); scheduleDraftSave() }
// 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 () { if (dirty.value && !autosaving.value) autosaveDraft() } // auto-save actif → aucune alerte ; on tente un dernier flush best-effort
// ── 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 = capPickups((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) } if (route.query.day || route.query.skill) focusDay(route.query.day, route.query.skill); 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); stopLivePositions(); if (_kbRO) _kbRO.disconnect() })
onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 — flush le brouillon (attendu) en partant ; plus jamais d'« abandonner ? »
</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: 3px; justify-content: center; align-items: center; margin-top: 2px; }
/* Barre verticale d'occupation (miroir de la bande mobile) dans l'en-tête desktop */
.hdr-vbar { width: 7px; height: 22px; background: #eef2f7; border-radius: 4px; overflow: hidden; display: inline-flex; align-items: flex-end; flex: 0 0 auto; }
.hdr-vfill { width: 100%; border-radius: 4px; transition: height .3s; }
.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-hd-skills { display: inline-flex; align-items: center; gap: 3px; cursor: pointer; }
.suggest-hd-skills:hover .assign-skill { filter: brightness(1.12); }
.suggest-hd-more { font-size: 10px; font-weight: 700; color: #64748b; background: #e2e8f0; border-radius: 8px; padding: 0 5px; line-height: 15px; }
.suggest-prev-toggle { display: inline-flex; align-items: center; gap: 2px; margin-left: 8px; font-size: 10.5px; font-weight: 600; color: #64748b; background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 8px; padding: 0 6px; cursor: pointer; user-select: none; }
.suggest-prev-toggle:hover { border-color: #94a3b8; }
.suggest-prev { opacity: 0.75; background: #f8fafc; border-radius: 6px; }
.suggest-clk { cursor: pointer; }
.suggest-clk:hover { color: #4f46e5; text-decoration: underline; }
.suggest-dur { font-size: 11px; white-space: nowrap; color: #475569; border-bottom: 1px dashed #cbd5e1; cursor: pointer; }
.suggest-dur:hover { color: #4f46e5; border-bottom-color: #4f46e5; }
.suggest-leg-time { font-size: 10px; color: #94a3b8; padding: 0 6px 2px 30px; line-height: 1.2; }
.suggest-leg-return { color: #64748b; font-style: italic; padding-top: 2px; border-top: 1px dashed #e2e8f0; margin-top: 1px; }
.assign-sla { display: inline-flex; align-items: center; gap: 2px; font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 8px; white-space: nowrap; margin-left: 4px; }
.assign-sla.sla-breached { background: #fee2e2; color: #b91c1c; }
.assign-sla.sla-at_risk { background: #ffedd5; color: #c2410c; }
.rt-chip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border: 1.5px solid #cbd5e1; border-radius: 14px; font-size: 12px; font-weight: 600; color: #334155; cursor: pointer; user-select: none; background: #fff; }
.rt-chip.off { opacity: 0.55; border-style: dashed; }
.rt-chip-all { border-color: #94a3b8; color: #475569; gap: 3px; }
.rt-chip-all:hover { border-color: #64748b; background: #f1f5f9; }
.rt-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.rt-n { display: inline-flex; align-items: center; justify-content: center; min-width: 17px; height: 17px; border-radius: 9px; padding: 0 4px; color: #fff; font-size: 10px; font-weight: 800; }
/* Bande « jobs dus non assignés » (vue Tournées) : chips secteur (filtrent liste+carte) + chips job + Suggérer */
.rt-unassigned { margin-bottom: 6px; padding: 6px 8px; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; }
.rt-ua-top { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; }
/* Carrousel : rangée unique qui défile horizontalement (pas de retour à la ligne). */
.rt-ua-jobs { display: flex; align-items: center; flex-wrap: nowrap; gap: 5px; margin-top: 5px; overflow-x: auto; overflow-y: hidden; padding-bottom: 4px; scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch; overscroll-behavior-x: contain; }
.rt-ua-jobs > .rt-ua-chip { flex: 0 0 auto; scroll-snap-align: start; }
.rt-ua-jobs::-webkit-scrollbar { height: 6px; }
.rt-ua-jobs::-webkit-scrollbar-thumb { background: #fdba74; border-radius: 3px; }
.rt-ua-lbl { font-size: 12px; color: #9a3412; display: inline-flex; align-items: center; gap: 3px; }
.rt-ua-open { background: #fff; border: 1px solid #fed7aa; border-radius: 12px; padding: 2px 8px; cursor: pointer; font-weight: 600; }
.rt-ua-open:hover { border-color: #fb923c; background: #fff7ed; }
.rt-sect { display: inline-flex; align-items: center; gap: 2px; padding: 2px 8px; border: 1px solid #cbd5e1; border-radius: 12px; background: #fff; font-size: 11.5px; font-weight: 600; color: #475569; cursor: pointer; user-select: none; }
.rt-sect.on { background: #6366f1; border-color: #6366f1; color: #fff; }
.rt-sect-n { font-weight: 800; margin-left: 2px; opacity: .85; }
.rt-sect-x { border-style: dashed; color: #94a3b8; }
.rt-chip-sep { width: 1px; align-self: stretch; min-height: 16px; background: #fed7aa; margin: 0 2px; }
.rt-ua-chip { display: inline-flex; align-items: center; gap: 3px; max-width: 230px; padding: 2px 8px 2px 6px; border: 1px solid #e2e8f0; border-left: 3px solid #9e9e9e; border-radius: 6px; background: #fff; font-size: 11.5px; color: #334155; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rt-ua-chip:hover { border-color: #6366f1; }
.jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; }
.jt-chip:hover { border-color: #6366f1; }
.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; }
.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; }
/* Mobile : la rangée d'un job (badges, durée, AM/PM, actions) dépasse souvent l'écran →
défilement horizontal de la rangée (au lieu de rogner la fin), sujet borné mais lisible. */
@media (max-width: 600px) {
.suggest-entry { flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; }
.suggest-entry > * { flex-shrink: 0; }
.suggest-entry .suggest-entry-t, .suggest-entry .suggest-clk { flex: 0 0 auto; min-width: 130px; max-width: 62vw; }
.suggest-entry::-webkit-scrollbar { height: 5px; }
.suggest-entry::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
}
.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-grp-chip { display: inline-flex; align-items: center; gap: 3px; border: 1px solid #c7d2fe; background: #eef2ff; color: #4338ca; border-radius: 12px; padding: 1px 8px; font-size: 11px; font-weight: 600; cursor: pointer; user-select: none; }
.suggest-grp-chip:hover { border-color: #6366f1; }
.suggest-grp-x { opacity: .55; margin-left: 1px; }
.suggest-grp-x:hover { opacity: 1; color: #dc2626; }
.suggest-grp-clear { border-color: #cbd5e1; background: #f8fafc; color: #64748b; }
.notify-preview { white-space: pre-wrap; word-break: break-word; font-family: inherit; font-size: 11.5px; line-height: 1.45; color: #475569; margin: 0; background: #f8fafc; border-radius: 6px; padding: 8px; }
.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; }
.skill-rank { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: #e2e8f0; color: #64748b; font-size: 9px; font-weight: 800; margin-right: 5px; flex-shrink: 0; }
.skill-rank-1 { background: #fef3c7; color: #b45309; box-shadow: 0 0 0 1.5px rgba(250,204,21,0.7); }
.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-geolinks { padding-left: 22px; gap: 14px !important; }
.jd-geolink { display: inline-flex; align-items: center; gap: 3px; font-size: 12px; color: #2563eb; text-decoration: none; font-weight: 500; }
.jd-geolink:hover { text-decoration: underline; }
/* Sélecteur de vue (Semaine/Jour/Tournées) à gauche — mis en évidence */
.view-switch { border: 1px solid #d7dce3; border-radius: 9px; overflow: hidden; }
.view-switch :deep(.q-btn) { font-weight: 600; padding: 5px 13px; }
.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; }
/* Suivi terrain (géofencing) : étapes horizontales façon suivi de colis. */
.jd-geo { margin-top: 12px; padding: 10px 8px; border: 1px solid #d6eeeb; background: #f3fbfa; border-radius: 8px; }
.jd-geo-track { display: flex; align-items: flex-start; }
.jd-geo-step { flex: 1; text-align: center; position: relative; }
.jd-geo-step::before { content: ''; position: absolute; top: 13px; left: -50%; width: 100%; height: 2px; background: #cfe3e0; z-index: 0; }
.jd-geo-step:first-child::before { display: none; }
.jd-geo-step.done::before { background: #26a69a; }
.jd-geo-ic { position: relative; z-index: 1; width: 28px; height: 28px; margin: 0 auto; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: #e0e0e0; color: #fff; }
.jd-geo-step.done .jd-geo-ic { background: #26a69a; }
.jd-geo-step.current .jd-geo-ic { box-shadow: 0 0 0 3px rgba(38,166,154,.3); }
.jd-geo-lbl { font-size: 10.5px; color: #607d8b; margin-top: 3px; line-height: 1.25; }
.jd-geo-step.done .jd-geo-lbl { color: #37474f; font-weight: 600; }
.jd-geo-at { display: block; font-size: 10px; color: #26a69a; font-weight: 700; }
.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; }
/* Dernier commentaire (le plus récent) mis en avant — info potentiellement la plus importante. */
.de-msg-latest { background: #f3f0fb; border-radius: 6px; padding: 6px 8px; margin-bottom: 4px; }
.de-msg-badge { display: inline-block; margin-left: 6px; font-size: 9px; font-weight: 700; color: #fff; background: #7e57c2; border-radius: 4px; padding: 0 5px; vertical-align: middle; }
/* Chooser « Créer » : options pleine largeur, contenu aligné à gauche (icône + titre + sous-titre). */
.cc-opt { width: 100%; border-radius: 10px; padding: 12px 14px; }
.cc-opt :deep(.q-btn__content) { flex-wrap: nowrap; justify-content: flex-start; }
.cc-sub { align-self: flex-start; margin: -4px 0 2px 8px; }
.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>