<template>
  <q-page padding>
    <!-- Rangée 1 : actions principales (nav · Outils · Générer · Publier) -->
    <div class="row items-center q-mb-sm q-gutter-xs">
      <div class="text-h6 text-weight-bold">Planification</div>
      <q-chip v-if="dirty" dense size="sm" color="orange" text-color="white" icon="circle">{{ dirtyCount }} non publié(s)</q-chip>
      <q-chip v-if="offShiftWeekCount" dense size="sm" color="orange-8" 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="start" style="width:150px" @update:model-value="onWeekChange" />
      <q-select dense outlined v-model="days" :options="[7, 14]" style="width:76px" emit-value map-options @update:model-value="onDaysChange" />
      <q-btn dense flat round icon="undo" :disable="!history.length" @click="undo"><q-tooltip>Annuler (Ctrl+Z)</q-tooltip></q-btn>
      <q-btn dense flat round icon="redo" :disable="!future.length" @click="redo"><q-tooltip>Rétablir (Ctrl+Shift+Z)</q-tooltip></q-btn>
      <q-separator vertical class="q-mx-xs" />
      <!-- Menu OUTILS : regroupe la config secondaire pour désencombrer -->
      <q-btn-dropdown dense outline color="grey-8" icon="build" label="Outils" no-caps>
        <q-list dense style="min-width:230px">
          <q-item clickable v-close-popup @click="showDemand = !showDemand">
            <q-item-section avatar><q-icon name="tune" :color="showDemand ? 'indigo' : 'grey-7'" /></q-item-section>
            <q-item-section>Demande de personnel</q-item-section>
            <q-item-section side><q-icon v-if="showDemand" name="check" color="indigo" /></q-item-section>
          </q-item>
          <q-item clickable>
            <q-item-section avatar><q-icon name="bookmark" color="brown" /></q-item-section>
            <q-item-section>Modèles de semaine</q-item-section>
            <q-item-section side><q-icon name="chevron_right" color="grey-6" /></q-item-section>
            <q-menu anchor="top end" self="top start">
              <q-list dense style="min-width:250px">
                <q-item clickable v-close-popup @click="saveTemplate"><q-item-section avatar><q-icon name="save" /></q-item-section><q-item-section>Enregistrer la semaine…</q-item-section></q-item>
                <q-separator v-if="weekTemplates.length" />
                <q-item-label v-if="weekTemplates.length" header>Appliquer</q-item-label>
                <q-item v-for="(tm, i) in weekTemplates" :key="i" clickable @click="applyTemplate(tm)">
                  <q-item-section avatar><q-btn flat dense round size="sm" :icon="tm.default ? 'star' : 'star_border'" :color="tm.default ? 'amber-8' : 'grey-5'" @click.stop="setDefaultTemplate(i)"><q-tooltip>Modèle par défaut (★)</q-tooltip></q-btn></q-item-section>
                  <q-item-section>{{ tm.name }}</q-item-section>
                  <q-item-section side><q-btn flat dense round size="sm" icon="delete" color="grey-6" @click.stop="deleteTemplate(i)" /></q-item-section>
                </q-item>
              </q-list>
            </q-menu>
          </q-item>
          <q-item clickable v-close-popup @click="openGarde"><q-item-section avatar><q-icon name="shield" color="brown" /></q-item-section><q-item-section>Rotation de garde</q-item-section></q-item>
          <q-separator />
          <q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="speed" /></q-item-section><q-item-section>Cadence équipe</q-item-section></q-item>
          <q-item clickable v-close-popup @click="showTagManager = true"><q-item-section avatar><q-icon name="sell" color="teal" /></q-item-section><q-item-section>Gérer les compétences (tags)</q-item-section></q-item>
          <q-item clickable v-close-popup @click="openAssignPanel"><q-item-section avatar><q-icon name="drag_indicator" color="deep-purple" /></q-item-section><q-item-section>Jobs à assigner (glisser-déposer)</q-item-section></q-item>
          <q-item clickable v-close-popup @click="$router.push('/dispatch')"><q-item-section avatar><q-icon name="open_in_new" color="indigo" /></q-item-section><q-item-section>Tableau Dispatch (détails & priorités)</q-item-section></q-item>
          <q-item clickable v-close-popup @click="openLeave"><q-item-section avatar><q-icon name="beach_access" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
        </q-list>
      </q-btn-dropdown>
      <q-btn v-if="defaultTemplate" dense flat color="amber-9" icon="star" :label="defaultTemplate.name" @click="applyDefault"><q-tooltip>Appliquer le modèle par défaut (consciente des absences)</q-tooltip></q-btn>
      <q-separator vertical class="q-mx-xs" />
      <q-btn unelevated color="primary" icon="auto_awesome" label="Générer" :loading="generating" @click="doGenerate" />
      <q-checkbox v-model="notifySms" label="SMS" dense size="sm"><q-tooltip>Notifier les techs par SMS à la publication</q-tooltip></q-checkbox>
      <q-btn :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" @click="doPublish" />
      <q-btn outline color="deep-orange" icon="sync_alt" label="Publier au legacy" @click="openLegacyPush"><q-tooltip class="bg-grey-9">Réassigne les tickets aux techniciens DANS le système legacy (osTicket) selon l'assignation Ops — aperçu avant d'écrire.</q-tooltip></q-btn>
      <q-btn flat dense round icon="refresh" :loading="loading" @click="() => guard(loadWeek)" />
    </div>

    <!-- Rangée 2 : filtres + légende en popover -->
    <div class="row items-center q-gutter-sm q-mb-sm">
      <q-input dense outlined clearable v-model="search" placeholder="Rechercher un technicien…" style="width:230px"><template #prepend><q-icon name="search" /></template></q-input>
      <q-select dense outlined clearable v-model="groupFilter" :options="groupOptions" emit-value map-options label="Équipe" style="width:180px" />
      <q-input dense outlined type="number" v-model.number="maxHours" label="Max h/sem" style="width:110px" />
      <span class="text-caption text-grey-6">{{ visibleTechs.length }} / {{ techs.length }} techs</span>
      <q-chip v-if="cellClipboard.length" dense size="sm" color="indigo" text-color="white" icon="content_paste">{{ cellClipboard.length }} copié(s)</q-chip>
      <q-space />
      <q-btn dense flat round icon="help_outline" color="grey-7"><q-tooltip>Légende & raccourcis</q-tooltip>
        <q-menu>
          <div class="q-pa-md" style="max-width:420px">
            <div class="text-subtitle2 q-mb-sm">Légende</div>
            <div class="row items-center q-gutter-xs q-mb-xs"><span class="tod-leg"></span><span class="text-grey-8">dispo (matin → soir)</span></div>
            <div class="row items-center q-gutter-xs q-mb-xs"><span class="occ-leg"></span><span class="text-grey-8">occupation (vert → rouge)</span></div>
            <div class="row items-center q-gutter-xs q-mb-xs"><span class="leg-absent"></span><span class="text-grey-8">absent / congé</span></div>
            <div class="row items-center q-gutter-xs q-mb-xs"><span class="leg-garde"></span><span class="text-grey-8">garde (hors bureau)</span></div>
            <div class="row items-center q-gutter-xs q-mb-sm"><span class="free q-mr-xs">·</span><span class="text-grey-8">libre</span><span class="cell-dirty-demo q-ml-md q-mr-xs">J</span><span class="text-grey-8">modifié (non publié)</span></div>
            <div class="text-subtitle2 q-mb-xs">Raccourcis</div>
            <div class="text-caption text-grey-8"><b>glisser</b> = sélection · <b>shift+clic</b> = bloc · clic en-tête = colonne · clic nom = rangée · <b>ctrl+clic</b> = +1 · <b>ctrl+C/V</b> = copier/coller · <b>Suppr/⌫</b> = vider · <b>A</b> = absent · <b>G</b> = garde</div>
          </div>
        </q-menu>
      </q-btn>
    </div>

    <!-- Filtre par compétence (chip/tag) → n'affiche que les techs capables, triés par priorité -->
    <div v-if="allSkills.length" class="row items-center q-gutter-xs q-mb-sm">
      <q-icon name="sell" size="16px" color="teal" /><span class="text-caption text-grey-7">Compétences :</span>
      <q-chip v-for="sk in allSkills" :key="sk" clickable dense size="sm" :color="skillFilter.includes(sk) ? 'teal' : 'grey-3'" :text-color="skillFilter.includes(sk) ? 'white' : 'grey-8'" @click="toggleSkill(sk)">{{ sk }}</q-chip>
      <template v-if="skillFilter.length">
        <q-btn flat dense size="sm" color="grey-7" icon="close" label="Effacer" @click="skillFilter = []" />
        <span class="text-caption text-teal text-weight-medium">▸ {{ skillFilter.length > 1 ? 'toutes requises (ET)' : 'capables' }} · {{ visibleTechs.length }} tech(s), triés par priorité</span>
      </template>
    </div>

    <!-- Demande -->
    <q-card v-if="showDemand" flat bordered class="q-mb-md">
      <q-card-section class="q-pb-none">
        <div class="row items-center">
          <div class="text-subtitle2 text-weight-bold">Demande — effectif requis par créneau</div><q-space />
          <q-btn dense flat icon="schedule" label="Types de shift" @click="openShiftEditor" />
          <q-btn dense flat icon="add" label="Ajouter" @click="addDemand" />
          <q-btn dense unelevated color="indigo" icon="playlist_add_check" label="Appliquer à la semaine" :loading="applying" class="q-ml-sm" @click="applyDemand" />
        </div>
        <div class="text-caption text-grey-7 q-mt-xs">Coche les jours <b>fériés</b> (F) dans l'en-tête · fin de semaine = sam/dim (auto). Si <b>Durée/job</b> &gt; 0, les nombres = <b>nb de jobs</b> → effectif = ⌈jobs × durée ÷ heures du shift⌉ (compétences requises = colonne Compétences).</div>
      </q-card-section>
      <q-card-section>
        <table class="demand-tbl">
          <thead><tr><th>Modèle</th><th>Zone</th><th>Compétences</th><th>Durée/job (h)</th><th>Semaine</th><th>Fin de sem.</th><th>Férié</th><th></th></tr></thead>
          <tbody>
            <tr v-for="(d, i) in demand" :key="i">
              <td><q-select dense options-dense outlined v-model="d.shift" :options="tplOptions" emit-value map-options style="min-width:150px" @update:model-value="saveDemand" /></td>
              <td><q-input dense outlined v-model="d.zone" style="width:120px" @update:model-value="saveDemand" /></td>
              <td><SkillSelect v-model="d.skills" style="min-width:150px" @update:model-value="saveDemand" /></td>
              <td><q-input dense outlined type="number" step="0.5" v-model.number="d.job_h" placeholder="0" style="width:80px" @update:model-value="saveDemand" /></td>
              <td><q-input dense outlined type="number" v-model.number="d.weekday" style="width:70px" @update:model-value="saveDemand" /></td>
              <td><q-input dense outlined type="number" v-model.number="d.weekend" style="width:70px" @update:model-value="saveDemand" /></td>
              <td><q-input dense outlined type="number" v-model.number="d.holiday" style="width:70px" @update:model-value="saveDemand" /></td>
              <td><q-btn flat dense round size="sm" icon="delete" color="grey-7" @click="removeDemand(i)" /></td>
            </tr>
            <tr v-if="!demand.length"><td colspan="8" class="text-grey-6 q-pa-sm">Aucune ligne — clique « Ajouter ».</td></tr>
          </tbody>
        </table>
      </q-card-section>
    </q-card>

    <q-banner v-if="solverStats" dense rounded class="q-mb-md" :class="solverStats.shortfall ? 'bg-orange-1 text-orange-9' : '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="red-6" 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>


    <div class="grid-wrap">
      <table class="roster-grid">
        <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 class="hol-toggle" :class="{ on: isHoliday(d.iso) }" @click.stop="toggleHoliday(d.iso)"><q-tooltip>Marquer férié</q-tooltip>F</div>
            </th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="(t, ti) in visibleTechs" :key="t.id" :class="{ paused: isPaused(t), 'res-hidden': isHidden(t.id) }">
            <td class="tech-col">
              <div class="tech-row">
                <q-btn flat round dense size="9px" :icon="isRowSelected(ti) ? 'check_box' : 'check_box_outline_blank'" :color="isRowSelected(ti) ? 'primary' : 'grey-5'" @click.stop="maybeSelectRow(ti)"><q-tooltip>Sélectionner la rangée</q-tooltip></q-btn>
                <q-btn flat round dense size="9px" :icon="isPaused(t) ? 'play_arrow' : 'pause'" :color="isPaused(t) ? 'grey' : 'primary'" @click.stop="togglePause(t)"><q-tooltip>{{ isPaused(t) ? 'Réactiver' : 'Pause' }}</q-tooltip></q-btn>
                <q-badge v-if="techRank(t)" color="teal">{{ techRank(t) }}<q-tooltip>Priorité {{ techRank(t) }} — combine maîtrise (niveau) + vitesse (efficacité) + coût.</q-tooltip></q-badge>
                <!-- Clic sur la RESSOURCE (nom ou chips) → propriétés de l'employé (compétences, horaire) -->
                <span class="tech-name clk" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Compétences & horaire de {{ t.name }}</q-tooltip>{{ t.name }}</span>
                <q-icon v-if="roleIcon(t)" :name="roleIcon(t)" size="15px" class="role-ic"><q-tooltip class="bg-grey-9">{{ roleLabel(t) }}</q-tooltip></q-icon>
                <span v-if="t.group" class="grp">{{ t.group }}</span>
                <span v-if="hoursOf(t.id)" class="th" :class="hoursOf(t.id) > maxHours ? 'text-red text-weight-bold' : 'text-grey-6'">{{ hoursOf(t.id) }}h<q-icon v-if="hoursOf(t.id) > maxHours" name="warning" color="red" size="12px" /></span>
                <q-btn flat dense round size="9px" icon="timeline" color="indigo-5" @click.stop="openTimeline(t)"><q-tooltip>Timeline dispatch de {{ t.name }} — jobs de la semaine & priorités</q-tooltip></q-btn>
                <div class="tech-skills clk" @click.stop="openSkillEditor(t, $event)">
                  <span v-for="sk in (t.skills || [])" :key="sk" class="skill-chip" :style="{ background: getTagColor(sk) }">{{ sk }}<span v-if="(t.skill_levels || {})[sk] || (t.skill_eff || {})[sk]" class="chip-lvl" :style="{ background: skillEffColor(t, sk) }"><q-tooltip class="bg-grey-9">Niveau {{ (t.skill_levels || {})[sk] || '—' }} · {{ effSuffix(skillEffOf(t, sk)) }}</q-tooltip>{{ (t.skill_levels || {})[sk] || '·' }}</span></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), '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)">
                <div class="tl">
                  <div v-for="(b, bi) in cellBands(t.id, d.iso)" :key="'b' + bi" class="tl-shift tl-shift-click" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }" @click.stop="openDayFromCell(t, d)"><q-tooltip class="bg-grey-9" :delay="500" style="font-size:11px">{{ b.oncall ? 'Garde — voir le jour' : 'Quart publié — voir les jobs / la tournée' }}</q-tooltip></div>
                  <div v-for="(b, bi) in cellBlocks(t.id, d.iso)" :key="'j' + bi" class="tl-blk tl-blk-click" :style="blockStyle(b, cellPct(t.id, d.iso))" @click.stop="openDayFromCell(t, d)" @mousedown.stop><q-tooltip class="bg-grey-9" :delay="400" style="font-size:11px">Clic = voir la tournée / les jobs</q-tooltip></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>
                  <q-tooltip class="bg-grey-9" :offset="[0, 6]" max-width="320px">
                    <div class="text-weight-medium">{{ cellTip(t.id, d.iso) }}</div>
                    <div class="text-blue-3" style="font-size:10px">Clic sur le quart / les jobs = tournée · clic sur le fond = horaire · glisser = sélection</div>
                    <template v-if="cellJobs(t.id, d.iso).length">
                      <div class="text-amber-4 q-mt-xs" style="font-size:11px">{{ cellJobs(t.id, d.iso).length }} job(s) · par priorité</div>
                      <div v-for="j in cellJobs(t.id, d.iso)" :key="j.name" class="row items-center no-wrap" style="gap:5px;font-size:11px;line-height:1.5">
                        <span :style="{ display:'inline-block', width:'7px', height:'7px', borderRadius:'50%', background: prioColor(j.priority), flex:'0 0 auto' }"></span>
                        <span v-if="j.start" class="text-grey-4" style="flex:0 0 auto">{{ j.start }}</span>
                        <span class="ellipsis">{{ j.subject }}</span><span v-if="j.customer" class="text-grey-5" style="flex:0 0 auto">· {{ j.customer }}</span>
                      </div>
                      <div class="text-grey-5 q-mt-xs" style="font-size:10px">Outils › Tableau Dispatch pour le détail complet</div>
                    </template>
                  </q-tooltip>
                </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="orange-8" />{{ 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>
            </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>
        <tfoot>
          <tr class="sum"><td class="tech-col">👥 Effectif</td><td v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend }">{{ stat(d.iso).staff || '' }}</td></tr>
          <tr class="sum"><td class="tech-col">⏱ Heures</td><td v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend }">{{ stat(d.iso).hours || '' }}</td></tr>
          <tr v-if="hasOnCall" class="sum oncall-row"><td class="tech-col">🛡️ Garde</td><td v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend }">{{ stat(d.iso).on_call || '' }}</td></tr>
          <tr class="sum"><td class="tech-col">🎫 Tickets</td><td v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend }">{{ stat(d.iso).tickets || '' }}</td></tr>
          <tr v-if="weekCost" class="sum"><td class="tech-col">💲 Coût ({{ Math.round(weekCost) }} $/sem)</td><td v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend }">{{ dayCost(d.iso) || '' }}</td></tr>
        </tfoot>
      </table>
    </div>

    <!-- Matrice « Couverture — dispo vs requis » retirée : le besoin = toutes les spécialités chaque jour (règle simple, à automatiser via AI plus tard). -->

    <q-dialog v-model="showShiftEditor">
      <q-card style="min-width:580px">
        <q-card-section class="row items-center q-pb-none">
          <div class="text-subtitle1 text-weight-bold">Types de shift</div><q-space />
          <q-btn flat round dense icon="close" v-close-popup />
        </q-card-section>
        <q-card-section>
          <table class="demand-tbl">
            <thead><tr><th>Nom</th><th>Début</th><th>Fin</th><th>Heures</th><th>Couleur</th><th>🛡️ Garde</th><th></th></tr></thead>
            <tbody>
              <tr v-for="t in editTpls" :key="t.name">
                <td><span class="code-chip" :style="chip(t.color)">{{ (t.template_name||'?')[0].toUpperCase() }}</span> {{ t.template_name }}</td>
                <td><q-input dense outlined type="time" v-model="t.start" style="width:115px" /></td>
                <td><q-input dense outlined type="time" v-model="t.end" style="width:115px" /></td>
                <td class="text-center text-weight-medium">{{ calcHours(t.start, t.end) }} h</td>
                <td><input type="color" v-model="t.color" style="width:36px;height:26px;border:none;background:none" /></td>
                <td class="text-center"><q-toggle dense v-model="t.on_call" :true-value="1" :false-value="0" color="brown"><q-tooltip>Quart de garde (urgences) — non offert au booking</q-tooltip></q-toggle></td>
                <td><q-btn flat dense round size="sm" icon="save" color="primary" @click="saveShiftTpl(t)"><q-tooltip>Enregistrer</q-tooltip></q-btn><q-btn flat dense round size="sm" icon="delete" color="grey-7" @click="delShiftTpl(t)" /></td>
              </tr>
            </tbody>
          </table>
          <q-separator class="q-my-md" />
          <div class="row items-center q-gutter-sm">
            <q-input dense outlined v-model="newTpl.template_name" label="Nom (auto si vide)" style="width:150px" />
            <div style="width:230px;padding:0 8px">
              <q-range v-model="newTplRange" :min="0" :max="24" :step="0.5" snap label :left-label-value="fmtH(newTplRange.min) + 'h'" :right-label-value="fmtH(newTplRange.max) + 'h'" color="primary" />
            </div>
            <span class="text-caption text-grey-7 text-weight-medium">{{ fmtH(newTplRange.min) }}h–{{ fmtH(newTplRange.max) }}h · {{ calcHours(newTpl.start, newTpl.end) }} h</span>
            <input type="color" v-model="newTpl.color" style="width:36px;height:26px;border:none;background:none" />
            <q-toggle dense v-model="newTpl.on_call" :true-value="1" :false-value="0" label="🛡️ Garde" color="brown" />
            <q-btn dense unelevated color="primary" icon="add" label="Ajouter" @click="addShiftTpl" />
          </div>
        </q-card-section>
      </q-card>
    </q-dialog>

    <q-dialog v-model="showLeave">
      <q-card style="min-width:680px">
        <q-card-section class="row items-center q-pb-none">
          <div class="text-subtitle1 text-weight-bold">Congés &amp; disponibilités</div><q-space />
          <q-select dense outlined v-model="leaveFilter" :options="['Demandé', 'Approuvé', 'Refusé', '']" style="width:130px" @update:model-value="loadLeave" />
          <q-btn flat round dense icon="close" v-close-popup class="q-ml-sm" />
        </q-card-section>
        <q-card-section>
          <table class="demand-tbl" style="width:100%">
            <thead><tr><th>Technicien</th><th>Type</th><th>Du</th><th>Au</th><th>Motif</th><th>Statut</th><th></th></tr></thead>
            <tbody>
              <tr v-for="l in leaveRows" :key="l.name">
                <td>{{ l.technician_name || l.technician }}</td><td>{{ l.availability_type }}</td><td>{{ l.from_date }}</td><td>{{ l.to_date }}</td><td>{{ l.reason }}</td>
                <td><q-badge :color="l.status === 'Approuvé' ? 'green' : l.status === 'Refusé' ? 'red' : 'orange'">{{ l.status }}</q-badge></td>
                <td><template v-if="l.status === 'Demandé'"><q-btn flat dense round size="sm" icon="check" color="green" @click="approveLeave(l, false)" /><q-btn flat dense round size="sm" icon="close" color="red" @click="approveLeave(l, true)" /></template></td>
              </tr>
              <tr v-if="!leaveRows.length"><td colspan="7" class="text-grey-6 q-pa-sm">Aucune demande.</td></tr>
            </tbody>
          </table>
          <q-separator class="q-my-md" />
          <div class="text-caption text-weight-bold q-mb-xs">Nouvelle demande</div>
          <div class="row items-center q-gutter-sm">
            <TechSelect v-model="newLeave.technician" :options="techOptions" label="Technicien" style="width:200px" />
            <q-select dense outlined v-model="newLeave.availability_type" :options="['Congé', 'Pause', 'Indisponible', 'Maladie']" label="Type" style="width:130px" />
            <q-input dense outlined type="date" v-model="newLeave.from_date" label="Du" style="width:140px" />
            <q-input dense outlined type="date" v-model="newLeave.to_date" label="Au" style="width:140px" />
            <q-input dense outlined v-model="newLeave.reason" label="Motif" style="width:150px" />
            <q-toggle dense v-model="newLeave.long_term" :true-value="1" :false-value="0" label="Longue durée"><q-tooltip>Maternité, invalidité… → à remplacer (pas juste sauter comme des vacances)</q-tooltip></q-toggle>
            <q-btn dense unelevated color="primary" icon="add" label="Créer" @click="createLeave" />
          </div>
          <div class="text-caption text-grey-7 q-mt-sm">Une demande <b>approuvée</b> rend le tech indisponible pour le solveur sur ces dates.</div>
        </q-card-section>
      </q-card>
    </q-dialog>

    <q-dialog v-model="showTeamEditor">
      <q-card style="min-width:900px">
        <q-card-section class="row items-center q-pb-none"><div class="text-subtitle1 text-weight-bold">Équipe — cadence &amp; coût</div><q-space /><q-btn flat round dense icon="close" v-close-popup /></q-card-section>
        <q-card-section>
          <div class="text-caption text-grey-7 q-mb-sm">Cadence : 1.00 normal · 1.10 = +10 % (plus lent) · 0.90 = −10 % (plus rapide). Coût chargé/h = salaire × (1 + charges %) + autres (véhicule, outils, frais). Le solveur préfère les techs rapides et moins coûteux.</div>
          <div style="max-height:55vh;overflow:auto">
            <table class="demand-tbl" style="width:100%">
              <thead><tr><th>Technicien</th><th>Compétences</th><th>Cadence</th><th>Salaire/h</th><th>Charges %</th><th>Autres/h</th><th>Coût chargé/h</th></tr></thead>
              <tbody>
                <tr v-for="t in editTechs" :key="t.id">
                  <td>{{ t.name }}<span v-if="t.group" class="grp">{{ t.group }}</span></td>
                  <td><SkillSelect v-model="t.skills" style="min-width:160px" @update:model-value="saveSkills(t)" /></td>
                  <td><q-input dense outlined type="number" step="0.05" v-model.number="t.efficiency" style="width:80px" @blur="saveEff(t)" /></td>
                  <td><q-input dense outlined type="number" step="0.5" v-model.number="t.salary" style="width:80px" @blur="saveCost(t)" /></td>
                  <td><q-input dense outlined type="number" step="1" v-model.number="t.charges" style="width:80px" @blur="saveCost(t)" /></td>
                  <td><q-input dense outlined type="number" step="0.5" v-model.number="t.other" style="width:80px" @blur="saveCost(t)" /></td>
                  <td class="text-weight-bold text-center">{{ loadedCost(t) }} $</td>
                </tr>
              </tbody>
            </table>
          </div>
        </q-card-section>
      </q-card>
    </q-dialog>

    <!-- Rotation de garde par département -->
    <q-dialog v-model="showGarde">
      <q-card style="min-width:680px;max-width:760px">
        <q-card-section class="row items-center q-pb-none">
          <div class="text-subtitle1 text-weight-bold">🛡️ Rotation de garde (par département)</div><q-space />
          <q-btn flat round dense icon="close" v-close-popup />
        </q-card-section>
        <q-card-section class="q-gutter-y-md">
          <!-- Règles actives -->
          <div v-if="gardeRules.length">
            <div class="text-caption text-weight-bold text-grey-7 q-mb-xs">Règles actives</div>
            <q-list dense bordered class="rounded-borders">
              <q-item v-for="(r, i) in gardeRules" :key="r.id">
                <q-item-section>
                  <q-item-label class="text-weight-medium">{{ r.dept }} · {{ shiftName(r.shift) }}<span v-if="r.shiftWeekend"> · WE : {{ shiftName(r.shiftWeekend) }}</span></q-item-label>
                  <q-item-label caption>{{ gardeDowLabel(r) }} · dès {{ (r.anchor || '').slice(5) }} · {{ gardeSeqLabel(r) }}</q-item-label>
                </q-item-section>
                <q-item-section side class="row no-wrap">
                  <q-btn flat dense round size="sm" icon="edit" color="primary" @click="editGardeRule(r)"><q-tooltip>Modifier (ordre, techs, période)</q-tooltip></q-btn>
                  <q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="removeGardeRule(i)" />
                </q-item-section>
              </q-item>
            </q-list>
          </div>

          <!-- Éditeur de règle (sous-panneau) -->
          <div class="garde-editor q-pa-md rounded-borders">
            <div class="text-subtitle2 text-weight-bold q-mb-md">{{ editingGardeId ? '✏️ Modifier la règle' : '➕ Nouvelle règle' }}</div>

            <!-- Section 1 : Quand & quel quart -->
            <div class="text-caption text-weight-bold text-brown-7 q-mb-sm">1 · Quand &amp; quel quart</div>
            <div class="row q-col-gutter-sm">
              <div class="col-12 col-sm-6"><q-select dense outlined class="full-width" v-model="newGardeRule.dept" :options="groupNames" use-input fill-input hide-selected new-value-mode="add-unique" input-debounce="0" label="Département (optionnel)" /></div>
              <div class="col-12 col-sm-6"><q-input dense outlined class="full-width" type="date" v-model="newGardeRule.anchor" label="Rotation démarre la semaine du" /></div>
              <div class="col-12 col-sm-6"><q-select dense outlined class="full-width" v-model="newGardeRule.shift" :options="gardeTemplateOptions" emit-value map-options label="Quart en semaine (soir)" /></div>
              <div class="col-12 col-sm-6"><q-select dense outlined class="full-width" clearable v-model="newGardeRule.shiftWeekend" :options="gardeTemplateOptions" emit-value map-options label="Quart fin de semaine" hint="sinon = quart de semaine" /></div>
            </div>
            <div class="q-mt-sm">
              <div class="text-caption text-grey-7 q-mb-xs">Jours couverts (hors bureau) :</div>
              <div class="row items-center q-gutter-xs">
                <q-btn :outline="!isSetActive(WD_SEMAINE)" :unelevated="isSetActive(WD_SEMAINE)" dense size="sm" color="brown" no-caps label="Soirs de semaine" @click="toggleWeekdaysSet(WD_SEMAINE)" />
                <q-btn :outline="!isSetActive(WD_FINSEM)" :unelevated="isSetActive(WD_FINSEM)" dense size="sm" color="brown" no-caps label="Fin de semaine" @click="toggleWeekdaysSet(WD_FINSEM)" />
                <span class="text-grey-5 q-mx-xs">·</span>
                <q-chip v-for="dw in GARDE_DOW" :key="dw.v" clickable dense size="sm" :color="newGardeRule.weekdays.includes(dw.v) ? 'brown' : 'grey-4'" :text-color="newGardeRule.weekdays.includes(dw.v) ? 'white' : 'grey-8'" @click="toggleGardeDow(dw.v)">{{ dw.l }}</q-chip>
              </div>
            </div>

            <q-separator class="q-my-md" />

            <!-- Section 2 : Séquence de rotation -->
            <div class="text-caption text-weight-bold text-brown-7 q-mb-sm">2 · Séquence de rotation</div>
            <div class="row items-center q-gutter-sm">
              <q-select dense outlined v-model="gardePick" :options="techOptions" emit-value map-options label="Ajouter un tech à la suite" style="min-width:240px" class="col" />
              <q-btn dense unelevated color="brown" icon="add" label="Ajouter" :disable="!gardePick" @click="addTechToSeq" />
            </div>
            <div v-if="newGardeRule.steps.length" class="q-mt-sm">
              <div v-for="(s, i) in newGardeRule.steps" :key="i" class="row items-center no-wrap q-gutter-xs q-mb-xs">
                <span class="text-caption text-weight-bold text-brown-7" style="min-width:22px">{{ i + 1 }}.</span>
                <q-select dense outlined options-dense v-model="s.tech" :options="techOptions" emit-value map-options class="col" />
                <q-input dense outlined type="number" min="1" v-model.number="s.weeks" style="width:88px" suffix="sem." />
                <q-btn flat dense round size="xs" icon="arrow_upward" :disable="i === 0" @click="moveTech(i, -1)"><q-tooltip>Monter</q-tooltip></q-btn>
                <q-btn flat dense round size="xs" icon="arrow_downward" :disable="i === newGardeRule.steps.length - 1" @click="moveTech(i, 1)"><q-tooltip>Descendre</q-tooltip></q-btn>
                <q-btn flat dense round size="xs" icon="close" color="grey-6" @click="newGardeRule.steps.splice(i, 1)"><q-tooltip>Retirer</q-tooltip></q-btn>
              </div>
              <div class="text-caption text-grey-6">« sem. » = semaines consécutives. Pour <b>N semaines d'écart</b> entre 2 tours d'un tech, mets <b>N+1 étapes</b> (ex. A→B→C = 2 sem. d'écart).</div>
            </div>
            <div v-else class="text-caption text-grey-6 q-mt-xs">Ajoute les techs dans l'ordre de passage (doublons permis pour des tours inégaux).</div>
            <div v-if="gardePreview.length" class="q-mt-sm bg-brown-1 rounded-borders q-pa-sm">
              <div class="text-caption text-weight-medium text-brown-9">Aperçu — qui est de garde :</div>
              <div class="row q-gutter-xs q-mt-xs">
                <q-chip v-for="(p, i) in gardePreview" :key="i" dense size="sm" color="white" text-color="brown-9">{{ p.week.slice(8) }}/{{ p.week.slice(5, 7) }} → {{ p.name }}</q-chip>
              </div>
            </div>

            <div class="row items-center q-mt-md">
              <q-btn dense unelevated color="brown" :icon="editingGardeId ? 'save' : 'add'" :label="editingGardeId ? 'Mettre à jour la règle' : 'Enregistrer la règle'" @click="addGardeRule" />
              <q-btn v-if="editingGardeId" flat dense class="q-ml-xs" label="Annuler" @click="editingGardeId = null; newGardeRule.steps = []; newGardeRule.weekdays = []" />
            </div>
          </div>

          <!-- Section 3 : Publication -->
          <div class="row items-center q-gutter-sm">
            <div class="text-caption text-grey-6 col">La grille montre la garde <b>en direct</b> (calque). « Publier » la matérialise sur l'horizon (remplacement propre) pour dispatch &amp; les techs. Vacances ⇒ substitut auto.</div>
            <q-select dense outlined v-model="gardeHorizon" :options="[4, 8, 12, 26]" emit-value map-options style="width:96px" label="semaines" />
            <q-btn dense unelevated color="primary" icon="cloud_upload" label="Publier la garde" @click="applyGardeRules" />
          </div>
        </q-card-section>
      </q-card>
    </q-dialog>

    <!-- Éditeur de compétences (modal, comme Dispatch) : overflow visible → dropdown + niveau/couleur jamais clippés -->
    <!-- Éditeur compétences : POPOVER ancré au clic (sur la rangée), liste déjà ouverte (autofocus) -->
    <q-menu v-model="skillMenuShown" :target="skillMenuTarget" anchor="bottom left" self="top left" no-focus max-height="80vh">
      <div v-if="skillDialog" class="q-pa-sm" style="width:368px;min-height:300px" @click.stop @mousedown.stop>
        <div class="row items-center q-mb-xs"><div class="text-subtitle2 text-weight-bold col ellipsis">🏷 {{ skillDialog.name }}</div><q-btn flat dense round size="sm" icon="close" v-close-popup /></div>
        <TagEditor :model-value="skillDialog.skills" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" autofocus placeholder="Chercher ou créer une compétence…"
          @update:model-value="items => onTagsChange(skillDialog, items)"
          @create="onCreateRosterTag" />
        <div v-if="(skillDialog.skills || []).length" class="q-mt-md">
          <div class="row items-center text-caption text-grey-6 q-pb-xs">
            <div class="col">Compétence</div>
            <div style="width:90px" class="text-center">Score</div>
            <div style="width:88px" class="text-center">Efficacité</div>
          </div>
          <div v-for="sk in skillDialog.skills" :key="sk" class="row items-center no-wrap q-py-xs" style="border-top:1px solid #eee">
            <div class="col row items-center no-wrap">
              <q-btn flat dense round size="xs" icon="circle" :style="{ color: getTagColor(sk) }"><q-tooltip>Couleur</q-tooltip>
                <q-menu><div class="q-pa-xs" style="width:208px">
                  <div class="row">
                    <q-btn v-for="c in TAG_PALETTE" :key="c" v-close-popup flat dense round size="xs" icon="circle" :style="{ color: c }" @click="onUpdateRosterTag({ name: sk, color: c })" />
                  </div>
                  <div class="row items-center no-wrap q-mt-xs q-px-xs">
                    <span class="text-caption text-grey-7 q-mr-sm">Perso</span>
                    <input type="color" :value="getTagColor(sk)" @change="e => onUpdateRosterTag({ name: sk, color: e.target.value })" style="width:42px;height:26px;border:1px solid #ddd;border-radius:4px;background:none;cursor:pointer;padding:0" />
                    <span class="text-caption text-grey-5 q-ml-sm">toute couleur</span>
                  </div>
                </div></q-menu>
              </q-btn>
              <span class="skill-chip" :style="{ background: getTagColor(sk) }">{{ sk }}</span>
            </div>
            <div style="width:90px" class="text-center no-wrap">
              <q-icon v-for="n in 5" :key="n" :name="skillLevelOf(skillDialog, sk) >= n ? 'star' : 'star_outline'" :color="skillLevelOf(skillDialog, sk) >= n ? 'indigo' : 'grey-4'" size="16px" class="cursor-pointer" @click="setSkillLevel(skillDialog, sk, skillLevelOf(skillDialog, sk) === n ? 0 : n)" />
            </div>
            <div style="width:88px">
              <q-input dense outlined type="number" step="5" debounce="600" :model-value="skillEffPct(skillDialog, sk)" @update:model-value="v => setSkillEffPct(skillDialog, sk, v)" suffix="%" placeholder="glob." input-class="text-right" />
            </div>
          </div>
          <div class="text-caption text-grey-6 q-mt-xs"><b>Score</b> ★ = maîtrise · <b>Efficacité</b> : <b>+</b> plus vite / <b>−</b> plus lent pour CETTE compétence (ex. <b>+80</b>), vide = globale · × sur le chip = retirer.</div>
        </div>
        <q-separator class="q-my-sm" />
        <div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🗓 Horaire — semaine affichée</div>
        <div class="row q-gutter-xs">
          <q-btn dense outline size="sm" no-caps color="primary" label="5×8h (L–V)" @click="applyWeekPreset(skillDialog, [1,2,3,4,5], 8, 16)" />
          <q-btn dense outline size="sm" no-caps color="primary" label="4×10h (L–J)" @click="applyWeekPreset(skillDialog, [1,2,3,4], 7, 17)" />
          <q-btn dense outline size="sm" no-caps color="primary" label="3×12h (L–M)" @click="applyWeekPreset(skillDialog, [1,2,3], 7, 19)" />
        </div>
      </div>
    </q-menu>

    <!-- Impact d'un retrait de compétence : jobs assignés devenus invalides → redistribuer -->
    <q-dialog v-model="skillImpactOpen">
      <q-card style="min-width:420px;max-width:520px" v-if="skillImpactDialog">
        <q-card-section class="row items-center q-pb-none">
          <q-icon name="warning" color="orange" size="22px" class="q-mr-sm" />
          <div class="text-subtitle1 text-weight-bold col">{{ skillImpactDialog.kind === 'absence' ? 'Absence — impact' : 'Compétence retirée — impact' }}</div>
          <q-btn flat round dense icon="close" v-close-popup />
        </q-card-section>
        <q-card-section>
          <div v-if="skillImpactDialog.kind === 'absence'" class="text-body2 q-mb-sm"><b>{{ skillImpactDialog.jobs.length }}</b> job(s) assigné(s) à <b>{{ skillImpactDialog.tech.name }}</b> tombent sur son absence — à redistribuer.</div>
          <div v-else class="text-body2 q-mb-sm"><b>{{ skillImpactDialog.jobs.length }}</b> job(s) assigné(s) à <b>{{ skillImpactDialog.tech.name }}</b> exigent « <b>{{ skillImpactDialog.skill }}</b> », qu'on vient de retirer — il/elle ne peut plus les faire.</div>
          <q-list dense bordered class="rounded-borders" style="max-height:320px;overflow:auto">
            <q-item v-for="j in skillImpactDialog.jobs" :key="j.name" class="q-py-sm">
              <q-item-section>
                <q-item-label>{{ j.service_type || 'Job' }}{{ j.customer_name ? ' — ' + j.customer_name : '' }}</q-item-label>
                <q-item-label caption><q-icon name="event" size="12px" /> {{ j.scheduled_date ? j.scheduled_date.slice(5) : '—' }} {{ j.start_time ? j.start_time.slice(0, 5) : '' }} · {{ j.location_label || j.service_location || '—' }}</q-item-label>
              </q-item-section>
              <q-item-section side style="min-width:188px">
                <q-select dense outlined options-dense :model-value="impactPlan[j.name]" :options="candidateOptions(j.name)" emit-value map-options :loading="loadingCandidates" label="Réassigner à" @update:model-value="v => { impactPlan[j.name] = v }" />
              </q-item-section>
            </q-item>
          </q-list>
          <div class="text-caption text-grey-6 q-mt-sm">Choix <b>classés</b> = techs qualifiés <b>libres au même créneau</b> (le 1er = suggéré). « À recontacter » → file RDV (créneaux filtrés, SMS client possible).</div>
        </q-card-section>
        <q-card-actions align="right">
          <q-btn flat label="Ignorer" v-close-popup :disable="redistributing" />
          <q-btn outline color="grey-8" no-caps label="Tout à recontacter" :loading="redistributing" @click="doRedistribute('requeue')" />
          <q-btn unelevated color="primary" icon="check" no-caps label="Appliquer" :loading="redistributing" @click="applyImpactPlan" />
        </q-card-actions>
      </q-card>
    </q-dialog>

    <!-- Gestionnaire global des compétences (tags) : renommer / recolorer / supprimer partout -->
    <q-dialog v-model="showTagManager">
      <q-card style="min-width:420px;max-width:540px">
        <q-card-section class="row items-center q-pb-none">
          <div class="text-subtitle1 text-weight-bold col">🏷 Gérer les compétences</div>
          <q-btn flat round dense icon="close" v-close-popup />
        </q-card-section>
        <q-card-section style="max-height:62vh;overflow:auto">
          <div v-if="!managedTags.length" class="text-grey-6">Aucune compétence — ajoute-en via un employé.</div>
          <q-list v-else dense separator>
            <q-item v-for="tg in managedTags" :key="tg.label">
              <q-item-section avatar>
                <q-btn flat dense round size="sm" icon="circle" :style="{ color: tg.color }"><q-tooltip>Couleur</q-tooltip>
                  <q-menu><div class="q-pa-xs" style="width:208px">
                    <div class="row"><q-btn v-for="c in TAG_PALETTE" :key="c" v-close-popup flat dense round size="xs" icon="circle" :style="{ color: c }" @click="onUpdateRosterTag({ name: tg.label, color: c })" /></div>
                    <div class="row items-center no-wrap q-mt-xs q-px-xs"><span class="text-caption text-grey-7 q-mr-sm">Perso</span><input type="color" :value="tg.color" @change="e => onUpdateRosterTag({ name: tg.label, color: e.target.value })" style="width:42px;height:26px;border:1px solid #ddd;border-radius:4px;background:none;cursor:pointer;padding:0" /></div>
                  </div></q-menu>
                </q-btn>
              </q-item-section>
              <q-item-section>
                <q-item-label class="cursor-pointer">{{ tg.label }}
                  <q-popup-edit :model-value="tg.label" auto-save v-slot="scope" @save="v => renameTagGlobal(tg.label, v)">
                    <q-input dense autofocus :model-value="scope.value" @update:model-value="scope.value = $event" label="Renommer (partout)" @keyup.enter="scope.set" />
                  </q-popup-edit>
                </q-item-label>
                <q-item-label caption>{{ tg.count }} technicien(s) · clic = renommer</q-item-label>
              </q-item-section>
              <q-item-section side>
                <q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="deleteTagGlobal(tg)"><q-tooltip>Supprimer partout</q-tooltip></q-btn>
              </q-item-section>
            </q-item>
          </q-list>
          <div class="text-caption text-grey-6 q-mt-sm">Renommer/​supprimer s'applique à <b>tous les techniciens</b> qui ont la compétence. Recolorer change la couleur partout.</div>
        </q-card-section>
      </q-card>
    </q-dialog>

    <!-- Panneau FLOTTANT déplaçable : jobs à assigner (groupes parent-enfant) → glisser sur une case (tech × jour) -->
    <div v-if="assignPanel.open" class="assign-panel" :style="{ left: assignPanel.x + 'px', top: assignPanel.y + 'px', width: assignPanel.w + 'px', height: assignPanel.h + 'px', maxHeight: '92vh' }">
      <div class="assign-hdr" @mousedown="panelHeaderDown">
        <q-icon name="drag_indicator" size="18px" /><span>Jobs à assigner ({{ assignTypeFilter.length ? assignJobsFiltered.length + '/' + assignPanel.jobs.length : assignPanel.jobs.length }})</span><q-space />
        <q-btn 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>
      </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>
      <!-- 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>
        <div class="assign-map-cap">📍 Pins colorés par <b>compétence</b> · <b>lettre</b> = repère (même lettre dans la liste) · clic = sélectionne le(s) job(s)<span v-if="assignNoCoord" class="text-deep-orange-7"> · ⚠ {{ 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">{{ grp.label }} <span style="opacity:.6">({{ grp.jobs.length }})</span></div>
          <div v-if="assignSort === 'group' && grp.jobs.length > 1" class="assign-grp-hdr" @click="toggleGroupSel(grp)"><q-icon name="account_tree" size="12px" /> Groupe ({{ grp.jobs.length }}) — tout sélectionner (terrain)</div>
          <div v-for="(j, idx) in grp.jobs" :key="j.name" :data-jobname="j.name" class="assign-job" :class="{ blocked: j.status === 'On Hold', child: assignSort === 'group' && grp.jobs.length > 1 && idx > 0, sel: !!selectedJobs[j.name], dragging: draggingSet.has(j.name), 'job-flash': flashJob === j.name }" :style="{ borderLeft: '5px solid ' + panelJobColor(j) }" draggable="true" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
            <div class="row items-center no-wrap">
              <q-checkbox dense size="xs" :model-value="!!selectedJobs[j.name]" @update:model-value="selectedJobs[j.name] = $event" @click.stop @mousedown.stop class="q-mr-xs" />
              <q-icon :name="jobIsOnsite(j) ? 'home_repair_service' : 'cloud'" size="13px" :color="jobIsOnsite(j) ? 'teal' : 'grey-5'" class="q-mr-xs"><q-tooltip>{{ jobIsOnsite(j) ? 'Sur site (terrain)' : 'À distance / netadmin — pas pour un tech terrain' }}</q-tooltip></q-icon>
              <span v-if="groupLabel(j)" class="assign-grp-badge q-mr-xs"><q-tooltip class="bg-grey-9">Même adresse de service (groupe {{ groupLabel(j)[0] }}) — simple repère de proximité</q-tooltip>{{ groupLabel(j) }}</span>
              <q-badge v-if="j.step_order" color="indigo" class="q-mr-xs">{{ j.step_order }}</q-badge>
              <span class="ellipsis text-weight-medium" :style="assignPanel.showMap ? 'cursor:pointer' : ''" @click="assignPanel.showMap && focusAssignJob(j)">{{ j.subject || j.service_type || j.name }}<q-tooltip v-if="j.legacy_detail" max-width="380px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.legacy_detail }}</q-tooltip></span>
              <q-space />
              <q-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>
              {{ j.customer_name || j.location_label || j.service_location || '' }}<span v-if="j.depends_on"> · après {{ j.depends_on }}</span> · {{ j.duration_h || 1 }}h<span v-if="j.scheduled_date" :class="isOverdue(j.scheduled_date) ? 'text-deep-orange-7 text-weight-bold' : 'text-grey-7'"> · 📅 {{ fmtDueLabel(j.scheduled_date) }}</span>
            </div>
            <div v-if="j._showThread" class="assign-thread" @mousedown.stop @click.stop>
              <div v-if="j._thread && j._thread.loading" class="text-grey-6" style="font-size:11px"><q-spinner size="12px" class="q-mr-xs" />Chargement du fil…</div>
              <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>
        </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> — glisse sur un tech<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>

    <!-- 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-blue-9 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-deep-orange-8">{{ 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-deep-orange-7"> · {{ 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-deep-orange-8">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-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>

    <!-- 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-deep-orange-7': day.weekend }">{{ day.label }}</div>
              <q-badge v-if="day.offShift" color="orange-8" class="q-ml-sm"><q-icon name="warning" size="11px" class="q-mr-xs" />hors quart publié</q-badge>
              <q-space />
              <q-badge v-if="day.pct != null" text-color="white" :style="{ background: occColor(day.pct) }">{{ day.usedH }}h · {{ day.pct }}%</q-badge>
              <q-badge v-else color="grey-5" class="q-ml-xs">{{ day.usedH }}h</q-badge>
            </div>
            <div v-if="!day.offShift" class="tldlg-bar">
              <div v-for="(b, bi) in day.bands" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
              <div v-for="(b, bi) in day.blocks" :key="'k' + bi" class="tl-blk" :style="blockStyle(b, day.pct)"></div>
              <span v-for="tk in axisTicks" :key="'t' + tk.h" class="tldlg-tick" :style="{ left: tk.left }">{{ tk.h }}</span>
            </div>
            <div v-for="j in day.jobs" :key="j.name" class="tldlg-job">
              <span class="tldlg-dot" :style="{ background: prioColor(j.priority) }"><q-tooltip>{{ j.priority }}</q-tooltip></span>
              <span class="tldlg-time">{{ j.start || '—' }}</span>
              <span class="ellipsis">{{ j.subject }}</span>
              <span v-if="j.customer" class="text-grey-6 ellipsis">· {{ j.customer }}</span>
              <q-space /><span class="text-grey-6" style="flex:0 0 auto">{{ j.dur }}h</span>
            </div>
          </div>
        </q-card-section>
        <q-card-section v-if="timelineDays.length" class="q-pt-none text-caption text-grey-6">Trié par priorité puis heure · 🔴 urgent 🟠 élevée 🔵 moyenne ⚪ basse. Heures posées en premier-trou-libre.</q-card-section>
      </q-card>
    </q-dialog>

    <q-menu v-model="menu.show" :target="menu.target" anchor="bottom left" 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="red-6" icon="event_busy" :label="menuIsAbsent ? 'Absent ✓' : 'Absent'" class="col" @click="toggleAbsentMenu"><q-tooltip>Marquer / retirer absent (A)</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">
          <!-- 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="dayEditor.list.length" class="de-map-wrap">
            <div ref="dayMapEl" class="de-map-gl"></div>
            <div class="de-map-cap">🗺 Itinéraire routier · molette/boutons = zoom · glisser = déplacer<span v-if="dayNoCoord" class="text-deep-orange-7"> · ⚠ {{ dayNoCoord }} sans coords (absent de la carte)</span></div>
          </div>
          <div v-if="!dayEditor.list.length" class="text-grey-6 q-pa-md text-center">Aucun job ce jour.</div>
          <!-- liste éditable : flèches/glisser pour réordonner · durée en minutes · ✕ pour 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" class="de-travel">
              <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>
            </div>
            <div class="de-row" :class="{ 'de-drag': dayEditor.dragIdx === i }"
                 draggable="true" @dragstart="dayDragStart(i, $event)" @dragover.prevent @drop="dayDropOn(i)" @dragend="dayDragEnd">
              <div class="column" style="gap:0">
                <q-btn flat dense round size="9px" icon="keyboard_arrow_up" :disable="i === 0" @click="moveDayJob(i, -1)" />
                <q-btn flat dense round size="9px" icon="keyboard_arrow_down" :disable="i === dayEditor.list.length - 1" @click="moveDayJob(i, 1)" />
              </div>
              <q-icon name="drag_indicator" size="16px" class="text-grey-5" style="cursor:grab" />
              <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-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-deep-orange-7"> · 🔒 RDV fixe</span><span v-if="j.customer"> · {{ j.customer }}</span></div>
                <div v-if="j.address" class="ellipsis" style="font-size:11px;color:#90a4ae"><q-icon name="place" size="11px" /> {{ j.address }}<span v-if="!hasLL(j)" class="text-deep-orange-7"> · ⚠ sans coords (hors carte)</span></div>
              </div>
              <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>
              <!-- sélecteur de priorité retiré (défaut « Moyenne » conservé en donnée) → gain de place ; priorité gérée au Dispatch -->
              <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>
            </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-deep-orange-7"> · fil indisponible</span></div>
            </div>
          </template>
        </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">Glisser/flèches = ordre (heures recalculées) · 🔒 = RDV fixe · clic = détails · total <b>{{ dayTotalH() }}h</b></span><q-space />
          <q-btn dense unelevated color="primary" :loading="dayEditor.saving" label="Enregistrer" @click="saveDayOrder" />
        </q-card-section>
      </q-card>
    </q-dialog>
  </q-page>
</template>

<script setup>
/**
 * PlanificationPage — grille hebdomadaire roster (ressources × jours) + prise en charge dispatch.
 * Backend : targo-hub /roster/* (src/api/roster.js) → ERPNext facturation + solveur OR-Tools.
 * Voir aussi : services/targo-hub/lib/roster.js (endpoints) et docs/ROSTER.md (vue d'ensemble).
 *
 * CARTE DES SECTIONS (chercher les bannières « ── … ── ») :
 *   1. État réactif & constantes ............ refs techs/templates/assignments, LS_*, dayList
 *   2. Calque de garde LIVE ................. gardeOverlay (règles) ⊕ manualGarde (touche G) → gardeEffective
 *   3. Filtres & scoring priorité ........... skillFilter (ET), techCompetence/techSpeed/techProximity → priorityScores
 *   4. Ressources masquées .................. hiddenTechs / visibleTechs
 *   5. Compétences inline ................... skillLevel (1-5) + skillEff (%/compétence) + TagEditor + gestionnaire global
 *   6. Sélection / peinture cellules ........ souris (onDown/onEnter) + clavier (onKey : A absent, G garde, copier/coller)
 *   7. Occupation & timeline ................ occCells → cellBands/cellBlocks/cellPct/cellJobs ; openTimeline (dialogue)
 *   8. Panneau « jobs à assigner » .......... multi-sélection + terrain/distant + drag-drop + aperçu occupation projetée
 *   9. Dialogue d'impact .................... retrait compétence / absence → redistribution (candidats classés)
 *  10. Chargement & solveur ................. loadBase/loadWeek/loadStats · doGenerate/doPublish
 *  11. Helpers date/temps/couleur .......... iso/hToNum/numToTime · occColor/todColor/getTagColor
 */
import { ref, computed, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
// Icônes de rôle monochromes outline (Material Symbols, style « une couleur » demandé) : échelle = installation.
import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API Mapbox Matrix), déjà utilisé par le Dispatch
import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé avec le board Dispatch)
import TechSelect from 'src/components/shared/TechSelect.vue'
import SkillSelect from 'src/components/shared/SkillSelect.vue'
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags partagé (Dispatch) : condensé, création à la volée, couleurs

const $q = useQuasar()
const router = useRouter()
const DIRTY_MSG = 'Vous avez des modifications non publiées. Les abandonner ?'

const techs = ref([])
const templates = ref([])
const assignments = ref([])
const coverageData = ref([])
const dailyStats = ref([])
const solverStats = ref(null)
const loading = ref(false); const generating = ref(false); const publishing = ref(false); const applying = ref(false)
const days = ref(7)
const start = ref(upcomingMonday())
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 gardeRules = ref([]); const showGarde = ref(false)
const manualGarde = ref({}) // overrides manuels de garde : 'techId|iso' → 'on' | 'off' (touche « G »)
const newGardeRule = reactive({ dept: '', shift: '', shiftWeekend: '', weekdays: [], anchor: '', steps: [] })
const GARDE_DOW = [{ v: 1, l: 'L' }, { v: 2, l: 'M' }, { v: 3, l: 'M' }, { v: 4, l: 'J' }, { v: 5, l: 'V' }, { v: 6, l: 'S' }, { v: 0, l: 'D' }]
const history = ref([]); const future = ref([])
const search = ref(''); const groupFilter = ref(null); const maxHours = ref(40); const skillFilter = ref([])
// Ressources masquées (hors front-line : compta, etc.) — masquées ET ignorées du calcul ; localStorage.
const hiddenTechs = ref([]); const showHidden = ref(false)
function isHidden (id) { return hiddenTechs.value.includes(id) }
function toggleHidden (id) { const i = hiddenTechs.value.indexOf(id); if (i >= 0) hiddenTechs.value.splice(i, 1); else hiddenTechs.value.push(id); localStorage.setItem('roster-hidden-techs-v1', JSON.stringify(hiddenTechs.value)) }
const hiddenCount = computed(() => techs.value.filter(t => isHidden(t.id)).length)
const showShiftEditor = ref(false); const editTpls = ref([])
const showTeamEditor = ref(false); const editTechs = ref([])
const notifySms = ref(false)
const showLeave = ref(false); const leaveRows = ref([]); const leaveFilter = ref('Demandé')
const newLeave = reactive({ technician: '', availability_type: 'Congé', from_date: '', to_date: '', reason: '', long_term: 0 })
const newTpl = reactive({ template_name: '', start: '08:00', end: '16:00', color: '#1976d2', on_call: 0 })
function numToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') }
// Slider à 2 poignées pour le nouveau modèle (heures custom) ↔ newTpl.start/end
const newTplRange = computed({ get: () => ({ min: hToNum(newTpl.start) || 8, max: hToNum(newTpl.end) || 16 }), set: (v) => { newTpl.start = numToTime(v.min); newTpl.end = numToTime(v.max) } })

const LS_DEMAND = 'roster-demand-v1'; const LS_HOL = 'roster-holidays-v1'; const LS_TPL = 'roster-week-templates-v1'; const LS_GARDE = 'roster-garde-rules-v1'; const LS_GARDE_MANUAL = 'roster-garde-manual-v1'

// Date LOCALE du jour (PAS toISOString, qui bascule au lendemain le soir en UTC− → cause des « jours décalés »).
function todayISO () { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') }
function upcomingMonday () { const t = todayISO(); return addDaysISO(t, (1 - dowOf(t) + 7) % 7) } // prochain lundi (aujourd'hui si lundi), calcul UTC-cohérent
function thisMonday () { return mondayISO(todayISO()) } // lundi de la semaine COURANTE (pour « 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 tplOptions = computed(() => templates.value.map(t => ({ label: t.template_name, value: t.name })))
const techOptions = computed(() => techs.value.map(t => ({ label: t.name, value: t.id })))
const tplByName = computed(() => Object.fromEntries(templates.value.map(t => [t.name, t])))
// ── CALQUE de garde LIVE ──────────────────────────────────────────────────
// La garde n'est PAS matérialisée pour l'affichage : on la recalcule à la volée depuis les règles
// (rotation déterministe ancrée + saut d'absent, via rotationTech). Éditer la séquence ou marquer
// une absence se reflète INSTANTANÉMENT, sans régénérer → fini la désync « la suite est bousillée ».
// Clé techId|iso → nom du shift de garde (semaine vs week-end). Vacances ⇒ substitut auto.
const gardeOverlay = computed(() => {
  const map = {}; const rules = gardeRules.value; if (!rules.length) return map
  for (const d of dayList.value) {
    const dow = dowOf(d.iso); const weekend = (dow === 0 || dow === 6)
    for (const rule of rules) {
      if (!(rule.weekdays || []).includes(dow)) continue
      const sh = (weekend && rule.shiftWeekend) ? rule.shiftWeekend : rule.shift
      if (!tplByName.value[sh]) continue
      const id = rotationTech(rule, d.iso); if (!id) continue
      map[id + '|' + d.iso] = sh // si 2 règles visent le même tech/jour, la dernière gagne (rare)
    }
  }
  return map
})
// Shift de garde à utiliser pour un ajout MANUEL un jour donné : celui des règles (semaine/WE), sinon 1er modèle on_call.
function gardeShiftForDay (iso) {
  const dow = dowOf(iso); const weekend = (dow === 0 || dow === 6)
  for (const rule of gardeRules.value) { if (!(rule.weekdays || []).includes(dow)) continue; const sh = (weekend && rule.shiftWeekend) ? rule.shiftWeekend : rule.shift; if (tplByName.value[sh]) return sh }
  const oc = templates.value.find(t => t.on_call); return oc ? oc.name : null
}
// Garde EFFECTIVE = rotation (gardeOverlay) + overrides MANUELS (touche « G »/menu, localStorage) :
//   manualGarde[key]='on' → ajoute la garde (shift du jour) ; 'off' → la retire (override d'une garde de règle).
// Permet de DÉPLACER la garde à la main (tech en congé → la placer ailleurs) SANS toucher aux règles.
const gardeEffective = computed(() => {
  const base = { ...gardeOverlay.value }
  for (const key in manualGarde.value) {
    const v = manualGarde.value[key]
    if (v === 'off') delete base[key]
    else if (v === 'on') { const sh = gardeShiftForDay(key.split('|')[1]); if (sh) base[key] = sh }
  }
  return base
})
function onGarde (techId, iso) { return !!gardeEffective.value[techId + '|' + iso] }
// Nb de techs de garde par jour (garde EFFECTIVE) → ligne de pied cohérente avec la grille
const gardeCountByDate = computed(() => { const m = {}; for (const k in gardeEffective.value) { const iso = k.split('|')[1]; m[iso] = (m[iso] || 0) + 1 } return m })
function chip (color) { return { background: color || '#1976d2', color: '#fff' } }

// techs visibles (recherche + groupe + tri)
const groupOptions = computed(() => { const s = new Set(); for (const t of techs.value) if (t.group) s.add(t.group); return [...s].sort().map(g => ({ label: g, value: g })) })
// Compétences distinctes (chips de filtrage) + score de PRIORITÉ provisoire.
const allSkills = computed(() => [...new Set(techs.value.flatMap(t => t.skills || []))].sort())
// Édition des compétences dans la cellule du nom (popover) + suggestions ALIGNÉES sur les catégories de job.
const jobTypes = ref([]) // service_types distincts (catégories de job) → suggérés comme compétences
const skillDialog = ref(null) // tech dont on édite les compétences
const skillMenuTarget = ref(null) // élément cliqué = ancre du popover (près de la souris, sur la rangée)
const skillMenuShown = ref(false)
function openSkillEditor (t, ev) { skillDialog.value = t; skillMenuTarget.value = (ev && ev.currentTarget) || null; skillMenuShown.value = true }
// TagEditor (showLevel) ↔ tech : skills = libellés (CSV) · skill_levels = {compétence: niveau 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 })
}
function skillEffOf (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? Number(e) : (Number(t.efficiency) || 1) } // facteur (pour le calcul de priorité)
// Efficacité saisie en % de PERFORMANCE : + = plus VITE (moins de temps) · − = plus LENT. Conversion ↔ facteur.
function effPctOf (factor) { return Math.round((1 - (Number(factor) || 1)) * 100) }
function factorFromPct (pct) { const f = 1 - (Number(pct) || 0) / 100; return Math.max(0.1, Math.min(3, Math.round(f * 100) / 100)) }
function skillEffPct (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? effPctOf(e) : '' } // '' = hérite de la globale
function setSkillEffPct (t, sk, pct) { const m = { ...(t.skill_eff || {}) }; if (pct === '' || pct == null) delete m[sk]; else m[sk] = factorFromPct(pct); t.skill_eff = m; queueSkillSave(t) } // libre (ex. +80)
function setSkillLevel (t, sk, v) { if (!t.skill_levels) t.skill_levels = {}; t.skill_levels = { ...t.skill_levels, [sk]: v }; queueSkillSave(t) }
// Efficacité GLOBALE du tech, éditable/réinitialisable depuis le popover (débouncée via setTechEfficiency).
// Couleur du cercle (vitesse PAR compétence) : vite → vert · normal → bleu-gris · lent → rouge. Intensité ∝ écart.
function effColor (factor) { const p = effPctOf(factor); if (!p) return '#607d8b'; const a = Math.min(Math.abs(p), 30) / 30; const hue = p > 0 ? 122 : 4; return 'hsl(' + hue + ',' + Math.round(50 + a * 28) + '%,' + (44 - Math.round(a * 8)) + '%)' }
function skillEffColor (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e == null || e === '') ? '#607d8b' : effColor(Number(e)) } // neutre si pas d'override (pure par-compétence)
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 })
const draggingJobName = ref(null); const dropCell = ref(null); const dragHours = ref(0)
const selectedJobs = reactive({}) // jobName → true
const dropPreview = reactive({ key: null, addH: 0 })
const draggingSet = reactive(new Set()); let _dragGhost = null // jobs en cours de glissé (source estompée) + fantôme custom
async function openAssignPanel () { assignPanel.open = true; assignPanel.loading = true; for (const k in selectedJobs) delete selectedJobs[k]; try { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } catch (e) { err(e) } finally { assignPanel.loading = false } }

// Write-back legacy : aperçu (dryRun, 0 écriture) → confirmation → écrit ticket.assign_to dans osTicket.
const legacyPush = reactive({ open: false, loading: false, applying: false, preview: null })
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(); $q.notify({ type: r.errors ? 'warning' : 'positive', message: `Legacy : ${r.pushed} ticket(s) réassigné(s)` + (r.mismatch ? ` · ${r.mismatch} ignoré(s) (nom ≠)` : '') + (r.errors ? ` · ${r.errors} erreur(s)` : '') }); 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('group') // group (parent-enfant) | skill | date | 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) {
  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) })
