<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="showShiftEditor = true" />
          <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">{{ deEnt(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" />{{ deEnt(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">{{ deEnt(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 interne — ajoutée au fil du ticket, jamais envoyée au client…' : (replyChannel.ok ? ('Réponse ENVOYÉE 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: 'Note interne', value: true, icon: 'sticky_note_2' }, { label: replyChannel.ok ? ('Répondre au client · ' + replyChannel.label) : 'Répondre au client', value: false, icon: 'reply', disable: !replyChannel.ok }]" :toggle-color="noteMode ? 'blue-grey-7' : 'deep-orange'" color="grey-3" text-color="grey-8" />
                <q-space />
                <q-btn dense unelevated no-caps size="sm" :color="noteMode ? 'blue-grey-7' : 'deep-orange'" :icon="noteMode ? 'sticky_note_2' : 'send'" :label="noteMode ? 'Enregistrer la note' : 'Envoyer au client'" :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 — ajoutée au fil du ticket, 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 text-grey-6">{{ hoursOf(t.id) }}h<q-tooltip class="bg-grey-9" style="font-size:11px">Heures planifiées sur l'étendue affichée ({{ days }} j) — total, PAS une limite hebdomadaire</q-tooltip></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, 'tl-blk-assist': b.assist }" :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. 7–18h), 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). -->

    <!-- Types de shift — extrait dans components/planif/ShiftTypesDialog.vue (décomposition #4) ; @changed → recharge les templates partagés -->
    <ShiftTypesDialog v-model="showShiftEditor" :templates="templates" @changed="refreshTemplates" />

    <!-- 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 8–16 -->
          <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 8–16 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>
            <!-- Assignation façon courriel To/Cc — UN champ : À = assigné (principal) · Cc = assistants (renfort, créneau grisé). -->
            <div class="q-mt-xs jd-assign">
              <div class="row items-center q-gutter-xs">
                <q-chip v-if="jobDetail.techName" dense removable @remove="jdUnassign" 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>À&nbsp;·&nbsp;{{ jobDetail.techName }}</q-chip>
                <q-chip v-else dense outline color="grey-7" text-color="grey-8" icon="person_off">Non assigné</q-chip>
                <template v-if="jobDetail.canTeam">
                  <q-chip v-for="a in jobDetail.team" :key="a.tech_id" dense removable @remove="jdRemoveAssistant(a.tech_id)" outline color="blue-grey-6" text-color="blue-grey-8"><q-avatar color="blue-grey-6" text-color="white" size="20px">{{ initials(jdAssistantName(a)) }}</q-avatar>Cc&nbsp;·&nbsp;{{ jdAssistantName(a) }}<q-tooltip class="bg-grey-9" style="font-size:11px">Assistant (renfort) — son créneau est réservé et grisé (anti double-booking)</q-tooltip></q-chip>
                  <q-spinner v-if="jobDetail.teamLoading" size="14px" color="blue-grey-6" />
                </template>
                <q-space />
                <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>
              <TechSelect v-model="jobDetail.assignTech" :options="jdTeamOptions" :label="jobDetail.techName ? (jobDetail.canTeam ? 'Ajouter un assistant (Cc)…' : 'Réassigner…') : 'Assigner à… (À)'" style="min-width:170px;flex:1" class="q-mt-xs" @update:model-value="v => v && jdAssignOrAssist(v)" />
              <div v-if="jobDetail.canTeam" class="text-caption text-grey-6 q-mt-xs">{{ jobDetail.techName ? 'Ajout = assistant (Cc / renfort) : son créneau est réservé et grisé. Retirer « À » = désassigner.' : 'Choisir un tech = l’assigner (À). Les ajouts suivants deviennent des assistants (Cc).' }}</div>
            </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>
          <!-- « Équipe / renfort » fusionnée dans le champ d'assignation To/Cc en haut du volet (À = assigné · Cc = assistants). -->
          <div class="text-subtitle2 q-mt-md q-mb-xs row items-center">Commentaires / fil du billet<span v-if="jdMessages.length" class="text-grey-5 q-ml-sm" style="font-size:10px">· plus récent en haut</span></div>
          <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 8–17" class="col" @click="quickShift(8, 17)" />
          <q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir 16–20" 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 d’oiseau (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 ShiftTypesDialog from 'src/components/planif/ShiftTypesDialog.vue' // Types de shift (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 8h–17h, 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) // v-model de <ShiftTypesDialog> (editTpls/newTpl/fonctions déplacés dans le composant)
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 l’enregistrement' })
  } 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)