const assignJobsFiltered = computed(() => { const f = assignTypeFilter.value; if (!f.length) return assignPanel.jobs; const set = new Set(f); return assignPanel.jobs.filter(j => set.has(j.required_skill || 'autre')) })
function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) }
// Date DUE (≠ date de création) : étiquette relative pour grouper/afficher (En retard / Aujourd'hui / future).
function isOverdue (d) { return !!d && d !== 'Sans date' && d < todayISO() }
function fmtDueLabel (d) { if (!d || d === 'Sans date') return 'Sans date due'; const t = todayISO(); if (d < t) return d.slice(5) + ' ⏰ en retard'; if (d === t) return "Aujourd'hui"; return d.slice(5) }
const assignGroups = computed(() => {
  const jobs = assignJobsFiltered.value
  if (assignSort.value === 'group') { // défaut : groupe parent-enfant (installation avant activation…), ordonné par step_order
    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] }))
})
// 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) {
  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
  let ok = 0
  for (const jn of assignable) { try { await roster.assignJob(jn, t.id, d.iso); ok++; delete selectedJobs[jn] } catch (e) { err(e) } } // SÉQUENTIEL (frappe_pg)
  assignPanel.jobs = assignPanel.jobs.filter(j => !assignable.includes(j.name)) // les bloqués restent dans le panneau
  $q.notify({ type: 'positive', message: ok + ' job(s) assigné(s) à ' + t.name + ' · ' + d.dnum, timeout: 2800 }); await reloadOccupancy() // refresh rapide du timeline (blocs) sans loadWeek complet
}
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 assignPanel.jobs) {
    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
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(() => assignPanel.jobs.filter(j => !_hasJobLL(j)).length)
function assignStops () { return assignLocations.value.stops }
function toggleAssignMap () { assignPanel.showMap = !assignPanel.showMap }
function focusAssignJob (j) { if (_assignMap && _hasJobLL(j)) _assignMap.easeTo({ center: [+j.longitude, +j.latitude], zoom: 13, duration: 400 }) }
// Carte → liste : déplie les détails du job + scrolle la ligne + flash visuel.
const flashJob = ref(null)
function focusAssignJobInList (name) {
  if (!name) return
  const job = assignPanel.jobs.find(j => j.name === name)
  if (job) { job._showThread = true; loadThread(job) }
  flashJob.value = name; setTimeout(() => { if (flashJob.value === name) flashJob.value = null }, 1800)
  nextTick(() => { const el = document.querySelector('.assign-body [data-jobname="' + name + '"]'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center', behavior: 'smooth' }) })
}
async function initAssignMap () {
  if (!MAPBOX_TOKEN || !assignMapEl.value || _assignMap) return
  const mapboxgl = await ensureMapbox(); if (!mapboxgl || !assignMapEl.value) return
  mapboxgl.accessToken = MAPBOX_TOKEN
  _assignMap = new mapboxgl.Map({ container: assignMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.55, 45.2], zoom: 8 })
  _assignMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
  _assignMapRO = new ResizeObserver(() => { if (_assignMap) _assignMap.resize() }); _assignMapRO.observe(assignMapEl.value)
  _assignMap.on('load', () => {
    _assignMap.resize()
    _assignMap.addSource('aj', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
    _assignMap.addLayer({ id: 'aj-c', type: 'circle', source: 'aj', paint: { 'circle-radius': ['case', ['>', ['get', 'count'], 1], 12, 9], 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' } })
    _assignMap.addLayer({ id: 'aj-l', type: 'symbol', source: 'aj', 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 = '' })
    _assignMap.on('click', 'aj-c', (e) => {
      const f = e.features[0]; const p = f.properties; const names = JSON.parse(p.names || '[]')
      names.forEach(n => { selectedJobs[n] = true }) // clic sur le pin = sélectionne tous les jobs de cette adresse
      if (names[0]) focusAssignJobInList(names[0]) // + scrolle/expand la job dans la liste
      new window.mapboxgl.Popup({ offset: 13 }).setLngLat(f.geometry.coordinates).setHTML(`<div style="font-size:12px;line-height:1.45"><b>${_esc(p.letter)}</b> · ${_esc(p.title)}<br>✓ ${p.count} job(s) sélectionné(s)<br><span style="color:#888">glisse-les sur un tech × jour</span></div>`).addTo(_assignMap)
    })
    refreshAssignMap()
  })
}
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 => ({ 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 } })) })
  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 () { if (_assignMapRO) { try { _assignMapRO.disconnect() } catch (e) {} _assignMapRO = null } if (_assignMap) { try { _assignMap.remove() } catch (e) {} _assignMap = null } }
watch(() => assignPanel.showMap, (on) => { if (on) { if (assignPanel.h < 600) assignPanel.h = 640; nextTick(initAssignMap) } else destroyAssignMap() })
watch(() => assignPanel.jobs, () => { if (assignPanel.showMap) nextTick(() => { _assignMap ? refreshAssignMap() : initAssignMap() }) })
watch(() => assignPanel.open, (o) => { if (!o) destroyAssignMap() })

// ── Timeline contextuelle d'une RESSOURCE (dispatch des jobs de la semaine visible) ──
// Réutilise les helpers de cellule (cellBands/cellBlocks/cellJobs/cellPct) → 0 nouvel appel réseau.
const timelineDlg = reactive({ open: false, tech: null })
function openTimeline (t) { timelineDlg.tech = t; timelineDlg.open = true }
// (Clic sur le progressbar → gotoDispatch : on ouvre le timeline ÉDITABLE du tableau Dispatch, drag-drop + suppression,
//  plutôt qu'un popup maison — réutilisation max + cohérence. Le réordonnancement/priorité se fait là-bas.)
// Deep-link vers le tableau Dispatch focalisé sur la ressource + le jour cliqué (sinon 1er jour de la semaine).
function gotoDispatch (t, dateIso) {
  const q = {}
  if (t) q.tech = t.id
  q.date = dateIso || (timelineDays.value[0] && timelineDays.value[0].iso) || start.value
  router.push({ path: '/dispatch', query: q })
}
// ── Éditeur de JOURNÉE (fenêtre contextuelle ciblée — clic sur le progressbar) ──
// Garde le contexte de la grille derrière. Timeline + réordonnancement DRAG-DROP + retrait d'un job.
const dayEditor = reactive({ open: false, tech: null, day: null, list: [], saving: false, dragIdx: null, travelMap: {}, routeReady: false })
function openDayEditor (t, d) {
  dayEditor.tech = t; dayEditor.day = d
  // RDV confirmé (ou heure légacy précise) = heure FIXE → verrouillé ; sinon flexible (replanifiable par la tournée).
  dayEditor.list = cellJobs(t.id, d.iso).map(j => ({ ...j, locked: j.booking_status === 'Confirmé', showDetail: false }))
  dayEditor.dragIdx = null; dayEditor.travelMap = {}; dayEditor.routeReady = false; dayEditor.open = true
  loadDayRoute() // charge la matrice de temps routiers RÉELS (Mapbox) → packedDay les utilise dès l'arrivée (réactif)
}
// 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 pts = dayEditor.list.filter(j => j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon)).slice(0, 25) // Matrix = 25 coords max
  if (pts.length < 2 || !MAPBOX_TOKEN) { dayEditor.travelMap = {}; dayEditor.routeReady = false; return }
  const coords = pts.map(j => `${(+j.lon).toFixed(6)},${(+j.lat).toFixed(6)}`).join(';')
  const url = `https://api.mapbox.com/directions-matrix/v1/mapbox/driving/${coords}?annotations=duration,distance&access_token=${MAPBOX_TOKEN}`
  try {
    const r = await fetch(url); if (!r.ok) throw new Error('matrix ' + r.status)
    const d = await r.json(); const dur = d.durations || [], dist = d.distances || []
    if (key !== ((dayEditor.tech && dayEditor.tech.id) + '|' + (dayEditor.day && dayEditor.day.iso))) return // l'éditeur a changé de cible entre-temps
    const map = {}
    for (let i = 0; i < pts.length; i++) for (let k = 0; k < pts.length; k++) {
      if (i === k) continue
      const sec = dur[i] && dur[i][k]; const m = dist[i] && dist[i][k]
      if (sec == null) continue
      map[pts[i].name + '>' + pts[k].name] = { min: Math.max(2, Math.round(sec / 60)), km: m != null ? Math.round(m / 100) / 10 : null, real: true }
    }
    dayEditor.travelMap = map; dayEditor.routeReady = true
  } catch (e) { dayEditor.travelMap = {}; dayEditor.routeReady = false } // repli haversine
}
const dayOcc = () => (dayEditor.tech && dayEditor.day) ? cellOcc(dayEditor.tech.id, dayEditor.day.iso) : null
const dayBands = () => (dayEditor.tech && dayEditor.day) ? cellBands(dayEditor.tech.id, dayEditor.day.iso) : []
// Blocs RECALCULÉS depuis la SÉQUENCE éditée (packedDay) → l'ordre + les durées + le transport se reflètent + plus d'overlap.
const dayBlocks = () => packedDay.value.map(p => ({ s: p.startMin, e: p.endMin, skill: p.skill }))
// 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) }
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) }
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) { return i > 0 ? travelBetween(dayEditor.list[i - 1], dayEditor.list[i]) : null } // trajet vers le job i depuis le précédent
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()
  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)

// ── 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
function ensureMapbox () {
  return new Promise((resolve) => {
    if (!document.getElementById('mapbox-css')) { const l = document.createElement('link'); l.id = 'mapbox-css'; l.rel = 'stylesheet'; l.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l) }
    if (window.mapboxgl) return resolve(window.mapboxgl)
    let s = document.getElementById('mapbox-js')
    if (!s) { s = document.createElement('script'); s.id = 'mapbox-js'; s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'; document.head.appendChild(s) }
    s.addEventListener('load', () => resolve(window.mapboxgl))
    const iv = setInterval(() => { if (window.mapboxgl) { clearInterval(iv); resolve(window.mapboxgl) } }, 150)
  })
}
const _esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
function dayStops () { // arrêts géolocalisés, dans l'ordre de tournée (packedDay) + infos pour le popup
  return packedDay.value.filter(hasLL).map((j, i) => ({
    lon: +j.lon, lat: +j.lat, label: String(i + 1), color: j.skill ? getTagColor(j.skill) : '#1976d2',
    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 = []; for (let i = 0; i < p.length - 1; i++) { const s = p[i].endMin, e = p[i + 1].startMin; if (e - s > 0.02) out.push({ s, e }) } return out }
// Centre la carte sur un job (clic sur la ligne de la liste).
function focusDayJob (j) { if (_dayMap && hasLL(j)) _dayMap.easeTo({ center: [+j.lon, +j.lat], zoom: 14, duration: 500 }) }
// Fil legacy (messages + réponses du ticket osTicket) chargé à la demande, mis en cache sur le job (j._thread).
async function loadThread (j) {
  const id = j && (j.legacy_id || j.legacy_ticket_id); if (!id || j._thread) return
  j._thread = { loading: true, messages: [] }
  try { const r = await roster.ticketThread(id); j._thread = { loading: false, messages: r.messages || [], count: r.count || 0, status: r.status || '' } }
  catch (e) { j._thread = { loading: false, error: true, messages: [] } }
}
function toggleJobDetail (j) { j.showDetail = !j.showDetail; if (j.showDetail) loadThread(j); focusDayJob(j) } // tournée : clic = déplie détails + fil + centre carte
function toggleAssignThread (j) { j._showThread = !j._showThread; if (j._showThread) loadThread(j) } // panneau d'assignation : icône fil
// Fermer un ticket inutile dans le legacy (depuis le panneau) → sort du dispatch (Completed). Acteur = utilisateur Authentik.
function closeLegacyTicket (j) {
  if (!j || !j.legacy_ticket_id) return
  $q.dialog({ title: 'Fermer le ticket', message: 'Fermer le ticket legacy <b>#' + j.legacy_ticket_id + '</b> ?<br><span class="text-grey-7">' + (j.subject || '').replace(/[<>]/g, '') + '</span><br>→ il sortira du dispatch (Completed).', html: true, cancel: 'Annuler', ok: { label: 'Fermer', color: 'negative', unelevated: true }, persistent: true }).onOk(async () => {
    try {
      const r = await roster.closeLegacyTicket(j.legacy_ticket_id)
      if (r && r.ok) { assignPanel.jobs = assignPanel.jobs.filter(x => x.name !== j.name); $q.notify({ type: 'positive', message: 'Ticket #' + j.legacy_ticket_id + ' fermé dans le legacy' }) }
      else $q.notify({ type: 'warning', message: 'Échec fermeture : ' + ((r && r.error) || '?') })
    } catch (e) { err(e) }
  })
}
// Fermeture EN LOT des jobs cochés (1 seul appel) — option « fermer » au lieu de « réassigner ».
function bulkCloseSelected () {
  const sel = assignPanel.jobs.filter(j => selectedJobs[j.name] && j.legacy_ticket_id)
  if (!sel.length) { $q.notify({ type: 'warning', message: 'Aucun ticket legacy coché' }); return }
  $q.dialog({ title: 'Fermer en lot', message: 'Fermer <b>' + sel.length + '</b> ticket(s) dans le legacy ?<br>→ ils sortent du dispatch (Completed).', html: true, cancel: 'Annuler', ok: { label: 'Fermer ' + sel.length, color: 'negative', unelevated: true }, persistent: true }).onOk(async () => {
    try {
      const r = await roster.batchCloseLegacy(sel.map(j => j.legacy_ticket_id))
      if (r && r.ok) {
        const done = new Set((r.results || []).filter(x => x.ok).map(x => String(x.t)))
        assignPanel.jobs = assignPanel.jobs.filter(j => !done.has(String(j.legacy_ticket_id)))
        for (const k in selectedJobs) delete selectedJobs[k]
        $q.notify({ type: 'positive', message: r.closed + ' ticket(s) fermé(s)' + (r.locked ? ' · ' + r.locked + ' verrouillé(s)' : '') + (r.skipped ? ' · ' + r.skipped + ' déjà fermé(s)' : '') })
      } else $q.notify({ type: 'warning', message: 'Échec : ' + ((r && r.error) || '?') })
    } catch (e) { err(e) }
  })
}
function fmtDT (iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString('fr-CA', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' } }
async function initDayMap () {
  if (!MAPBOX_TOKEN || !dayMapEl.value || _dayMap) return
  const mapboxgl = await ensureMapbox(); if (!mapboxgl || !dayMapEl.value) return
  mapboxgl.accessToken = MAPBOX_TOKEN
  _dayMap = new mapboxgl.Map({ container: dayMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.6756, 45.1599], zoom: 9 })
  _dayMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right') // boutons zoom +/-
  _dayMapRO = new ResizeObserver(() => { if (_dayMap) _dayMap.resize() }); _dayMapRO.observe(dayMapEl.value)
  _dayMap.on('load', () => {
    _dayMap.resize()
    _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 } })
    _dayMap.addSource('day-stops', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
    _dayMap.addLayer({ id: 'day-stops-c', type: 'circle', source: 'day-stops', paint: { 'circle-radius': 13, 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' } })
    _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.45"><b>${_esc(p.label)}. ${_esc(p.subject)}</b><br>🕒 ${_esc(p.time)}${p.customer ? '<br>👤 ' + _esc(p.customer) : ''}${p.address ? '<br>📍 ' + _esc(p.address) : ''}</div>`)
        .addTo(_dayMap)
    })
    refreshDayMap()
  })
}
function refreshDayMap () {
  if (!_dayMap || !_dayMap.isStyleLoaded()) { setTimeout(refreshDayMap, 200); return }
  const stops = dayStops()
  const sSrc = _dayMap.getSource('day-stops')
  if (sSrc) sSrc.setData({ type: 'FeatureCollection', features: stops.map(s => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [s.lon, s.lat] }, properties: { label: s.label, color: s.color, subject: s.subject, customer: s.customer, address: s.address, time: s.time } })) })
  if (stops.length === 1) _dayMap.easeTo({ center: [stops[0].lon, stops[0].lat], zoom: 13, 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]))
    _dayMap.fitBounds(b, { padding: 45, maxZoom: 14, duration: 400 })
  }
  fetchDayRouteGeom(stops)
}
async function fetchDayRouteGeom (stops) { // itinéraire ROUTIER réel (Directions) → tracé sur la carte
  const rSrc = _dayMap && _dayMap.getSource('day-route'); if (!rSrc) return
  if (!stops || stops.length < 2) { rSrc.setData({ type: 'FeatureCollection', features: [] }); return }
  try {
    const pts = stops.slice(0, 25).map(s => `${s.lon.toFixed(6)},${s.lat.toFixed(6)}`).join(';')
    const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${pts}?overview=full&geometries=geojson&access_token=${MAPBOX_TOKEN}`
    const r = await fetch(url); if (!r.ok) throw new Error('dir ' + r.status)
    const d = await r.json(); const geom = d.routes && d.routes[0] && d.routes[0].geometry
    const src = _dayMap && _dayMap.getSource('day-route')
    if (geom && src) src.setData({ type: 'Feature', geometry: geom, properties: {} })
  } catch (e) { /* repli : pas de tracé routier (les pins restent visibles) */ }
}
function destroyDayMap () {
  if (_dirTimer) { clearTimeout(_dirTimer); _dirTimer = null }
  if (_dayMapRO) { _dayMapRO.disconnect(); _dayMapRO = null }
  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
async function removeFromDay (j) {
  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 « à assigner »)', timeout: 2200 }) } catch (e) { err(e) }
}
async function saveDayOrder () {
  dayEditor.saving = true
  const packed = packedDay.value // heures recalculées par la tournée → on les persiste (start_time)
  const updates = dayEditor.list.map((j, i) => ({ job: j.name, route_order: i + 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) }
// 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()
  const list = techs.value.filter(t => (showHidden.value || !isHidden(t.id)) && (!groupFilter.value || t.group === groupFilter.value) && techHasSkill(t) && (!q || (t.name || '').toLowerCase().includes(q) || (t.group || '').toLowerCase().includes(q)))
  // Filtre par compétence actif → on TRIE par priorité (meilleur score d'abord) ; sinon ordre équipe/nom.
  if (skillFilter.value.length) return list.slice().sort((a, b) => techScore(a) - techScore(b) || (a.name || '').localeCompare(b.name || ''))
  return list.slice().sort((a, b) => (a.group || '~').localeCompare(b.group || '~') || (a.name || '').localeCompare(b.name || ''))
})

const cellsByTechDay = computed(() => { const m = {}; for (const a of assignments.value) { const t = (m[a.tech] || (m[a.tech] = {})); (t[a.date] || (t[a.date] = [])).push(a) } return m })
function cellsOf (techId, iso) { return (cellsByTechDay.value[techId] && cellsByTechDay.value[techId][iso]) || [] }
function isPaused (t) { return t.status === 'En pause' }
function hoursOf (techId) { let h = 0; for (const a of assignments.value) { if (a.tech !== techId) continue; const t = tplByName.value[a.shift]; if (t && t.on_call) continue; h += Number(a.hours) || 0 } return h } // garde exclue (mise en dispo, pas travaillée)

const serverSet = ref(new Set())
const currentSet = computed(() => new Set(assignments.value.map(a => a.tech + '|' + a.date + '|' + a.shift)))
const diffKeys = computed(() => { const cur = currentSet.value; const srv = serverSet.value; const d = []; for (const k of cur) if (!srv.has(k)) d.push(k); for (const k of srv) if (!cur.has(k)) d.push(k); return d })
const dirty = computed(() => diffKeys.value.length > 0)
const dirtyCount = computed(() => diffKeys.value.length)
const dirtyCells = computed(() => new Set(diffKeys.value.map(k => k.slice(0, k.lastIndexOf('|')))))
function isCellDirty (techId, iso) { return dirtyCells.value.has(techId + '|' + iso) }

const holSet = computed(() => new Set(holidays.value))
function isHoliday (iso) { return holSet.value.has(iso) }
function toggleHoliday (iso) { holidays.value = isHoliday(iso) ? holidays.value.filter(x => x !== iso) : [...holidays.value, iso]; localStorage.setItem(LS_HOL, JSON.stringify(holidays.value)) }
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({})
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) } }
  for (const sh of new Set(Object.values(gardeEffective.value))) grow(tplByName.value[sh]) // garde visible (calque + manuel)
  if (!isFinite(lo) || !isFinite(hi)) return { min: 7, max: 19 }
  lo = Math.max(0, Math.floor(lo)); hi = Math.min(24, Math.ceil(hi)); if (hi - lo < 4) hi = Math.min(24, lo + 4)
  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.
function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), background: blk && blk.skill ? getTagColor(blk.skill) : occColor(pct) } }
// 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: [] }
    m[techId + '|' + iso] = { bookableH, usedH: Math.round((o.h || 0) * 10) / 10, hasGarde, hasReg, pct: bookableH > 0 ? Math.round((o.h || 0) / bookableH * 100) : null, blocks: o.blocks || [], jobs: o.jobs || [] }
  }
  return m
})
function cellOcc (techId, iso) { return occCells.value[techId + '|' + iso] || null }
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 cellPct (techId, iso) { const o = cellOcc(techId, iso); return o ? o.pct : null }
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) } // jobs assignés un jour où le tech n'a AUCUN 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 === 'urgent' ? '#ef4444' : p === 'high' ? '#f59e0b' : p === 'medium' ? '#6366f1' : '#9e9e9e' }
// Aperçu en survol de drop : occupation projetée si on dépose la sélection ici.
function isDropTarget (techId, iso) { return dropPreview.key === techId + '|' + iso }
function projPct (techId, iso) { const o = cellOcc(techId, iso); if (!o || !o.bookableH) return null; return Math.round((o.usedH + dropPreview.addH) / o.bookableH * 100) }
function cellTip (techId, iso) {
  const parts = []
  if (hasReg(techId, iso)) parts.push(cellInterval(techId, iso))
  const o = cellOcc(techId, iso); if (o && o.bookableH) parts.push(o.usedH + ' h occupé / ' + o.bookableH + ' h offrable (' + o.pct + ' %)')
  const g = gardeEffective.value[techId + '|' + iso]; if (g) { const t = tplByName.value[g]; const nm = (t && t.template_name) || 'Garde'; const hrs = t ? (' ' + (t.start_time || '').slice(0, 5) + '–' + (t.end_time || '').slice(0, 5)) : ''; parts.push('🛡️ ' + nm + hrs) }
  return parts.join(' · ')
}
function cellInterval (techId, iso) {
  return cellsOf(techId, iso).filter(a => { const t = tplByName.value[a.shift]; return !(t && t.on_call) }).map(a => { const t = tplByName.value[a.shift]; const nm = (t && t.template_name) ? t.template_name.split(' ')[0] + ' ' : ''; return (t && t.start_time) ? (nm + t.start_time.slice(0, 5) + '–' + (t.end_time || '').slice(0, 5)) : (a.shift_name || a.shift) }).join(' + ')
}

// coût de main-d'œuvre (coût chargé × heures)
const costByTech = computed(() => Object.fromEntries(techs.value.map(t => [t.id, t.cost_h || 0])))
const costByDate = computed(() => { const m = {}; for (const a of assignments.value) { const t = tplByName.value[a.shift]; if (t && t.on_call) continue; m[a.date] = (m[a.date] || 0) + (Number(a.hours) || 0) * (costByTech.value[a.tech] || 0) } return m }) // garde exclue du coût de main-d'œuvre
const weekCost = computed(() => Object.values(costByDate.value).reduce((s, v) => s + v, 0))
function dayCost (iso) { return Math.round(costByDate.value[iso] || 0) }

const shiftName = (s) => { const t = tplByName.value[s]; return t ? (t.template_name || s) : s }
const covRows = computed(() => { const seen = {}; const rows = []; for (const c of coverageData.value) { const key = c.shift + '|' + c.zone; if (!seen[key]) { seen[key] = true; rows.push({ key, label: shiftName(c.shift) + ' · ' + c.zone }) } } return rows })
const covByKeyDay = computed(() => { const m = {}; for (const c of coverageData.value) m[c.shift + '|' + c.zone + '|' + c.date] = c; return m })
const gapByDay = computed(() => { const m = {}; for (const c of coverageData.value) m[c.date] = (m[c.date] || 0) + (c.shortfall || 0); return m })
// Détail du manque de couverture d'un jour (par quart · zone) pour l'infobulle du badge rouge.
function gapDetail (iso) { return coverageData.value.filter(c => c.date === iso && c.shortfall > 0).map(c => `${shiftName(c.shift)} · ${c.zone || '—'} : ${c.assigned}/${c.required} (−${c.shortfall})`).join('\n') }
function covCell (key, iso) { return covByKeyDay.value[key + '|' + iso] }
function covText (key, iso) { const c = covCell(key, iso); return c ? (c.assigned + '/' + c.required) : '' }
function covStyle (key, iso) { const c = covCell(key, iso); if (!c) return {}; return c.shortfall > 0 ? { background: '#ffcdd2', color: '#b71c1c', fontWeight: 700 } : { background: '#c8e6c9', color: '#1b5e20' } }

// undo / redo
function snap () { return JSON.parse(JSON.stringify(assignments.value)) }
function pushHistory () { history.value.push(snap()); if (history.value.length > 40) history.value.shift(); future.value = [] }
function undo () { if (!history.value.length) return; future.value.push(snap()); assignments.value = history.value.pop() }
function redo () { if (!future.value.length) return; history.value.push(snap()); assignments.value = future.value.pop() }

// garde anti-perte
function guard (fn) { if (dirty.value && !window.confirm(DIRTY_MSG)) return; fn() }
function onWeekChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { start.value = lastWeek.start; return } loadWeek() }
function onDaysChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { days.value = lastWeek.days; return } loadWeek() }
function navWeek (dir) { guard(() => { start.value = addDaysISO(start.value, dir * days.value); loadWeek() }) }
function navDay (dir) { guard(() => { start.value = addDaysISO(start.value, dir); loadWeek() }) } // décale la fenêtre d'UN jour (flèche simple)
function navToday () { guard(() => { start.value = thisMonday(); loadWeek() }) }

// chargement
async function loadBase () { const tr = await roster.listTechnicians(); techs.value = tr.technicians || []; const tp = await roster.listTemplates(); templates.value = tp.templates || [] }
async function refreshTemplates () { const tp = await roster.listTemplates(); templates.value = tp.templates || [] }

// cadence / efficacité par tech
// Libellé orienté PERFORMANCE/vitesse (≠ libellé "facteur temps") : facteur 0,9 = plus rapide, 1,2 = plus lent.
function effSuffix (e) { const d = Math.round((1 - Number(e)) * 100); if (!d) return 'normal'; return d > 0 ? ('+' + d + '% vite') : ('−' + Math.abs(d) + '% lent') }
// RÔLE dérivé des compétences (tags) : support→casque · installation→échelle · réparation/terrain→handyman.
const ROLE_ICON = { support: symOutlinedHeadsetMic, install: symOutlinedToolsLadder, repair: symOutlinedHandyman }
const ROLE_LABEL = { support: 'Support / service client', install: 'Installation', repair: 'Réparation / terrain' }
function techRole (t) {
  const s = (t.skills || []).map(x => String(x).toLowerCase())
  if (s.some(x => x.includes('support') || x.includes('service'))) return 'support'
  if (s.some(x => x.includes('install'))) return 'install'
  if (s.some(x => x.includes('répar') || x.includes('repar') || x.includes('fusion') || x.includes('terrain'))) return 'repair'
  return null
}
function roleIcon (t) { const r = techRole(t); return r ? ROLE_ICON[r] : null }
function roleLabel (t) { const r = techRole(t); return r ? ROLE_LABEL[r] : '' }
function openTeamEditor () { editTechs.value = techs.value.map(t => ({ id: t.id, name: t.name, group: t.group, skills: (t.skills || []).join(', '), efficiency: t.efficiency || 1, salary: t.cost_salary_h || 0, charges: t.cost_charges_pct || 0, other: t.cost_other_h || 0 })); showTeamEditor.value = true }
async function saveSkills (t) { try { await roster.setTechSkills(t.id, t.skills || ''); const tt = techs.value.find(x => x.id === t.id); if (tt) tt.skills = (t.skills || '').split(',').map(s => s.trim()).filter(Boolean); $q.notify({ type: 'positive', message: t.name + ' : compétences enregistrées' }) } catch (e) { err(e) } }

// congés / disponibilités
async function openLeave () { showLeave.value = true; await loadLeave() }
async function loadLeave () { try { leaveRows.value = (await roster.listAvailability(leaveFilter.value)).availability || [] } catch (e) { err(e) } }
async function approveLeave (l, reject) { try { await roster.approveAvailability(l.name, { reject, approver: 'ops' }); $q.notify({ type: reject ? 'info' : 'positive', message: (l.technician_name || l.technician) + (reject ? ' refusé' : ' approuvé') }); await loadLeave() } catch (e) { err(e) } }
async function createLeave () {
  if (!newLeave.technician || !newLeave.from_date || !newLeave.to_date) { $q.notify({ type: 'warning', message: 'Technicien + dates requis' }); return }
  const t = techs.value.find(x => x.id === newLeave.technician)
  try { await roster.requestAvailability({ technician: newLeave.technician, technician_name: t ? t.name : '', availability_type: newLeave.availability_type, from_date: newLeave.from_date, to_date: newLeave.to_date, reason: newLeave.reason, long_term: newLeave.long_term }); $q.notify({ type: 'positive', message: 'Demande créée' }); newLeave.from_date = ''; newLeave.to_date = ''; newLeave.reason = ''; newLeave.long_term = 0; await loadLeave() } catch (e) { err(e) }
}
async function saveEff (t) { const eff = Number(t.efficiency) || 1; try { await roster.setTechEfficiency(t.id, eff); const tt = techs.value.find(x => x.id === t.id); if (tt) tt.efficiency = eff; $q.notify({ type: 'positive', message: t.name + ' : cadence ' + eff }) } catch (e) { err(e) } }
function loadedCost (t) { return Math.round(((Number(t.salary) || 0) * (1 + (Number(t.charges) || 0) / 100) + (Number(t.other) || 0)) * 100) / 100 }
async function saveCost (t) { try { await roster.setTechCost(t.id, { salary: t.salary, charges: t.charges, other: t.other }); const tt = techs.value.find(x => x.id === t.id); if (tt) { tt.cost_salary_h = Number(t.salary) || 0; tt.cost_charges_pct = Number(t.charges) || 0; tt.cost_other_h = Number(t.other) || 0; tt.cost_h = loadedCost(t) } $q.notify({ type: 'positive', message: t.name + ' : ' + loadedCost(t) + ' $/h chargé' }) } catch (e) { err(e) } }

// éditeur de types de shift (intervalle d'heures)
function calcHours (st, et) { if (!st || !et) return 0; const [h1, m1] = st.split(':').map(Number); const [h2, m2] = et.split(':').map(Number); let mins = (h2 * 60 + m2) - (h1 * 60 + m1); if (mins < 0) mins += 1440; return Math.round(mins / 60 * 100) / 100 }
function openShiftEditor () { editTpls.value = templates.value.map(t => ({ name: t.name, template_name: t.template_name, start: (t.start_time || '08:00:00').slice(0, 5), end: (t.end_time || '16:00:00').slice(0, 5), color: t.color || '#1976d2', on_call: t.on_call ? 1 : 0 })); showShiftEditor.value = true }
async function saveShiftTpl (t) { try { await roster.updateTemplate(t.name, { start_time: t.start + ':00', end_time: t.end + ':00', hours: calcHours(t.start, t.end), color: t.color, on_call: t.on_call ? 1 : 0 }); await refreshTemplates(); $q.notify({ type: 'positive', message: t.template_name + ' enregistré (' + calcHours(t.start, t.end) + ' h)' }) } catch (e) { err(e) } }
async function addShiftTpl () { const nm = (newTpl.template_name || '').trim() || (fmtH(hToNum(newTpl.start)) + 'h–' + fmtH(hToNum(newTpl.end)) + 'h'); try { await roster.createTemplate({ template_name: nm, start_time: newTpl.start + ':00', end_time: newTpl.end + ':00', hours: calcHours(newTpl.start, newTpl.end), color: newTpl.color, default_required: 1, on_call: newTpl.on_call ? 1 : 0 }); newTpl.template_name = ''; newTpl.on_call = 0; await refreshTemplates(); openShiftEditor(); $q.notify({ type: 'positive', message: 'Type « ' + nm + ' » ajouté' }) } catch (e) { err(e) } }
async function delShiftTpl (t) { if (!window.confirm('Supprimer le type « ' + t.template_name + ' » ?')) return; try { await roster.deleteShiftTemplate(t.name); await refreshTemplates(); editTpls.value = editTpls.value.filter(x => x.name !== t.name); $q.notify({ type: 'info', message: 'Type supprimé' }) } catch (e) { err(e) } }
function snapshotServer (list) { serverSet.value = new Set(list.map(a => a.tech + '|' + a.date + '|' + a.shift)) }
async function loadWeek () {
  loading.value = true
  try {
    const a = await roster.listAssignments(start.value, days.value); assignments.value = a.assignments || []
    snapshotServer(assignments.value); history.value = []; future.value = []; solverStats.value = null
    lastWeek.start = start.value; lastWeek.days = days.value
    await loadStats()
  } catch (e) { err(e) } finally { loading.value = false }
}
async function loadStats () {
  try { const s = await roster.getStats(start.value, days.value); dailyStats.value = s.stats || [] } catch (e) { /* non bloquant */ }
  try { const o = await roster.getOccupancy(start.value, 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 */ }
}
// 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, days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ } }

async function doGenerate () {
  generating.value = true
  try {
    const res = await roster.generate(start.value, days.value)
    if (res.status !== 'OPTIMAL' && res.status !== 'FEASIBLE') { err(new Error(res.error || res.message || ('solveur: ' + res.status))); return }
    pushHistory(); assignments.value = res.assignments || []; coverageData.value = res.coverage_report || []
    solverStats.value = { assignments: (res.assignments || []).length, shortfall: res.total_shortfall || 0, spread: res.spread_hours || 0, ms: res.solve_ms || 0 }
    $q.notify({ type: 'positive', message: 'Horaire généré : ' + solverStats.value.assignments + ' assignations (non publié)' })
  } catch (e) { err(e) } finally { generating.value = false }
}
async function doPublish () {
  publishing.value = true
  try {
    // Réécriture de semaine : efface la période + recrée la grille (anti-doublons).
    const r = await roster.publishWeek(start.value, days.value, assignments.value, notifySms.value)
    $q.notify({ type: r.errors ? 'warning' : 'positive', message: `Publié : ${r.created} assignations` + (r.deleted ? ` (${r.deleted} remplacées)` : '') + (r.errors ? ` · ${r.errors} erreurs` : '') + (r.notified ? ` · ${r.notified} SMS` : '') })
    await loadWeek()
  } catch (e) { err(e) } finally { publishing.value = false }
}

// demande
function loadLS () { try { demand.value = JSON.parse(localStorage.getItem(LS_DEMAND) || '[]') } catch { demand.value = [] } try { holidays.value = JSON.parse(localStorage.getItem(LS_HOL) || '[]') } catch { holidays.value = [] } try { weekTemplates.value = JSON.parse(localStorage.getItem(LS_TPL) || '[]') } catch { weekTemplates.value = [] } try { gardeRules.value = JSON.parse(localStorage.getItem(LS_GARDE) || '[]') } catch { gardeRules.value = [] } try { manualGarde.value = JSON.parse(localStorage.getItem(LS_GARDE_MANUAL) || '{}') } catch { manualGarde.value = {} } try { customTags.value = JSON.parse(localStorage.getItem('roster-skill-tags-v1') || '[]') } catch { customTags.value = [] } try { hiddenTechs.value = JSON.parse(localStorage.getItem('roster-hidden-techs-v1') || '[]') } catch { hiddenTechs.value = [] } }

// ── Rotation de garde par département (récurrence + rotation) ────────────────
const GARDE_EPOCH = '2026-01-05' // lundi de référence pour l'index de semaine
const gardeTemplateOptions = computed(() => templates.value.slice().sort((a, b) => (b.on_call ? 1 : 0) - (a.on_call ? 1 : 0)).map(t => ({ label: t.template_name + (t.on_call ? ' 🛡️' : ''), value: t.name })))
const groupNames = computed(() => [...new Set(techs.value.map(t => t.group).filter(Boolean))].sort())
const editingGardeId = ref(null); const gardePick = ref(null)
function d2ms (iso) { const a = iso.split('-').map(Number); return Date.UTC(a[0], a[1] - 1, a[2]) }
function mondayISO (iso) { return addDaysISO(iso, -((dowOf(iso) + 6) % 7)) }
function weekNo (iso) { return Math.round((d2ms(mondayISO(iso)) - d2ms(GARDE_EPOCH)) / (7 * 86400000)) } // n° de semaine absolu (réf. lundi)
function openGarde () { if (!newGardeRule.anchor) newGardeRule.anchor = mondayISO(start.value); showGarde.value = true }
// Séquence = étapes {tech, weeks}. Ajouter à la suite (doublons OK), réordonner, retirer.
function addTechToSeq () { if (gardePick.value) { newGardeRule.steps.push({ tech: gardePick.value, weeks: 1 }); gardePick.value = null } }
function moveTech (i, dir) { const a = newGardeRule.steps; const j = i + dir; if (j < 0 || j >= a.length) return; const x = a[i]; a.splice(i, 1); a.splice(j, 0, x) }
function editGardeRule (r) {
  Object.assign(newGardeRule, { dept: r.dept || '', shift: r.shift, shiftWeekend: r.shiftWeekend || '', weekdays: [...(r.weekdays || [])], anchor: r.anchor || mondayISO(start.value), steps: ruleSteps(r) })
  editingGardeId.value = r.id
}
const WD_SEMAINE = [1, 2, 3, 4, 5]; const WD_FINSEM = [6, 0]
function isSetActive (set) { return set.length && set.every(v => newGardeRule.weekdays.includes(v)) }
function toggleWeekdaysSet (set) { if (isSetActive(set)) newGardeRule.weekdays = newGardeRule.weekdays.filter(v => !set.includes(v)); else newGardeRule.weekdays = [...new Set([...newGardeRule.weekdays, ...set])] }
function toggleGardeDow (v) { const i = newGardeRule.weekdays.indexOf(v); if (i >= 0) newGardeRule.weekdays.splice(i, 1); else newGardeRule.weekdays.push(v) }
// ── Moteur de rotation : on PARCOURT la séquence semaine par semaine depuis l'ANCRAGE ──
function cycleWeeks (steps) { return (steps || []).reduce((s, x) => s + (Number(x.weeks) || 1), 0) }
function stepTechAt (steps, w) { for (const s of steps) { const n = Number(s.weeks) || 1; if (w < n) return s.tech; w -= n } return steps[0] && steps[0].tech }
// Rétrocompat : règle au nouveau format (steps) OU à l'ancien (techs[]+periodWeeks). Collapse les doublons consécutifs.
function ruleSteps (r) {
  if (r.steps && r.steps.length) return r.steps.map(s => ({ tech: s.tech, weeks: Number(s.weeks) || 1 }))
  const p = r.periodWeeks || 1; const out = []
  for (const t of (r.techs || [])) { const last = out[out.length - 1]; if (last && last.tech === t) last.weeks += p; else out.push({ tech: t, weeks: p }) }
  return out
}
function ruleAnchor (r) { return r.anchor || GARDE_EPOCH } // ancrage stable si non défini (vieilles règles)
function rotationTech (rule, iso) {
  const steps = ruleSteps(rule); const cyc = cycleWeeks(steps); if (!cyc) return null
  const w0 = (((weekNo(iso) - weekNo(ruleAnchor(rule))) % cyc) + cyc) % cyc
  for (let k = 0; k < cyc; k++) { const id = stepTechAt(steps, (w0 + k) % cyc); if (id && !isAbsent(id, iso)) return id } // saut d'absent
  return stepTechAt(steps, w0)
}
// Aperçu : qui est de garde, semaine par semaine, depuis l'ancrage — reflète la file en cours d'édition (ignore absences)
const gardePreview = computed(() => {
  const rule = newGardeRule; const cyc = cycleWeeks(rule.steps); if (!cyc || !rule.weekdays.length) return []
  const anchor = rule.anchor || mondayISO(start.value); const out = []
  for (let i = 0; i < Math.min(14, cyc + 4); i++) {
    const ws = addDaysISO(mondayISO(anchor), i * 7); const w0 = (((weekNo(ws) - weekNo(anchor)) % cyc) + cyc) % cyc
    const id = stepTechAt(rule.steps, w0)
    out.push({ week: ws, name: (techs.value.find(t => t.id === id) || {}).name || id })
  }
  return out
})
function saveGarde () { localStorage.setItem(LS_GARDE, JSON.stringify(gardeRules.value)) }
function addGardeRule () {
  if (!newGardeRule.shift || !newGardeRule.steps.length || !newGardeRule.weekdays.length) { $q.notify({ type: 'warning', message: 'Shift, jours et au moins un tech requis' }); return }
  const rule = { id: editingGardeId.value || Date.now(), dept: newGardeRule.dept || '—', shift: newGardeRule.shift, shiftWeekend: newGardeRule.shiftWeekend || '', weekdays: [...newGardeRule.weekdays], anchor: newGardeRule.anchor || mondayISO(start.value), steps: newGardeRule.steps.map(s => ({ tech: s.tech, weeks: Number(s.weeks) || 1 })) }
  if (editingGardeId.value) gardeRules.value = gardeRules.value.map(r => r.id === editingGardeId.value ? rule : r)
  else gardeRules.value = [...gardeRules.value, rule]
  saveGarde(); editingGardeId.value = null; newGardeRule.steps = []; newGardeRule.weekdays = []
  $q.notify({ type: 'positive', message: 'Règle enregistrée — clique « Générer la garde » pour l\'appliquer' })
}
function removeGardeRule (i) { gardeRules.value = gardeRules.value.filter((_, j) => j !== i); saveGarde(); if (editingGardeId.value && !gardeRules.value.some(r => r.id === editingGardeId.value)) editingGardeId.value = null }
function gardeDowLabel (r) { return (r.weekdays || []).map(w => (GARDE_DOW.find(x => x.v === w) || {}).l).join('') }
function gardeSeqLabel (r) { return ruleSteps(r).map(s => ((techs.value.find(t => t.id === s.tech) || {}).name || s.tech) + (s.weeks > 1 ? ' ×' + s.weeks : '')).join(' → ') }
// Génère les gardes de la semaine affichée selon les règles (rotation par département)
const gardeHorizon = ref(8) // nb de semaines à matérialiser (évènement récurrent)
// Génère la garde sur un HORIZON (plusieurs semaines) et l'écrit directement (publié) → navigable semaine par semaine.
async function applyGardeRules () {
  if (!gardeRules.value.length && !Object.keys(manualGarde.value).length) { $q.notify({ type: 'info', message: 'Aucune règle — ajoute-en une' }); return }
  if (dirty.value && !window.confirm('Les modifications non publiées de la grille seront rechargées. Continuer ?')) return
  const weeks = gardeHorizon.value || 8; const wk0 = mondayISO(start.value)
  // Construit la même garde EFFECTIVE que l'affichage, mais sur tout l'horizon : rotation par règles…
  const horizon = new Set(); for (let i = 0; i < weeks * 7; i++) horizon.add(addDaysISO(wk0, i))
  const map = {}
  for (const iso of horizon) {
    const dow = dowOf(iso); const weekend = (dow === 0 || dow === 6)
    for (const rule of gardeRules.value) {
      if (!rule.weekdays.includes(dow)) continue
      const sh = (weekend && rule.shiftWeekend) ? rule.shiftWeekend : rule.shift
      if (!tplByName.value[sh]) continue
      const id = rotationTech(rule, iso); if (!id) continue
      map[id + '|' + iso] = sh
    }
  }
  // … + overrides MANUELS « G » dans l'horizon (déplacements faits à la main) → publiés aussi.
  for (const key in manualGarde.value) { const iso = key.split('|')[1]; if (!horizon.has(iso)) continue; const v = manualGarde.value[key]; if (v === 'off') delete map[key]; else if (v === 'on') { const sh = gardeShiftForDay(iso); if (sh) map[key] = sh } }
  const list = []
  for (const key in map) { const [id, iso] = key.split('|'); const sh = map[key]; const tpl = tplByName.value[sh]; const t = techs.value.find(x => x.id === id); list.push({ tech: id, tech_name: t ? t.name : id, date: iso, shift: sh, hours: (tpl && tpl.hours) || 8, zone: (tpl && tpl.zone) || '' }) }
  const shifts = [...new Set([...gardeRules.value.flatMap(r => [r.shift, r.shiftWeekend].filter(Boolean)), ...Object.values(map)])]
  try {
    const r = await roster.applyGardeHorizon(wk0, weeks, list, shifts)
    showGarde.value = false; await loadWeek()
    $q.notify({ type: 'positive', message: `Garde publiée sur ${weeks} sem. : ${r.created} assignations` + (r.deleted ? ` (${r.deleted} remplacées)` : '') + '. La grille la montrait déjà en direct ; c\'est maintenant visible par dispatch et les techs.', timeout: 6000 })
  } catch (e) { err(e) }
}
function saveDemand () { localStorage.setItem(LS_DEMAND, JSON.stringify(demand.value)) }
function addDemand () { demand.value = [...demand.value, { shift: templates.value[0] && templates.value[0].name, zone: 'Montréal', skills: '', job_h: 0, weekday: 1, weekend: 0, holiday: 0 }]; saveDemand() }
function removeDemand (i) { demand.value = demand.value.filter((_, j) => j !== i); saveDemand() }
async function applyDemand () {
  if (!demand.value.length) { $q.notify({ type: 'warning', message: 'Aucune ligne de demande' }); return }
  applying.value = true
  try {
    await roster.clearRequirements(start.value, days.value)
    const reqs = []
    for (const d of dayList.value) {
      const slot = isHoliday(d.iso) ? 'holiday' : (d.weekend ? 'weekend' : 'weekday')
      for (const row of demand.value) {
        const n = Number(row[slot]) || 0; if (n <= 0 || !row.shift) continue
        const jobH = Number(row.job_h) || 0
        const sh = (tplByName.value[row.shift] && tplByName.value[row.shift].hours) || 8
        const count = jobH > 0 ? Math.max(1, Math.ceil(n * jobH / sh)) : n  // mode jobs → effectif
        reqs.push({ requirement_date: d.iso, shift_template: row.shift, zone: row.zone || '', required_count: count, required_skills: row.skills || '' })
      }
    }
    if (reqs.length) await roster.bulkRequirements(reqs)
    await loadWeek(); $q.notify({ type: 'positive', message: 'Demande appliquée : ' + reqs.length + ' besoins' })
  } catch (e) { err(e) } finally { applying.value = false }
}

// modèles de semaine
function saveTemplate () {
  $q.dialog({ title: 'Nouveau modèle', message: "Nom du modèle d'horaire", prompt: { model: '', type: 'text' }, cancel: true }).onOk(name => {
    if (!name) return; const byDow = {}
    for (const a of assignments.value) { const dow = dowOf(a.date); (byDow[dow] || (byDow[dow] = {}))[a.tech] = a.shift }
    weekTemplates.value = [...weekTemplates.value, { name, byDow }]; localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value))
    $q.notify({ type: 'positive', message: 'Modèle « ' + name + ' » enregistré' })
  })
}
function deleteTemplate (i) { weekTemplates.value = weekTemplates.value.filter((_, j) => j !== i); localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value)) }
// Modèle par défaut (★) — un seul à la fois, appliqué en 1 clic
const defaultTemplate = computed(() => weekTemplates.value.find(t => t.default) || null)
function setDefaultTemplate (i) { weekTemplates.value = weekTemplates.value.map((t, j) => ({ ...t, default: j === i ? !t.default : false })); localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value)) }
function applyDefault () { const d = defaultTemplate.value; if (!d) { $q.notify({ type: 'info', message: 'Aucun modèle par défaut — marque-en un avec ★ dans Modèles' }); return } applyTemplate(d) }
function countPatternDays (tm, techId) { let n = 0; for (const d of dayList.value) { const map = tm.byDow[dowOf(d.iso)]; if (map && map[techId]) n++ } return n }
// Application « consciente des absences » : on n'assigne pas un tech absent ce jour-là.
// Absent toute la semaine (≈ congé permanent: maternité/blessure) → flag « à remplacer ».
// Absent quelques jours (≈ vacances) → on saute juste ces jours, le reste du patron tient.
function applyTemplate (tm) {
  pushHistory()
  const skipped = {}; let applied = 0
  for (const d of dayList.value) {
    const map = tm.byDow[dowOf(d.iso)]; if (!map) continue
    for (const techId in map) {
      if (isAbsent(techId, d.iso)) { skipped[techId] = (skipped[techId] || 0) + 1; continue }
      const tpl = tplByName.value[map[techId]]; if (!tpl) continue
      const t = techs.value.find(x => x.id === techId); setCellReplace(techId, t ? t.name : techId, d.iso, tpl); applied++
    }
  }
  const fullOut = []; const partial = []
  for (const techId in skipped) {
    const t = techs.value.find(x => x.id === techId); const name = (t ? t.name : techId)
    const type = absByTechDay.value[techId + '|' + (dayList.value.find(d => isAbsent(techId, d.iso)) || {}).iso] || ''
    const lbl = name + (type ? ' (' + type + ')' : '')
    if (/longue durée/i.test(type) || skipped[techId] >= countPatternDays(tm, techId)) fullOut.push(lbl); else partial.push(lbl)
  }
  let msg = 'Modèle « ' + tm.name + ' » appliqué (' + applied + ' assignations)'
  if (partial.length) msg += ' · absence partielle ignorée : ' + partial.join(', ')
  if (fullOut.length) msg += ' · ABSENT toute la semaine — à remplacer : ' + fullOut.join(', ')
  $q.notify({ type: fullOut.length ? 'warning' : 'positive', message: msg, timeout: fullOut.length ? 9000 : 4500, multiLine: true })
}

// édition + sélection
const menu = reactive({ show: false, target: null, tech: null, day: null })
const menuRange = ref({ min: 8, max: 16 }); const quickEntry = ref('')
function rect (sti, sdi, eti, edi) {
  const t0 = Math.min(sti, eti), t1 = Math.max(sti, eti), d0 = Math.min(sdi, edi), d1 = Math.max(sdi, edi)
  const out = []
  for (let i = t0; i <= t1; i++) for (let j = d0; j <= d1; j++) out.push(visibleTechs.value[i].id + '|' + dayList.value[j].iso)
  return out
}
function onDown (ti, di, ev) { if (ev.button !== 0 || ev.shiftKey || ev.ctrlKey || ev.metaKey) return; drag.on = true; drag.ti = ti; drag.di = di; drag.moved = false; drag.base = [] }
function onEnter (ti, di) { if (!drag.on) return; drag.moved = true; selection.value = [...new Set([...drag.base, ...rect(drag.ti, drag.di, ti, di)])] }
function onUp () { if (drag.on) { drag.on = false; if (drag.moved) justDragged.value = true } }
function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a => a.shift === tpl.name)) return; assignments.value = [...assignments.value, { tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }] }
function setCellReplace (techId, techName, iso, tpl) { const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept }
function removeShift (techId, iso, shift) { assignments.value = assignments.value.filter(a => !(a.tech === techId && a.date === iso && a.shift === shift)) }
function clearLocal (techId, iso) { assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)) }
// Ouvre le menu d'horaire (Jour/Soir/Garde/Absent + heures) ancré sur la cellule.
function openShiftMenu (t, d, ev, ti, di) {
  selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d; menu.target = ev.currentTarget
  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 }
  openShiftMenu(t, d, ev, ti, di)
}
// Clic sur la BANDE de quart/garde ou un BLOC de job → ouvre la tournée du jour. Guardé contre les fins de drag.
function openDayFromCell (t, d) { if (justDragged.value) { justDragged.value = false; return } activeCell.value = { id: t.id, name: t.name, iso: d.iso }; openDayEditor(t, d) }
// Clic DROIT sur une cellule → menu d'horaire aussi (bonus / accessibilité).
function onCellContext (t, d, ev, ti, di) {
  if (justDragged.value) { justDragged.value = false; return }
  activeCell.value = { id: t.id, name: t.name, iso: d.iso }
  openShiftMenu(t, d, ev, ti, di)
}
function selectBlock (ti, di) { const a = anchor.value; const t0 = Math.min(a.ti, ti); const t1 = Math.max(a.ti, ti); const d0 = Math.min(a.di, di); const d1 = Math.max(a.di, di); const add = []; for (let i = t0; i <= t1; i++) for (let j = d0; j <= d1; j++) add.push(visibleTechs.value[i].id + '|' + dayList.value[j].iso); selection.value = [...new Set([...selection.value, ...add])] }
function maybeSelectCol (di) { const ks = visibleTechs.value.map(t => t.id + '|' + dayList.value[di].iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] }
function maybeSelectRow (ti) { const ks = dayList.value.map(d => visibleTechs.value[ti].id + '|' + d.iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] }
function isRowSelected (ti) { const t = visibleTechs.value[ti]; if (!t || !dayList.value.length) return false; return dayList.value.every(d => selSet.value.has(t.id + '|' + d.iso)) } // rangée entière sélectionnée ?
const menuCellShifts = computed(() => (menu.tech && menu.day) ? cellsOf(menu.tech.id, menu.day.iso) : [])
const menuIsAbsent = computed(() => (menu.tech && menu.day) ? isAbsent(menu.tech.id, menu.day.iso) : false)
function toggleAbsentMenu () { if (menu.tech && menu.day) { toggleAbsentCells([menu.tech.id + '|' + menu.day.iso]); menu.show = false } }
const menuIsGarde = computed(() => (menu.tech && menu.day) ? onGarde(menu.tech.id, menu.day.iso) : false)
function toggleGardeMenu () { if (menu.tech && menu.day) { toggleGardeCells([menu.tech.id + '|' + menu.day.iso]); menu.show = false } }
function removeShiftFromMenu (a) { pushHistory(); removeShift(a.tech, a.date, a.shift) }
function clearOne () { if (menu.tech && menu.day) { pushHistory(); clearLocal(menu.tech.id, menu.day.iso); menu.show = false } }
// Menu : copier / coller (marche au clic, sans Cmd+clic) + ajuster l'horaire au slider
function copyFromMenu () { if (!menu.tech || !menu.day) return; cellClipboard.value = cellsOf(menu.tech.id, menu.day.iso).map(a => a.shift); $q.notify({ type: 'positive', message: cellClipboard.value.length ? (cellClipboard.value.length + ' shift(s) copié(s) — ouvre une autre case puis Coller') : 'Case vide copiée (Coller la videra)' }) }
function pasteFromMenu () { if (!menu.tech || !menu.day) return; pushHistory(); if (!cellClipboard.value.length) { clearLocal(menu.tech.id, menu.day.iso) } else { for (const name of cellClipboard.value) { const tpl = tplByName.value[name]; if (tpl) addShift(menu.tech.id, menu.tech.name, menu.day.iso, tpl) } } menu.show = false }
// Applique une fenêtre [min,max] à la case du menu : trouve/crée un modèle auto-nommé puis remplace.
// Trouve OU crée un modèle de shift régulier pour une plage horaire (réutilisé par menu + barre de sélection).
async function ensureWindowTpl (min, max) {
  const s = numToTime(min); const e = numToTime(max); const nm = fmtH(min) + 'h–' + fmtH(max) + 'h'
  let tpl = templates.value.find(t => t.template_name === nm)
  if (!tpl) { try { await roster.createTemplate({ template_name: nm, start_time: s + ':00', end_time: e + ':00', hours: calcHours(s, e), color: '#1976d2', default_required: 1, on_call: 0 }); await refreshTemplates(); tpl = templates.value.find(t => t.template_name === nm) } catch (e2) { err(e2); return null } }
  return tpl
}
async function applyWindow (min, max) {
  if (!menu.tech || !menu.day || max <= min) return
  const tpl = await ensureWindowTpl(min, max)
  if (tpl) { pushHistory(); setCellReplace(menu.tech.id, menu.tech.name, menu.day.iso, tpl); menu.show = false }
}
function quickShift (min, max) { return applyWindow(min, max) }
async function applyMenuRange () { return applyWindow(menuRange.value.min, menuRange.value.max) }
// Saisie rapide d'heures : « 8-17 » · « 8:30-16 » · « 830-16 » · « 85 » (=8→17, dernier chiffre en pm si ≤ début).
function parseHM (tok) { tok = String(tok).trim().toLowerCase().replace(/h/g, ':').replace(/[^\d:]/g, ''); if (!tok) return null; if (tok.includes(':')) { const [h, m] = tok.split(':'); return Number(h) + (Number(m || 0)) / 60 } if (tok.length >= 3) return Number(tok.slice(0, -2)) + Number(tok.slice(-2)) / 60; return Number(tok) }
function parseQuickShift (str) {
  const s = (str || '').trim().toLowerCase(); if (!s) return null
  if (/[-–—]|to|→|\s/.test(s)) { const p = s.split(/[-–—]|to|→|\s+/).filter(Boolean); if (p.length < 2) return null; const a = parseHM(p[0]); const b = parseHM(p[1]); return (a == null || b == null || b <= a || b > 24) ? null : { min: a, max: b } }
  if (/^\d{2}$/.test(s)) { const a = Number(s[0]); let b = Number(s[1]); if (b <= a) b += 12; return (b <= a || b > 24) ? null : { min: a, max: b } } // « 85 » = 8→17
  return null
}
function applyQuick () { const r = parseQuickShift(quickEntry.value); if (!r) { $q.notify({ type: 'warning', message: 'Format : 8-17 · 8:30-16 · 85' }); return } quickEntry.value = ''; applyWindow(r.min, r.max) }
function assignBulk (tpl) { pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); const t = techs.value.find(x => x.id === tid); addShift(tid, t ? t.name : tid, iso, tpl) } selection.value = [] }
// ── Barre de sélection : mêmes 4 actions que le menu de cellule ──
async function bulkWindow (min, max) { if (!selection.value.length) return; const tpl = await ensureWindowTpl(min, max); if (tpl) assignBulk(tpl) }
function bulkQuick () { const r = parseQuickShift(quickEntry.value); if (!r) { $q.notify({ type: 'warning', message: 'Format : 8-17 · 8:30-16 · 85' }); return } quickEntry.value = ''; bulkWindow(r.min, r.max) }
function bulkGarde () { const t = selection.value.slice(); toggleGardeCells(t); selection.value = [] }
function bulkAbsent () { const t = selection.value.slice(); toggleAbsentCells(t); selection.value = [] }
function clearBulk () { pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); clearLocal(tid, iso) } selection.value = [] }
// Copier-coller une case (bâtir l'horaire vite) : copie les shifts de la 1re case sélectionnée → colle dans les autres
const cellClipboard = ref([])
function copyCell () {
  const k = selection.value[0] || (activeCell.value && (activeCell.value.id + '|' + activeCell.value.iso))
  if (!k) { $q.notify({ type: 'warning', message: 'Clique ou sélectionne une case d\'abord' }); return }
  const [tid, iso] = k.split('|'); cellClipboard.value = cellsOf(tid, iso).map(a => a.shift)
  $q.notify({ type: 'positive', message: cellClipboard.value.length ? (cellClipboard.value.length + ' shift(s) copié(s) — sélectionne des cases puis Coller (ou Cmd+V)') : 'Case vide copiée (Coller videra les cases)' })
}
function pasteCells () {
  const targets = selection.value.length ? selection.value.slice() : (activeCell.value ? [activeCell.value.id + '|' + activeCell.value.iso] : [])
  if (!targets.length) { $q.notify({ type: 'warning', message: 'Sélectionne les cases cibles' }); return }
  pushHistory()
  for (const k of targets) { const [tid, iso] = k.split('|'); const t = techs.value.find(x => x.id === tid); if (!cellClipboard.value.length) { clearLocal(tid, iso); continue } for (const name of cellClipboard.value) { const tpl = tplByName.value[name]; if (tpl) addShift(tid, t ? t.name : tid, iso, tpl) } }
  if (selection.value.length) selection.value = []
}

async function togglePause (t) { try { const paused = !isPaused(t); await roster.pauseTechnician(t.id, paused); t.status = paused ? 'En pause' : 'Disponible'; $q.notify({ type: 'info', message: t.name + (paused ? ' en pause' : ' réactivé') }) } catch (e) { err(e) } }
function err (e) { $q.notify({ type: 'negative', message: '' + (e.message || e) }) }

function onKey (e) {
  const tag = (e.target && e.target.tagName) || ''
  if (/INPUT|TEXTAREA|SELECT/.test(tag) || (e.target && e.target.isContentEditable)) return // ne pas intercepter quand on tape dans un champ
  const k = e.key.toLowerCase()
  if ((e.ctrlKey || e.metaKey) && k === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return }
  if ((e.ctrlKey || e.metaKey) && k === 'c' && (selection.value.length || activeCell.value)) { e.preventDefault(); menu.show = false; copyCell(); return }
  if ((e.ctrlKey || e.metaKey) && k === 'v' && (selection.value.length || activeCell.value)) { e.preventDefault(); menu.show = false; pasteCells(); return }
  if ((k === 'delete' || k === 'backspace') && (selection.value.length || activeCell.value)) {
    e.preventDefault(); menu.show = false
    const targets = selection.value.length ? selection.value.slice() : [activeCell.value.id + '|' + activeCell.value.iso]
    pushHistory()
    for (const key of targets) { const [tid, iso] = key.split('|'); clearLocal(tid, iso) }
    if (selection.value.length) selection.value = []
  }
  if (k === 'a' && !e.altKey && (selection.value.length || activeCell.value)) { // « A » = bascule absent
    e.preventDefault(); menu.show = false
    const targets = selection.value.length ? selection.value.slice() : [activeCell.value.id + '|' + activeCell.value.iso]
    toggleAbsentCells(targets); if (selection.value.length) selection.value = []
  }
  if (k === 'g' && !e.altKey && !e.ctrlKey && !e.metaKey && (selection.value.length || activeCell.value)) { // « G » = bascule garde (manuel)
    e.preventDefault(); menu.show = false
    const targets = selection.value.length ? selection.value.slice() : [activeCell.value.id + '|' + activeCell.value.iso]
    toggleGardeCells(targets); if (selection.value.length) selection.value = []
  }
}
function onUnload (e) { if (dirty.value) { e.preventDefault(); e.returnValue = '' } }
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek(); try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || [] } catch (e) { /* catégories de job pour suggestions */ } openAssignPanel() /* panneau « Jobs à assigner » ouvert par défaut (fermable) */ })
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload) })
onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })
</script>

<style scoped>
/* Barre d'actions de sélection : flottante (fixed) → hors flux, aucune incidence sur la hauteur de la grille. */
.sel-actions { position: fixed; left: 50%; transform: translateX(-50%); bottom: 18px; z-index: 4000; display: flex; align-items: center; flex-wrap: wrap; gap: 2px; max-width: 94vw; padding: 6px 12px; background: #e0f2f1; color: #00695c; border: 1px solid #4db6ac; border-radius: 9px; box-shadow: 0 6px 22px rgba(0,0,0,.20); }
.garde-editor { background: #faf8f6; border: 1px solid #e8e0d8; } /* sous-panneau éditeur de garde */
/* Panneau flottant « jobs à assigner » (déplaçable, glisser-déposer) */
.assign-panel { position: fixed; z-index: 5000; width: 320px; max-height: 72vh; background: #fff; border: 1px solid #cfd8dc; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,.24); display: flex; flex-direction: column; }
.assign-hdr { display: flex; align-items: center; gap: 5px; padding: 6px 10px; background: #5e35b1; color: #fff; border-radius: 8px 8px 0 0; cursor: move; font-weight: 600; font-size: 13px; user-select: none; }
.assign-sortbar { display: flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 11px; color: #555; background: #f3f0fa; border-bottom: 1px solid #e0e0e0; }
.assign-sortbar select { font-size: 11px; border: 1px solid #cfc4e8; border-radius: 5px; padding: 1px 4px; background: #fff; color: #333; flex: 1; }
.assign-body { overflow: auto; padding: 5px; flex: 1 1 auto; min-height: 60px; }
.assign-map-wrap { flex: 0 0 auto; padding: 0 5px 4px; }
.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-skill { display: inline-block; color: #fff; border-radius: 6px; padding: 0 5px; font-size: 9px; font-weight: 600; margin-right: 3px; }
.assign-foot { border-top: 1px solid #e0e0e0; padding: 6px 9px; font-size: 11px; color: #555; line-height: 1.45; background: #fafafa; border-radius: 0 0 8px 8px; }
.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 #eee; text-align: center; padding: 2px; }
.roster-grid thead th { position: sticky; top: 0; background: #fafafa; z-index: 1; }
.tech-col { position: sticky; left: 0; background: #fff; text-align: left !important; white-space: nowrap; padding: 2px 8px !important; min-width: 240px; max-width: 300px; z-index: 2; }
.roster-grid thead .tech-col { z-index: 3; }
.roster-grid tbody tr:hover td { background: #f0f7ff; }
.roster-grid tbody tr:hover .tech-col { background: #f0f7ff; }
th.weekend, td.weekend { background: #f5f5f5; }
th.holiday, td.holiday { background: #fff3e0; }
th.clk, td.clk { cursor: pointer; }
.dow { font-size: 10px; color: #999; text-transform: uppercase; }
.dnum { font-size: 11px; font-weight: 600; }
.grp { font-size: 9px; color: #999; background: #f0f0f0; border-radius: 3px; padding: 0 4px; margin-left: 2px; }
.role-ic { color: #546e7a; vertical-align: middle; margin-right: 3px; display: inline-flex; } /* icône de rôle monochrome (Lucide) */
.tech-row { display: flex; align-items: center; gap: 3px; flex-wrap: nowrap; min-width: 0; }
.tech-name { white-space: nowrap; flex-shrink: 0; }
.th { white-space: nowrap; flex-shrink: 0; }
.tech-row > .q-btn, .tech-row > .q-badge, .tech-row > .role-ic, .tech-row > .grp, .tech-row > .eff { flex-shrink: 0; }
.tech-skills { display: flex; align-items: center; gap: 2px; overflow: hidden; flex: 1 1 auto; min-width: 0; } /* chips inline, débordement clippé */
.skill-edit-btn { flex-shrink: 0; }
.skill-chip { font-size: 10px; line-height: 15px; height: 15px; padding: 0 5px; border-radius: 8px; color: #fff; font-weight: 600; white-space: nowrap; flex-shrink: 0; display: inline-flex; align-items: center; gap: 2px; }
.chip-lvl { display: inline-flex; align-items: center; justify-content: center; min-width: 13px; height: 13px; padding: 0 1px; border-radius: 50%; background: rgba(0,0,0,.32); font-size: 8px; font-weight: 800; box-shadow: 0 0 0 1.5px #fff; margin-left: 1px; } /* niveau 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 */
.hide-eye { flex-shrink: 0; opacity: .45; } .tech-row:hover .hide-eye { opacity: 1; } /* œil masquer (discret, visible au survol) */
tr.res-hidden { opacity: .5; background: repeating-linear-gradient(45deg, #fafafa, #fafafa 6px, #f0f0f0 6px, #f0f0f0 12px); } /* ressource masquée affichée en grisé */
tr.res-hidden .hide-eye { opacity: 1; }
.tech-name.clk:hover { text-decoration: underline; }
.hol-toggle { font-size: 9px; color: #ccc; cursor: pointer; border: 1px solid #eee; border-radius: 3px; width: 14px; margin: 1px auto 0; line-height: 12px; }
.hol-toggle.on { background: #ff9800; color: #fff; border-color: #ff9800; }
.cell { cursor: pointer; min-height: 24px; position: relative; }
.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é */
.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: 11px; min-width: 64px; background: #f1f3f5; border-radius: 2px; margin: 2px 0; overflow: hidden; cursor: pointer; } /* fond cliquable → ajouter/modifier un quart */
.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:hover { background: #f7f5fc; }
.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; }
.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-detail { font-size: 11px; line-height: 1.4; color: #444; background: #f7f5fc; border-left: 3px solid #b39ddb; border-radius: 4px; margin: 0 4px 6px 40px; padding: 6px 8px; max-height: 280px; overflow: auto; }
.de-msg { border-top: 1px solid #e6e0f0; padding: 4px 0; }
.de-msg:first-of-type { border-top: none; }
.de-msg-hdr { font-size: 11px; font-weight: 700; color: #5e35b1; }
.de-msg-txt { font-size: 11px; white-space: pre-wrap; color: #37474f; }
.assign-thread { background: #f5f5f5; border-radius: 5px; padding: 4px 7px; margin-top: 4px; max-height: 220px; overflow: auto; }
.assign-msg { font-size: 11px; border-top: 1px solid #e0e0e0; padding: 3px 0; }
.assign-msg:first-child { border-top: none; }
.assign-msg-txt { white-space: pre-wrap; color: #37474f; }
.tl-shift { position: absolute; top: 0; bottom: 0; background: #ccd2d8; border-radius: 2px; border: 1px solid rgba(55,65,120,.5); box-sizing: border-box; } /* fenêtre dispo (contour foncé pour la distinguer du fond) */
.tl-shift.oncall { background: rgba(255,179,0,.14); border: 1px dashed #f9a825; } /* garde = sur appel hors heures (pointillé ambre) */
.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: 0; bottom: 0; border-radius: 1px; } /* occupé = barre de statut opaque */
.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; }
</style>