// numToTime : heure décimale → HH:MM (partagé — aussi utilisé par le glisser-créer de quart). newTpl/newTplRange déplacés dans ShiftTypesDialog.
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') }

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) : 7h–18h 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 6–18h ; 0–6 et 18–24 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 (0–6 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 7–18, étendue aux heures réelles des shifts (scroll pour le hors-plage).
// Vue calée sur 7–18h : on NE rend PAS 00–06h (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 (0–6 à gauche, 18–24 à droite) ; 6–18 remplit la largeur
const kbPxH = computed(() => Math.max(46, (kbBoardW.value - KB_NAME_W - 4) / (KB_VIS_TO - KB_VIS_FROM))) // px/heure : 7–18 = 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) }
}
// Champ unique d'assignation façon courriel To/Cc : 1er tech choisi = assigné (À) ; ajouts suivants (job natif) = assistants (Cc / renfort).
function jdAssignOrAssist (techId) {
  if (!techId) return
  if (jobDetail.techName && jobDetail.canTeam) { jobDetail.teamAdd = techId; jdAddAssistant(); jobDetail.teamAdd = null } // déjà un assigné + job natif → assistant
  else jdAssignTech(techId) // sinon → assigner / réassigner (vaut aussi pour les tickets legacy, sans assistant)
  jobDetail.assignTech = null // vider le champ après le choix
}
// Retirer le chip « À » (assigné) → renvoyer le job au pool (non assigné).
async function jdUnassign () {
  if (!jobDetail.name) return
  try { await roster.unassignJobRoster(jobDetail.name); jobDetail.techId = ''; jobDetail.techName = ''; await reloadOccupancy(); if (reloadPool) await reloadPool(); $q.notify({ type: 'info', message: 'Renvoyé au pool (non assigné)', timeout: 1800 }) } 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 8–16 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 h–16 h', value: '8-16' }, { label: 'Avant-midi · 8 h–12 h', value: '8-12' }, { label: 'Après-midi · 12 h–16 h', value: '12-16' }, { label: 'Soir · 13 h–21 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 })
// chip() (pastille couleur du type de shift) déplacé dans ShiftTypesDialog — seul consommateur.

// 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 1–5} (JSON).
// Par compétence : SCORE (maîtrise 1–5, 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 8–16 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 d’arrê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 1–5 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 (17h–minuit, 8h–minuit) 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 6–19, é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 }
// Normalise les jobs du pool à l'entrée (SOURCE UNIQUE, appelé aux 3 points de chargement) :
// (1) décode les entités HTML des champs texte (adresses legacy « l&#039;église » → « l'église ») via deEnt,
// (2) cap la durée AFFICHÉE des ramassages à 5 min (le temps suit jobDur partout : pool, carte, occupation).
function capPickups (jobs) {
  return (jobs || []).map(j => {
    if (!j) return j
    const d = { ...j }
    if (d.subject) d.subject = deEnt(d.subject)
    if (d.customer_name) d.customer_name = deEnt(d.customer_name)
    if (d.address) d.address = deEnt(d.address)
    if (d.service_location) d.service_location = deEnt(d.service_location)
    if (d.legacy_detail) d.legacy_detail = deEnt(d.legacy_detail)
    return isPickup(d) ? { ...d, est_min: PICKUP_MIN } : d
  })
}
// 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 d’enregistrer 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 8–16 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) 8–16 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.reply); /* défaut SÛR : commentaire INTERNE (jamais un courriel accidentel) — « Répondre au client » = bascule explicite */ 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 }
// Types de shift → extrait dans components/planif/ShiftTypesDialog.vue (état + fonctions déplacés ; @changed → refreshTemplates).
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: 300px; max-width: 360px; 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 1–5 · 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; min-width: 132px; position: relative; background: #eef1f4; } /* gris pâle = INDISPONIBLE par défaut ; un quart crée une zone blanche (.tl-shift) */
/* Colonnes-jour à largeur lisible : ~1 semaine tient dans la vue, la 2e semaine défile (.grid-wrap overflow-x). */
.roster-grid thead th:not(.tech-col) { min-width: 132px; }
.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 !important; background: #eceff1 !important; color: #90a4ae; opacity: .6; cursor: default; } /* assistant (renfort) = créneau GRISÉ (réservé, secondaire) */
.tl-blk.tl-blk-assist { opacity: .5; filter: grayscale(0.55); border: 1px dashed rgba(120,144,176,.7) !important; } /* idem dans la grille semaine : bloc assistant grisé */
.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>
