Sur la carte Tournées, les jobs NON assignés (props.pins) étaient de
simples gouttes oranges sans icône ni gestion du chevauchement, alors que
les arrêts ASSIGNÉS sont des pastilles round-rect (icône compétence + n°)
qui s'éclatent en éventail au survol quand elles se chevauchent
(recluster/fan). Uniformisé :
- renderPins() rend la MÊME pastille .rm-pill (icône de compétence via
pin.icon = skillSym), variante « non assigné » (.rm-pill-un : bordure
pointillée + halo orange), avec hover onEnter/onLeave.
- recluster() opère désormais sur stopMk.concat(pinMk) → arrêts et jobs
non assignés participent au même éclatement au chevauchement.
- routeUnassignedPins fournit `icon` (skillSym du required_skill/service).
⚠️ Visuel (pastille + éventail) NON vérifiable en préview locale : le jeu
de données dev n'a pas de coordonnées sur les jobs → 0 pin rendu. Code =
miroir fidèle du rendu d'arrêt éprouvé ; map s'initialise, 0 nouvelle
erreur console. À vérifier à l'œil en prod (adresses réelles géocodées).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6071 lines
578 KiB
Vue
6071 lines
578 KiB
Vue
<template>
|
||
<q-page padding>
|
||
<!-- Barre d'actions MOBILE (téléphone, < md) : ÉPURÉE — l'essentiel = Publier ; tout le secondaire dans le menu ⋮.
|
||
La bande de jours + la liste « À assigner » sont la vraie navigation tactile (cf. .plan-mobile). -->
|
||
<div class="row items-center q-mb-sm lt-md pm-toolbar">
|
||
<q-chip v-if="dirty" dense size="sm" color="orange" text-color="white" icon="circle">{{ dirtyCount }} non publié(s)</q-chip>
|
||
<q-chip v-if="offShiftWeekCount" dense size="sm" color="warning" text-color="white" icon="warning">{{ offShiftWeekCount }} hors quart</q-chip>
|
||
<span v-if="!dirty && !offShiftWeekCount" class="text-caption text-grey-6">{{ selDayLabel }}</span>
|
||
<q-space />
|
||
<q-btn dense flat round icon="more_vert" color="grey-8" aria-label="Plus d'actions">
|
||
<q-menu>
|
||
<q-list dense style="min-width:214px">
|
||
<q-item clickable v-close-popup @click="navToday"><q-item-section avatar><q-icon name="today" /></q-item-section><q-item-section>Aujourd'hui</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="navWeek(-1)"><q-item-section avatar><q-icon name="chevron_left" /></q-item-section><q-item-section>Semaine précédente</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="navWeek(1)"><q-item-section avatar><q-icon name="chevron_right" /></q-item-section><q-item-section>Semaine suivante</q-item-section></q-item>
|
||
<q-separator />
|
||
<q-item clickable v-close-popup @click="doGenerate"><q-item-section avatar><q-icon name="auto_awesome" color="primary" /></q-item-section><q-item-section>Générer (auto)</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="openLeave"><q-item-section avatar><q-icon name="beach_access" color="teal" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
|
||
<q-separator />
|
||
<q-item tag="label" clickable><q-item-section avatar><q-icon name="sms" /></q-item-section><q-item-section>Notifier par SMS</q-item-section><q-item-section side><q-toggle v-model="notifySms" dense /></q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="openCreateJob"><q-item-section avatar><q-icon name="add_task" color="teal-7" /></q-item-section><q-item-section>Créer un job</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="showSuggestSlots = true"><q-item-section avatar><q-icon name="event_available" color="teal-8" /></q-item-section><q-item-section>Trouver un créneau</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="openSchedGenBulk"><q-item-section avatar><q-icon name="event_note" color="primary" /></q-item-section><q-item-section>Générer les horaires (lot)</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="openLegacyPush"><q-item-section avatar><q-icon name="sync_alt" color="deep-orange" /></q-item-section><q-item-section>Publier au legacy</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="() => guard(loadWeek)"><q-item-section avatar><q-icon name="refresh" /></q-item-section><q-item-section>Rafraîchir</q-item-section></q-item>
|
||
</q-list>
|
||
</q-menu>
|
||
</q-btn>
|
||
<q-btn unelevated no-caps color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" @click="doPublish" />
|
||
</div>
|
||
|
||
<!-- Rangée 1 : actions principales (nav · Outils · Générer · Publier) — DESKTOP/tablette uniquement (gt-sm) -->
|
||
<div class="row items-center q-mb-sm q-gutter-xs gt-sm">
|
||
<div class="text-h6 text-weight-bold">Planification</div>
|
||
<q-chip v-if="dirty" dense size="sm" color="orange" text-color="white" icon="circle">{{ dirtyCount }} non publié(s)</q-chip>
|
||
<q-chip v-if="offShiftWeekCount" dense size="sm" color="warning" text-color="white" icon="warning">{{ offShiftWeekCount }} hors quart<q-tooltip class="bg-grey-9">{{ offShiftWeekCount }} job(s) assigné(s) cette période un jour où la ressource n'a AUCUN quart publié. Repère le ⚠ dans la grille → publier un quart ou réassigner.</q-tooltip></q-chip>
|
||
<q-space />
|
||
<q-btn-group flat>
|
||
<q-btn dense flat icon="keyboard_double_arrow_left" @click="navWeek(-1)"><q-tooltip>Semaine précédente</q-tooltip></q-btn>
|
||
<q-btn dense flat icon="chevron_left" @click="navDay(-1)"><q-tooltip>Jour précédent</q-tooltip></q-btn>
|
||
<q-btn dense flat label="Auj." @click="navToday" />
|
||
<q-btn dense flat icon="chevron_right" @click="navDay(1)"><q-tooltip>Jour suivant</q-tooltip></q-btn>
|
||
<q-btn dense flat icon="keyboard_double_arrow_right" @click="navWeek(1)"><q-tooltip>Semaine suivante</q-tooltip></q-btn>
|
||
</q-btn-group>
|
||
<q-input dense outlined type="date" v-model="boardDate" style="width:150px"><q-tooltip>{{ boardView === 'kanban' ? 'Jour affiché (mode Jour)' : 'Début de la fenêtre (mode Semaine)' }}</q-tooltip></q-input>
|
||
<q-select v-show="boardView === 'grid'" dense outlined v-model="days" :options="[{ label: 'Semaine', value: 7 }, { label: '2 sem.', value: 14 }]" style="width:108px" emit-value map-options @update:model-value="onDaysChange"><q-tooltip>Étendue de la grille</q-tooltip></q-select>
|
||
<span class="text-caption text-grey-7" style="margin-right:-2px">Horaire :</span>
|
||
<q-btn-toggle v-model="boardView" dense unelevated no-caps :options="[{ value: 'grid', icon: 'calendar_view_week', label: 'Semaine' }, { value: 'kanban', icon: 'today', label: 'Jour' }, { value: 'routes', icon: 'route', label: 'Tournées' }]" toggle-color="primary" color="grey-3" text-color="grey-8"><q-tooltip>Semaine = grille (vue d'ensemble) · Jour = disposition d'une journée · Tournées = trajets réels de la journée, 1 couleur par tech (OSRM)</q-tooltip></q-btn-toggle>
|
||
<q-btn dense flat round icon="undo" :disable="!history.length" @click="undo"><q-tooltip>Annuler (Ctrl+Z)</q-tooltip></q-btn>
|
||
<q-btn dense flat round icon="redo" :disable="!future.length" @click="redo"><q-tooltip>Rétablir (Ctrl+Shift+Z)</q-tooltip></q-btn>
|
||
<q-separator vertical class="q-mx-xs" />
|
||
<!-- Accès RAPIDE au panneau « Jobs à assigner » (liste · carte · lasso · suggestion auto) — badge = nombre à répartir -->
|
||
<q-btn dense :outline="!assignPanel.open" :unelevated="assignPanel.open" no-caps color="deep-purple" icon="assignment_ind" label="À assigner" @click="assignPanel.open ? (assignPanel.open = false) : openAssignPanel()">
|
||
<q-badge v-if="assignPanel.jobs.length" color="red" floating>{{ assignPanel.jobs.length }}</q-badge>
|
||
<q-tooltip>Afficher / masquer les jobs à répartir (liste · carte · lasso · suggestion auto)</q-tooltip>
|
||
</q-btn>
|
||
<!-- Création de job NATIVE OPS (#17) — Dispatch déprécié → tout ici. « Créer » = pool · « Créneau » = choisir tech+date+heure d'abord -->
|
||
<q-btn dense unelevated no-caps color="teal-7" icon="add_task" label="Créer un job" @click="openCreateJob"><q-tooltip>Créer un job / une installation (apparaît dans « À assigner »)</q-tooltip></q-btn>
|
||
<q-btn dense outline no-caps color="teal-8" icon="event_available" label="Créneau" @click="showSuggestSlots = true"><q-tooltip>Trouver le meilleur créneau (tech + date + heure) puis créer le job réservé</q-tooltip></q-btn>
|
||
<!-- Menu OUTILS : regroupe la config secondaire pour désencombrer -->
|
||
<q-btn-dropdown dense outline color="grey-8" icon="build" label="Outils" no-caps>
|
||
<q-list dense style="min-width:230px">
|
||
<q-item clickable v-close-popup @click="showDemand = !showDemand">
|
||
<q-item-section avatar><q-icon name="tune" :color="showDemand ? 'indigo' : 'grey-7'" /></q-item-section>
|
||
<q-item-section>Demande de personnel</q-item-section>
|
||
<q-item-section side><q-icon v-if="showDemand" name="check" color="indigo" /></q-item-section>
|
||
</q-item>
|
||
<q-item clickable v-close-popup @click="openDepotPicker">
|
||
<q-item-section avatar><q-icon name="warehouse" color="blue-grey-7" /></q-item-section>
|
||
<q-item-section>Point de départ (dépôt)<q-item-label caption class="ellipsis" style="max-width:210px">{{ depot && depot.address ? depot.address : 'non défini — cliquer pour situer' }}</q-item-label></q-item-section>
|
||
</q-item>
|
||
<q-item clickable v-close-popup @click="showDayJobs = !showDayJobs">
|
||
<q-item-section avatar><q-icon name="event_note" :color="showDayJobs ? 'indigo' : 'grey-7'" /></q-item-section>
|
||
<q-item-section>Jobs prévus en en-tête<q-item-label caption>liste par jour — glisser sur un tech</q-item-label></q-item-section>
|
||
<q-item-section side><q-icon v-if="showDayJobs" name="check" color="indigo" /></q-item-section>
|
||
</q-item>
|
||
<q-item clickable v-close-popup @click="showLegacyLoad = !showLegacyLoad">
|
||
<q-item-section avatar><q-icon name="assignment_ind" :color="showLegacyLoad ? 'brown' : 'grey-7'" /></q-item-section>
|
||
<q-item-section>Jobs Legacy sur les timelines<q-item-label caption>durées estimées des jobs osTicket datés (fenêtre affichée)</q-item-label></q-item-section>
|
||
<q-item-section side><q-icon v-if="showLegacyLoad" name="check" color="brown" /></q-item-section>
|
||
</q-item>
|
||
<q-item clickable>
|
||
<q-item-section avatar><q-icon name="bookmark" color="brown" /></q-item-section>
|
||
<q-item-section>Modèles de semaine</q-item-section>
|
||
<q-item-section side><q-icon name="chevron_right" color="grey-6" /></q-item-section>
|
||
<q-menu anchor="top end" self="top start">
|
||
<q-list dense style="min-width:250px">
|
||
<q-item clickable v-close-popup @click="saveTemplate"><q-item-section avatar><q-icon name="save" /></q-item-section><q-item-section>Enregistrer la semaine…</q-item-section></q-item>
|
||
<q-separator v-if="weekTemplates.length" />
|
||
<q-item-label v-if="weekTemplates.length" header>Appliquer</q-item-label>
|
||
<q-item v-for="(tm, i) in weekTemplates" :key="i" clickable @click="applyTemplate(tm)">
|
||
<q-item-section avatar><q-btn flat dense round size="sm" :icon="tm.default ? 'star' : 'star_border'" :color="tm.default ? 'amber-8' : 'grey-5'" @click.stop="setDefaultTemplate(i)"><q-tooltip>Modèle par défaut (★)</q-tooltip></q-btn></q-item-section>
|
||
<q-item-section>{{ tm.name }}</q-item-section>
|
||
<q-item-section side><q-btn flat dense round size="sm" icon="delete" color="grey-6" @click.stop="deleteTemplate(i)" /></q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
</q-menu>
|
||
</q-item>
|
||
<q-item clickable v-close-popup @click="doGenerate"><q-item-section avatar><q-icon name="auto_awesome" color="indigo" /></q-item-section><q-item-section>Générer l'horaire (semaine)<q-item-label caption>solveur de quarts — la semaine affichée</q-item-label></q-item-section><q-item-section side><q-spinner v-if="generating" size="16px" color="indigo" /></q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="openSchedGenBulk"><q-item-section avatar><q-icon name="event_note" color="primary" /></q-item-section><q-item-section>Générer les horaires (lot)<q-item-label caption>modèle → plusieurs techs, N semaines</q-item-label></q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="openGarde"><q-item-section avatar><q-icon name="shield" color="brown" /></q-item-section><q-item-section>Rotation de garde</q-item-section></q-item>
|
||
<q-separator />
|
||
<q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="speed" /></q-item-section><q-item-section>Cadence équipe</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="showTagManager = true"><q-item-section avatar><q-icon name="sell" color="teal" /></q-item-section><q-item-section>Gérer les compétences (tags)</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="openTechSync"><q-item-section avatar><q-icon name="group_add" color="green-7" /></q-item-section><q-item-section>Synchroniser les techniciens</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="openJobChar"><q-item-section avatar><q-icon name="timer" color="warning" /></q-item-section><q-item-section>Durées par caractéristique</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="openAssignPanel"><q-item-section avatar><q-icon name="drag_indicator" color="deep-purple" /></q-item-section><q-item-section>Jobs à assigner (glisser-déposer)</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="openLeave"><q-item-section avatar><q-icon name="beach_access" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
|
||
</q-list>
|
||
</q-btn-dropdown>
|
||
<q-btn v-if="defaultTemplate" dense flat color="warning" icon="star" :label="defaultTemplate.name" @click="applyDefault"><q-tooltip>Appliquer le modèle par défaut (consciente des absences)</q-tooltip></q-btn>
|
||
<q-separator vertical class="q-mx-xs" />
|
||
<q-btn unelevated color="primary" icon="auto_awesome" label="Suggérer" @click="openSuggest"><q-tooltip class="bg-grey-9">Répartition automatique des jobs du <b>jour sélectionné</b> (distance · compétence · priorité · taux d'occupation) — revue avant d'appliquer</q-tooltip></q-btn>
|
||
<q-checkbox v-model="notifySms" label="SMS" dense size="sm"><q-tooltip>Notifier les techs par SMS à la publication</q-tooltip></q-checkbox>
|
||
<q-btn :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" @click="doPublish" />
|
||
<q-btn outline color="deep-orange" icon="sync_alt" label="Publier au legacy" @click="openLegacyPush"><q-tooltip class="bg-grey-9">Réassigne les tickets aux techniciens DANS le système legacy (osTicket) selon l'assignation Ops — aperçu avant d'écrire.</q-tooltip></q-btn>
|
||
<q-btn flat dense round icon="refresh" :loading="loading" @click="() => guard(loadWeek)" />
|
||
</div>
|
||
|
||
<!-- Rangée 2 : filtres + légende en popover — DESKTOP/tablette uniquement (gt-sm) -->
|
||
<div class="row items-center q-gutter-sm q-mb-sm gt-sm">
|
||
<q-input dense outlined clearable v-model="search" placeholder="Rechercher un technicien…" style="width:230px"><template #prepend><q-icon name="search" /></template></q-input>
|
||
<q-input dense outlined type="number" v-model.number="maxHours" label="Max h/sem" style="width:110px" />
|
||
<span class="text-caption text-grey-6">{{ visibleTechs.length }} / {{ techs.length }} techs</span>
|
||
<q-chip v-if="cellClipboard.length" dense size="sm" color="indigo" text-color="white" icon="content_paste">{{ cellClipboard.length }} copié(s)</q-chip>
|
||
<q-space />
|
||
<q-btn dense flat round icon="help_outline" color="grey-7"><q-tooltip>Légende & raccourcis</q-tooltip>
|
||
<q-menu>
|
||
<div class="q-pa-md" style="max-width:420px">
|
||
<div class="text-subtitle2 q-mb-sm">Légende</div>
|
||
<div class="row items-center q-gutter-xs q-mb-xs"><span class="tod-leg"></span><span class="text-grey-8">dispo (matin → soir)</span></div>
|
||
<div class="row items-center q-gutter-xs q-mb-xs"><span class="occ-leg"></span><span class="text-grey-8">occupation (vert → rouge)</span></div>
|
||
<div class="row items-center q-gutter-xs q-mb-xs"><span class="leg-absent"></span><span class="text-grey-8">absent / congé</span></div>
|
||
<div class="row items-center q-gutter-xs q-mb-xs"><span class="leg-garde"></span><span class="text-grey-8">garde (hors bureau)</span></div>
|
||
<div class="row items-center q-gutter-xs q-mb-sm"><span class="free q-mr-xs">·</span><span class="text-grey-8">libre</span><span class="cell-dirty-demo q-ml-md q-mr-xs">J</span><span class="text-grey-8">modifié (non publié)</span></div>
|
||
<div class="text-subtitle2 q-mb-xs">Raccourcis</div>
|
||
<div class="text-caption text-grey-8"><b>glisser</b> = sélection · <b>shift+clic</b> = bloc · clic en-tête = colonne · clic nom = rangée · <b>ctrl+clic</b> = +1 · <b>ctrl+C/V</b> = copier/coller · <b>Suppr/⌫</b> = vider · <b>A</b> = absent · <b>G</b> = garde</div>
|
||
</div>
|
||
</q-menu>
|
||
</q-btn>
|
||
</div>
|
||
|
||
<!-- Filtre par compétence (chip/tag) → n'affiche que les techs capables, triés par priorité — DESKTOP/tablette (gt-sm) ; sur mobile le pool « À assigner » a ses propres chips -->
|
||
<div v-if="allSkills.length" class="row items-center q-gutter-xs q-mb-sm gt-sm">
|
||
<q-icon name="sell" size="16px" color="teal" /><span class="text-caption text-grey-7">Compétences :</span>
|
||
<q-chip v-for="sk in allSkills" :key="sk" clickable dense size="sm" :color="skillFilter.includes(sk) ? 'teal' : 'grey-3'" :text-color="skillFilter.includes(sk) ? 'white' : 'grey-8'" @click="toggleSkill(sk)">{{ sk }}</q-chip>
|
||
<template v-if="skillFilter.length">
|
||
<q-btn flat dense size="sm" color="grey-7" icon="close" label="Effacer" @click="skillFilter = []" />
|
||
<span class="text-caption text-teal text-weight-medium">▸ {{ skillFilter.length > 1 ? 'toutes requises (ET)' : 'capables' }} · {{ visibleTechs.length }} tech(s), triés par priorité</span>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Demande -->
|
||
<q-card v-if="showDemand" flat bordered class="q-mb-md">
|
||
<q-card-section class="q-pb-none">
|
||
<div class="row items-center">
|
||
<div class="text-subtitle2 text-weight-bold">Demande — effectif requis par créneau</div><q-space />
|
||
<q-btn dense flat icon="schedule" label="Types de shift" @click="openShiftEditor" />
|
||
<q-btn dense flat icon="add" label="Ajouter" @click="addDemand" />
|
||
<q-btn dense unelevated color="indigo" icon="playlist_add_check" label="Appliquer à la semaine" :loading="applying" class="q-ml-sm" @click="applyDemand" />
|
||
</div>
|
||
<div class="text-caption text-grey-7 q-mt-xs">Coche les jours <b>fériés</b> (F) dans l'en-tête · fin de semaine = sam/dim (auto). Si <b>Durée/job</b> > 0, les nombres = <b>nb de jobs</b> → effectif = ⌈jobs × durée ÷ heures du shift⌉ (compétences requises = colonne Compétences).</div>
|
||
</q-card-section>
|
||
<q-card-section>
|
||
<table class="demand-tbl">
|
||
<thead><tr><th>Modèle</th><th>Zone</th><th>Compétences</th><th>Durée/job (h)</th><th>Semaine</th><th>Fin de sem.</th><th>Férié</th><th></th></tr></thead>
|
||
<tbody>
|
||
<tr v-for="(d, i) in demand" :key="i">
|
||
<td><q-select dense options-dense outlined v-model="d.shift" :options="tplOptions" emit-value map-options style="min-width:150px" @update:model-value="saveDemand" /></td>
|
||
<td><q-input dense outlined v-model="d.zone" style="width:120px" @update:model-value="saveDemand" /></td>
|
||
<td><SkillSelect v-model="d.skills" style="min-width:150px" @update:model-value="saveDemand" /></td>
|
||
<td><q-input dense outlined type="number" step="0.5" v-model.number="d.job_h" placeholder="0" style="width:80px" @update:model-value="saveDemand" /></td>
|
||
<td><q-input dense outlined type="number" v-model.number="d.weekday" style="width:70px" @update:model-value="saveDemand" /></td>
|
||
<td><q-input dense outlined type="number" v-model.number="d.weekend" style="width:70px" @update:model-value="saveDemand" /></td>
|
||
<td><q-input dense outlined type="number" v-model.number="d.holiday" style="width:70px" @update:model-value="saveDemand" /></td>
|
||
<td><q-btn flat dense round size="sm" icon="delete" color="grey-7" @click="removeDemand(i)" /></td>
|
||
</tr>
|
||
<tr v-if="!demand.length"><td colspan="8" class="text-grey-6 q-pa-sm">Aucune ligne — clique « Ajouter ».</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</q-card-section>
|
||
</q-card>
|
||
|
||
<q-banner v-if="solverStats" dense rounded class="q-mb-md" :class="solverStats.shortfall ? 'bg-orange-1 text-warning' : 'bg-green-1 text-green-9'">
|
||
<q-icon :name="solverStats.shortfall ? 'warning' : 'check_circle'" class="q-mr-xs" />
|
||
{{ solverStats.assignments }} assignations · {{ solverStats.shortfall ? (solverStats.shortfall + ' poste(s) non couvert(s)') : 'couverture complète' }} · équité {{ solverStats.spread }} h · {{ solverStats.ms }} ms
|
||
</q-banner>
|
||
|
||
<!-- Barre d'actions FLOTTANTE (position: fixed) → n'altère JAMAIS la hauteur de la grille : pas de décalage
|
||
du tableau quand la sélection démarre pendant un glisser (le curseur reste sur la rangée visée). -->
|
||
<div v-if="selection.length" class="sel-actions" @mousedown.stop>
|
||
<span class="text-weight-medium q-mr-xs">{{ selection.length }} cellule(s) :</span>
|
||
<q-btn dense unelevated size="sm" color="primary" label="Jour" @click="bulkWindow(8, 17)" />
|
||
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir" @click="bulkWindow(16, 20)" />
|
||
<q-btn dense unelevated size="sm" color="brown" icon="shield" label="Garde" @click="bulkGarde" />
|
||
<q-btn dense unelevated size="sm" color="negative" icon="event_busy" label="Absent" @click="bulkAbsent" />
|
||
<q-input dense outlined v-model="quickEntry" placeholder="8-17" style="width:84px" @keyup.enter="bulkQuick" @mousedown.stop><q-tooltip>Saisie rapide : 8-17 · 830-16 · 85</q-tooltip></q-input>
|
||
<q-separator vertical class="q-mx-xs" />
|
||
<q-btn dense flat size="sm" icon="content_copy" label="Copier" @click="copyCell" />
|
||
<q-btn dense flat size="sm" icon="content_paste" :label="cellClipboard.length ? ('Coller (' + cellClipboard.length + ')') : 'Coller'" :disable="!cellClipboard.length" @click="pasteCells" />
|
||
<q-btn dense flat size="sm" icon="layers_clear" label="Libérer" @click="clearBulk" />
|
||
<q-btn dense flat size="sm" icon="close" label="Annuler" @click="selection = []" />
|
||
</div>
|
||
|
||
|
||
<!-- VUE MOBILE (téléphone, < md) en mode Semaine : bande de jours + barres de charge par tech (« heat level »
|
||
d'un coup d'œil). Remplace la grille large (cachée en gt-sm). Inspiré des apps de suivi quotidien. -->
|
||
<div v-if="boardView === 'grid'" class="plan-mobile lt-md">
|
||
<div v-if="nextHolidayNotify" class="pm-hol-banner">
|
||
<q-icon name="event_busy" color="warning" size="16px" />
|
||
<span class="ellipsis">Férié à venir : <b>{{ nextHolidayNotify.name }}</b> ({{ nextHolidayNotify.date }}) — {{ nextHolidayNotify.to_notify }} tech(s) à prévenir</span>
|
||
<q-space />
|
||
<q-btn dense unelevated no-caps size="sm" color="warning" icon="send" label="Préavis" @click="sendHolidayPreavis(nextHolidayNotify)" />
|
||
</div>
|
||
<div class="pm-strip">
|
||
<button v-for="d in mobileDayLoads" :key="d.iso" type="button" class="pm-day" :class="{ active: d.iso === mobileSelDay, today: d.iso === todayIso, weekend: d.weekend, holiday: isHoliday(d.iso) }" :title="(isHoliday(d.iso) ? '★ ' + holidayLabel(d.iso) + ' (férié) — ' : '') + Math.round(d.h) + 'h à faire ce jour' + (d.pending ? ' (dont ' + d.pending + 'h à répartir)' : '') + ' / ' + d.cap + 'h de capacité' + (d.noShift ? ' — ⚠ ' + d.noShift + ' tech(s) sans quart (8h estimés)' : '')" @click="mobileSelIso = d.iso">
|
||
<span v-if="isHoliday(d.iso)" class="pm-day-hol">★<q-tooltip>{{ holidayLabel(d.iso) }} (férié)</q-tooltip></span>
|
||
<span v-if="d.noShift" class="pm-day-warn" :title="d.noShift + ' tech(s) avec du travail mais aucun quart créé ce jour — disponibilité estimée à 8 h. Touchez le jour puis créez les quarts.'">▲</span>
|
||
<span class="pm-dow">{{ d.dow }}</span>
|
||
<span class="pm-num">{{ (d.dnum || '').split('/')[0] }}</span>
|
||
<span class="pm-bar"><span class="pm-fill" :style="{ height: (d.h > 0 ? Math.max(12, Math.min(100, Math.round(d.ratio * 100))) : 0) + '%', background: heatColor(d.ratio) }"></span></span>
|
||
<span class="pm-h">{{ d.cap > 0 ? Math.round(d.h) + '/' + Math.round(d.cap) : (d.h + 'h') }}<span v-if="d.over" class="pm-over">⚠</span></span>
|
||
</button>
|
||
</div>
|
||
<div class="pm-techs">
|
||
<div class="pm-techs-hd"><q-icon name="groups" size="16px" class="q-mr-xs" />Charge — <b class="q-ml-xs">{{ selDayLabel }}</b><q-space /><span class="text-grey-6">{{ mobileTechBusy.length }} occupé(s)</span></div>
|
||
<div class="pm-techs-list">
|
||
<button v-for="t in mobileTechBusy" :key="t.id" type="button" class="pm-tech" :class="{ 'no-shift': t.noShift }" @click="onTechTap(t)">
|
||
<span class="pm-ava">{{ initials(t.name) }}</span>
|
||
<span class="pm-tech-name ellipsis">{{ t.name }}</span>
|
||
<span class="pm-tbar"><span class="pm-tfill" :style="{ width: (t.h > 0 ? Math.max(8, Math.min(100, Math.round(t.ratio * 100))) : 0) + '%', background: heatColor(t.ratio) }"></span></span>
|
||
<span v-if="t.noShift" class="pm-warn" title="Aucun quart planifié ce jour — capacité estimée à 8 h. Touchez le tech pour créer un quart.">▲</span>
|
||
<span class="pm-th" :class="{ over: t.over }">{{ t.h }}<span class="pm-cap" :class="{ est: t.noShift }">/{{ t.cap }}h</span></span>
|
||
</button>
|
||
<div v-if="!mobileTechLoads.length" class="pm-empty">Aucun technicien (filtre ?).</div>
|
||
<div v-else-if="!mobileTechBusy.length" class="pm-empty">Aucune charge ce jour.</div>
|
||
<button v-if="mobileTechIdle.length" type="button" class="pm-more" @click="pmShowIdle = !pmShowIdle">{{ pmShowIdle ? '▲ Masquer les libres' : '▼ ' + mobileTechIdle.length + ' tech(s) libre(s) (0 h)' }}</button>
|
||
<template v-if="pmShowIdle">
|
||
<button v-for="t in mobileTechIdle" :key="t.id" type="button" class="pm-tech pm-tech-idle" @click="onTechTap(t)">
|
||
<span class="pm-ava">{{ initials(t.name) }}</span>
|
||
<span class="pm-tech-name ellipsis">{{ t.name }}</span>
|
||
<span class="pm-tbar"></span>
|
||
<span class="pm-th">0h</span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<!-- Pool « À assigner » : chips de filtre (compétence) + tri (jour sélectionné d'abord) + liste BORNÉE (scroll interne, pas d'étirement). -->
|
||
<div class="pm-pool">
|
||
<div class="pm-pool-hd"><q-icon name="inbox" size="16px" class="q-mr-xs" />À assigner <span class="pm-count">{{ poolJobs.length }}</span><q-space /><span class="text-grey-5 q-mr-sm" style="font-size:10px;display:inline-flex;align-items:center;gap:1px">prio<i class="pm-leg" style="background:#ef4444"></i><i class="pm-leg" style="background:#f59e0b"></i><i class="pm-leg" style="background:#9e9e9e"></i></span><q-select v-model="poolSort" :options="poolSortOpts" dense options-dense borderless emit-value map-options class="pm-sort"><template #prepend><q-icon name="sort" size="15px" color="grey-6" /></template></q-select></div>
|
||
<div v-if="poolSkills.length" class="pm-chips">
|
||
<button v-for="s in poolSkills" :key="s.skill" type="button" class="pm-chip" :class="{ on: poolSkillSel.has(s.skill) }" :style="poolSkillSel.has(s.skill) ? { background: getTagColor(s.skill), borderColor: getTagColor(s.skill), color: '#fff' } : {}" @click="togglePoolSkill(s.skill)">{{ s.skill }} {{ s.n }}</button>
|
||
<button v-if="poolSkillSel.size" type="button" class="pm-chip pm-chip-x" @click="poolSkillSel = new Set()">✕ tout</button>
|
||
</div>
|
||
<div class="pm-pool-list">
|
||
<!-- Façon Gmail : GLISSER → action (droite = reporter +1 j · gauche = sheet Options) + icônes DIRECTES (★ urgent · 📝 note · ⋮ options). Tap = sélectionner.
|
||
Swipe MAISON (pointer events) : le tap reste un vrai clic sur le bouton (jamais cassé), le glissement n'agit qu'au drag horizontal franc. -->
|
||
<template v-for="(j, pi) in poolJobs" :key="j.name"><div v-if="poolSort === 'sector' && (pi === 0 || jobCity(j) !== jobCity(poolJobs[pi - 1]))" class="pm-sector-hd" style="display:flex;align-items:center;gap:4px;padding:5px 8px;margin:6px 0 2px;background:#eef2f7;border-radius:6px;font-size:11px;font-weight:600;color:#475569;position:sticky;top:0;z-index:2;cursor:pointer" @click="selectAllSector(jobCity(j) || 'Sans secteur')"><q-icon name="place" size="13px" color="green-5" />{{ jobCity(j) || 'Sans secteur' }}<q-icon name="done_all" size="14px" color="primary" class="q-ml-xs"><q-tooltip class="bg-grey-9">Sélectionner / désélectionner tout ce secteur (puis « Assigner »)</q-tooltip></q-icon><span style="margin-left:auto;font-weight:400;color:#94a3b8">{{ (poolSectorCounts[jobCity(j) || 'Sans secteur'] || {}).n }} · {{ fmtMin((poolSectorCounts[jobCity(j) || 'Sans secteur'] || {}).mins || 0) }}</span></div><div class="pm-swipe">
|
||
<div class="pm-swipe-bg"><span class="pm-swipe-hint l"><q-icon name="check_circle" size="17px" /> Sélectionner</span><span class="pm-swipe-hint r">Sélectionner <q-icon name="check_circle" size="17px" /></span></div>
|
||
<button type="button" class="pm-job" :class="{ sel: selectedJobs[j.name], hold: j.status === 'On Hold', swiping: swipe.active && swipe.name === j.name }" :style="{ borderLeft: '4px solid ' + prioColor(j.priority), ...swipeStyle(j) }"
|
||
@click="onJobClick(j)" @pointerdown="swipeStart($event, j)" @pointermove="swipeMove($event)" @pointerup="swipeEnd($event, j)" @pointercancel="swipeReset">
|
||
<q-icon :name="selectedJobs[j.name] ? 'check_circle' : (jobIsOnsite(j) ? 'home_repair_service' : 'cloud')" size="16px" :color="selectedJobs[j.name] ? 'primary' : (jobIsOnsite(j) ? 'teal' : 'grey-5')" class="q-mr-xs" />
|
||
<span class="pm-job-main">
|
||
<span class="pm-job-t ellipsis">{{ j.subject || j.service_type || j.name }}</span>
|
||
<span class="pm-job-s ellipsis">{{ j.required_skill || '—' }}<template v-if="j.customer_name"> · {{ j.customer_name }}</template></span>
|
||
<span v-if="j.scheduled_date || j.creation" class="pm-job-d">
|
||
<span v-if="j.scheduled_date" :class="isOverdue(j.scheduled_date) ? 'text-warning text-weight-medium' : 'text-grey-6'">📅 {{ fmtDueLabel(j.scheduled_date) }}</span>
|
||
<span v-if="j.creation" class="text-grey-5"> · créé {{ String(j.creation).slice(0, 10) }}</span>
|
||
</span>
|
||
<span v-if="j.notes" class="pm-job-note ellipsis"><q-icon name="sticky_note_2" size="12px" color="warning" /> {{ j.notes }}</span>
|
||
</span>
|
||
<span class="pm-job-icons">
|
||
<q-icon :name="j.priority === 'high' ? 'star' : 'star_border'" :color="j.priority === 'high' ? 'amber-7' : 'grey-5'" size="21px" @click.stop="toggleUrgent(j)"><q-tooltip>{{ j.priority === 'high' ? 'Prioritaire — toucher pour retirer' : 'Marquer prioritaire' }}</q-tooltip></q-icon>
|
||
<q-icon :name="j.notes ? 'sticky_note_2' : 'note_add'" :color="j.notes ? 'deep-orange-6' : 'grey-4'" size="19px" @click.stop="openJobSheet(j, { note: true })"><q-tooltip>Note importante / détails</q-tooltip></q-icon>
|
||
<q-icon name="more_vert" color="grey-6" size="19px" @click.stop="openJobSheet(j)"><q-tooltip>Options (priorité, statut, compétence, report)</q-tooltip></q-icon>
|
||
</span>
|
||
<span class="pm-job-h">{{ j.est_min ? fmtMin(j.est_min) : ((j.duration_h || 1) + 'h') }}</span>
|
||
</button>
|
||
</div></template>
|
||
<div v-if="!poolJobs.length" class="pm-empty">Rien à assigner 🎉</div>
|
||
</div>
|
||
</div>
|
||
<!-- Feuille d'assignation (overlay bas) : techs candidats — n'écrase PAS la liste (zéro décalage au défilement). -->
|
||
<div v-if="selectedNames.length" class="pm-sheet">
|
||
<div class="pm-sheet-hd"><q-icon name="touch_app" size="18px" class="q-mr-xs" />{{ selectedNames.length }} job(s) → touchez un technicien<q-space /><q-btn flat dense no-caps size="sm" label="Annuler" color="white" @click="clearSel()" /></div>
|
||
<!-- Chips compétence DÉCOCHABLES : auto-cochées (compétences des jobs sélectionnés) ; décocher relâche le filtre ; tout décoché = TOUS les techs. -->
|
||
<div v-if="assignSkills.length" class="pm-sheet-skills">
|
||
<span class="pm-ss-lbl">Compétence :</span>
|
||
<button v-for="s in assignSkills" :key="s" type="button" class="pm-chip" :class="{ on: assignSkillActive.has(s) }" :style="assignSkillActive.has(s) ? { background: getTagColor(s), borderColor: getTagColor(s), color: '#fff' } : {}" @click="toggleAssignSkill(s)">{{ s }}<q-icon :name="assignSkillActive.has(s) ? 'check' : 'add'" size="12px" class="q-ml-xs" /></button>
|
||
<span class="pm-ss-hint">{{ assignSkillActive.size ? 'décochez pour tous les techs' : '✓ tous les techs' }}</span>
|
||
</div>
|
||
<div class="pm-sheet-body">
|
||
<button v-for="t in mobileTechAssign" :key="t.id" type="button" class="pm-tech assignable" :class="{ 'no-shift': t.noShift }" @click="onTechTap(t)">
|
||
<span class="pm-ava">{{ initials(t.name) }}</span>
|
||
<span class="pm-tech-name ellipsis">{{ t.name }}</span>
|
||
<span class="pm-tbar"><span class="pm-tfill" :style="{ width: (t.h > 0 ? Math.max(8, Math.min(100, Math.round(t.ratio * 100))) : 0) + '%', background: heatColor(t.ratio) }"></span></span>
|
||
<span v-if="t.noShift" class="pm-warn" title="Aucun quart ce jour — touchez pour créer un quart">▲</span>
|
||
<span class="pm-th" :class="{ over: t.over }">{{ t.h }}<span class="pm-cap" :class="{ est: t.noShift }">/{{ t.cap }}h</span></span>
|
||
</button>
|
||
<div v-if="!mobileTechAssign.length" class="pm-empty">Aucun technicien capable — ajustez les compétences (chips).</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Feuille d'OPTIONS du job (glisser-gauche ou bouton) : priorité directe, statut, compétence, report, note. -->
|
||
<q-dialog v-model="jobSheet.open" position="bottom" @hide="onJobSheetHide">
|
||
<q-card v-if="jobSheet.job" class="pm-opt-card">
|
||
<q-card-section class="row items-center q-pb-xs">
|
||
<q-icon name="work_outline" size="18px" class="q-mr-sm text-grey-7" />
|
||
<div class="text-subtitle2 ellipsis" style="max-width:74vw">{{ jobSheet.job.subject || jobSheet.job.service_type || jobSheet.job.name }}</div>
|
||
<q-space /><q-btn flat dense round icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-pt-none">
|
||
<!-- Détails du ticket (lecture) -->
|
||
<div class="pm-tk-details">
|
||
<div v-if="jobSheet.job.customer_name" class="pm-tk-row"><q-icon name="person" size="14px" color="grey-6" />{{ jobSheet.job.customer_name }}</div>
|
||
<div v-if="jobSheet.job.address || jobSheet.job.service_location" class="pm-tk-row"><q-icon name="place" size="14px" color="grey-6" /><span class="ellipsis">{{ jobSheet.job.address || jobSheet.job.service_location }}</span></div>
|
||
<!-- Contact client (P1) : tél/courriel du compte F lié au ticket, tappables -->
|
||
<div v-if="sheetThread.contact && (sheetThread.contact.phone || sheetThread.contact.email)" class="pm-tk-row"><q-icon name="contact_phone" size="14px" color="grey-6" /><a v-if="sheetThread.contact.phone" :href="'tel:' + sheetThread.contact.phone" class="pm-tk-link">{{ sheetThread.contact.phone }}</a><span v-if="sheetThread.contact.phone && sheetThread.contact.email" class="text-grey-4"> · </span><a v-if="sheetThread.contact.email" :href="'mailto:' + sheetThread.contact.email" class="pm-tk-link ellipsis">{{ sheetThread.contact.email }}</a></div>
|
||
<div class="pm-tk-row"><q-icon name="sell" size="14px" color="grey-6" />{{ jobSheet.job.required_skill || '—' }}<span v-if="jobSheet.job.est_min || jobSheet.job.duration_h" class="text-grey-6"> · ≈ {{ jobSheet.job.est_min ? fmtMin(jobSheet.job.est_min) : (jobSheet.job.duration_h + 'h') }}</span></div>
|
||
<div v-if="jobSheet.job.scheduled_date || jobSheet.job.creation" class="pm-tk-row"><q-icon name="event" size="14px" color="grey-6" /><span v-if="jobSheet.job.scheduled_date" :class="isOverdue(jobSheet.job.scheduled_date) ? 'text-warning text-weight-medium' : ''">{{ fmtDueLabel(jobSheet.job.scheduled_date) }}</span><span v-if="jobSheet.job.creation" class="text-grey-5"> · créé {{ String(jobSheet.job.creation).slice(0, 10) }}</span></div>
|
||
<div v-if="jobSheet.job.legacy_ticket_id || jobSheet.job.name" class="pm-tk-row text-grey-5"><q-icon name="confirmation_number" size="14px" color="grey-6" />{{ jobSheet.job.legacy_ticket_id || jobSheet.job.name }}</div>
|
||
<!-- Validation « service livré » (à la demande) : croise tech-présent (geofence) + service en ligne (modem) -->
|
||
<div class="pm-tk-row">
|
||
<q-icon name="router" size="14px" color="grey-6" />
|
||
<template v-if="serviceCheck.done">
|
||
<q-chip dense square class="q-my-none q-ml-none" :color="serviceCheck.online === true ? 'teal-1' : (serviceCheck.found ? 'deep-orange-1' : 'grey-3')" :text-color="serviceCheck.online === true ? 'teal-9' : (serviceCheck.found ? 'deep-orange-9' : 'grey-8')" :icon="serviceCheck.online === true ? 'check_circle' : (serviceCheck.found ? 'cancel' : 'help')">{{ serviceCheck.label }}</q-chip>
|
||
<q-btn flat dense round size="sm" icon="refresh" color="grey-6" :loading="serviceCheck.loading" @click="checkJobService(jobSheet.job)"><q-tooltip>Revérifier le service</q-tooltip></q-btn>
|
||
</template>
|
||
<q-btn v-else flat dense no-caps size="sm" color="primary" icon="router" :label="serviceCheck.loading ? 'Vérification…' : 'Vérifier le service'" :loading="serviceCheck.loading" @click="checkJobService(jobSheet.job)" />
|
||
</div>
|
||
</div>
|
||
<!-- Conversation client UNIFIÉE — UN seul champ : Réponse au client (Outbox courriel/SMS) OU 📌 Note importante (épinglée au job). Fil le plus récent en premier. -->
|
||
<div class="pm-opt-label">Conversation client <span v-if="sheetThread.messages.length" class="text-grey-5">({{ sheetThread.messages.length }})</span></div>
|
||
<div class="pm-postbox">
|
||
<q-input dense outlined v-model="composerText" type="textarea" rows="2" :placeholder="noteMode ? 'Note importante — épinglée au job (ex. appeler 30 min avant · préfère AM)…' : (replyChannel.ok ? ('Réponse au client par ' + replyChannel.label + '…') : 'Réponse au client…')" />
|
||
<div class="row items-center q-mt-xs q-gutter-xs">
|
||
<q-btn-toggle v-model="noteMode" dense unelevated no-caps size="sm" :options="[{ label: replyChannel.ok ? ('Réponse · ' + replyChannel.label) : 'Réponse', value: false, icon: 'reply', disable: !replyChannel.ok }, { label: 'Note importante', value: true, icon: 'push_pin' }]" toggle-color="primary" color="grey-3" text-color="grey-8" />
|
||
<q-space />
|
||
<q-btn dense unelevated no-caps size="sm" :color="noteMode ? 'deep-orange-6' : 'teal-7'" :icon="noteMode ? 'push_pin' : 'send'" :label="noteMode ? 'Enregistrer' : 'Envoyer'" :loading="postBusy" :disable="noteMode ? false : !postDraft.trim()" @mousedown.prevent="submitComposer()" />
|
||
</div>
|
||
<div class="row items-center q-mt-xs pm-postbox-foot">
|
||
<q-icon :name="noteMode ? 'push_pin' : (replyChannel.ok ? 'public' : 'block')" size="12px" :color="noteMode ? 'deep-orange-6' : (replyChannel.ok ? 'teal-7' : 'grey-5')" />
|
||
<span class="pm-postbox-hint">{{ noteMode ? 'Note interne épinglée au job — jamais envoyée au client' : (replyChannel.ok ? ('Envoyée au client par ' + replyChannel.label + (jobSheet.job.legacy_ticket_id ? ' · consignée au ticket' : '')) : 'Aucun courriel/téléphone au dossier client') }}</span>
|
||
<q-space />
|
||
<q-btn flat dense no-caps size="sm" color="primary" icon="open_in_new" label="Fil complet" @click="openFullThread()" />
|
||
</div>
|
||
</div>
|
||
<div class="pm-thread" v-if="sheetThread.loading || sheetThread.messages.length">
|
||
<div v-if="sheetThread.loading" class="pm-thread-empty"><q-spinner size="15px" class="q-mr-xs" />Chargement du fil…</div>
|
||
<div v-for="(m, i) in sheetThread.messages" :key="i" class="pm-post" :class="{ 'pm-post--client': m.fromClient }">
|
||
<div class="pm-post-hd">
|
||
<q-avatar size="20px" class="pm-post-av" :style="{ background: pmIdentity(m).color }">{{ pmIdentity(m).initials || '?' }}</q-avatar>
|
||
<b class="ellipsis">{{ pmIdentity(m).name || (m.fromClient ? 'Client' : 'Staff') }}</b>
|
||
<span v-if="m.fromClient" class="pm-post-tag">client</span>
|
||
<q-space />
|
||
<span v-if="m.at" class="pm-post-at">{{ relTime(m.at) }}</span>
|
||
</div>
|
||
<div class="pm-post-text">{{ m.text }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="pm-opt-label">Priorité</div>
|
||
<div class="row q-gutter-xs">
|
||
<q-chip v-for="p in POOL_PRIOS" :key="p.value" clickable dense :selected="jobSheet.job.priority === p.value" :style="{ background: jobSheet.job.priority === p.value ? p.color : '#eef2f7', color: jobSheet.job.priority === p.value ? '#fff' : '#475569' }" @click="setJobPriority(jobSheet.job, p.value)">{{ p.label }}</q-chip>
|
||
</div>
|
||
<div class="pm-opt-label">Statut</div>
|
||
<div class="row q-gutter-xs">
|
||
<q-chip v-for="s in POOL_STATUSES" :key="s.value" clickable dense :icon="s.icon" :color="jobSheet.job.status === s.value ? s.color : 'grey-3'" :text-color="jobSheet.job.status === s.value ? 'white' : 'grey-8'" @click="setJobStatus(jobSheet.job, s.value)">{{ s.label }}</q-chip>
|
||
</div>
|
||
<div class="pm-opt-label">Compétence requise</div>
|
||
<q-select dense outlined options-dense clearable v-model="jobSheet.job.required_skill" :options="allSkills" @update:model-value="v => setJobSkill(jobSheet.job, v || '')" />
|
||
<div class="pm-opt-label">Reporter (date prévue)</div>
|
||
<div class="row q-gutter-sm items-center">
|
||
<q-btn dense unelevated no-caps color="indigo" icon="snooze" label="+1 jour" @click="snoozeJob(jobSheet.job, 1)" />
|
||
<q-input dense outlined type="date" v-model="snoozeDate" style="min-width:152px" @update:model-value="v => v && setJobDate(jobSheet.job, v)"><template #prepend><q-icon name="event" size="18px" color="indigo" /></template><q-tooltip>Choisir une date (pré-réglée à +1 semaine)</q-tooltip></q-input>
|
||
</div>
|
||
</q-card-section>
|
||
<!-- Barre du bas : bouton « Fermer » atteignable au pouce (en plus du X et du clic à l'extérieur). Ferme → @hide enregistre la note. -->
|
||
<q-card-actions align="right" class="pm-opt-actions"><q-btn unelevated no-caps color="primary" icon="check" label="Fermer" v-close-popup /></q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Note importante du répartiteur (champ Dispatch Job.notes) -->
|
||
<q-dialog v-model="noteDialog.open" position="bottom">
|
||
<q-card v-if="noteDialog.job" class="pm-opt-card">
|
||
<q-card-section class="row items-center q-pb-none">
|
||
<q-icon name="sticky_note_2" size="18px" color="warning" class="q-mr-sm" />
|
||
<div class="text-subtitle2">Note importante</div>
|
||
<q-space /><q-btn flat dense round icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section>
|
||
<q-input dense outlined type="textarea" autogrow autofocus v-model="noteDialog.text" placeholder="ex. appeler 30 min avant · client préfère AM" maxlength="2000" />
|
||
</q-card-section>
|
||
<q-card-actions align="right" class="q-pt-none">
|
||
<q-btn v-if="noteDialog.text" flat no-caps color="grey-7" label="Effacer" @click="noteDialog.text = ''; saveNote()" />
|
||
<q-btn unelevated no-caps color="warning" icon="check" label="Enregistrer" @click="saveNote" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
</div>
|
||
|
||
<div class="grid-wrap gt-sm" v-show="boardView === 'grid'">
|
||
<table class="roster-grid" :class="{ 'day-mode': dayMode }">
|
||
<thead>
|
||
<tr>
|
||
<th class="tech-col"><div class="row items-center no-wrap">Ressource<q-space /><q-btn v-if="hiddenCount" flat dense round size="xs" :icon="showHidden ? 'visibility' : 'visibility_off'" :color="showHidden ? 'primary' : 'grey-6'" @click="showHidden = !showHidden"><q-badge floating color="grey-7" style="top:-7px;right:6px">{{ hiddenCount }}</q-badge><q-tooltip>{{ showHidden ? 'Cacher les ressources masquées' : (hiddenCount + ' masquée(s) — afficher en grisé') }}</q-tooltip></q-btn></div></th>
|
||
<th v-for="(d, di) in dayList" :key="d.iso" class="clk" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }" @click="maybeSelectCol(di)">
|
||
<div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div>
|
||
<div v-if="visStat(d.iso).hours || estLoad(d.iso)" class="cap-strip" @click.stop>
|
||
<span class="hdr-vbar"><span class="hdr-vfill" :style="{ height: hdrFillH(d.iso) + '%', background: heatColor(hdrRatio(d.iso)) }"></span></span>
|
||
<span class="cap-seg load-cell" :class="loadClass(d.iso)">{{ estLoad(d.iso) }}/{{ visStat(d.iso).hours }}h
|
||
<q-tooltip class="bg-grey-9"><b>{{ estLoad(d.iso) }} h estimées</b> (jobs prévus) / <b>{{ visStat(d.iso).hours }} h de shift</b><template v-if="visStat(d.iso).hours"> · {{ Math.round(estLoad(d.iso) / visStat(d.iso).hours * 100) }}%</template><br><span style="opacity:.8">{{ skillFilter.length ? ('compétence(s) : ' + skillFilter.join(', ')) : 'ressources affichées' }} ({{ visibleTechs.length }} tech)</span></q-tooltip>
|
||
</span>
|
||
</div>
|
||
<div v-if="dayMode" class="grid-axis"><span v-for="tk in axisTicks" :key="'ga' + tk.h" class="grid-axis-tick" :style="{ left: tk.left }">{{ tk.h }}</span></div>
|
||
<div v-if="showDayJobs" class="dayjobs-hdr" @click.stop @mousedown.stop>
|
||
<div v-for="j in (unassignedByDay[d.iso] || []).slice(0, 8)" :key="j.name" class="dayjob" :class="{ dragging: draggingSet.has(j.name), hold: j.status === 'On Hold' }" :style="{ borderLeft: '3px solid ' + panelJobColor(j) }" draggable="true" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
|
||
<span class="dj-est">{{ j.est_min ? fmtMin(j.est_min) : ((j.duration_h || 1) + 'h') }}</span><span class="dj-sub">{{ j.subject || j.service_type || j.name }}</span>
|
||
<q-tooltip class="bg-grey-9" style="font-size:11px" :delay="300">{{ j.subject || j.service_type }}<template v-if="j.customer_name"> · {{ j.customer_name }}</template><template v-if="j.address"><br>📍 {{ j.address }}</template><template v-if="j.required_skill"><br>🧩 {{ j.required_skill }}</template><template v-if="j.status === 'On Hold'"><br>🔒 en attente d'une étape — non assignable</template></q-tooltip>
|
||
</div>
|
||
<div v-if="(unassignedByDay[d.iso] || []).length > 8" class="dayjobs-more" @click.stop="openDayInPanel(d.iso)">+{{ (unassignedByDay[d.iso] || []).length - 8 }} de plus — ouvrir la liste<q-tooltip>Ouvre le panneau « Jobs à assigner » filtré sur ce jour</q-tooltip></div>
|
||
<div v-if="!(unassignedByDay[d.iso] || []).length" class="dayjobs-empty">—</div>
|
||
</div>
|
||
<div class="hol-toggle" :class="{ on: isHoliday(d.iso) }" @click.stop="toggleHoliday(d.iso)"><q-tooltip>Marquer férié</q-tooltip>F</div>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(t, ti) in visibleTechs" :key="t.id" :class="{ paused: isPaused(t), 'res-hidden': isHidden(t.id) }">
|
||
<td class="tech-col">
|
||
<div class="tech-row">
|
||
<q-btn flat round dense size="9px" :icon="isRowSelected(ti) ? 'check_box' : 'check_box_outline_blank'" :color="isRowSelected(ti) ? 'primary' : 'grey-5'" @click.stop="maybeSelectRow(ti)"><q-tooltip>Sélectionner la rangée</q-tooltip></q-btn>
|
||
<q-btn flat round dense size="9px" :icon="isPaused(t) ? 'play_arrow' : 'pause'" :color="isPaused(t) ? 'grey' : 'primary'" @click.stop="togglePause(t)"><q-tooltip>{{ isPaused(t) ? 'Réactiver' : 'Pause' }}</q-tooltip></q-btn>
|
||
<q-badge v-if="techRank(t)" color="teal">{{ techRank(t) }}<q-tooltip>Priorité {{ techRank(t) }} — combine maîtrise (niveau) + vitesse (efficacité) + coût.</q-tooltip></q-badge>
|
||
<!-- Clic sur la RESSOURCE (nom ou chips) → propriétés de l'employé (compétences, horaire) -->
|
||
<span class="tech-name clk" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Compétences & horaire de {{ t.name }}</q-tooltip>{{ t.name }}</span>
|
||
<span v-if="t.group" class="grp">{{ t.group }}</span>
|
||
<span v-if="hoursOf(t.id)" class="th" :class="hoursOf(t.id) > maxHours ? 'text-red text-weight-bold' : 'text-grey-6'">{{ hoursOf(t.id) }}h<q-icon v-if="hoursOf(t.id) > maxHours" name="warning" color="red" size="12px" /></span>
|
||
<q-btn flat dense round size="9px" icon="more_vert" color="grey-6" @click.stop><q-tooltip>Actions — {{ t.name }}</q-tooltip>
|
||
<q-menu auto-close anchor="bottom right" self="top right">
|
||
<q-list dense style="min-width:190px">
|
||
<q-item clickable @click="openTimeline(t)"><q-item-section avatar><q-icon name="timeline" color="green-5" /></q-item-section><q-item-section>Timeline de la semaine</q-item-section></q-item>
|
||
<q-item clickable @click="techAppLinkMenu(t)"><q-item-section avatar><q-icon name="phone_iphone" color="deep-purple-5" /></q-item-section><q-item-section>Lien app terrain</q-item-section></q-item>
|
||
</q-list>
|
||
</q-menu>
|
||
</q-btn>
|
||
<div class="tech-skills clk" @click.stop="openSkillEditor(t, $event)">
|
||
<span v-if="tSkills(t).length" class="skill-dot" :class="{ 'has-lvl': !!(t.skill_levels || {})[tSkills(t)[0]] }" :style="{ background: getTagColor(tSkills(t)[0]) }"><q-icon :name="skillSym(tSkills(t)[0])" size="11px" /><span v-if="(t.skill_levels || {})[tSkills(t)[0]]" class="sd-lvl">{{ (t.skill_levels || {})[tSkills(t)[0]] }}</span><q-tooltip class="bg-grey-9"><b>{{ tSkills(t)[0] }}</b> · niveau {{ (t.skill_levels || {})[tSkills(t)[0]] || '—' }}<template v-if="(t.skill_eff || {})[tSkills(t)[0]]"> · {{ effSuffix(skillEffOf(t, tSkills(t)[0])) }}</template></q-tooltip></span><span v-if="tSkills(t).length > 1" class="skill-more">+{{ tSkills(t).length - 1 }}<q-tooltip class="bg-grey-9" style="font-size:11px"><div class="text-weight-medium q-mb-xs">Autres compétences</div><div v-for="sk in tSkills(t).slice(1)" :key="sk" class="row items-center no-wrap" style="gap:5px;line-height:1.7"><q-icon :name="skillSym(sk)" size="13px" :style="{ color: getTagColor(sk) }" /><span>{{ sk }}</span><span v-if="(t.skill_levels || {})[sk]" class="text-grey-5">· niv {{ (t.skill_levels || {})[sk] }}</span></div></q-tooltip></span>
|
||
<span v-if="!(t.skills || []).length" class="add-skill-hint">+ compétences</span>
|
||
</div>
|
||
<q-btn flat dense round size="9px" class="hide-eye" :icon="isHidden(t.id) ? 'visibility_off' : 'visibility'" :color="isHidden(t.id) ? 'grey-5' : 'grey-6'" @click.stop="toggleHidden(t.id)"><q-tooltip>{{ isHidden(t.id) ? 'Réafficher / considérer cette ressource' : 'Masquer & ignorer (hors front-line)' }}</q-tooltip></q-btn>
|
||
</div>
|
||
</td>
|
||
<td v-for="(d, di) in dayList" :key="d.iso" class="cell" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso), sel: isSelected(t.id, d.iso), dirty: isCellDirty(t.id, d.iso), over: cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).over, noshift: (cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).noShift) || (officeBlockH(t.id, d.iso) && !hasReg(t.id, d.iso)), 'drop-hover': dropCell === t.id + '|' + d.iso }" @mousedown="onDown(ti, di, $event)" @mouseenter="onEnter(ti, di)" @click="onCellClick(t, d, $event, ti, di)" @contextmenu.prevent="onCellContext(t, d, $event, ti, di)" @dragover.prevent="onCellDragOver(t, d)" @dragleave="dropCell === t.id + '|' + d.iso && (dropCell = null, dropPreview.key = null)" @drop.prevent="onCellDrop($event, t, d)">
|
||
<template v-if="isAbsent(t.id, d.iso) || isPaused(t)">
|
||
<div class="tl"><div class="tl-absent"></div><q-tooltip class="bg-grey-9">Absent · {{ absenceLabel(t.id, d.iso) }}</q-tooltip></div>
|
||
</template>
|
||
<template v-else-if="hasReg(t.id, d.iso) || onGarde(t.id, d.iso) || (cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).noShift)">
|
||
<div class="tl">
|
||
<div v-if="cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).hasReg" class="tl-shift tl-shift-click" :class="{ over: cellOcc(t.id, d.iso).over }" :style="pos(cellOcc(t.id, d.iso).shiftS, cellOcc(t.id, d.iso).shiftE)" @click.stop="openDayFromCell(t, d)"></div>
|
||
<div v-for="(b, bi) in cellGardeBands(t.id, d.iso)" :key="'g' + bi" class="tl-shift tl-shift-click oncall" :style="{ left: b.left, width: b.width }" @click.stop="openDayFromCell(t, d)"></div>
|
||
<div v-for="(b, bi) in cellBlocks(t.id, d.iso)" :key="'j' + bi" class="tl-blk tl-blk-click" :class="{ 'tl-blk-legacy': b.legacy, 'tl-blk-done': b.done }" :style="blockStyle(b, cellPct(t.id, d.iso))" @click.stop="openDayFromCell(t, d)" @mousedown.stop><q-icon v-if="b.done || (b.e - b.s) >= (dayMode ? 0.75 : 2.5)" :name="blkIcon(b)" :style="{ color: blkColor(b) }" size="12px" /><q-tooltip class="bg-grey-9" :delay="350" style="font-size:11px"><template v-if="b.assist">👥 <b>Assistant (renfort)</b> · ≈{{ Math.round((b.e - b.s) * 10) / 10 }}h</template><template v-else><b>{{ b.subject || b.skill || 'Job' }}</b><br>{{ b.legacy ? (b.legacy_dept || b.dept || 'Ticket') : (b.skill || 'Job') }} · ≈{{ Math.round((b.e - b.s) * 10) / 10 }}h<span v-if="blkOpsOnly(b)"><br><span class="text-warning">⚠ Dispatché OPS — pas publié dans F</span></span><br><span class="text-info">clic = détails du jour (carte + trajet) · clic droit = renvoyer au pool</span></template></q-tooltip><q-menu v-if="b.job && !b.legacy && !b.assist" context-menu touch-position auto-close><q-list dense style="min-width:180px"><q-item clickable @click="unassignOne(b)"><q-item-section avatar><q-icon name="inbox" color="grey-7" /></q-item-section><q-item-section>Renvoyer au pool</q-item-section></q-item></q-list></q-menu></div>
|
||
<!-- Aperçu d'occupation projetée pendant le drag : barre fantôme + delta -->
|
||
<div v-if="isDropTarget(t.id, d.iso) && projPct(t.id, d.iso) != null" class="tl-proj" :style="{ width: Math.min(100, projPct(t.id, d.iso)) + '%', background: occColor(projPct(t.id, d.iso)) }"></div>
|
||
<!-- pas de bulle sur le FOND de cellule : clic sur un bloc = son ticket · clic ailleurs dans la cellule = éditeur de jour (liste + timeline + carte) -->
|
||
</div>
|
||
</template>
|
||
<span v-else-if="offShiftJobs(t.id, d.iso).length" class="offshift-warn" @click.stop="openTimeline(t)"><q-icon name="warning" size="13px" color="warning" />{{ offShiftJobs(t.id, d.iso).length }}<q-tooltip class="bg-grey-9">{{ offShiftJobs(t.id, d.iso).length }} job(s) assigné(s) ce jour SANS quart publié — publier un quart ou réassigner. Clic → timeline.</q-tooltip></span>
|
||
<span v-else class="free">·</span>
|
||
<div v-if="isDropTarget(t.id, d.iso)" class="drop-badge" :class="{ over: projPct(t.id, d.iso) >= 100 }">+{{ dropPreview.addH }}h<template v-if="projPct(t.id, d.iso) != null"> → {{ projPct(t.id, d.iso) }}%</template></div>
|
||
<q-icon v-if="cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).over" name="warning" color="warning" size="14px" class="ovl-warn"><q-tooltip class="bg-grey-9" style="font-size:11.5px; max-width:260px"><b>⚠ Surcharge</b><br>{{ overWhy(t.id, d.iso) }}</q-tooltip></q-icon>
|
||
<div v-if="officeBlockH(t.id, d.iso)" class="office-blk" :style="officeBlockStyle(t.id, d.iso)" @click.stop><q-menu anchor="bottom right" self="top right" auto-close><q-list dense style="min-width:250px; max-width:360px"><q-item-label header class="q-py-xs">{{ cellOfficeN(t.id, d.iso) }} ticket(s) sans déplacement · ≈{{ fmtOfficeH(t.id, d.iso) }} · {{ t.name }}</q-item-label><q-item v-for="(j, ji) in officeJobs(t.id, d.iso)" :key="'om' + ji" clickable @click="openLegacyBlock({ lid: j.id, subject: j.subject, dept: j.dept })"><q-item-section avatar style="min-width:18px"><span class="office-dot" :style="{ background: legacyDeptColor(j.dept) || '#9fa8da' }"></span></q-item-section><q-item-section><q-item-label lines="2">{{ j.subject }}</q-item-label><q-item-label caption>{{ j.dept }} · #{{ j.id }}<span v-if="j.est_min || j.duration_h"> · ≈{{ j.est_min ? fmtMin(j.est_min) : (j.duration_h + 'h') }}</span></q-item-label></q-item-section><q-item-section side><q-icon name="open_in_new" size="14px" color="grey-6" /></q-item-section></q-item></q-list></q-menu></div>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="!visibleTechs.length"><td :colspan="dayList.length + 1" class="text-grey-6 q-pa-md text-center">Aucun technicien (filtre ?).</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- ── VUE BOARD (Kanban horizontal, façon Dispatch/Gaiia) : pool « À assigner » VERTICAL (recherche + tri) à gauche ;
|
||
techs en LANES horizontales à ÉCHELLE D'HEURES (réutilise cellBands + pos + axisTicks). Glisser = assigner (hub). ── -->
|
||
<!-- P3 — Onglet « Tournées » : trajets RÉELS (jobs assignés) de la journée sélectionnée, 1 couleur/tech (composant RouteMap partagé, OSRM) -->
|
||
<div v-if="boardView === 'routes'" class="routes-wrap q-pa-sm">
|
||
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
|
||
<span class="text-caption text-grey-7">Journée :</span>
|
||
<OccupancyStrip :days="routeStripDays" :selected="routesDay" style="flex:1 1 100%;min-width:0" @select="routesDay = $event" />
|
||
<q-space />
|
||
<q-btn dense unelevated no-caps size="sm" :color="showLivePos ? 'teal-6' : 'grey-4'" :text-color="showLivePos ? 'white' : 'grey-8'" icon="my_location" :label="'GPS live' + (dayLivePositions.length ? ' (' + dayLivePositions.length + ')' : '')" @click="showLivePos = !showLivePos"><q-tooltip>Positions GPS des techs (Traccar), rafraîchies ~25 s. Cliquer pour {{ showLivePos ? 'masquer' : 'afficher' }}.</q-tooltip></q-btn>
|
||
<span class="text-caption text-grey-6">{{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail</span>
|
||
</div>
|
||
<!-- Chips techs : enlever/ajouter une tournée de la carte d'un clic — pastille = nb de jobs, dans la couleur du tech -->
|
||
<div class="row items-center q-mb-xs" style="gap:5px;flex-wrap:wrap">
|
||
<span v-for="r in allDayRoutes" :key="'chip' + r.id" class="rt-chip" :class="{ off: hiddenRouteTechs.has(r.id) }" :style="hiddenRouteTechs.has(r.id) ? {} : { borderColor: r.color, background: r.color + '14' }" @click="toggleRouteTech(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)">
|
||
<span class="rt-dot" :style="{ background: hiddenRouteTechs.has(r.id) ? '#cbd5e1' : r.color }"></span>{{ r.name }}
|
||
<span class="rt-n" :style="{ background: hiddenRouteTechs.has(r.id) ? '#94a3b8' : r.color }">{{ r.stops.length }}</span>
|
||
<q-tooltip>{{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour RÉAFFICHER cette tournée' : 'Cliquer pour retirer cette tournée de la carte' }}</q-tooltip>
|
||
</span>
|
||
</div>
|
||
<!-- Jobs DUS ce jour, NON assignés (mis en évidence) — chips secteur filtrent liste+carte · clic = détail · « Suggérer » = dispatch auto -->
|
||
<div v-if="routeDayUnassigned.length" class="rt-unassigned">
|
||
<div class="rt-ua-top">
|
||
<span class="rt-ua-lbl"><q-icon name="inbox" size="15px" color="deep-orange-7" /> <b>{{ routeDayUnassignedView.length }}</b> à répartir<span v-if="routeSectorSel.size" class="text-grey-6"> / {{ routeDayUnassigned.length }}</span> :</span>
|
||
<template v-if="routeSectors.length > 1">
|
||
<button v-for="s in routeSectors" :key="s.sector" type="button" class="rt-sect" :class="{ on: routeSectorSel.has(s.sector) }" @click="toggleRouteSector(s.sector)"><q-icon name="place" size="11px" />{{ s.sector }}<span class="rt-sect-n">{{ s.n }}</span></button>
|
||
<button v-if="routeSectorSel.size" type="button" class="rt-sect rt-sect-x" @click="routeSectorSel = new Set()">✕ tout</button>
|
||
</template>
|
||
<template v-if="routePrios.length > 1">
|
||
<span class="rt-chip-sep" />
|
||
<button v-for="p in routePrios" :key="p.value" type="button" class="rt-sect rt-prio" :class="{ on: routePrioSel.has(p.value) }"
|
||
:style="routePrioSel.has(p.value) ? { background: p.color, borderColor: p.color, color: '#fff' } : { borderColor: p.color, color: p.color }"
|
||
@click="toggleRoutePrio(p.value)"><q-icon name="flag" size="11px" />{{ p.label }}<span class="rt-sect-n">{{ p.n }}</span></button>
|
||
</template>
|
||
<q-space />
|
||
<q-btn dense unelevated no-caps size="sm" color="primary" icon="auto_awesome" label="Suggérer" @click="openSuggest"><q-tooltip>Répartir automatiquement les jobs {{ routeSectorSel.size ? 'du secteur' : 'de ce jour' }}</q-tooltip></q-btn>
|
||
</div>
|
||
<!-- Carrousel horizontal (1 rangée, défile latéralement) : compact au-dessus de la carte, tactile/tablette, montre TOUS les jobs (pas de plafond à 16). -->
|
||
<div class="rt-ua-jobs">
|
||
<span v-for="j in routeDayUnassignedView" :key="j.name" class="rt-ua-chip" :style="{ borderLeftColor: prioColor(j.priority) }" @click="openJobDetail(j)">
|
||
<q-icon :name="skillSym(j.required_skill || j.service_type)" size="14px" :style="{ color: getTagColor(j.required_skill || j.service_type) }" />{{ j.subject || j.service_type || j.name }}<template v-if="jobCity(j)"> · {{ jobCity(j) }}</template>
|
||
<q-tooltip class="bg-grey-9">{{ j.required_skill || '—' }}<template v-if="j.customer_name"> · {{ j.customer_name }}</template><template v-if="j.address"><br>📍 {{ j.address }}</template></q-tooltip>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<RouteMap ref="routesMapRef" :routes="dayRoutes" :live="showLivePos ? dayLivePositions : []" :pins="routeUnassignedPins" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" @pin-click="p => openJobDetail(p.job)" />
|
||
<div class="suggest-legend q-mt-xs">
|
||
<span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)">
|
||
<span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.name }}
|
||
<q-icon :name="techHomes[r.id] ? 'home' : 'business'" size="13px" :color="techHomes[r.id] ? 'teal-7' : 'blue-grey-4'" style="cursor:pointer" @click.stop="openHomePicker({ id: r.id, name: r.name })"><q-tooltip>Départ : {{ techHomes[r.id] ? 'domicile' : 'bureau TARGO (défaut)' }} — cliquer pour définir/modifier</q-tooltip></q-icon>
|
||
· {{ r.stops.length }} arrêt(s)<span v-if="dayRouteMetrics[r.id]"> · 🚗 {{ dayRouteMetrics[r.id].km }} km / {{ dayRouteMetrics[r.id].mins }} min</span>
|
||
</span>
|
||
<span v-if="!dayRoutes.length" class="text-grey-6" style="font-size:12px">Aucune tournée géolocalisée ce jour — les jobs assignés n'ont pas de coordonnées, ou la journée est vide.</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="boardView === 'kanban'" class="kbb-wrap">
|
||
<!-- Pool « À assigner » : liste verticale + recherche + tri (priorité/ville/distance/compétence/durée) -->
|
||
<div class="kbb-pool" :class="{ 'drop-hover': dropCell === '__pool__' }" @dragover.prevent="dropCell = '__pool__'" @dragleave="dropCell = null" @drop="onKanbanUnassign">
|
||
<div class="kbb-pool-hd"><q-icon name="inbox" size="15px" /> À assigner <span class="kb-count">{{ kanbanPoolView.length }}</span></div>
|
||
<div class="kbb-pool-tools">
|
||
<q-input dense outlined v-model="kbSearch" placeholder="Rechercher…" clearable><template #prepend><q-icon name="search" size="16px" /></template></q-input>
|
||
<q-select dense outlined v-model="kbSort" emit-value map-options :options="[{ label: 'Priorité', value: 'prio' }, { label: 'Ville', value: 'city' }, { label: 'Distance (dépôt)', value: 'dist' }, { label: 'Compétence', value: 'skill' }, { label: 'Durée', value: 'dur' }]"><template #prepend><q-icon name="sort" size="16px" /></template></q-select>
|
||
</div>
|
||
<div class="kbb-pool-body">
|
||
<div v-for="j in kanbanPoolView" :key="j.name" class="kb-card" :class="{ hold: j.status === 'On Hold', dragging: draggingSet.has(j.name) }" :style="{ borderLeftColor: kbColor(j) }" :draggable="j.status !== 'On Hold'" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
|
||
<div class="kb-card-t"><q-icon :name="skillSym(j.required_skill)" size="13px" :style="{ color: kbColor(j) }" /> {{ j.subject || j.service_type || j.name }}</div>
|
||
<div class="kb-card-m"><span class="kb-dot" :style="{ background: prioColor(j.priority) }"></span>{{ j.est_min ? fmtMin(j.est_min) : ((j.duration_h || 1) + 'h') }}<template v-if="kbCity(j)"> · {{ kbCity(j) }}</template></div>
|
||
<q-tooltip class="bg-grey-9" :delay="350" style="font-size:11px">{{ j.subject || j.service_type }}<template v-if="j.customer_name"><br>{{ j.customer_name }}</template><template v-if="j.address"><br>📍 {{ j.address }}</template><template v-if="j.required_skill"><br>🧩 {{ j.required_skill }}</template><template v-if="j.status === 'On Hold'"><br>🔒 en attente — non assignable</template></q-tooltip>
|
||
</div>
|
||
<div v-if="!kanbanPoolView.length" class="kb-empty">— rien à assigner —</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Board : techs en lanes horizontales + échelle d'heures (la liste de techs suit le filtre skill/sous-groupe) -->
|
||
<div class="kbb-board" ref="kbBoardEl">
|
||
<div class="kbb-daybar"><b>{{ kanbanDay ? (kanbanDay.dow + ' ' + kanbanDay.dnum) : '—' }}</b><span class="text-grey-6">· glisser une carte sur une lane = assigner · {{ visibleTechs.length }} tech(s)</span></div>
|
||
<div class="kbb-scroll" ref="kbScrollEl">
|
||
<div class="kbb-axis-row">
|
||
<div class="kbb-name-sp"></div>
|
||
<div class="kbb-axis" :style="{ width: kbInnerW + 'px' }"><span v-for="tk in kbTicks" :key="'a' + tk.h" class="grid-axis-tick" :style="{ left: tk.left }">{{ tk.h }}</span><div v-if="nowLblStyle" class="kbb-now-lbl" :style="nowLblStyle">{{ nowHHMM }}</div></div>
|
||
</div>
|
||
<div class="kbb-rows">
|
||
<!-- Trait « maintenant » CONTINU : 1 seul élément sur toute la hauteur des lanes -->
|
||
<div v-if="nowLineStyle" class="kbb-now-full" :style="nowLineStyle"></div>
|
||
<div v-for="t in visibleTechs" :key="t.id" class="kbb-row">
|
||
<div class="kbb-name"><span class="ellipsis">{{ t.name }}</span><q-icon v-if="kanbanDay && cellOcc(t.id, kanbanDay.iso) && cellOcc(t.id, kanbanDay.iso).over" name="warning" color="warning" size="15px"><q-tooltip class="bg-grey-9" style="font-size:11.5px; max-width:260px"><b>⚠ Surcharge</b><br>{{ overWhy(t.id, kanbanDay.iso) }}</q-tooltip></q-icon></div>
|
||
<div class="kbb-lane" :style="kbLaneStyle" :class="{ 'drop-hover': kanbanDay && dropCell === t.id + '|' + kanbanDay.iso }"
|
||
@dragover.prevent="kanbanDay && onCellDragOver(t, kanbanDay)" @dragleave="dropCell = null" @drop="kanbanDay && onKbDrop($event, t)"
|
||
@click.self="kanbanDay && openDayFromCell(t, kanbanDay)" @contextmenu.self.prevent="onKbLaneMenu(t, $event)">
|
||
<!-- quart prévu = délimitation pointillée fine (ex. 7–18h), garde = pointillé ambre -->
|
||
<div v-for="(b, bi) in kbShiftBands(t.id)" :key="'s' + bi" class="kbb-shift" :class="{ oncall: b.oncall }" :style="kbPos(b.s, b.e)"></div>
|
||
<!-- garde = shift SUR APPEL : bande jaune pâle + téléphone rouge (occupe la timeline, ne stretche pas l'axe) -->
|
||
<div v-for="(g, gi) in kbGardeBands(t.id)" :key="'g' + gi" class="kbb-garde" :style="kbPos(g.s, g.e)"><q-icon name="phone_in_talk" color="negative" size="13px" /><q-tooltip class="bg-grey-9" style="font-size:11px">📞 Sur appel (garde) {{ fmtH(g.s) }}h–{{ fmtH(g.e) }}h — soir / fin de semaine</q-tooltip></div>
|
||
<template v-for="(b, bi) in kanbanLaneBlocks(t.id)" :key="'j' + bi + (b.assist ? 'a' : '')">
|
||
<!-- trajet d'approche (km/min) = zone pâle AVANT le job -->
|
||
<div v-if="b.legMin" class="kbb-leg" :style="kbPos(b.legS, b.legE)"><span class="kbb-leg-t">{{ b.legMin }}′</span><q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px">{{ b.legReal ? '🚗 Trajet routier (Mapbox)' : '≈ Trajet estimé' }} : {{ b.legKm }} km · {{ b.legMin }} min avant ce job</q-tooltip></div>
|
||
<div class="kbb-blk" :class="{ assist: b.assist, 'kbb-blk-legacy': b.legacy, 'kbb-blk-opsonly': blkOpsOnly(b) }" :style="{ ...kbPos(b.s, Math.min(b.e, 24)), ...blkFill(b) }" :draggable="!b.assist && !b.legacy" @dragstart="!b.assist && !b.legacy && onJobDragStart($event, b)" @dragend="onJobDragEnd" @click.stop="b.legacy ? openLegacyBlock(b) : (!b.assist && kbBlockClick(t))" @dblclick.stop="!b.legacy && kbBlockDbl(b, t)">
|
||
<div class="kbb-blk-l1"><q-icon :name="blkIcon(b)" size="12px" :style="{ color: blkColor(b) }" /><span class="kbb-blk-t">{{ b.subject }}</span></div>
|
||
<div class="kbb-blk-l2">{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h<span v-if="b.legacy"> · {{ b.dept }}</span><span v-else-if="b.customer"> · {{ b.customer }}</span><span v-if="b.assist"> · assistant</span></div>
|
||
<q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px"><template v-if="b.legacy">🗂 <b>{{ b.subject }}</b><br>{{ b.dept || 'osTicket' }} · ≈{{ Math.round((b.dur || 0) * 10) / 10 }}h · #{{ b.lid }}<br><span class="text-light-green-4">✓ Assigné dans F (autoritaire)</span><br><span class="text-info">clic = ouvrir le ticket ↗</span></template><template v-else>{{ b.subject }}<template v-if="b.customer"><br>{{ b.customer }}</template><template v-if="b.address"><br>📍 {{ b.address }}</template><template v-if="b.assist"><br>👥 assistant (renfort)</template><br>{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h<template v-if="blkOpsOnly(b)"><br><span class="text-warning">⚠ Dispatché OPS — pas encore publié dans F</span></template><template v-if="!b.assist"><br><span class="text-info">clic droit = renvoyer au pool</span></template></template></q-tooltip>
|
||
<q-menu v-if="!b.assist && !b.legacy" context-menu touch-position auto-close><q-list dense style="min-width:190px"><q-item clickable @click="unassignOne(b)"><q-item-section avatar><q-icon name="inbox" color="grey-7" /></q-item-section><q-item-section>Renvoyer au pool<q-item-label caption>désassigner ce tech</q-item-label></q-item-section></q-item><q-item clickable @click="openJobDetail(b, t)"><q-item-section avatar><q-icon name="open_in_full" color="grey-7" /></q-item-section><q-item-section>Détails / équipe</q-item-section></q-item></q-list></q-menu>
|
||
</div>
|
||
</template>
|
||
<div v-if="kanbanDay && cellOfficeN(t.id, kanbanDay.iso)" class="office-dots kbb-office" @click.stop><span v-for="(j, ji) in officeJobs(t.id, kanbanDay.iso).slice(0, 12)" :key="'kod' + ji" class="office-dot" :style="{ background: legacyDeptColor(j.dept) || '#9fa8da' }"></span><q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px; max-width:320px"><b>{{ cellOfficeN(t.id, kanbanDay.iso) }} ticket(s) sans déplacement</b> (admin / support / NP) — hors charge dispatch.<br><span v-for="(j, ji) in officeJobs(t.id, kanbanDay.iso).slice(0, 12)" :key="'kop' + ji">• {{ j.subject }} <span class="text-grey-5">· {{ j.dept }}</span><br></span><br><span class="text-info">clic = liste · ouvrir un ticket ↗</span></q-tooltip><q-menu anchor="bottom right" self="top right" auto-close><q-list dense style="min-width:250px; max-width:360px"><q-item-label header class="q-py-xs">{{ cellOfficeN(t.id, kanbanDay.iso) }} ticket(s) sans déplacement · {{ t.name }}</q-item-label><q-item v-for="(j, ji) in officeJobs(t.id, kanbanDay.iso)" :key="'kom' + ji" clickable @click="openLegacyBlock({ lid: j.id, subject: j.subject, dept: j.dept })"><q-item-section avatar style="min-width:18px"><span class="office-dot" :style="{ background: legacyDeptColor(j.dept) || '#9fa8da' }"></span></q-item-section><q-item-section><q-item-label lines="2">{{ j.subject }}</q-item-label><q-item-label caption>{{ j.dept }} · #{{ j.id }}</q-item-label></q-item-section><q-item-section side><q-icon name="open_in_new" size="14px" color="grey-6" /></q-item-section></q-item></q-list></q-menu></div>
|
||
</div>
|
||
</div>
|
||
<div v-if="!visibleTechs.length" class="kb-empty" style="margin:10px">Aucun technicien (filtre ?).</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Matrice « Couverture — dispo vs requis » retirée : le besoin = toutes les spécialités chaque jour (règle simple, à automatiser via AI plus tard). -->
|
||
|
||
<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 & 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 & 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 & 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 & les techs. Vacances ⇒ substitut auto.</div>
|
||
<q-select dense outlined v-model="gardeHorizon" :options="[4, 8, 12, 26]" emit-value map-options style="width:96px" label="semaines" />
|
||
<q-btn dense unelevated color="primary" icon="cloud_upload" label="Publier la garde" @click="applyGardeRules" />
|
||
</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Éditeur de compétences (modal, comme Dispatch) : overflow visible → dropdown + niveau/couleur jamais clippés -->
|
||
<!-- Éditeur compétences : POPOVER ancré au clic (sur la rangée), liste déjà ouverte (autofocus) -->
|
||
<q-menu v-model="skillMenuShown" :target="skillMenuTarget" anchor="bottom left" self="top left" no-focus max-height="80vh">
|
||
<div v-if="skillDialog" class="q-pa-sm" style="width:368px;min-height:300px" @click.stop @mousedown.stop>
|
||
<div class="row items-center q-mb-xs"><div class="text-subtitle2 text-weight-bold col ellipsis">🏷 {{ skillDialog.name }}</div><q-btn flat dense round size="sm" icon="close" v-close-popup /></div>
|
||
<TagEditor :model-value="skillDialog.skills" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" sortable autofocus placeholder="Chercher ou créer une compétence…"
|
||
@update:model-value="items => onTagsChange(skillDialog, items)"
|
||
@create="onCreateRosterTag" />
|
||
<div v-if="(skillDialog.skills || []).length > 1" class="text-caption text-grey-6 q-mt-xs" style="line-height:1.3">↔ <b>Glisse les chips</b> pour l'ordre de priorité — la <b>1<sup>re</sup> (★)</b> = fonction principale : le dispatch auto envoie ces jobs à ce tech <b>en premier</b> (épargne les moins spécialisés).</div>
|
||
<div v-if="(skillDialog.skills || []).length" class="q-mt-md">
|
||
<div class="row items-center text-caption text-grey-6 q-pb-xs">
|
||
<div class="col">Compétence</div>
|
||
<div style="width:90px" class="text-center">Score</div>
|
||
<div style="width:88px" class="text-center">Efficacité</div>
|
||
</div>
|
||
<div v-for="(sk, si) in skillDialog.skills" :key="sk" class="row items-center no-wrap q-py-xs" style="border-top:1px solid #eee">
|
||
<div class="col row items-center no-wrap">
|
||
<span class="skill-rank" :class="{ 'skill-rank-1': si === 0 }" :title="si === 0 ? 'Priorité 1 — compétence principale' : 'Priorité ' + (si + 1)">{{ si + 1 }}</span>
|
||
<q-btn flat dense round size="xs" icon="circle" :style="{ color: getTagColor(sk) }"><q-tooltip>Couleur</q-tooltip>
|
||
<q-menu><div class="q-pa-xs" style="width:208px">
|
||
<div class="row">
|
||
<q-btn v-for="c in TAG_PALETTE" :key="c" v-close-popup flat dense round size="xs" icon="circle" :style="{ color: c }" @click="onUpdateRosterTag({ name: sk, color: c })" />
|
||
</div>
|
||
<div class="row items-center no-wrap q-mt-xs q-px-xs">
|
||
<span class="text-caption text-grey-7 q-mr-sm">Perso</span>
|
||
<input type="color" :value="getTagColor(sk)" @change="e => onUpdateRosterTag({ name: sk, color: e.target.value })" style="width:42px;height:26px;border:1px solid #ddd;border-radius:4px;background:none;cursor:pointer;padding:0" />
|
||
<span class="text-caption text-grey-5 q-ml-sm">toute couleur</span>
|
||
</div>
|
||
</div></q-menu>
|
||
</q-btn>
|
||
<span class="skill-chip" :style="{ background: getTagColor(sk) }">{{ sk }}</span>
|
||
</div>
|
||
<div style="width:90px" class="text-center no-wrap">
|
||
<q-icon v-for="n in 5" :key="n" :name="skillLevelOf(skillDialog, sk) >= n ? 'star' : 'star_outline'" :color="skillLevelOf(skillDialog, sk) >= n ? 'indigo' : 'grey-4'" size="16px" class="cursor-pointer" @click="setSkillLevel(skillDialog, sk, skillLevelOf(skillDialog, sk) === n ? 0 : n)" />
|
||
</div>
|
||
<div style="width:88px">
|
||
<q-input dense outlined type="number" step="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</div>
|
||
<div class="row items-center q-gutter-xs">
|
||
<q-btn dense unelevated size="sm" no-caps color="primary" icon="event_note" label="Générer l'horaire…" @click="openSchedGen(skillDialog)"><q-tooltip>Modèles (5×8h · 4×10h · 3×12h) + heures par jour + N semaines → publie les quarts</q-tooltip></q-btn>
|
||
<q-btn dense outline size="sm" no-caps color="primary" label="5×8h rapide" @click="applyWeekPreset(skillDialog, [1,2,3,4,5], 8, 16)"><q-tooltip>Applique 5×8h à la semaine affichée (à publier)</q-tooltip></q-btn>
|
||
</div>
|
||
<q-separator class="q-my-sm" />
|
||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🏠 Domicile <span class="text-grey-5 text-weight-regular">(départ de tournée si plus proche que le dépôt)</span></div>
|
||
<div class="row items-center no-wrap q-gutter-xs">
|
||
<q-btn dense outline size="sm" no-caps color="teal" :icon="techHomes[skillDialog.id] ? 'edit_location_alt' : 'add_location_alt'" :label="techHomes[skillDialog.id] ? 'Modifier' : 'Définir'" @click="openHomePicker(skillDialog)" />
|
||
<span class="text-caption text-grey-6 ellipsis col">{{ techHomes[skillDialog.id] && techHomes[skillDialog.id].address ? techHomes[skillDialog.id].address : 'non défini' }}</span>
|
||
</div>
|
||
</div>
|
||
</q-menu>
|
||
|
||
<!-- Impact d'un retrait de compétence : jobs assignés devenus invalides → redistribuer -->
|
||
<q-dialog v-model="skillImpactOpen">
|
||
<q-card style="min-width:420px;max-width:520px" v-if="skillImpactDialog">
|
||
<q-card-section class="row items-center q-pb-none">
|
||
<q-icon name="warning" color="orange" size="22px" class="q-mr-sm" />
|
||
<div class="text-subtitle1 text-weight-bold col">{{ skillImpactDialog.kind === 'absence' ? 'Absence — impact' : 'Compétence retirée — impact' }}</div>
|
||
<q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section>
|
||
<div v-if="skillImpactDialog.kind === 'absence'" class="text-body2 q-mb-sm"><b>{{ skillImpactDialog.jobs.length }}</b> job(s) assigné(s) à <b>{{ skillImpactDialog.tech.name }}</b> tombent sur son absence — à redistribuer.</div>
|
||
<div v-else class="text-body2 q-mb-sm"><b>{{ skillImpactDialog.jobs.length }}</b> job(s) assigné(s) à <b>{{ skillImpactDialog.tech.name }}</b> exigent « <b>{{ skillImpactDialog.skill }}</b> », qu'on vient de retirer — il/elle ne peut plus les faire.</div>
|
||
<q-list dense bordered class="rounded-borders" style="max-height:320px;overflow:auto">
|
||
<q-item v-for="j in skillImpactDialog.jobs" :key="j.name" class="q-py-sm">
|
||
<q-item-section>
|
||
<q-item-label>{{ j.service_type || 'Job' }}{{ j.customer_name ? ' — ' + j.customer_name : '' }}</q-item-label>
|
||
<q-item-label caption><q-icon name="event" size="12px" /> {{ j.scheduled_date ? j.scheduled_date.slice(5) : '—' }} {{ j.start_time ? j.start_time.slice(0, 5) : '' }} · {{ j.location_label || j.service_location || '—' }}</q-item-label>
|
||
</q-item-section>
|
||
<q-item-section side style="min-width:188px">
|
||
<q-select dense outlined options-dense :model-value="impactPlan[j.name]" :options="candidateOptions(j.name)" emit-value map-options :loading="loadingCandidates" label="Réassigner à" @update:model-value="v => { impactPlan[j.name] = v }" />
|
||
</q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
<div class="text-caption text-grey-6 q-mt-sm">Choix <b>classés</b> = techs qualifiés <b>libres au même créneau</b> (le 1er = suggéré). « À recontacter » → file RDV (créneaux filtrés, SMS client possible).</div>
|
||
</q-card-section>
|
||
<q-card-actions align="right">
|
||
<q-btn flat label="Ignorer" v-close-popup :disable="redistributing" />
|
||
<q-btn outline color="grey-8" no-caps label="Tout à recontacter" :loading="redistributing" @click="doRedistribute('requeue')" />
|
||
<q-btn unelevated color="primary" icon="check" no-caps label="Appliquer" :loading="redistributing" @click="applyImpactPlan" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Gestionnaire global des compétences (tags) : renommer / recolorer / supprimer partout -->
|
||
<q-dialog v-model="showTagManager">
|
||
<q-card style="min-width:420px;max-width:540px">
|
||
<q-card-section class="row items-center q-pb-none">
|
||
<div class="text-subtitle1 text-weight-bold col">🏷 Gérer les compétences</div>
|
||
<q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section style="max-height:62vh;overflow:auto">
|
||
<div v-if="!managedTags.length" class="text-grey-6">Aucune compétence — ajoute-en via un employé.</div>
|
||
<q-list v-else dense separator>
|
||
<q-item v-for="tg in managedTags" :key="tg.label">
|
||
<q-item-section avatar>
|
||
<q-btn flat dense round size="sm" icon="circle" :style="{ color: tg.color }"><q-tooltip>Couleur</q-tooltip>
|
||
<q-menu><div class="q-pa-xs" style="width:208px">
|
||
<div class="row"><q-btn v-for="c in TAG_PALETTE" :key="c" v-close-popup flat dense round size="xs" icon="circle" :style="{ color: c }" @click="onUpdateRosterTag({ name: tg.label, color: c })" /></div>
|
||
<div class="row items-center no-wrap q-mt-xs q-px-xs"><span class="text-caption text-grey-7 q-mr-sm">Perso</span><input type="color" :value="tg.color" @change="e => onUpdateRosterTag({ name: tg.label, color: e.target.value })" style="width:42px;height:26px;border:1px solid #ddd;border-radius:4px;background:none;cursor:pointer;padding:0" /></div>
|
||
</div></q-menu>
|
||
</q-btn>
|
||
</q-item-section>
|
||
<q-item-section>
|
||
<q-item-label class="cursor-pointer">{{ tg.label }}
|
||
<q-popup-edit :model-value="tg.label" auto-save v-slot="scope" @save="v => renameTagGlobal(tg.label, v)">
|
||
<q-input dense autofocus :model-value="scope.value" @update:model-value="scope.value = $event" label="Renommer (partout)" @keyup.enter="scope.set" />
|
||
</q-popup-edit>
|
||
</q-item-label>
|
||
<q-item-label caption>{{ tg.count }} technicien(s) · clic = renommer</q-item-label>
|
||
</q-item-section>
|
||
<q-item-section side>
|
||
<q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="deleteTagGlobal(tg)"><q-tooltip>Supprimer partout</q-tooltip></q-btn>
|
||
</q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
<div class="text-caption text-grey-6 q-mt-sm">Renommer/supprimer s'applique à <b>tous les techniciens</b> qui ont la compétence. Recolorer change la couleur partout.</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Panneau FLOTTANT déplaçable : jobs à assigner (groupes parent-enfant) → glisser sur une case (tech × jour) -->
|
||
<div v-if="assignPanel.open" class="assign-panel" :style="{ left: assignPanel.x + 'px', top: assignPanel.y + 'px', width: assignPanel.w + 'px', height: assignPanel.h + 'px', maxHeight: '92vh' }">
|
||
<div class="assign-hdr" @mousedown="panelHeaderDown">
|
||
<q-icon name="drag_indicator" size="18px" /><span>Jobs à assigner ({{ assignTypeFilter.length ? assignJobsFiltered.length + '/' + assignPanel.jobs.length : assignPanel.jobs.length }})</span><q-space />
|
||
<q-btn v-if="assignNoCoord" flat dense no-caps size="sm" color="warning" icon="wrong_location" :label="String(assignNoCoord)" :loading="assignPanel.locating" class="q-mr-xs" @click="batchLocatePool"><q-tooltip>Localiser les {{ assignNoCoord }} job(s) sans coordonnées : adresse → base RQA + GPS (ils apparaissent ensuite sur la carte)</q-tooltip></q-btn>
|
||
<q-btn flat dense no-caps size="sm" color="amber-4" icon="auto_awesome" :label="suggestFiltered ? 'Suggérer (' + suggestJobCount + ')' : 'Suggérer'" :loading="suggestDlg.building" class="q-mr-xs" @click="openSuggest"><q-tooltip>Proposer une répartition optimisée (proximité · charge · compétence){{ suggestFiltered ? ' — ' + suggestJobCount + ' job(s) ' + suggestScope : '' }} — tu revois et ajustes avant d'appliquer</q-tooltip></q-btn>
|
||
<q-btn flat dense round size="sm" icon="map" :color="assignPanel.showMap ? 'amber-4' : 'white'" @click="toggleAssignMap"><q-tooltip>Carte des jobs (regrouper par région)</q-tooltip></q-btn>
|
||
<q-btn flat dense round size="sm" icon="refresh" color="white" :loading="assignPanel.loading" @click="openAssignPanel" />
|
||
<q-btn flat dense round size="sm" icon="close" color="white" @click="assignPanel.open = false" />
|
||
</div>
|
||
<div class="assign-sortbar" @mousedown.stop>
|
||
<span>Trier :</span>
|
||
<select v-model="assignSort" @mousedown.stop>
|
||
<option value="group">Groupe (parent-enfant)</option>
|
||
<option value="skill">Compétence</option>
|
||
<option value="date">Date</option>
|
||
<option value="city">Ville</option>
|
||
<option value="priority">Priorité</option>
|
||
</select>
|
||
<q-btn dense flat size="sm" :icon="assignSortDir === 'asc' ? 'arrow_upward' : 'arrow_downward'" @click="assignSortDir = assignSortDir === 'asc' ? 'desc' : 'asc'" @mousedown.stop><q-tooltip>{{ assignSortDir === 'asc' ? 'Croissant (ancien → récent)' : 'Décroissant (récent → ancien)' }}</q-tooltip></q-btn>
|
||
<q-btn dense flat size="sm" :icon="allCollapsed ? 'unfold_more' : 'unfold_less'" no-caps @click="toggleCollapseAll" @mousedown.stop><q-tooltip>{{ allCollapsed ? 'Tout déplier' : 'Tout replier (voir un seul groupe)' }}</q-tooltip></q-btn>
|
||
<q-space />
|
||
<q-btn v-if="selectedNames.length" dense flat size="sm" icon="deselect" :label="'✕ ' + selectedNames.length" no-caps color="grey-7" @click="clearSel" @mousedown.stop><q-tooltip>Tout désélectionner</q-tooltip></q-btn>
|
||
</div>
|
||
<div v-if="assignTypes.length > 1" class="assign-chips" @mousedown.stop>
|
||
<span v-for="t in assignTypes" :key="t.k" class="assign-chip-f" :style="assignTypeFilter.includes(t.k) ? { background: getTagColor(t.k), color: '#fff', borderColor: getTagColor(t.k) } : { borderColor: getTagColor(t.k) }" @click="toggleAssignType(t.k)">{{ t.k }} <b>{{ t.n }}</b></span>
|
||
<span v-if="assignTypeFilter.length" class="assign-chip-f clear" @click="assignTypeFilter = []"><q-tooltip>Tout afficher</q-tooltip>✕</span>
|
||
</div>
|
||
<div v-if="assignDates.length > 1" class="assign-chips" @mousedown.stop>
|
||
<q-icon name="event" size="14px" color="grey-6" class="q-mr-xs" />
|
||
<span v-for="d in assignDates" :key="d.iso" class="assign-chip-f" :style="assignDateFilter.includes(d.iso) ? { background: d.overdue ? '#f59e0b' : '#6366f1', color: '#fff', borderColor: d.overdue ? '#f59e0b' : '#6366f1' } : (d.overdue ? { borderColor: '#f59e0b', color: '#b45309' } : {})" @click="toggleAssignDate(d.iso)">{{ d.label }} <b>{{ d.n }}</b></span>
|
||
<span v-if="assignDateFilter.length" class="assign-chip-f clear" @click="assignDateFilter = []"><q-tooltip>Toutes les dates</q-tooltip>✕</span>
|
||
</div>
|
||
<!-- Carte des jobs à assigner : 1 pin par adresse (lettre = groupe de ≥2 jobs à la même adresse) → voir les régions, clic = sélectionner le groupe local -->
|
||
<div v-show="assignPanel.showMap" class="assign-map-wrap" @mousedown.stop>
|
||
<div ref="assignMapEl" class="assign-map"></div>
|
||
<q-btn dense round size="sm" class="map-sat-btn" :color="satView ? 'primary' : 'grey-8'" :icon="satView ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satView ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
|
||
<q-btn dense round size="sm" class="map-lasso-btn" :color="assignLasso ? 'primary' : 'grey-8'" icon="highlight_alt" @click="toggleLasso"><q-tooltip>{{ assignLasso ? 'Lasso ACTIF — trace une zone à main levée pour sélectionner les jobs dedans (reclique pour quitter)' : 'Lasso : encercler un groupe de jobs (tracé libre)' }}</q-tooltip></q-btn>
|
||
<div class="assign-map-cap">📍 Pins par <b>compétence</b> · amas = <b>total de jobs</b> (clic = zoom) · <b><q-icon name="highlight_alt" size="12px" /> lasso</b> = sélectionner une zone<span v-if="assignNoCoord" class="text-warning"> · ⚠ {{ assignNoCoord }} sans coords</span></div>
|
||
</div>
|
||
<div class="assign-body">
|
||
<div v-if="assignPanel.loading" class="text-grey-6 q-pa-md text-center">Chargement…</div>
|
||
<div v-else-if="!assignPanel.jobs.length" class="text-grey-6 q-pa-md text-center">Aucun job à assigner 🎉</div>
|
||
<div v-for="grp in assignGroups" :key="grp.key" class="assign-grp" :class="{ 'grp-hl': groupSelected(grp) }">
|
||
<div v-if="grp.label" class="assign-grp-lbl" style="cursor:pointer;display:flex;align-items:center;gap:3px" @click="toggleCollapse(grp.key)"><q-icon :name="assignCollapsed.has(grp.key) ? 'chevron_right' : 'expand_more'" size="15px" /><span>{{ grp.label }} <span style="opacity:.6">({{ grp.jobs.length }})</span></span><q-space /><q-icon name="done_all" size="16px" :color="groupSelected(grp) ? 'primary' : 'grey-5'" @click.stop="toggleGroupSelAll(grp)"><q-tooltip>Sélectionner / désélectionner tout ce groupe</q-tooltip></q-icon></div>
|
||
<template v-if="!(grp.label && assignCollapsed.has(grp.key))">
|
||
<div v-if="assignSort === 'group' && grp.jobs.length > 1" class="assign-grp-hdr" @click="toggleGroupSel(grp)"><q-icon name="account_tree" size="12px" /> Groupe ({{ grp.jobs.length }}) — tout sélectionner (terrain)</div>
|
||
<div v-for="(j, idx) in grp.jobs" :key="j.name" :data-jobname="j.name" class="assign-job" :class="{ blocked: j.status === 'On Hold', child: assignSort === 'group' && grp.jobs.length > 1 && idx > 0, sel: !!selectedJobs[j.name], dragging: draggingSet.has(j.name), 'job-flash': flashJob === j.name }" :style="{ borderLeft: '5px solid ' + panelJobColor(j) }" draggable="true" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
|
||
<div class="row items-center no-wrap">
|
||
<q-checkbox dense size="xs" :model-value="!!selectedJobs[j.name]" @update:model-value="selectedJobs[j.name] = $event" @click.stop @mousedown.stop class="q-mr-xs" />
|
||
<q-icon :name="jobIsOnsite(j) ? 'home_repair_service' : 'cloud'" size="13px" :color="jobIsOnsite(j) ? 'teal' : 'grey-5'" class="q-mr-xs"><q-tooltip>{{ jobIsOnsite(j) ? 'Sur site (terrain)' : 'À distance / netadmin — pas pour un tech terrain' }}</q-tooltip></q-icon>
|
||
<span v-if="groupLabel(j)" class="assign-grp-badge q-mr-xs"><q-tooltip class="bg-grey-9">Même adresse de service (groupe {{ groupLabel(j)[0] }}) — simple repère de proximité</q-tooltip>{{ groupLabel(j) }}</span>
|
||
<q-badge v-if="j.step_order" color="indigo" class="q-mr-xs">{{ j.step_order }}</q-badge>
|
||
<span class="ellipsis text-weight-medium" :style="assignPanel.showMap ? 'cursor:pointer' : ''" @click="assignPanel.showMap && focusAssignJob(j)">{{ j.subject || j.service_type || j.name }}<q-tooltip v-if="j.legacy_detail" max-width="380px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.legacy_detail }}</q-tooltip></span>
|
||
<!-- SLA : pastille rouge (dépassé) / orange (proche) — réutilise les politiques SLA des tickets -->
|
||
<span v-if="jobSlaBadge(j)" class="assign-sla" :class="'sla-' + jobSlaBadge(j).state"><q-icon :name="jobSlaBadge(j).icon" size="11px" />{{ jobSlaBadge(j).short }}<q-tooltip class="bg-grey-9">{{ jobSlaBadge(j).label }}</q-tooltip></span>
|
||
<q-space />
|
||
<q-btn v-if="j.status !== 'On Hold'" flat dense round size="sm" icon="person_add" color="primary" class="q-ml-xs" @click.stop @mousedown.stop>
|
||
<q-tooltip>Assigner à un technicien (classés par pertinence : compétence · dispo · distance · charge)</q-tooltip>
|
||
<q-menu anchor="bottom right" self="top right" @click.stop>
|
||
<div class="assign-pick-hd">Assigner « {{ (j.subject || j.name).slice(0, 30) }} » · 📅 {{ fmtDueLabel(jobTargetDay(j)) }}</div>
|
||
<q-list dense style="min-width:270px;max-height:44vh;overflow:auto">
|
||
<q-item v-for="tk in techsForJob(j)" :key="tk.id" clickable v-close-popup @click="quickAssign(j, tk)" :class="{ 'assign-pick-incap': !tk.capable }">
|
||
<q-item-section avatar style="min-width:32px"><q-avatar size="26px" :color="tk.capable ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:10px;font-weight:700">{{ initials(tk.name) }}</q-avatar></q-item-section>
|
||
<q-item-section>
|
||
<q-item-label>{{ tk.name }}<q-icon v-if="!tk.capable && j.required_skill" name="warning" size="12px" color="orange" class="q-ml-xs"><q-tooltip>Compétence « {{ j.required_skill }} » absente</q-tooltip></q-icon></q-item-label>
|
||
<q-item-label caption><span v-if="tk.distKm != null">📍 {{ Math.round(tk.distKm) }} km</span><span v-else class="text-grey-5">dist. ?</span> · {{ tk.h }}/{{ tk.cap }}h<span v-if="tk.noShift" class="text-warning"> · ▲ sans quart</span><span v-else-if="tk.over" class="text-negative"> · surchargé</span></q-item-label>
|
||
</q-item-section>
|
||
<q-item-section side><q-icon name="chevron_right" size="16px" color="grey-5" /></q-item-section>
|
||
</q-item>
|
||
<q-item v-if="!techsForJob(j).length"><q-item-section class="text-grey-6 text-caption">Aucun technicien visible.</q-item-section></q-item>
|
||
</q-list>
|
||
</q-menu>
|
||
</q-btn>
|
||
<q-icon v-if="j.legacy_ticket_id" name="forum" size="14px" :color="j._showThread ? 'indigo' : 'grey-5'" class="q-ml-xs" style="cursor:pointer" @click.stop="toggleAssignThread(j)" @mousedown.stop><q-tooltip>Voir le fil du ticket #{{ j.legacy_ticket_id }}</q-tooltip></q-icon>
|
||
<q-icon v-if="j.status === 'On Hold'" name="lock" size="13px" color="orange"><q-tooltip>En attente de {{ j.depends_on || 'la tâche précédente' }}</q-tooltip></q-icon>
|
||
</div>
|
||
<div class="assign-sub">
|
||
<span v-if="j.required_skill" class="assign-skill" :style="{ background: getTagColor(j.required_skill) }">{{ j.required_skill }}</span>
|
||
<span v-if="j.required_skill" class="assign-lvl" :class="{ set: j.required_level > 1 }" @click.stop @mousedown.stop><q-icon name="military_tech" size="11px" />niv {{ j.required_level || 1 }}<q-tooltip class="bg-grey-9">Niveau min requis pour ce job (persistant · mode « juste ce qu'il faut » → réserve les experts)</q-tooltip><q-menu anchor="bottom left" self="top left" @click.stop><q-list dense style="min-width:150px"><q-item v-for="n in [1,2,3,4,5]" :key="n" clickable v-close-popup @click="setJobReqLevel(j, n)" :active="(j.required_level||1)===n"><q-item-section>Niveau {{ n }}{{ n===1 ? ' — de base' : (n===5 ? ' — expert' : '') }}</q-item-section></q-item></q-list></q-menu></span>
|
||
{{ j.customer_name || j.location_label || j.service_location || '' }}<span v-if="j.depends_on"> · après {{ j.depends_on }}</span> · <span class="text-positive text-weight-medium">≈ {{ j.est_min ? fmtMin(j.est_min) : (j.duration_h || 1) + 'h' }}<q-tooltip v-if="j.est_labels && j.est_labels.length" class="bg-grey-9" style="font-size:11px">Estimé (auto) : {{ j.est_labels.join(' + ') }}</q-tooltip></span><span v-if="j.scheduled_date"> · 📅 <span :class="isOverdue(j.scheduled_date) ? 'text-warning text-weight-bold' : 'text-grey-7'">{{ fmtDueLabel(j.scheduled_date) }}</span><q-icon name="edit_calendar" size="14px" :color="isOverdue(j.scheduled_date) ? 'warning' : 'grey-5'" class="q-ml-xs" style="cursor:pointer" @click.stop @mousedown.stop><q-tooltip>Replanifier (aujourd'hui / demain / date)</q-tooltip><q-menu anchor="bottom left" self="top left" @click.stop><q-list dense style="min-width:160px"><q-item clickable v-close-popup @click="setJobDate(j, todayISO())"><q-item-section avatar style="min-width:28px"><q-icon name="today" size="18px" color="primary" /></q-item-section><q-item-section>Aujourd'hui</q-item-section></q-item><q-item clickable v-close-popup @click="setJobDate(j, tomorrowISO())"><q-item-section avatar style="min-width:28px"><q-icon name="event" size="18px" color="primary" /></q-item-section><q-item-section>Demain</q-item-section></q-item><q-separator /><q-date :model-value="j.scheduled_date" @update:model-value="v => setJobDate(j, v)" mask="YYYY-MM-DD" minimal today-btn /></q-list></q-menu></q-icon></span>
|
||
</div>
|
||
<div v-if="j._showThread" class="assign-thread" @mousedown.stop @click.stop>
|
||
<div v-if="j._thread && j._thread.loading" class="text-grey-6" style="font-size:11px"><q-spinner size="12px" class="q-mr-xs" />Chargement du fil…</div>
|
||
<template v-else-if="j._thread && j._thread.messages && j._thread.messages.length">
|
||
<div v-for="(m, mi) in j._thread.messages" :key="mi" class="assign-msg"><b>{{ m.author }}</b><span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span><div class="assign-msg-txt">{{ m.text }}</div></div>
|
||
</template>
|
||
<div v-else class="text-grey-6" style="font-size:11px;white-space:pre-wrap">{{ j.legacy_detail || 'Aucun fil pour ce ticket.' }}</div>
|
||
<div class="text-right q-mt-xs"><q-btn dense flat size="sm" color="negative" icon="block" label="Fermer ce ticket (legacy)" @click="closeLegacyTicket(j)"><q-tooltip>Ticket inutile → status closed dans osTicket + sort du dispatch</q-tooltip></q-btn></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div v-if="assignPanel.jobs.length" class="assign-foot">
|
||
<template v-if="selectedNames.length"><b>{{ selectedNames.length }}</b> sélectionné(s) · <b>{{ selectedHours }}h</b>
|
||
<q-btn dense unelevated size="sm" color="primary" icon="person_add" :label="'Assigner (' + selectedNames.length + ')'" class="q-ml-sm">
|
||
<q-tooltip>Assigner TOUTE la sélection à un technicien (chaque job garde sa date) — classés par compétence · dispo · distance · charge</q-tooltip>
|
||
<q-menu anchor="top right" self="bottom right">
|
||
<div class="assign-pick-hd">Assigner {{ selectedNames.length }} job(s) · {{ selectedHours }}h à…</div>
|
||
<q-list dense style="min-width:280px;max-height:46vh;overflow:auto">
|
||
<q-item v-for="tk in techsForSelection()" :key="tk.id" clickable v-close-popup @click="quickAssignSelection(tk)" :class="{ 'assign-pick-incap': !tk.capable }">
|
||
<q-item-section avatar style="min-width:32px"><q-avatar size="26px" :color="tk.capable ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:10px;font-weight:700">{{ initials(tk.name) }}</q-avatar></q-item-section>
|
||
<q-item-section>
|
||
<q-item-label>{{ tk.name }}<q-icon v-if="!tk.capable" name="warning" size="12px" color="orange" class="q-ml-xs"><q-tooltip>Manque une compétence requise par la sélection</q-tooltip></q-icon></q-item-label>
|
||
<q-item-label caption><span v-if="tk.distKm != null">📍 {{ Math.round(tk.distKm) }} km</span><span v-else class="text-grey-5">dist. ?</span> · {{ tk.h }}/{{ tk.cap }}h<span v-if="tk.noShift" class="text-warning"> · ▲ sans quart</span><span v-else-if="tk.over" class="text-negative"> · surchargé</span></q-item-label>
|
||
</q-item-section>
|
||
<q-item-section side><q-icon name="chevron_right" size="16px" color="grey-5" /></q-item-section>
|
||
</q-item>
|
||
<q-item v-if="!techsForSelection().length"><q-item-section class="text-grey-6 text-caption">Aucun technicien visible.</q-item-section></q-item>
|
||
</q-list>
|
||
</q-menu>
|
||
</q-btn>
|
||
<q-btn dense flat size="sm" color="primary" icon="edit_calendar" label="Date" class="q-ml-sm"><q-tooltip>Replanifier toute la sélection (aujourd'hui / demain / date)</q-tooltip><q-menu><q-list dense style="min-width:160px"><q-item clickable v-close-popup @click="setSelDate(todayISO())"><q-item-section avatar style="min-width:28px"><q-icon name="today" size="18px" color="primary" /></q-item-section><q-item-section>Aujourd'hui</q-item-section></q-item><q-item clickable v-close-popup @click="setSelDate(tomorrowISO())"><q-item-section avatar style="min-width:28px"><q-icon name="event" size="18px" color="primary" /></q-item-section><q-item-section>Demain</q-item-section></q-item><q-separator /><q-date :model-value="todayISO()" @update:model-value="v => setSelDate(v)" mask="YYYY-MM-DD" minimal today-btn /></q-list></q-menu></q-btn>
|
||
<q-btn dense unelevated size="sm" color="negative" icon="block" :label="'Fermer (' + selectedNames.length + ')'" class="q-ml-sm" @click="bulkCloseSelected"><q-tooltip>Fermer les tickets sélectionnés dans le legacy (au lieu de réassigner)</q-tooltip></q-btn></template>
|
||
<template v-else>Coche des jobs (groupe lié surligné), puis glisse la sélection sur une case <b>tech × jour</b>. <q-icon name="home_repair_service" size="11px" color="teal" />=terrain · <q-icon name="cloud" size="11px" color="grey-5" />=à distance · 🔒=bloqué.</template>
|
||
</div>
|
||
<div class="assign-resize" @mousedown.stop="panelResizeDown"><q-tooltip>Redimensionner</q-tooltip></div>
|
||
</div>
|
||
|
||
<!-- D : revue de la répartition suggérée (surcouche) — déplacer/retirer avant d'appliquer -->
|
||
<q-dialog v-model="suggestDlg.open">
|
||
<q-card style="width:940px;max-width:96vw;max-height:90vh;display:flex;flex-direction:column">
|
||
<q-card-section class="row items-center q-py-sm" style="border-bottom:1px solid #e2e8f0">
|
||
<q-icon name="auto_awesome" color="amber-7" size="22px" class="q-mr-sm" />
|
||
<div><div class="text-subtitle1 text-weight-bold">{{ suggestDlg.mode === 'config' ? 'Techniciens disponibles' : 'Répartition suggérée' }}</div><div class="text-caption text-grey-7"><template v-if="suggestDlg.mode === 'config'">Choisis qui participe au dispatch auto{{ suggestDlg.fromSel ? ' de la sélection' : '' }} · {{ suggestSelCount }} sélectionné(s)</template><template v-else>{{ suggestDlg.plan.length }} job(s) · {{ suggestGroups.length }} tech(s){{ suggestDlg.fromSel ? ' · sélection' : '' }} — proximité + charge + compétence. Ajuste puis applique.</template></div></div>
|
||
<q-space />
|
||
<q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="col scroll q-pa-sm" style="overflow:auto">
|
||
<!-- Étape 1 — disponibilité des techniciens + création de quarts 8–16 -->
|
||
<template v-if="suggestDlg.mode === 'config'">
|
||
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
|
||
<span class="text-caption text-grey-7 q-mr-xs">Stratégie :</span>
|
||
<q-btn-toggle v-model="suggestDlg.strategy" dense no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8" :options="[{ label: '⚡ Optimiser', value: 'optimize' }, { label: 'Intelligent', value: 'smart' }, { label: 'Meilleurs d\'abord', value: 'best' }, { label: 'Équilibré', value: 'balance' }, { label: 'Juste ce qu\'il faut', value: 'enough' }]" />
|
||
<span class="text-caption text-grey-6">{{ ({ optimize: 'solveur VRP (OR-Tools) : routes réelles minimisées + compétences + temps sur place — regroupe les secteurs, spécialistes d\'abord', smart: 'proximité + compétence + charge', best: 'concentre le travail sur les meilleurs (cascade)', balance: 'répartit également entre tous (round-robin)', enough: 'qualifié mais le moins sur-qualifié → réserve les experts (distance + temps pris en compte)' })[suggestDlg.strategy] }}</span>
|
||
</div>
|
||
<!-- Réglages optimiseur : UN COÛT par critère (pas de slider) — persistés localement -->
|
||
<div v-if="suggestDlg.strategy === 'optimize'" class="row items-center q-gutter-sm q-mb-sm" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:6px 10px;flex-wrap:wrap">
|
||
<span class="text-caption text-grey-7">Coûts :</span>
|
||
<q-input v-model.number="solverOpts.rankWeight" type="number" dense outlined style="width:98px" label="Spécialité" :min="0" :max="50"><q-tooltip>Coût par cran d'ordre de compétence — 0 = distance seule ; élevé = garde les spécialistes sur leur métier</q-tooltip></q-input>
|
||
<q-input v-model.number="solverOpts.overtimeCoef" type="number" dense outlined style="width:104px" label="Heures sup" :min="0" :max="200"><q-tooltip>Coût par minute au-delà de 100 % du quart — élevé = étale davantage avant de dépasser (plafond 120 %)</q-tooltip></q-input>
|
||
<q-input v-model.number="solverOpts.speedKmh" type="number" dense outlined style="width:86px" label="km/h" :min="20" :max="90"><q-tooltip>Vitesse de repli (nœuds sans coordonnées)</q-tooltip></q-input>
|
||
<q-input v-model.number="solverOpts.maxSeconds" type="number" dense outlined style="width:92px" label="Calcul s" :min="2" :max="30"><q-tooltip>Budget de calcul par jour</q-tooltip></q-input>
|
||
</div>
|
||
<!-- FENÊTRE de répartition = les CHIPS de date cochés dans le pool (ou la sélection lasso). Sinon → un jour choisi ici. -->
|
||
<div class="row items-center q-mb-sm" style="gap:6px;color:#475569;background:#eef2ff;border-radius:6px;padding:5px 8px">
|
||
<q-icon name="event" size="16px" color="primary" /><span class="text-caption text-weight-medium">Répartir :</span>
|
||
<template v-if="suggestFiltered">
|
||
<span class="text-caption text-weight-medium text-indigo-8">{{ suggestJobCount }} job(s) {{ suggestScope }}</span>
|
||
<q-icon name="arrow_forward" size="13px" color="grey-6" /><span class="text-caption text-grey-7">{{ suggestWindow.map(windowDayLabel).join(', ') }}</span>
|
||
<q-space /><span class="text-caption text-grey-6">{{ selectedNames.length ? 'sélection lasso' : 'ajuste les chips sous le pool' }}</span>
|
||
</template>
|
||
<template v-else>
|
||
<span class="text-caption">pour :</span>
|
||
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === todayISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === todayISO() ? 'white' : 'grey-8'" label="Aujourd'hui" @click="suggestDay = todayISO()" />
|
||
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === tomorrowISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === tomorrowISO() ? 'white' : 'grey-8'" label="Demain" @click="suggestDay = tomorrowISO()" />
|
||
<q-input dense outlined type="date" v-model="suggestDay" style="width:150px" />
|
||
<q-space /><span class="text-caption text-grey-6">{{ suggestJobCount }} job(s) de ce jour · retards exclus (chip « ⏰ en retard » pour les inclure)</span>
|
||
</template>
|
||
</div>
|
||
<div class="row items-center q-mb-xs" style="gap:4px">
|
||
<span class="text-caption text-grey-7 q-mr-xs">Rapide :</span>
|
||
<q-btn dense flat size="sm" no-caps label="Tous" @click="suggestSelectTechs('all')" />
|
||
<q-btn dense flat size="sm" no-caps label="Avec quart" @click="suggestSelectTechs('shift')" />
|
||
<q-btn dense flat size="sm" no-caps label="Aucun" @click="suggestSelectTechs('none')" />
|
||
</div>
|
||
<div class="row items-center q-mb-sm" style="gap:5px;flex-wrap:wrap">
|
||
<span class="text-caption text-grey-7 q-mr-xs">Par compétence :</span>
|
||
<span v-for="s in suggestSkillGroups" :key="s.skill" class="suggest-skill-chip" :class="{ on: suggestSkillOn(s.skill) }" :style="suggestSkillOn(s.skill) ? { background: getTagColor(s.skill), color: '#fff', borderColor: getTagColor(s.skill) } : { borderColor: getTagColor(s.skill), color: getTagColor(s.skill) }" @click="suggestSelectSkill(s.skill)">{{ s.skill }} <b>{{ s.n }}</b></span>
|
||
</div>
|
||
<div class="suggest-tech-grid">
|
||
<div v-for="t in suggestTechsRanked" :key="t.id" class="suggest-tech-chip" :class="{ on: !!suggestDlg.techSel[t.id] }" @click="suggestDlg.techSel[t.id] = !suggestDlg.techSel[t.id]">
|
||
<q-icon :name="suggestDlg.techSel[t.id] ? 'check_circle' : 'radio_button_unchecked'" size="16px" :color="suggestDlg.techSel[t.id] ? 'primary' : 'grey-5'" />
|
||
<q-avatar size="22px" color="blue-grey-1" text-color="blue-grey-8" style="font-size:9px;font-weight:700">{{ initials(t.name) }}</q-avatar>
|
||
<span class="ellipsis" style="flex:1">{{ t.name }}</span>
|
||
<q-icon v-if="(Number(t.efficiency) || 1) < 0.95" name="bolt" size="14px" color="green-6"><q-tooltip>Rapide (efficacité {{ t.efficiency }})</q-tooltip></q-icon>
|
||
<q-icon v-else-if="(Number(t.efficiency) || 1) > 1.05" name="hourglass_bottom" size="13px" color="orange-6"><q-tooltip>Plus lent (efficacité {{ t.efficiency }})</q-tooltip></q-icon>
|
||
<q-icon v-if="!(dayList || []).some(d => hasShiftDay(t.id, d.iso))" name="event_busy" size="13px" color="amber-7"><q-tooltip>Aucun quart publié cette période</q-tooltip></q-icon>
|
||
<q-icon name="edit" size="14px" color="grey-6" class="cursor-pointer" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Modifier compétences & niveaux (★) + efficacité</q-tooltip></q-icon>
|
||
</div>
|
||
</div>
|
||
<q-toggle v-model="suggestDlg.makeShifts" dense size="sm" color="primary" class="q-mt-sm" label="Créer un quart 8–16 pour les techs sans quart (à l'application)" />
|
||
</template>
|
||
<!-- Étape 2 — revue de la répartition proposée -->
|
||
<template v-else>
|
||
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
|
||
<q-btn-toggle v-model="suggestDlg.view" dense no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8" :options="[{ label: 'Liste', value: 'list', icon: 'view_list' }, { label: 'Carte des tournées', value: 'map', icon: 'map' }]" />
|
||
<template v-if="suggestDlg.view === 'map' && suggestWindow.length > 1">
|
||
<q-space />
|
||
<span class="text-caption text-grey-7">Jour :</span>
|
||
<q-btn-toggle v-model="suggestMapDay" dense no-caps unelevated toggle-color="indigo" color="grey-3" text-color="grey-8" :options="suggestWindow.map(iso => ({ label: windowDayLabel(iso), value: iso }))" />
|
||
</template>
|
||
</div>
|
||
<div v-if="suggestDlg.view === 'map'">
|
||
<RouteMap :routes="suggestRoutes.map(r => ({ id: r.techId, name: r.techName, color: r.color, home: r.home, stops: r.stops }))" height="440px" @metrics="onRouteMetrics" @stop-click="onReviewStopClick" />
|
||
<div class="suggest-legend">
|
||
<span v-for="r in suggestRoutes" :key="r.techId" class="suggest-leg"><span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.techName }} · {{ r.stops.length }} arrêt(s)<span v-if="realRoutes[r.techId]"> · 🚗 {{ realRoutes[r.techId].km }} km / {{ realRoutes[r.techId].mins }} min</span><span v-else-if="r.km"> · 🚗 ≈{{ r.km }} km</span></span>
|
||
<span v-if="!suggestRoutes.length" class="text-grey-6" style="font-size:12px">Aucun job géolocalisé ce jour (utilise « Localiser » sur le pool pour les placer sur la carte).</span>
|
||
</div>
|
||
</div>
|
||
<div v-show="suggestDlg.view !== 'map'">
|
||
<div v-if="!suggestDlg.plan.length" class="text-grey-6 text-center q-pa-lg">Rien à répartir (aucun job éligible).</div>
|
||
<div v-for="g in suggestGroups" :key="g.techId" class="suggest-tech" :class="{ 'suggest-wknd': g.placeholder }">
|
||
<div class="suggest-tech-hd">
|
||
<q-avatar size="24px" :color="g.placeholder ? 'amber-2' : 'blue-grey-1'" :text-color="g.placeholder ? 'amber-9' : 'blue-grey-8'" style="font-size:10px;font-weight:700"><q-icon v-if="g.placeholder" name="hourglass_top" size="15px" /><template v-else>{{ initials(g.techName) }}</template></q-avatar>
|
||
<b class="q-ml-sm">{{ g.techName }}</b>
|
||
<!-- Compétences du tech DANS L'ORDRE de priorité (1re = ★ principale) — clic = éditer/réordonner (même éditeur) -->
|
||
<span v-if="!g.placeholder && techById[g.techId]" class="suggest-hd-skills q-ml-xs" @click.stop="openSkillEditorFor(g.techId, $event)">
|
||
<span v-for="(sk, si) in (techById[g.techId].skills || []).slice(0, 2)" :key="sk" class="assign-skill" :style="{ background: getTagColor(sk), opacity: si === 0 ? 1 : 0.75 }">{{ si === 0 ? '★ ' : '' }}{{ sk }}</span>
|
||
<span v-if="(techById[g.techId].skills || []).length > 2" class="suggest-hd-more">+{{ (techById[g.techId].skills || []).length - 2 }}</span>
|
||
<q-tooltip>Ordre de priorité : {{ (techById[g.techId].skills || []).join(' → ') || 'aucune compétence' }} — clic pour éditer/réordonner</q-tooltip>
|
||
</span>
|
||
<span class="text-grey-6 q-ml-sm" style="font-size:12px">{{ g.jobs }} job(s) · {{ Math.round(g.hours * 10) / 10 }}h<span v-if="g.km"> · 🚗 ≈{{ g.km }} km / {{ g.mins }} min</span><span v-if="!g.placeholder && g.existing > 0" class="text-orange-8" title="Heures déjà assignées à ce tech ces jours-là"> · déjà {{ g.existing }}h</span><span v-if="g.sectors && g.sectors.length" class="text-teal-8" title="Secteur(s) où ce tech a DÉJÀ des jobs assignés"> · 📍 {{ g.sectors.slice(0, 2).join(', ') }}<template v-if="g.sectors.length > 2"> +{{ g.sectors.length - 2 }}</template></span><span v-if="g.placeholder" class="text-amber-9"> · file à assigner</span></span>
|
||
<!-- Occupation projetée = charge déjà assignée + heures suggérées / capacité (quarts réels ou 8h) -->
|
||
<div v-if="!g.placeholder && g.cap" class="suggest-occ" :title="'Occupation projetée ' + g.projected + 'h / ' + g.cap + 'h (déjà ' + g.existing + 'h assigné + ' + (Math.round(g.hours*10)/10) + 'h proposé)'">
|
||
<div class="suggest-occ-bar"><div class="suggest-occ-fill" :class="{ over: g.over, high: !g.over && g.fillPct >= 85 }" :style="{ width: g.fillPct + '%' }"></div></div>
|
||
<span class="suggest-occ-txt" :class="{ 'text-negative': g.over }">{{ g.projected }}/{{ g.cap }}h</span>
|
||
</div>
|
||
<q-icon v-if="g.over" name="error" color="negative" size="16px" class="q-ml-xs"><q-tooltip>Surcharge : {{ g.projected }}h projetées > {{ g.cap }}h de capacité (inclut le déjà-assigné)</q-tooltip></q-icon>
|
||
<q-space />
|
||
<!-- Quart manquant (Q2 §1) : alerte + créer le quart 8-16 (local, enregistré au Publier) -->
|
||
<q-btn v-if="g.noShiftDays && g.noShiftDays.length" flat dense no-caps size="sm" color="amber-8" icon="event_busy" :label="'Créer quart' + (g.noShiftDays.length > 1 ? ' ×' + g.noShiftDays.length : '')" @click="createShiftForGroup(g)"><q-tooltip>{{ g.techName }} n'a pas de quart {{ g.noShiftDays.length > 1 ? 'sur ' + g.noShiftDays.length + ' jour(s)' : 'ce jour' }} — créer un quart 8-16</q-tooltip></q-btn>
|
||
<!-- Point de DÉPART : 🏠 domicile défini ou 🏢 bureau TARGO (défaut) — clic = définir/modifier sur la carte -->
|
||
<q-btn v-if="!g.placeholder" flat dense round size="sm" :icon="techHomes[g.techId] ? 'home' : 'business'" :color="techHomes[g.techId] ? 'teal-7' : 'blue-grey-4'" @click="openHomePicker(techById[g.techId] || { id: g.techId, name: g.techName })"><q-tooltip>Départ : {{ techHomes[g.techId] ? ('domicile — ' + (techHomes[g.techId].address || 'défini')) : 'bureau TARGO (défaut)' }} — cliquer pour définir/modifier le domicile</q-tooltip></q-btn>
|
||
<!-- Éditer / réordonner les compétences du tech directement depuis l'optimiseur (même éditeur : chips ★ + glisser priorité) -->
|
||
<q-btn v-if="!g.placeholder" flat dense round size="sm" icon="edit" color="grey-6" @click="openSkillEditorFor(g.techId, $event)"><q-tooltip>Compétences & niveaux de {{ g.techName }} — glisser pour l'ordre de priorité</q-tooltip></q-btn>
|
||
<!-- Tech malade/absent → le retire ET redistribue ses jobs équitablement sur les autres (ré-optimise) -->
|
||
<q-btn v-if="!g.placeholder" flat dense round size="sm" icon="sick" color="deep-orange-4" @click="markTechSick(g)"><q-tooltip>{{ g.techName }} malade / absent — retirer et redistribuer ses {{ g.jobs }} job(s) sur les autres</q-tooltip></q-btn>
|
||
<!-- Assigner / fusionner TOUTE la file (marche depuis un placeholder → vrai tech, ou pour fusionner 2 files) -->
|
||
<q-btn flat dense round size="sm" :icon="g.placeholder ? 'person_add' : 'drive_file_move'" :color="g.placeholder ? 'amber-9' : 'grey-7'"><q-tooltip>{{ g.placeholder ? 'Assigner cette tournée à un tech' : ('Déplacer / fusionner tous les jobs de ' + g.techName) }}</q-tooltip>
|
||
<q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:250px;max-height:42vh;overflow:auto">
|
||
<q-item-label header class="q-py-xs">{{ g.placeholder ? 'Assigner les' : 'Déplacer les' }} {{ g.jobs }} job(s) à…<span v-if="g.skills && g.skills.length" class="text-grey-6"> (compétence {{ g.skills.join(', ') }})</span></q-item-label>
|
||
<q-item v-for="t in capableTechsFirst(g.skills)" :key="t.id" clickable v-close-popup @click="moveSuggestGroup(g, t.id, t.name)" :disable="t.id === g.techId || (!covers(t, g.skills) && anyCovers(g.skills))">
|
||
<q-item-section avatar style="min-width:30px"><q-avatar size="22px" :color="covers(t, g.skills) ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:9px;font-weight:700">{{ initials(t.name) }}</q-avatar></q-item-section>
|
||
<q-item-section>{{ t.name }}<span v-if="planCountByTech[t.id] && t.id !== g.techId" class="text-teal-7" style="font-size:11px"> · fusionner ({{ planCountByTech[t.id] }})</span></q-item-section>
|
||
<q-item-section side v-if="!covers(t, g.skills)"><q-icon name="block" color="grey-5" size="15px"><q-tooltip>Sans la compétence requise</q-tooltip></q-icon></q-item-section>
|
||
</q-item>
|
||
</q-list></q-menu>
|
||
</q-btn>
|
||
</div>
|
||
<div v-for="d in g.dayList" :key="d.iso" class="suggest-day">
|
||
<div class="suggest-day-lbl">{{ d.label }} <span class="text-grey-6">· {{ Math.round(d.hours * 10) / 10 }}h<span v-if="d.km"> · 🚗 ≈{{ d.km }} km / {{ d.mins }} min</span></span>
|
||
<!-- Jobs DÉJÀ assignés (occupation) : cliquer pour voir — permet de s'ajuster au secteur existant du tech -->
|
||
<span v-if="d.prevJobs && d.prevJobs.length" class="suggest-prev-toggle" @click="prevOpen[g.techId + '|' + d.iso] = !prevOpen[g.techId + '|' + d.iso]">
|
||
🔒 {{ d.prevJobs.length }} déjà assigné(s)<template v-if="d.prevCities.length"> · {{ d.prevCities.slice(0, 2).join(', ') }}</template>
|
||
<q-icon :name="prevOpen[g.techId + '|' + d.iso] ? 'expand_less' : 'expand_more'" size="13px" />
|
||
</span>
|
||
</div>
|
||
<template v-if="prevOpen[g.techId + '|' + d.iso]">
|
||
<div v-for="pj in d.prevJobs" :key="'prev' + pj.name" class="suggest-entry suggest-prev">
|
||
<q-icon name="lock" size="11px" color="grey-5" class="q-mr-xs" />
|
||
<span v-if="pj.start" class="text-grey-6 q-mr-xs" style="font-size:11px">{{ pj.start }}</span>
|
||
<span v-if="pj.skill" class="assign-skill" :style="{ background: getTagColor(pj.skill) }">{{ pj.skill }}</span>
|
||
<span class="suggest-entry-t ellipsis text-grey-7">{{ pj.subject }}<span v-if="pj.customer" class="text-grey-5"> · {{ pj.customer }}</span></span>
|
||
<span class="text-grey-5 q-mx-xs" style="font-size:11px;white-space:nowrap">{{ Math.round((pj.dur || 0) * 10) / 10 }}h</span>
|
||
</div>
|
||
</template>
|
||
<template v-for="(e, ei) in d.entries" :key="e.jobName">
|
||
<div v-if="ei === 0 && legHome(g, d) != null" class="suggest-leg-time">🚗 {{ (legsByKey[g.techId + '|' + d.iso] || {}).kind === 'depot' ? '🏢 bureau TARGO' : '🏠 domicile' }} → 1er arrêt : {{ legHome(g, d) }} min</div>
|
||
<div class="suggest-entry">
|
||
<span class="suggest-step">{{ ei + 1 }}</span>
|
||
<span v-if="e.skill" class="assign-skill" :style="{ background: getTagColor(e.skill) }">{{ e.skill }}</span>
|
||
<span v-if="e.lvl" class="suggest-mlvl" :title="'Maîtrise du tech : ' + e.lvl + (e.reqLevel > 1 ? ' / niveau requis ' + e.reqLevel : '')">★{{ e.lvl }}<span v-if="e.reqLevel > 1" style="opacity:.6">/{{ e.reqLevel }}</span></span>
|
||
<q-icon v-if="e.eff && e.eff < 0.95" name="bolt" size="12px" color="green-6" class="q-mr-xs"><q-tooltip>Tech rapide sur ce type ({{ e.eff }})</q-tooltip></q-icon>
|
||
<span v-if="entrySlaBadge(e)" class="assign-sla q-mr-xs" :class="'sla-' + entrySlaBadge(e).state"><q-icon :name="entrySlaBadge(e).icon" size="11px" />{{ entrySlaBadge(e).short }}<q-tooltip class="bg-grey-9">{{ entrySlaBadge(e).label }}</q-tooltip></span>
|
||
<!-- Coordination d'adresse : 🔗 teal = épinglé sur le tech qui a DÉJÀ un job à cette adresse · 🔗 indigo = N jobs du pool fusionnés en 1 arrêt -->
|
||
<q-icon v-if="e.coordWith" name="link" size="14px" color="teal-7" class="q-mr-xs"><q-tooltip>Coordonné : même adresse que « {{ e.coordWith }} » déjà assigné à {{ e.techName }} — placé sur SON arrêt</q-tooltip></q-icon>
|
||
<q-btn v-else-if="e.sameAddrN > 1" flat dense round size="xs" :icon="e.grouped ? 'inventory_2' : 'link'" :color="e.grouped ? 'indigo-7' : 'indigo-4'" class="q-mr-xs">
|
||
<q-tooltip>{{ e.grouped ? 'Groupé en projet 📦' : e.sameAddrN + ' jobs à la MÊME adresse — 1 seul arrêt · clic pour grouper en projet' }}</q-tooltip>
|
||
<q-menu auto-close><q-list dense style="min-width:300px">
|
||
<q-item clickable @click="groupSameAddr(e, g, d)" :disable="!!e.grouped"><q-item-section avatar style="min-width:32px"><q-icon name="inventory_2" size="18px" color="indigo" /></q-item-section><q-item-section>📦 Grouper en « projet » ({{ e.sameAddrN }} tâches même adresse — parent = la plus longue)</q-item-section></q-item>
|
||
</q-list></q-menu>
|
||
</q-btn>
|
||
<span class="suggest-entry-t ellipsis suggest-clk" @click="openEntryDetail(e, g)" title="Voir le détail du ticket (adresse, description, fil)">{{ e.subject }}<span v-if="e.customer" class="text-grey-6"> · {{ e.customer }}</span></span>
|
||
<span class="suggest-dur q-mx-xs" title="Temps sur place — cliquer pour réviser">{{ Math.round(e.dur * 10) / 10 }}h
|
||
<q-popup-edit :model-value="e.dur" v-slot="scope" buttons label-set="OK" label-cancel="Annuler" @save="v => setEntryDur(e, v)">
|
||
<q-input type="number" v-model.number="scope.value" dense autofocus :step="0.25" :min="0.25" :max="24" suffix="h" label="Temps sur place" @keyup.enter="scope.set" />
|
||
</q-popup-edit>
|
||
</span>
|
||
<q-icon v-if="!e.capable" name="warning" size="14px" color="deep-orange" class="q-mr-xs"><q-tooltip>{{ e.techName }} n'a pas la compétence « {{ e.skill }} » — à réassigner</q-tooltip></q-icon>
|
||
<q-icon v-if="e.noShift" name="event_busy" size="14px" color="amber-8" class="q-mr-xs"><q-tooltip>Aucun quart publié ce jour (assignable, mais ⚠ hors quart)</q-tooltip></q-icon>
|
||
<!-- Contraintes horaires (prises en compte par ⚡ Ré-optimiser) : AM/PM = fenêtre · ⚡ = urgent, servir en début de journée -->
|
||
<!-- PRIORITÉ persistante (MÊME drapeau que les conversations) : contour gris = non classé, coloré = niveau. Liste rapide. -->
|
||
<q-btn flat dense round size="sm" :icon="priorityMeta(jobPriorityOf(e.jobName)).icon" :color="priorityMeta(jobPriorityOf(e.jobName)).color">
|
||
<q-tooltip>Priorité : {{ priorityMeta(jobPriorityOf(e.jobName)).label }} (persistante — SLA/commercial). Ré-optimiser pour appliquer.</q-tooltip>
|
||
<q-menu auto-close anchor="bottom right" self="top right"><q-list dense style="min-width:180px">
|
||
<q-item-label header class="q-py-xs">Priorité</q-item-label>
|
||
<q-item v-for="lvl in PRIORITY_LEVELS" :key="lvl" clickable @click="setEntryPriority(e.jobName,lvl)" :active="jobPriorityOf(e.jobName) === lvl" active-class="bg-green-1">
|
||
<q-item-section avatar><q-icon name="flag" :color="priorityMeta(lvl).color" size="18px" /></q-item-section>
|
||
<q-item-section>{{ priorityMeta(lvl).label }}</q-item-section>
|
||
<q-item-section side v-if="jobPriorityOf(e.jobName) === lvl"><q-icon name="check" color="primary" size="16px" /></q-item-section>
|
||
</q-item>
|
||
</q-list></q-menu>
|
||
</q-btn>
|
||
<span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'am' }" @click="toggleJobAmpm(e.jobName, 'am')"><q-tooltip>Avant-midi (8-12) — Ré-optimiser pour appliquer</q-tooltip>AM</span>
|
||
<span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'pm' }" @click="toggleJobAmpm(e.jobName, 'pm')"><q-tooltip>Après-midi (12-16) — Ré-optimiser pour appliquer</q-tooltip>PM</span>
|
||
<q-space />
|
||
<q-btn flat dense round size="sm" icon="swap_horiz" color="grey-7"><q-tooltip>Déplacer vers un autre tech (classés par proximité + charge)</q-tooltip>
|
||
<q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:250px;max-height:46vh;overflow:auto">
|
||
<q-item-label header class="q-py-xs">Techs par <b>proximité</b> + <b>charge</b><span v-if="e.skill" class="text-grey-6"> · {{ e.skill }}</span></q-item-label>
|
||
<q-item v-for="r in techsForEntry(e)" :key="r.id" clickable v-close-popup @click="moveSuggestEntry(e, r.id, r.name)" :active="r.id === e.techId" :disable="!r.capable && anyCovers([e.skill])">
|
||
<q-item-section avatar style="min-width:30px"><q-avatar size="22px" :color="r.capable ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:9px;font-weight:700">{{ initials(r.name) }}</q-avatar></q-item-section>
|
||
<q-item-section>{{ r.name }}<div class="text-grey-6" style="font-size:10px">{{ r.distKm != null ? '📍 ≈' + (Math.round(r.distKm * 10) / 10) + ' km' : 'sans domicile' }} · {{ r.h }}/{{ r.cap }}h<span v-if="r.noShift"> · sans quart</span></div></q-item-section>
|
||
<q-item-section side v-if="!r.capable"><q-icon name="block" color="grey-5" size="15px"><q-tooltip>Sans la compétence</q-tooltip></q-icon></q-item-section>
|
||
</q-item>
|
||
</q-list></q-menu>
|
||
</q-btn>
|
||
<q-btn flat dense round size="sm" icon="computer" :color="isJobRemote(e.jobName) ? 'indigo-6' : 'grey-4'" @click="toggleJobRemote(e)"><q-tooltip>{{ isJobRemote(e.jobName) ? '🖥 SANS déplacement (à distance) — Ré-optimiser pour l\'exclure des tournées' : 'Marquer « sans déplacement » (configurable à distance, ex. boîtier tel via ACS)' }}</q-tooltip></q-btn>
|
||
<q-btn flat dense round size="sm" icon="inbox" color="grey-6" @click="removeSuggestEntry(e)"><q-tooltip>Retirer (le job reste au pool)</q-tooltip></q-btn>
|
||
</div>
|
||
<div v-if="legInfo(g, d, ei)" class="suggest-leg-time" :title="legInfo(g, d, ei).real ? 'Temps de route réel (OSRM)' : 'Estimation à vol d\'oiseau'">🚗 {{ legInfo(g, d, ei).mins }} min{{ legInfo(g, d, ei).real ? '' : ' ≈' }}</div>
|
||
</template>
|
||
<!-- Retour au point de départ (le tech revient chez lui / au bureau) — inclus dans la distance/temps de la tournée -->
|
||
<div v-if="legReturn(g, d) != null" class="suggest-leg-time suggest-leg-return">🔄 retour {{ (legsByKey[g.techId + '|' + d.iso] || {}).kind === 'depot' ? '🏢 bureau TARGO' : '🏠 domicile' }} : {{ legReturn(g, d) }} min</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</q-card-section>
|
||
<q-card-actions align="right" style="border-top:1px solid #e2e8f0">
|
||
<q-btn v-if="suggestDlg.mode === 'review'" flat no-caps icon="arrow_back" label="Techniciens" @click="suggestDlg.mode = 'config'" />
|
||
<q-space v-if="suggestDlg.mode === 'review'" />
|
||
<q-btn flat no-caps label="Annuler" v-close-popup />
|
||
<q-btn v-if="suggestDlg.mode === 'review'" outline no-caps color="primary" icon="bolt" label="Ré-optimiser" :loading="suggestDlg.building" @click="reoptimize"><q-tooltip>Relance le solveur VRP en tenant compte des contraintes AM/PM/⚡ posées sur les jobs</q-tooltip></q-btn>
|
||
<q-btn v-if="suggestDlg.mode === 'config'" unelevated no-caps color="primary" icon="auto_awesome" :label="'Générer (' + suggestSelCount + ' tech)'" :loading="suggestDlg.building" :disable="!suggestSelCount" @click="runSuggestion" />
|
||
<q-btn v-else unelevated no-caps color="primary" icon="check" :label="'Appliquer ' + suggestDlg.plan.length + ' assignation(s)'" :loading="suggestDlg.applying" :disable="!suggestDlg.plan.length" @click="applySuggestion" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Write-back legacy : aperçu des réassignations osTicket → confirmer avant d'écrire dans le legacy -->
|
||
<q-dialog v-model="legacyPush.open">
|
||
<q-card style="min-width:540px;max-width:660px">
|
||
<q-card-section class="row items-center q-pb-sm">
|
||
<q-icon name="sync_alt" color="deep-orange" size="22px" class="q-mr-sm" />
|
||
<div>
|
||
<div class="text-subtitle1 text-weight-bold">Publier les assignations au legacy</div>
|
||
<div class="text-caption text-grey-7">Réassigne <code>ticket.assign_to</code> au technicien dans osTicket (selon l'assignation Ops).</div>
|
||
</div>
|
||
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-pt-none">
|
||
<div v-if="legacyPush.loading" class="text-grey-6 q-pa-md text-center"><q-spinner size="20px" class="q-mr-sm" />Analyse des assignations…</div>
|
||
<template v-else-if="legacyPush.preview">
|
||
<q-banner v-if="!legacyPush.preview.pushed" dense rounded class="bg-blue-1 text-info q-mb-sm"><template #avatar><q-icon name="info" /></template>Rien à pousser. Assigne d'abord des jobs à des techs (glisser-déposer), puis reviens ici.</q-banner>
|
||
<div v-else class="text-body2 q-mb-sm"><b class="text-warning">{{ legacyPush.preview.pushed }}</b> ticket(s) seront réassignés dans le legacy<span v-if="legacyPush.preview.skipped" class="text-grey-6"> · {{ legacyPush.preview.skipped }} déjà à jour/fermé(s)</span><span v-if="legacyPush.preview.mismatch" class="text-warning"> · {{ legacyPush.preview.mismatch }} ignoré(s) (nom ≠)</span>.</div>
|
||
<q-list dense bordered class="rounded-borders" style="max-height:300px;overflow:auto">
|
||
<q-item v-for="(s, i) in (legacyPush.preview.samples || [])" :key="i" class="q-py-xs">
|
||
<q-item-section avatar style="min-width:28px"><q-icon :name="s.action ? 'warning' : 'arrow_forward'" :color="s.action ? 'deep-orange' : 'green'" size="16px" /></q-item-section>
|
||
<q-item-section>
|
||
<q-item-label v-if="s.action" style="font-size:12px" class="text-warning">Ticket #{{ s.ticket }} — Ops « {{ s.ops_nom }} » ≠ legacy « {{ s.legacy_staff }} » → <b>ignoré</b></q-item-label>
|
||
<q-item-label v-else style="font-size:12px">Ticket #{{ s.ticket }} → <b>{{ s.vers_staff }}</b><span class="text-grey-6"> · {{ s.sujet }}</span></q-item-label>
|
||
</q-item-section>
|
||
<q-item-section side v-if="s.job"><q-btn flat dense round size="sm" icon="close" color="negative" :disable="legacyPush.applying" @click="removeFromPush(s)"><q-tooltip>Désassigner ce job (retour au pool) — ne sera pas poussé</q-tooltip></q-btn></q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
<div class="text-caption text-grey-6 q-mt-xs">Sécurité : seuls les tickets <b>ouverts</b> sont touchés, le nom du staff legacy doit concorder, et c'est idempotent.</div>
|
||
</template>
|
||
</q-card-section>
|
||
<q-card-actions align="right">
|
||
<q-checkbox v-if="legacyPush.preview && legacyPush.preview.pushed" v-model="legacyPush.notify" dense size="sm" color="deep-orange" class="q-mr-auto" label="Notifier les techs par courriel">
|
||
<q-tooltip class="bg-grey-9" max-width="320px">Envoie à chaque tech réassigné le courriel d'assignation (résumé + adresse + lien <b>Répondre</b>), identique au legacy.</q-tooltip>
|
||
</q-checkbox>
|
||
<q-btn flat label="Annuler" v-close-popup />
|
||
<q-btn unelevated color="deep-orange" icon="cloud_upload" label="Écrire dans le legacy" :loading="legacyPush.applying" :disable="!legacyPush.preview || !legacyPush.preview.pushed" @click="applyLegacyPush" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Sélecteur d'emplacement (jobs « hors carte ») : carte bornée au sud de Montréal + recherche d'adresse RQA -->
|
||
<q-dialog v-model="locPicker.open" @show="initLocMap" @hide="destroyLocMap">
|
||
<q-card style="width: 700px; max-width: 96vw">
|
||
<q-card-section class="row items-center q-pb-xs">
|
||
<div class="text-subtitle2">{{ locPicker.mode === 'depot' ? '🏁 Point de départ (dépôt)' : locPicker.mode === 'home' ? ('🏠 ' + locPicker.subject) : '📍 Situer le job' }}<span v-if="locPicker.mode === 'job'" class="text-grey-7"> — {{ locPicker.subject }}</span></div>
|
||
<q-space />
|
||
<q-btn dense flat round icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-pt-none">
|
||
<q-input dense outlined v-model="locPicker.search" placeholder="Chercher une adresse (base RQA Québec)…" class="q-mb-xs" :loading="locPicker.searching" @keyup.enter="locSearch" clearable>
|
||
<template #append><q-btn dense flat icon="search" @click="locSearch" /></template>
|
||
</q-input>
|
||
<div v-if="locPicker.mode === 'home'" class="row items-center q-mb-xs">
|
||
<q-btn dense outline no-caps size="sm" color="indigo" icon="my_location" label="Position GPS actuelle du tech" :loading="liveGpsBusy" @click="pickerUseLiveGps" />
|
||
<span class="text-caption text-grey-6 q-ml-sm">…ou cherche / clique sur la carte</span>
|
||
</div>
|
||
<div v-if="locPicker.mode === 'job' && locPicker.autoMatched" class="text-caption text-positive q-mb-xs row items-center no-wrap">
|
||
<q-icon name="check_circle" size="15px" class="q-mr-xs" /> Adresse reconnue automatiquement (base RQA) — vérifie la carte, puis « Utiliser cet emplacement ».
|
||
</div>
|
||
<div v-else-if="locPicker.mode === 'job' && locPicker.results.length > 1 && locPicker.lat == null" class="text-caption text-warning q-mb-xs row items-center no-wrap">
|
||
<q-icon name="info" size="15px" class="q-mr-xs" /> Plusieurs correspondances — choisis la bonne ci-dessous.
|
||
</div>
|
||
<q-list v-if="locPicker.results.length" bordered dense class="loc-results q-mb-xs">
|
||
<q-item v-for="(rr, i) in locPicker.results" :key="i" clickable @click="pickLocResult(rr)">
|
||
<q-item-section>
|
||
<q-item-label>{{ rr.address_full || ((rr.numero || '') + ' ' + (rr.rue || '')) }}</q-item-label>
|
||
<q-item-label caption>{{ rr.ville }} {{ rr.code_postal }}<span v-if="rr.fiber_available" class="text-positive"> · fibre</span></q-item-label>
|
||
</q-item-section>
|
||
<q-item-section side><q-icon name="place" color="warning" /></q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
<div class="loc-map-wrap">
|
||
<div ref="locMapEl" class="loc-map"></div>
|
||
<q-btn dense round size="sm" class="map-sat-btn" :color="satView ? 'primary' : 'grey-8'" :icon="satView ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satView ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
|
||
<div class="loc-map-hint">Clic = situer · glisser le repère · <b>clic droit = Street View</b></div>
|
||
</div>
|
||
<q-input dense outlined v-model="locPicker.address" label="Adresse (modifiable)" class="q-mt-sm" />
|
||
<div class="text-caption text-grey-6 q-mt-xs" v-if="locPicker.lat != null">Coordonnées : {{ locPicker.lat }}, {{ locPicker.lon }}</div>
|
||
</q-card-section>
|
||
<q-card-actions align="right">
|
||
<q-btn flat label="Annuler" v-close-popup />
|
||
<q-btn unelevated color="primary" icon="place" label="Utiliser cet emplacement" :loading="locPicker.saving" :disable="locPicker.lat == null" @click="saveLocPicker" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Détail d'un job Legacy (clic sur un bloc hachuré) : fil du ticket osTicket, lecture seule -->
|
||
<q-dialog v-model="legacyDetail.open">
|
||
<q-card style="min-width:520px;max-width:680px">
|
||
<q-card-section class="row items-center q-pb-sm">
|
||
<q-icon name="assignment" color="brown" size="20px" class="q-mr-sm" />
|
||
<div class="col" style="min-width:0">
|
||
<div class="text-subtitle1 text-weight-bold ellipsis">{{ legacyDetail.subject }}</div>
|
||
<div class="text-caption text-grey-7">Ticket Legacy #{{ legacyDetail.lid }}<span v-if="legacyDetail.dept"> · {{ legacyDetail.dept }}</span></div>
|
||
</div>
|
||
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-pt-none" style="max-height:60vh;overflow:auto">
|
||
<div v-if="legacyDetail.loading" class="text-center q-pa-md"><q-spinner size="26px" color="brown" /><div class="text-caption text-grey-6 q-mt-sm">Lecture du ticket…</div></div>
|
||
<template v-else-if="legacyDetail.thread && legacyDetail.thread.messages && legacyDetail.thread.messages.length">
|
||
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ legacyDetail.thread.messages.length }} message(s){{ legacyDetail.thread.status ? ' · ' + legacyDetail.thread.status : '' }}</div>
|
||
<div v-for="(m, mi) in legacyDetail.thread.messages" :key="mi" class="de-msg">
|
||
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span></div>
|
||
<div class="de-msg-txt">{{ m.text }}</div>
|
||
</div>
|
||
</template>
|
||
<div v-else class="text-grey-6 q-pa-md text-center">{{ (legacyDetail.thread && legacyDetail.thread.error) ? 'Détail indisponible' : 'Aucun message dans ce ticket.' }}</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Volet DÉTAILS (double-clic sur un job du board) : grand panneau DROIT, pleine hauteur, ~pleine largeur — billet + commentaires -->
|
||
<q-dialog v-model="jobDetail.open" position="right" full-height>
|
||
<q-card class="jd-card">
|
||
<q-card-section class="row items-center q-pb-sm">
|
||
<q-icon name="assignment" color="primary" size="22px" class="q-mr-sm" />
|
||
<div class="col" style="min-width:0">
|
||
<div class="text-h6 ellipsis">{{ jobDetail.subject }}</div>
|
||
<div class="text-caption text-grey-7"><template v-if="jobDetail.customer">{{ jobDetail.customer }}</template><template v-if="jobDetail.lid"> · Ticket #{{ jobDetail.lid }}</template><template v-if="jobDetail.dept"> · {{ jobDetail.dept }}</template></div>
|
||
<!-- QUI fait la job : chip bien visible (highlight demandé) — assigné ou non -->
|
||
<div class="q-mt-xs">
|
||
<q-chip v-if="jobDetail.techName" dense color="indigo-6" text-color="white" class="text-weight-bold jd-tech-chip"><q-avatar color="indigo-9" text-color="white" size="20px">{{ initials(jobDetail.techName) }}</q-avatar>{{ jobDetail.techName }}</q-chip>
|
||
<q-chip v-else dense outline color="grey-7" text-color="grey-8" icon="person_off">Non assigné</q-chip>
|
||
</div>
|
||
</div>
|
||
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-separator />
|
||
<q-card-section class="jd-body scroll">
|
||
<div ref="jdMapEl" class="jd-map"></div>
|
||
<div v-if="kbCanTrack" class="jd-track-row"><q-toggle v-model="jdShowTrack" dense size="sm" color="warning" /><span class="text-caption text-weight-medium">Tracé GPS réel (Traccar)</span><span class="text-caption text-grey-6">{{ jdTrackMsg }}</span></div>
|
||
<div v-else class="text-caption text-grey-5 q-mb-sm">Tracé GPS réel : aujourd'hui / hier seulement (Traccar).</div>
|
||
<div class="jd-meta">
|
||
<div v-if="jobDetail.time"><q-icon name="schedule" size="16px" /> {{ jobDetail.time }}</div>
|
||
<div v-if="jobDetail.skill"><q-icon name="construction" size="16px" /> {{ jobDetail.skill }}</div>
|
||
<div v-if="jobDetail.address"><q-icon name="place" size="16px" /> {{ jobDetail.address }}</div>
|
||
</div>
|
||
<!-- Suivi terrain (géofencing GPS) : étapes façon suivi de colis — En route → Arrivé → Reparti, avec l'heure atteinte. -->
|
||
<div v-if="jobDetail.geofence && jobDetail.geofence.state" class="jd-geo">
|
||
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="my_location" size="18px" class="q-mr-xs" color="teal-7" /> Suivi terrain (GPS)</div>
|
||
<div class="jd-geo-track">
|
||
<div v-for="s in geoTimeline" :key="s.k" class="jd-geo-step" :class="{ done: s.done, current: s.current }">
|
||
<div class="jd-geo-ic"><q-icon :name="s.icon" size="15px" /></div>
|
||
<div class="jd-geo-lbl">{{ s.label }}<span v-if="s.at" class="jd-geo-at">{{ s.at }}</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="jobDetail.canTeam" class="jd-team">
|
||
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="group" size="18px" class="q-mr-xs" /> Équipe / renfort<q-space /><q-spinner v-if="jobDetail.teamLoading" size="16px" color="deep-purple" /></div>
|
||
<div v-if="jobDetail.team && jobDetail.team.length" class="row items-center q-gutter-xs q-mb-xs">
|
||
<q-chip v-for="a in jobDetail.team" :key="a.tech_id" dense removable @remove="jdRemoveAssistant(a.tech_id)" color="deep-purple-1" text-color="deep-purple-9" icon="group">{{ a.tech_name || a.tech_id }}</q-chip>
|
||
</div>
|
||
<div v-else class="text-caption text-grey-6 q-mb-xs">Aucun assistant. Ajoutez un renfort → un bloc <b>hachuré</b> sera réservé dans son horaire (anti double‑booking).</div>
|
||
<div class="row items-center no-wrap q-gutter-xs">
|
||
<TechSelect v-model="jobDetail.teamAdd" :options="jdTeamOptions" label="Ajouter un assistant (nom · compétence)" style="flex:1;min-width:0" />
|
||
<q-btn dense unelevated color="deep-purple" icon="group_add" label="Ajouter" :disable="!jobDetail.teamAdd" @click="jdAddAssistant" no-caps />
|
||
</div>
|
||
</div>
|
||
<div v-if="jobDetail.detail" class="jd-detail">{{ jobDetail.detail }}</div>
|
||
<div class="text-subtitle2 q-mt-md q-mb-xs">Commentaires / fil du billet</div>
|
||
<div v-if="jobDetail.loading" class="text-center q-pa-md"><q-spinner size="26px" color="primary" /><div class="text-caption text-grey-6 q-mt-sm">Lecture du ticket…</div></div>
|
||
<template v-else-if="jobDetail.thread && jobDetail.thread.messages && jobDetail.thread.messages.length">
|
||
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ jobDetail.thread.messages.length }} message(s){{ jobDetail.thread.status ? ' · ' + jobDetail.thread.status : '' }}</div>
|
||
<div v-for="(m, mi) in jobDetail.thread.messages" :key="mi" class="de-msg">
|
||
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span></div>
|
||
<div class="de-msg-txt">{{ m.text }}</div>
|
||
</div>
|
||
</template>
|
||
<div v-else class="text-grey-6 q-pa-md text-center">{{ jobDetail.lid ? ((jobDetail.thread && jobDetail.thread.error) ? 'Détail indisponible' : 'Aucun commentaire.') : 'Pas de billet Legacy lié à ce job.' }}</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Drop d'une job sur un tech SANS quart : créer un quart (durée) ou marquer absent (durée + retour) -->
|
||
<q-dialog v-model="dropAsk.open">
|
||
<q-card style="min-width:380px;max-width:440px">
|
||
<q-card-section class="q-pb-xs">
|
||
<div class="text-subtitle1 row items-center"><q-icon name="report_problem" color="warning" size="22px" class="q-mr-sm" /><b>{{ dropAsk.tech && dropAsk.tech.name }}</b> n'a aucun quart</div>
|
||
<div class="text-caption text-grey-7">{{ dropAsk.day && (dropAsk.day.dow + ' ' + dropAsk.day.dnum) }} · {{ dropAsk.names.length }} job(s) à placer</div>
|
||
</q-card-section>
|
||
|
||
<!-- Choix initial -->
|
||
<q-card-section v-if="dropAsk.mode === 'choice'" class="q-gutter-sm">
|
||
<q-btn unelevated color="primary" icon="add_box" label="Créer un quart et assigner" class="full-width" no-caps @click="dropAsk.mode = 'shift'" align="left" />
|
||
<q-btn outline color="deep-orange" icon="event_busy" label="Marquer le technicien absent" class="full-width" no-caps @click="dropAsk.mode = 'absence'" align="left" />
|
||
<div class="text-caption text-grey-6 q-px-xs">Tant qu'aucun quart n'existe, la job reste au pool — elle n'est pas assignée en douce.</div>
|
||
</q-card-section>
|
||
|
||
<!-- Créer un quart -->
|
||
<q-card-section v-else-if="dropAsk.mode === 'shift'" class="q-gutter-sm">
|
||
<q-select dense outlined v-model="dropAsk.preset" :options="shiftPresets" emit-value map-options label="Durée du quart" />
|
||
<div v-if="dropAsk.preset === 'custom'" class="row q-gutter-sm">
|
||
<q-input dense outlined type="time" v-model="dropAsk.cStart" label="Début" style="flex:1" />
|
||
<q-input dense outlined type="time" v-model="dropAsk.cEnd" label="Fin" style="flex:1" />
|
||
</div>
|
||
<div class="text-caption text-grey-6">Quart créé (non publié) + {{ dropAsk.names.length }} job(s) assignée(s). Annulable ensuite.</div>
|
||
</q-card-section>
|
||
|
||
<!-- Marquer absent -->
|
||
<q-card-section v-else-if="dropAsk.mode === 'absence'" class="q-gutter-sm">
|
||
<q-option-group v-model="dropAsk.scope" :options="[{ label: 'Ce jour', value: 'day' }, { label: 'Cette semaine', value: 'week' }, { label: 'Plage de dates', value: 'range' }]" type="radio" inline dense color="deep-orange" />
|
||
<div v-if="dropAsk.scope === 'range'" class="row q-gutter-sm">
|
||
<q-input dense outlined type="date" v-model="dropAsk.from" label="Du" style="flex:1" />
|
||
<q-input dense outlined type="date" v-model="dropAsk.to" label="Au" style="flex:1" />
|
||
</div>
|
||
<q-select dense outlined v-model="dropAsk.absType" :options="absTypes" label="Motif" />
|
||
<div class="text-caption text-warning row items-center"><q-icon name="info" size="15px" class="q-mr-xs" />Retour prévu le <b class="q-ml-xs">{{ dropAskReturn }}</b>. Les job(s) iront au pool → proposition de réassignation.</div>
|
||
</q-card-section>
|
||
|
||
<q-card-actions align="right">
|
||
<q-btn v-if="dropAsk.mode !== 'choice'" flat dense label="‹ Retour" no-caps @click="dropAsk.mode = 'choice'" />
|
||
<q-space />
|
||
<q-btn flat label="Annuler" no-caps v-close-popup />
|
||
<q-btn v-if="dropAsk.mode === 'shift'" unelevated color="primary" icon="check" label="Créer & assigner" no-caps @click="dropAskCreateShift" />
|
||
<q-btn v-if="dropAsk.mode === 'absence'" unelevated color="deep-orange" icon="event_busy" label="Marquer absent" no-caps @click="dropAskAbsence" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Absence sur PLAGE de jours (défaut = jour affiché ; « cette semaine » ou plage du..au) -->
|
||
<q-dialog v-model="absDialog.open">
|
||
<q-card style="min-width:344px">
|
||
<q-card-section class="row items-center q-pb-sm">
|
||
<q-icon name="event_busy" color="negative" size="20px" class="q-mr-sm" />
|
||
<div class="text-subtitle1 text-weight-bold">Absence — {{ absDialog.techName }}</div>
|
||
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-pt-none">
|
||
<div class="row q-gutter-xs q-mb-sm">
|
||
<q-btn dense unelevated size="sm" color="grey-7" label="Ce jour" @click="absDialog.to = absDialog.from" />
|
||
<q-btn dense unelevated size="sm" color="grey-7" label="Cette semaine" @click="absWeek" />
|
||
</div>
|
||
<div class="row q-col-gutter-sm">
|
||
<q-input class="col" dense outlined type="date" v-model="absDialog.from" label="Du" />
|
||
<q-input class="col" dense outlined type="date" v-model="absDialog.to" label="Au" />
|
||
</div>
|
||
<div class="text-caption text-grey-6 q-mt-xs">{{ absDays().length }} jour(s) · type « Congé »</div>
|
||
</q-card-section>
|
||
<q-card-actions align="right">
|
||
<q-btn flat dense label="Retirer l'absence" color="grey-7" @click="applyAbs(true)" />
|
||
<q-btn unelevated dense label="Marquer absent" color="negative" @click="applyAbs(false)" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Synchroniser les techniciens : réconciliation staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech -->
|
||
<q-dialog v-model="techSync.open">
|
||
<q-card style="width: 720px; max-width: 96vw">
|
||
<q-card-section class="row items-center q-pb-xs">
|
||
<div class="text-subtitle2">👥 Synchroniser les techniciens</div>
|
||
<q-space />
|
||
<q-btn dense flat round icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section v-if="techSync.loading" class="text-center q-pa-lg"><q-spinner size="28px" color="primary" /></q-card-section>
|
||
<template v-else-if="techSync.report">
|
||
<q-card-section class="q-pt-none">
|
||
<div class="text-caption text-grey-7 q-mb-sm">Staff legacy actifs : <b>{{ techSync.report.counts.staff_active }}</b> · Fiches roster : <b>{{ techSync.report.counts.fiches }}</b> · Groupe Authentik <code>tech</code> : <b>{{ techSync.report.authentik ? techSync.report.counts.tech_group : '—' }}</b></div>
|
||
<q-expansion-item default-opened dense icon="group_add" header-class="text-green-8" :label="`À ajouter au roster (${techSync.report.staff_missing_fiche.length})`">
|
||
<div v-if="!techSync.report.staff_missing_fiche.length" class="text-grey-6 q-pa-sm">Aucun — le roster est à jour ✅</div>
|
||
<q-list v-else dense bordered class="ts-list">
|
||
<q-item v-for="s in techSync.report.staff_missing_fiche" :key="s.staff_id" tag="label">
|
||
<q-item-section side><q-checkbox v-model="techSync.sel[s.staff_id]" dense color="green-7" /></q-item-section>
|
||
<q-item-section><q-item-label>{{ s.name }} <span class="text-grey-5">#{{ s.staff_id }}</span></q-item-label><q-item-label caption>{{ s.email || '— sans courriel —' }}</q-item-label></q-item-section>
|
||
<q-item-section side><q-chip dense size="sm" :color="s.in_tech_group ? 'green-2' : 'orange-2'" :text-color="s.in_tech_group ? 'green-9' : 'orange-9'">{{ s.in_tech_group ? 'accès tech ✓' : 'hors groupe tech' }}</q-chip></q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
</q-expansion-item>
|
||
<q-expansion-item v-if="techSync.report.authentik && techSync.report.fiche_no_access.length" dense icon="lock" header-class="text-warning" :label="`Fiches SANS accès Ops (${techSync.report.fiche_no_access.length})`">
|
||
<div class="text-caption text-grey-6 q-px-sm q-pt-xs">Pas dans le groupe Authentik <code>tech</code> → à ajouter manuellement sur auth.targo.ca (lecture seule ici).</div>
|
||
<q-list dense class="ts-list"><q-item v-for="t in techSync.report.fiche_no_access" :key="t.technician_id"><q-item-section><q-item-label>{{ t.name }}</q-item-label><q-item-label caption>{{ t.email }}</q-item-label></q-item-section></q-item></q-list>
|
||
</q-expansion-item>
|
||
<q-expansion-item v-if="techSync.report.authentik && techSync.report.access_no_fiche.length" dense icon="help_outline" :label="`Dans le groupe tech, hors roster (${techSync.report.access_no_fiche.length})`">
|
||
<div class="text-caption text-grey-6 q-px-sm q-pt-xs">Ont l'accès Ops mais aucune fiche (ex-employé ? courriel ≠ legacy ?).</div>
|
||
<div class="q-pa-sm" style="font-size:11px;color:#78909c;line-height:1.6">{{ techSync.report.access_no_fiche.join(' · ') }}</div>
|
||
</q-expansion-item>
|
||
<q-banner v-if="!techSync.report.authentik" dense class="bg-orange-1 text-warning q-mt-sm"><template #avatar><q-icon name="warning" /></template>Authentik non joignable — vérif d'accès indisponible, seul l'ajout au roster fonctionne.</q-banner>
|
||
</q-card-section>
|
||
<q-card-actions align="right">
|
||
<q-btn flat label="Fermer" v-close-popup />
|
||
<q-btn unelevated color="green-7" icon="group_add" :label="`Créer les fiches cochées (${techSyncSelCount})`" :loading="techSync.applying" :disable="!techSyncSelCount" @click="applyTechSync" />
|
||
</q-card-actions>
|
||
</template>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Tableur « durées par caractéristique » (additif) — édition inline -->
|
||
<q-dialog v-model="jobChar.open">
|
||
<q-card style="width: 860px; max-width: 97vw">
|
||
<q-card-section class="row items-center q-pb-xs">
|
||
<div class="text-subtitle2">⏱ Durées par caractéristique <span class="text-grey-6">— additif, hors transport</span></div>
|
||
<q-space /><q-btn dense flat round icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section v-if="jobChar.loading" class="text-center q-pa-md"><q-spinner color="primary" size="26px" /></q-card-section>
|
||
<q-card-section v-else class="q-pt-none">
|
||
<div class="text-caption text-grey-7 q-mb-sm">Durée d'un job = <b>1 base</b> + <b>caractéristiques</b> cochées (×qté), × vitesse du tech. Minutes éditables. Les valeurs <b>apprises</b> (geofence) viendront calibrer ces seeds par régression.</div>
|
||
<table class="jc-table">
|
||
<thead><tr><th>Catégorie</th><th>Libellé</th><th>Min</th><th>×Qté</th><th>Mots-clés (auto-détection)</th><th></th></tr></thead>
|
||
<tbody>
|
||
<tr v-for="(it, i) in jobChar.items" :key="i" :class="'jc-' + it.cat">
|
||
<td><q-select dense options-dense outlined v-model="it.cat" :options="['base', 'addon', 'modifier']" style="min-width:92px" /></td>
|
||
<td><q-input dense outlined v-model="it.label" /></td>
|
||
<td style="white-space:nowrap"><q-input dense outlined type="number" v-model.number="it.minutes" style="width:64px" /><span v-if="it.learned_min != null" class="jc-learn"> appris {{ it.learned_min }}m·{{ it.samples }}</span></td>
|
||
<td class="text-center"><q-toggle dense size="sm" v-model="it.per_qty" /></td>
|
||
<td><q-input dense outlined v-model="it.keywords" placeholder="ex. enfoui,enterré" /></td>
|
||
<td><q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="jobChar.items.splice(i, 1)" /></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</q-card-section>
|
||
<q-card-actions align="right">
|
||
<q-btn flat dense icon="add" label="Ajouter une ligne" color="primary" @click="addJobCharRow" />
|
||
<q-space />
|
||
<q-btn flat label="Fermer" v-close-popup />
|
||
<q-btn unelevated color="primary" icon="save" label="Enregistrer" :loading="jobChar.saving" @click="saveJobChar" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Timeline contextuelle d'une ressource : dispatch des jobs de la semaine visible (heures + priorités) -->
|
||
<q-dialog v-model="timelineDlg.open">
|
||
<q-card style="min-width:560px;max-width:780px">
|
||
<q-card-section class="row items-center q-pb-sm">
|
||
<q-icon name="timeline" color="indigo" size="22px" class="q-mr-sm" />
|
||
<div class="text-subtitle1 text-weight-bold">Timeline — {{ timelineDlg.tech && timelineDlg.tech.name }}</div>
|
||
<q-space />
|
||
<q-btn flat dense no-caps size="sm" icon="open_in_new" label="Dispatch" color="indigo" @click="gotoDispatch(timelineDlg.tech)"><q-tooltip>Ouvrir le tableau Dispatch sur cette ressource</q-tooltip></q-btn>
|
||
<q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-pt-none" style="max-height:70vh;overflow:auto">
|
||
<div v-if="!timelineDays.length" class="text-grey-6 q-pa-md text-center">Aucun job planifié cette semaine pour cette ressource.</div>
|
||
<div v-for="day in timelineDays" :key="day.iso" class="tldlg-day">
|
||
<div class="row items-center q-mb-xs">
|
||
<div class="text-weight-medium" :class="{ 'text-warning': day.weekend }">{{ day.label }}</div>
|
||
<q-badge v-if="day.offShift" color="warning" class="q-ml-sm"><q-icon name="warning" size="11px" class="q-mr-xs" />hors quart publié</q-badge>
|
||
<q-space />
|
||
<q-badge v-if="day.pct != null" text-color="white" :style="{ background: occColor(day.pct) }">{{ day.usedH }}h · {{ day.pct }}%</q-badge>
|
||
<q-badge v-else color="grey-5" class="q-ml-xs">{{ day.usedH }}h</q-badge>
|
||
</div>
|
||
<div v-if="!day.offShift" class="tldlg-bar">
|
||
<div v-for="(b, bi) in day.bands" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
|
||
<div v-for="(b, bi) in day.blocks" :key="'k' + bi" class="tl-blk" :style="blockStyle(b, day.pct)"></div>
|
||
<span v-for="tk in axisTicks" :key="'t' + tk.h" class="tldlg-tick" :style="{ left: tk.left }">{{ tk.h }}</span>
|
||
</div>
|
||
<div v-for="j in day.jobs" :key="j.name" class="tldlg-job">
|
||
<span class="tldlg-dot" :style="{ background: prioColor(j.priority) }"><q-tooltip>{{ j.priority }}</q-tooltip></span>
|
||
<span class="tldlg-time">{{ j.start || '—' }}</span>
|
||
<span class="ellipsis">{{ j.subject }}</span>
|
||
<span v-if="j.customer" class="text-grey-6 ellipsis">· {{ j.customer }}</span>
|
||
<q-space /><span class="text-grey-6" style="flex:0 0 auto">{{ j.dur }}h</span>
|
||
</div>
|
||
</div>
|
||
</q-card-section>
|
||
<q-card-section v-if="timelineDays.length" class="q-pt-none text-caption text-grey-6">Trié par priorité puis heure · 🔴 urgent 🟠 élevée 🔵 moyenne ⚪ basse. Heures posées en premier-trou-libre.</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<div ref="menuAnchorEl" :style="{ position: 'fixed', width: '1px', height: '1px', left: menu.x + 'px', top: menu.y + 'px', pointerEvents: 'none', zIndex: 0 }"></div>
|
||
<q-menu v-model="menu.show" :target="menu.target" anchor="bottom right" self="top left" max-height="85vh">
|
||
<q-list dense style="width:262px;user-select:none;-webkit-user-select:none">
|
||
<q-item-label header class="q-py-xs">{{ menu.tech && menu.tech.name }} — {{ menu.day && menu.day.dnum }}</q-item-label>
|
||
<!-- 4 actions : Jour · Soir · Garde · Absent -->
|
||
<div class="row q-gutter-xs q-px-sm q-pb-xs">
|
||
<q-btn dense unelevated size="sm" color="primary" label="Jour 8–17" class="col" @click="quickShift(8, 17)" />
|
||
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir 16–20" class="col" @click="quickShift(16, 20)" />
|
||
</div>
|
||
<div class="row q-gutter-xs q-px-sm q-pb-xs">
|
||
<q-btn dense :unelevated="menuIsGarde" :outline="!menuIsGarde" size="sm" color="brown" icon="shield" :label="menuIsGarde ? 'Garde ✓' : 'Garde'" class="col" @click="toggleGardeMenu"><q-tooltip>Mettre / retirer de garde (G) — en parallèle d'un shift</q-tooltip></q-btn>
|
||
<q-btn dense :unelevated="menuIsAbsent" :outline="!menuIsAbsent" size="sm" color="negative" icon="event_busy" :label="menuIsAbsent ? 'Absent ✓' : 'Absent'" class="col" @click="openAbsDialog"><q-tooltip>Absence : ce jour (défaut), la semaine, ou une plage de dates</q-tooltip></q-btn>
|
||
</div>
|
||
<!-- Saisie rapide d'heures : 8-17 · 830-16 · 85 (=8→17) -->
|
||
<div class="q-px-sm q-pb-xs" @click.stop @mousedown.stop>
|
||
<q-input dense outlined v-model="quickEntry" placeholder="Heures : 8-17 · 830-16 · 85" @keyup.enter="applyQuick()">
|
||
<template #append><q-btn flat dense round size="sm" icon="keyboard_return" color="primary" @click="applyQuick()"><q-tooltip>Appliquer</q-tooltip></q-btn></template>
|
||
</q-input>
|
||
</div>
|
||
<!-- Plage personnalisée (slider replié) -->
|
||
<q-expansion-item dense dense-toggle icon="tune" label="Personnaliser la plage" header-class="text-caption text-grey-7">
|
||
<div class="q-px-md q-pb-sm" @click.stop @mousedown.stop>
|
||
<q-range v-model="menuRange" :min="0" :max="24" :step="0.5" snap color="primary" class="q-mt-sm" />
|
||
<div class="row items-center no-wrap q-gutter-sm">
|
||
<span class="text-caption text-weight-bold">{{ fmtH(menuRange.min) }}h–{{ fmtH(menuRange.max) }}h</span>
|
||
<q-space />
|
||
<q-btn dense unelevated size="sm" color="primary" label="Appliquer" @click="applyMenuRange" />
|
||
</div>
|
||
</div>
|
||
</q-expansion-item>
|
||
<q-separator />
|
||
<!-- Shifts en place + actions compactes -->
|
||
<q-item v-for="a in menuCellShifts" :key="'c' + a.shift" dense>
|
||
<q-item-section>{{ a.shift_name || a.shift }} <span class="text-grey-6">{{ a.hours }}h</span></q-item-section>
|
||
<q-item-section side><q-btn flat dense round size="sm" icon="close" color="grey-7" @click="removeShiftFromMenu(a)"><q-tooltip>Retirer</q-tooltip></q-btn></q-item-section>
|
||
</q-item>
|
||
<div class="row items-center q-px-sm q-py-xs q-gutter-sm">
|
||
<q-btn flat dense size="sm" icon="content_copy" color="grey-8" @click="copyFromMenu"><q-tooltip>Copier la case</q-tooltip></q-btn>
|
||
<q-btn flat dense size="sm" icon="content_paste" color="grey-8" :disable="!cellClipboard.length" @click="pasteFromMenu"><q-tooltip>Coller{{ cellClipboard.length ? ' (' + cellClipboard.length + ')' : '' }}</q-tooltip></q-btn>
|
||
<q-space />
|
||
<q-btn v-if="menuCellShifts.length" flat dense size="sm" icon="layers_clear" color="grey-8" label="Vider" @click="clearOne" />
|
||
</div>
|
||
</q-list>
|
||
</q-menu>
|
||
|
||
<!-- Éditeur de JOURNÉE (clic sur le progressbar) : timeline + réordonner par drag-drop + retirer un job -->
|
||
<q-dialog v-model="dayEditor.open">
|
||
<q-card style="min-width:560px;max-width:680px">
|
||
<q-card-section class="row items-center q-pb-sm">
|
||
<q-icon name="view_timeline" color="indigo" size="22px" class="q-mr-sm" />
|
||
<div>
|
||
<div class="text-subtitle1 text-weight-bold">{{ dayEditor.tech && dayEditor.tech.name }} — {{ dayEditor.day && (dayEditor.day.dow + ' ' + dayEditor.day.dnum) }}</div>
|
||
<div class="text-caption text-grey-7" v-if="dayOcc()">{{ dayOcc().usedH }}h occupé / {{ dayOcc().bookableH }}h · {{ dayOcc().pct }}%</div>
|
||
</div>
|
||
<q-space />
|
||
<q-btn flat dense no-caps size="sm" icon="open_in_new" label="Dispatch" color="indigo" @click="gotoDispatch(dayEditor.tech, dayEditor.day && dayEditor.day.iso)"><q-tooltip>Ouvrir le tableau Dispatch complet</q-tooltip></q-btn>
|
||
<q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-pt-none">
|
||
<!-- Quart : STATIQUE par défaut ; « Modifier » révèle le curseur (édition accessible depuis le détail ouvert) -->
|
||
<div class="row items-center no-wrap q-mb-xs">
|
||
<q-icon name="schedule" size="16px" color="green-5" class="q-mr-xs" />
|
||
<span class="text-caption text-grey-7 q-mr-sm" style="min-width:34px">Quart</span>
|
||
<template v-if="!dayShiftEdit">
|
||
<span class="text-body2 text-weight-medium">{{ dayShiftLabel() }}</span>
|
||
<q-space />
|
||
<q-btn flat dense no-caps size="sm" icon="edit" :label="hasReg(dayEditor.tech && dayEditor.tech.id, dayEditor.day && dayEditor.day.iso) ? 'Modifier' : 'Ajouter'" color="green-5" @click="dayShiftEdit = true" />
|
||
</template>
|
||
<template v-else>
|
||
<q-range v-model="dayShift" :min="axisBounds.min" :max="axisBounds.max" :step="0.5" snap label :left-label-value="fmtH(dayShift.min) + 'h'" :right-label-value="fmtH(dayShift.max) + 'h'" color="green-5" class="col" style="margin:0 8px" @change="saveDayShift" />
|
||
<span class="text-caption text-grey-6 q-mr-xs" style="min-width:40px;text-align:right">{{ Math.round((dayShift.max - dayShift.min) * 10) / 10 }} h</span>
|
||
<q-btn flat dense round size="sm" icon="check" color="positive" @click="dayShiftEdit = false"><q-tooltip>Terminé</q-tooltip></q-btn>
|
||
</template>
|
||
</div>
|
||
<!-- timeline visuelle (réutilise les blocs colorés par compétence) -->
|
||
<div class="tldlg-bar" style="height:20px">
|
||
<div v-for="(b, bi) in dayBands()" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
|
||
<div v-for="(g, gi) in dayTravelSegs()" :key="'tr' + gi" class="tl-travel" :style="pos(g.s, Math.min(g.e, 24))"><q-tooltip class="bg-grey-9" style="font-size:11px">🚗 déplacement</q-tooltip></div>
|
||
<div v-for="(b, bi) in dayBlocks()" :key="'k' + bi" class="tl-blk" :style="blockStyle(b, dayOcc() && dayOcc().pct)"></div>
|
||
<span v-for="tk in axisTicks" :key="'t' + tk.h" class="tldlg-tick" :style="{ left: tk.left }">{{ tk.h }}</span>
|
||
</div>
|
||
<!-- carte interactive : itinéraire ROUTIER réel + pins numérotés, navigable (zoom molette/boutons, déplacement) -->
|
||
<div v-show="dayShowMap" class="de-map-wrap">
|
||
<div ref="dayMapEl" class="de-map-gl"></div>
|
||
<q-btn dense round size="sm" class="map-sat-btn" :color="satView ? 'primary' : 'grey-8'" :icon="satView ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satView ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
|
||
<div class="de-map-cap">🗺 Itinéraire routier · zoom molette · <b>clic droit = Street View</b><template v-if="dayHasDepot && dayHasHome"> · 🚩 Départ: <span class="dt-toggle" :class="{ on: dayOrigin && dayOrigin.kind === 'depot' }" @click="setDayOrigin('depot')">🏢 Bureau</span> <span class="dt-toggle" :class="{ on: dayOrigin && dayOrigin.kind === 'home' }" @click="setDayOrigin('home')">🏠 Domicile</span></template><span v-if="dayNoCoord" class="text-warning"> · ⚠ {{ dayNoCoord }} sans coords (absent de la carte)</span><span v-if="dayCanTrack" class="dt-toggle" :class="{ on: dayShowTrack }" @click="dayShowTrack = !dayShowTrack">● Tracé GPS réel{{ dayShowTrack && dayTrackMsg ? ' · ' + dayTrackMsg : '' }}<q-tooltip>Superposer le parcours GPS réel (Traccar) sur les jobs — voir si le tech est passé par là</q-tooltip></span></div>
|
||
</div>
|
||
<!-- Point de départ du tech (INLINE) : domicile éditable — sur la carte OU position GPS live actuelle. Les techs partent souvent de chez eux. -->
|
||
<div v-if="dayEditor.tech" class="row items-center no-wrap q-px-sm q-py-xs" style="gap:6px;font-size:12px;border-top:1px solid #eef2f7;border-bottom:1px solid #eef2f7">
|
||
<q-icon name="home_pin" size="16px" color="positive" />
|
||
<span class="text-grey-8 text-weight-medium">Départ :</span>
|
||
<span class="ellipsis text-grey-7 col">{{ techHomes[dayEditor.tech.id] && techHomes[dayEditor.tech.id].address ? techHomes[dayEditor.tech.id].address : (techHomes[dayEditor.tech.id] ? 'domicile situé (sans adresse)' : 'domicile non défini — dépôt par défaut') }}</span>
|
||
<q-btn dense flat no-caps size="sm" color="teal" icon="map" label="Carte" @click="openHomePicker(dayEditor.tech)"><q-tooltip>Choisir le domicile (point de départ) sur la carte</q-tooltip></q-btn>
|
||
<q-btn dense flat no-caps size="sm" color="indigo" icon="my_location" label="GPS actuel" :loading="liveGpsBusy" @click="setHomeFromLiveGps(dayEditor.tech)"><q-tooltip>Fixer le départ à la position GPS live actuelle du tech (Traccar)</q-tooltip></q-btn>
|
||
</div>
|
||
<!-- Appareil GPS (Traccar) du tech — INLINE : chip clair de l'appareil relié (📍 + pastille en-ligne) OU « non associé » + relier facilement -->
|
||
<div v-if="dayEditor.tech" class="row items-center no-wrap q-px-sm q-pb-xs" style="gap:6px;font-size:12px;border-bottom:1px solid #eef2f7">
|
||
<q-icon name="gps_fixed" size="16px" :color="jdDevice ? 'primary' : 'grey-4'" />
|
||
<span class="text-grey-8 text-weight-medium">Appareil GPS :</span>
|
||
<!-- RELIÉ : appareil clairement affiché + changer de véhicule + dissocier -->
|
||
<template v-if="jdDevice">
|
||
<q-chip dense :color="jdDevice.missing ? 'orange-1' : 'green-1'" :text-color="jdDevice.missing ? 'orange-9' : 'green-9'" icon="place" class="text-weight-medium">
|
||
<q-icon name="circle" size="8px" :color="jdDevice.online ? 'positive' : 'grey-5'" class="q-mr-xs" />{{ jdDevice.label }}
|
||
<q-tooltip>{{ jdDevice.online ? 'En ligne' : 'Hors ligne' }}{{ jdDevice.uniqueId ? ' · ' + jdDevice.uniqueId : '' }}</q-tooltip>
|
||
</q-chip>
|
||
<q-btn flat dense no-caps size="sm" color="indigo" icon="swap_horiz" label="Changer"><q-tooltip>Relier à un autre véhicule</q-tooltip>
|
||
<q-menu anchor="bottom left" self="top left"><div class="q-pa-xs" style="min-width:270px"><q-input dense outlined v-model="deviceFilter" placeholder="Filtrer les véhicules…" clearable autofocus class="q-mb-xs" /><q-list dense style="max-height:44vh;overflow:auto"><q-item v-for="o in deviceOptions" :key="o.value" clickable v-close-popup @click="onPickDevice(dayEditor.tech, o.value)" :active="jdDevice && jdDevice.value === o.value" active-class="bg-green-1"><q-item-section avatar style="min-width:22px"><q-icon name="circle" size="9px" :color="o.online ? 'positive' : 'grey-4'" /></q-item-section><q-item-section><q-item-label>{{ o.label }}</q-item-label><q-item-label v-if="o.uniqueId" caption>{{ o.uniqueId }}</q-item-label></q-item-section></q-item><q-item v-if="!deviceOptions.length"><q-item-section class="text-grey-6">{{ devicesLoading ? 'Chargement…' : 'Aucun appareil' }}</q-item-section></q-item></q-list></div></q-menu>
|
||
</q-btn>
|
||
<q-btn flat dense round size="sm" icon="link_off" color="grey-6" @click="onPickDevice(dayEditor.tech, null)"><q-tooltip>Dissocier l'appareil GPS</q-tooltip></q-btn>
|
||
</template>
|
||
<!-- NON RELIÉ : suggestion auto (nom similaire) + choisir dans la liste -->
|
||
<template v-else>
|
||
<span class="text-grey-6">non associé</span>
|
||
<q-btn v-if="jdDeviceSuggest" dense unelevated size="sm" no-caps color="primary" icon="link" :label="'Associer ' + jdDeviceSuggest.label" @click="onPickDevice(dayEditor.tech, jdDeviceSuggest.value)"><q-tooltip>Appareil probable (nom similaire au tech) — cliquer pour relier</q-tooltip></q-btn>
|
||
<q-btn flat dense no-caps size="sm" color="indigo" icon="add_location_alt" label="Choisir"><q-tooltip>Relier un véhicule Traccar</q-tooltip>
|
||
<q-menu anchor="bottom left" self="top left"><div class="q-pa-xs" style="min-width:270px"><q-input dense outlined v-model="deviceFilter" placeholder="Filtrer les véhicules…" clearable autofocus class="q-mb-xs" /><q-list dense style="max-height:44vh;overflow:auto"><q-item v-for="o in deviceOptions" :key="o.value" clickable v-close-popup @click="onPickDevice(dayEditor.tech, o.value)"><q-item-section avatar style="min-width:22px"><q-icon name="circle" size="9px" :color="o.online ? 'positive' : 'grey-4'" /></q-item-section><q-item-section><q-item-label>{{ o.label }}</q-item-label><q-item-label v-if="o.uniqueId" caption>{{ o.uniqueId }}</q-item-label></q-item-section></q-item><q-item v-if="!deviceOptions.length"><q-item-section class="text-grey-6">{{ devicesLoading ? 'Chargement…' : 'Aucun appareil' }}</q-item-section></q-item></q-list></div></q-menu>
|
||
</q-btn>
|
||
</template>
|
||
</div>
|
||
<div v-if="!dayEditor.list.length" class="text-grey-6 q-pa-md text-center">Aucun job ce jour.</div>
|
||
<!-- liste éditable : glisser (souris) · ▲▼ (tactile) · 📌 figer 1er/dernier · durée en min · ✕ retirer -->
|
||
<template v-for="(j, i) in dayEditor.list" :key="j.name">
|
||
<!-- temps de transport estimé depuis le job précédent (l'espace entre 2 blocs) -->
|
||
<div v-if="i > 0 || dayOrigin" class="de-travel" :class="{ 'de-depart': i === 0 }">
|
||
<template v-if="i === 0">
|
||
🏁 Départ<template v-if="dayOrigin"> · {{ dayOrigin.kind === 'home' ? ('🏠 domicile ' + (dayEditor.tech && dayEditor.tech.name)) : (dayOrigin.label || 'Dépôt') }}</template><template v-if="dayLeg(0)"> · {{ dayLeg(0).real ? '🚗' : '📏' }} {{ dayLeg(0).real ? '' : '~' }}{{ dayLeg(0).min }} min<template v-if="dayLeg(0).km != null"> · {{ dayLeg(0).km }} km</template></template><span v-if="dayOrigin && dayOrigin.kind === 'home'" class="text-positive"> (domicile plus proche)</span><q-tooltip v-if="dayOrigin" class="bg-grey-9" style="font-size:11px">Le tech quitte {{ dayOrigin.kind === 'home' ? 'son domicile' : 'le dépôt' }}{{ dayOrigin.address ? ' (' + dayOrigin.address + ')' : '' }} à l'heure du shift.</q-tooltip>
|
||
</template>
|
||
<template v-else>
|
||
<template v-if="dayLeg(i)">{{ dayLeg(i).real ? '🚗' : '📏' }} {{ dayLeg(i).real ? '' : '~' }}{{ dayLeg(i).min }} min<template v-if="dayLeg(i).km != null"> · {{ dayLeg(i).km }} km</template><q-tooltip class="bg-grey-9" style="font-size:11px">{{ dayLeg(i).real ? 'Temps routier réel (routes Mapbox)' : 'Estimation à vol d’oiseau (coords approximatives ou Mapbox indisponible)' }}</q-tooltip></template>
|
||
<template v-else>🚗 transport ? (adresse/coords manquantes)</template>
|
||
</template>
|
||
</div>
|
||
<div class="de-row" :class="{ 'de-drag': dayEditor.dragIdx === i, 'de-legacy': j.legacy, 'de-pinned': j.pin }"
|
||
:draggable="!j.legacy" @dragstart="dayDragStart(i, $event)" @dragover.prevent @drop="dayDropOn(i)" @dragend="dayDragEnd">
|
||
<div class="column" style="gap:2px">
|
||
<template v-if="!j.legacy">
|
||
<q-btn flat dense round size="xs" icon="keyboard_arrow_up" :disable="i === 0" @click="moveDayJob(i, -1)"><q-tooltip>Monter</q-tooltip></q-btn>
|
||
<q-btn flat dense round size="xs" icon="keyboard_arrow_down" :disable="i === dayEditor.list.length - 1" @click="moveDayJob(i, 1)"><q-tooltip>Descendre</q-tooltip></q-btn>
|
||
</template>
|
||
</div>
|
||
<q-icon v-if="!j.legacy" name="drag_indicator" size="20px" class="text-grey-6" style="cursor:grab"><q-tooltip>Glisser pour réordonner (souris). Sur mobile : ▲▼ ou 📌 figer.</q-tooltip></q-icon>
|
||
<q-icon v-else name="lock" size="14px" class="text-green-4" style="margin:0 1px"><q-tooltip class="bg-grey-9" style="font-size:11px">Ticket F (legacy) — lecture seule. Position dans la tournée à titre indicatif (non réécrite dans F).</q-tooltip></q-icon>
|
||
<span class="de-ord">{{ i + 1 }}</span>
|
||
<span class="de-dot" :style="{ background: j.skill ? getTagColor(j.skill) : prioColor(j.priority) }"></span>
|
||
<div class="col" style="min-width:0;cursor:pointer" @click="toggleJobDetail(j)"><q-tooltip v-if="j.detail && !j.showDetail" max-width="360px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.detail }}</q-tooltip>
|
||
<div class="ellipsis text-weight-medium" style="font-size:13px">{{ j.subject }} <q-badge v-if="j.legacy" color="green-1" text-color="green-8" class="q-ml-xs" style="font-weight:700">F</q-badge> <q-icon name="info_outline" size="12px" class="text-grey-5" /></div>
|
||
<div class="ellipsis text-grey-6" style="font-size:11px">{{ fmtHM(packedDay[i].startMin) }}–{{ fmtHM(packedDay[i].endMin) }}<span v-if="j.locked" class="text-warning"> · 🔒 RDV fixe</span><span v-if="j.customer"> · {{ j.customer }}</span></div>
|
||
<div v-if="j.address || !hasLL(j)" class="ellipsis" style="font-size:11px;color:#90a4ae"><template v-if="j.address"><q-icon name="place" size="11px" /> {{ j.address }}</template><span v-if="!hasLL(j) && !j.legacy" class="loc-pick-link text-warning" @click.stop="openLocPicker(j)"> · ⚠ sans coords — 📍 situer</span><span v-else-if="!hasLL(j)" class="text-grey-5"> · sans coords (absent de la carte)</span></div>
|
||
</div>
|
||
<template v-if="!j.legacy">
|
||
<div class="de-dur"><input type="number" min="5" step="5" :value="jobMinutes(j)" @change="setJobMinutes(j, $event.target.value)" @click.stop @mousedown.stop /><span>min</span></div>
|
||
<q-btn flat dense round size="sm" icon="open_in_new" color="green-5" @click.stop="openJobDetail(j, dayEditor.tech)"><q-tooltip>Détail du ticket (OPS) — fil, équipe, client</q-tooltip></q-btn>
|
||
<!-- sélecteur de priorité retiré (défaut « Moyenne » conservé en donnée) → gain de place ; priorité gérée au Dispatch -->
|
||
<q-btn v-if="!j.actual_start" flat dense round size="sm" icon="play_arrow" color="green-6" @click="startJobChrono(j)"><q-tooltip>Démarrer (chrono réel → apprentissage)</q-tooltip></q-btn>
|
||
<q-btn v-else-if="!j.actual_end" flat dense round size="sm" icon="stop_circle" color="deep-orange" class="chrono-run" @click="finishJobChrono(j)"><q-tooltip>Terminer (chrono réel)</q-tooltip></q-btn>
|
||
<span v-else class="de-actual" :style="ratioStyle(j)" :title="'Réel mesuré / estimé (min) — geofencing ou chrono'">⏱{{ j.actual_min != null ? j.actual_min : '?' }}/{{ jobMinutes(j) }}m</span>
|
||
<q-btn flat dense round size="sm" icon="edit_location_alt" :color="hasLL(j) ? 'blue-grey-5' : 'deep-orange'" @click="openLocPicker(j)"><q-tooltip>{{ hasLL(j) ? 'Repositionner sur la carte' : 'Situer sur la carte (sans coords)' }}</q-tooltip></q-btn>
|
||
<q-btn flat dense round size="sm" icon="push_pin" :color="j.pin ? 'indigo' : 'grey-5'">
|
||
<q-tooltip>{{ j.pin === 'first' ? 'Figé en 1re position' : j.pin === 'last' ? 'Figé en dernière position' : 'Figer la position (1er / dernier) — respecté par « Optimiser »' }}</q-tooltip>
|
||
<q-menu auto-close anchor="bottom right" self="top right">
|
||
<q-list dense style="min-width:180px">
|
||
<q-item clickable @click="setPin(j, 'first')"><q-item-section avatar><q-icon name="vertical_align_top" :color="j.pin === 'first' ? 'indigo' : 'grey-7'" /></q-item-section><q-item-section>Figer en 1<sup>re</sup> position</q-item-section></q-item>
|
||
<q-item clickable @click="setPin(j, 'last')"><q-item-section avatar><q-icon name="vertical_align_bottom" :color="j.pin === 'last' ? 'indigo' : 'grey-7'" /></q-item-section><q-item-section>Figer en dernière position</q-item-section></q-item>
|
||
<q-item v-if="j.pin" clickable @click="setPin(j, null)"><q-item-section avatar><q-icon name="link_off" color="grey-7" /></q-item-section><q-item-section>Détacher</q-item-section></q-item>
|
||
</q-list>
|
||
</q-menu>
|
||
</q-btn>
|
||
<q-btn flat dense round size="sm" :icon="j.locked ? 'lock' : 'lock_open'" :color="j.locked ? 'deep-orange' : 'grey-5'" @click="j.locked = !j.locked"><q-tooltip>{{ j.locked ? 'Heure FIXE (RDV) — verrouillée, non replanifiée' : 'Heure flexible — replanifiée par la tournée' }}</q-tooltip></q-btn>
|
||
<q-btn flat dense round size="sm" icon="close" color="negative" @click="removeFromDay(j)"><q-tooltip>Retirer du tech (retour au pool)</q-tooltip></q-btn>
|
||
</template>
|
||
<q-btn v-else flat dense round size="sm" icon="open_in_new" color="green-5" @click.stop="openLegacyBlock({ lid: j.lid || j.legacy_id, subject: j.subject, dept: j.dept })"><q-tooltip>Ouvrir le ticket F (lecture seule)</q-tooltip></q-btn>
|
||
</div>
|
||
<div v-if="j.showDetail" class="de-detail">
|
||
<div v-if="j._thread && j._thread.loading" class="text-grey-6" style="font-size:11px"><q-spinner size="13px" class="q-mr-xs" />Chargement du fil…</div>
|
||
<template v-else-if="j._thread && j._thread.messages && j._thread.messages.length">
|
||
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ j._thread.messages.length }} message(s){{ j._thread.status ? ' · ' + j._thread.status : '' }}</div>
|
||
<div v-for="(m, mi) in j._thread.messages" :key="mi" class="de-msg">
|
||
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span></div>
|
||
<div class="de-msg-txt">{{ m.text }}</div>
|
||
</div>
|
||
</template>
|
||
<div v-else style="white-space:pre-wrap;font-size:11px">{{ j.detail || 'Aucun détail importé pour ce ticket.' }}<span v-if="j._thread && j._thread.error" class="text-warning"> · fil indisponible</span></div>
|
||
</div>
|
||
</template>
|
||
<!-- (jobs legacy F géocodés = fusionnés dans la liste éditable ci-dessus → pins sur la carte + tournée optimisable) -->
|
||
</q-card-section>
|
||
<q-card-section v-if="dayEditor.list.length" class="row items-center q-pt-none">
|
||
<span class="text-caption text-grey-6"><q-icon name="drag_indicator" size="13px" /> glisser (souris) · ▲▼ (mobile) = ordre · <q-icon name="push_pin" size="12px" /> figer 1<sup>er</sup>/dernier · 🔒 RDV fixe · total <b>{{ dayTotalH() }}h</b></span><q-space />
|
||
<q-btn v-if="dayEditor.list.some(j => !j.legacy && !hasLL(j) && j.address)" dense outline no-caps size="sm" color="warning" icon="place" :loading="batchLocating" :label="'Localiser ' + dayEditor.list.filter(j => !j.legacy && !hasLL(j) && j.address).length + ' sans GPS'" class="q-mr-sm" @click="batchLocate"><q-tooltip max-width="260px">Faire correspondre l'adresse de chaque job sans coordonnées à une adresse existante (base RQA) → ajoute le GPS. Adresses ambiguës = à situer à la main.</q-tooltip></q-btn>
|
||
<q-btn dense flat no-caps size="sm" color="indigo" icon="route" label="Optimiser depuis le départ" class="q-mr-sm" @click="optimizeFromOrigin"><q-tooltip max-width="280px">Réordonne par plus proche voisin depuis {{ dayOrigin ? (dayOrigin.kind === 'home' ? 'le domicile du tech' : 'le dépôt') : 'le 1er arrêt' }} → la 1re job devient la plus proche. Puis « Enregistrer » (les tickets F restent indicatifs).</q-tooltip></q-btn>
|
||
<q-btn v-if="dayEditor.list.some(j => !j.legacy)" dense unelevated color="primary" :loading="dayEditor.saving" label="Enregistrer" @click="saveDayOrder" />
|
||
<span v-else class="text-caption text-grey-6">Tickets F — ordre de tournée indicatif (non réécrit dans F)</span>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- #17 — Création de job NATIVE OPS (work-order) : réutilise le modal partagé. @created → rafraîchit le pool. -->
|
||
<UnifiedCreateModal v-model="createJobOpen" mode="work-order" :context="createJobCtx"
|
||
:technicians="techs" :external-tags="tagCatalog" :external-get-color="getTagColor" @created="onJobCreated" />
|
||
<!-- « Trouver un créneau » → chips compétence (durée par défaut) + adresse → choisit tech+date+heure puis pré-remplit la création -->
|
||
<SuggestSlotsDialog v-model="showSuggestSlots" :skills="slotSkills" @select="onSlotSelected" @plan-shifts="onPlanShifts" />
|
||
<!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) → écrit les Shift Assignment -->
|
||
<WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" />
|
||
</q-page>
|
||
</template>
|
||
|
||
<script setup>
|
||
/**
|
||
* PlanificationPage — grille hebdomadaire roster (ressources × jours) + prise en charge dispatch.
|
||
* Backend : targo-hub /roster/* (src/api/roster.js) → ERPNext facturation + solveur OR-Tools.
|
||
* Voir aussi : services/targo-hub/lib/roster.js (endpoints) et docs/ROSTER.md (vue d'ensemble).
|
||
*
|
||
* CARTE DES SECTIONS (chercher les bannières « ── … ── ») :
|
||
* 1. État réactif & constantes ............ refs techs/templates/assignments, LS_*, dayList
|
||
* 2. Calque de garde LIVE ................. gardeOverlay (règles) ⊕ manualGarde (touche G) → gardeEffective
|
||
* 3. Filtres & scoring priorité ........... skillFilter (ET), techCompetence/techSpeed/techProximity → priorityScores
|
||
* 4. Ressources masquées .................. hiddenTechs / visibleTechs
|
||
* 5. Compétences inline ................... skillLevel (1-5) + skillEff (%/compétence) + TagEditor + gestionnaire global
|
||
* 6. Sélection / peinture cellules ........ souris (onDown/onEnter) + clavier (onKey : A absent, G garde, copier/coller)
|
||
* 7. Occupation & timeline ................ occCells → cellBands/cellBlocks/cellPct/cellJobs ; openTimeline (dialogue)
|
||
* 8. Panneau « jobs à assigner » .......... multi-sélection + terrain/distant + drag-drop + aperçu occupation projetée
|
||
* 9. Dialogue d'impact .................... retrait compétence / absence → redistribution (candidats classés)
|
||
* 10. Chargement & solveur ................. loadBase/loadWeek/loadStats · doGenerate/doPublish
|
||
* 11. Helpers date/temps/couleur .......... iso/hToNum/numToTime · occColor/todColor/getTagColor
|
||
*/
|
||
import { ref, computed, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||
import { useRoute } from 'vue-router' // deep-link ?day=&skill= (tableau de bord, slot-finder)
|
||
// Icônes de rôle monochromes outline (Material Symbols, style « une couleur » demandé) : échelle = installation.
|
||
import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined'
|
||
import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
||
import { useQuasar } from 'quasar'
|
||
import * as roster from 'src/api/roster'
|
||
import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement
|
||
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
|
||
import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API Mapbox Matrix), déjà utilisé par le Dispatch
|
||
import { legacyDeptColor, heatColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé) + heatColor (source unique, aussi <OccupancyStrip>)
|
||
import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur)
|
||
import { relTime, initials } from 'src/composables/useFormatters' // temps relatif + initiales (source unique, ex-locales dé-dupliquées)
|
||
import { messageIdentity, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' // resolvers partagés + priorité (drapeau) réutilisée des conversations
|
||
import { useSla, SLA_BADGE } from 'src/composables/useSla' // SLA existant (politiques éditables) — réutilisé sur les jobs (échéance = création + résolution)
|
||
import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client
|
||
import TechSelect from 'src/components/shared/TechSelect.vue'
|
||
import 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
|
||
import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo
|
||
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' // bande d'occupation réutilisable (sélecteur de jour Tournées + tableau de bord)
|
||
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) — Dispatch déprécié, tout ici
|
||
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » → pré-remplit la création (tech+date+heure+adresse)
|
||
import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) — repris/amélioré depuis Dispatch
|
||
|
||
const $q = useQuasar()
|
||
const router = useRouter()
|
||
const authStore = useAuthStore()
|
||
const DIRTY_MSG = 'Vous avez des modifications non publiées. Les abandonner ?'
|
||
|
||
const techs = ref([])
|
||
const templates = ref([])
|
||
const assignments = ref([])
|
||
const coverageData = ref([])
|
||
// Capacité OFFRABLE par jour (journée 8h–17h, AM+PM COMBINÉS — on ne gère que des heures/jour),
|
||
// calculée CLIENT-SIDE sur les ressources AFFICHÉES (visibleTechs) → suit le filtre par compétence
|
||
// (cliquer « installation » ⇒ visibleTechs = techs installation ⇒ capacité = leurs shifts).
|
||
// Le soir (17h+) reste une réserve garde/urgence, jamais proposée au client → exclu de la fenêtre.
|
||
const DAY_FROM = 8, DAY_TO = 17
|
||
const _segOv = (s, e, a, b) => Math.max(0, Math.min(e, b) - Math.max(s, a))
|
||
const _r1 = (x) => Math.round(x * 10) / 10
|
||
const _toH = (t) => { if (t == null) return 0; if (typeof t === 'number') return t; const p = String(t).split(':'); return (Number(p[0]) || 0) + (Number(p[1]) || 0) / 60 }
|
||
const capByDay = computed(() => {
|
||
const out = {}; const tids = visibleTechs.value.map(t => t.id); const cbtd = cellsByTechDay.value; const tpl = tplByName.value
|
||
for (const d of dayList.value) {
|
||
let cap = 0, used = 0
|
||
for (const tid of tids) {
|
||
const asgs = (cbtd[tid] && cbtd[tid][d.iso]) || [] // quarts publiés/proposés du tech ce jour
|
||
for (const a of asgs) { const t = tpl[a.shift]; if (!t || t.on_call) continue; const s = _toH(t.start_time), e = _toH(t.end_time); if (e > s) cap += _segOv(s, e, DAY_FROM, DAY_TO) }
|
||
const occ = occByTechDay.value[tid + '|' + d.iso] // jobs placés (blocs horaires)
|
||
if (occ && occ.blocks) for (const b of occ.blocks) used += _segOv(b.s, b.e, DAY_FROM, DAY_TO)
|
||
}
|
||
let due_h = 0, jobs_due = 0 // charge DUE = pool non assigné ce jour (filtré par compétence si actif)
|
||
for (const j of (assignPanel.jobs || [])) { if (j.scheduled_date !== d.iso) continue; if (skillFilter.value.length && !skillFilter.value.includes(j.required_skill)) continue; due_h += (j.est_min ? j.est_min / 60 : Number(j.duration_h || j.dur || 0)); jobs_due++ }
|
||
cap = _r1(cap); used = _r1(used)
|
||
out[d.iso] = { cap_h: cap, used_h: used, free_h: _r1(cap - used), due_h: _r1(due_h), jobs_due, overbooked: cap > 0 && due_h > cap }
|
||
}
|
||
return out
|
||
})
|
||
// Total des heures (et effectif) sur les RESSOURCES AFFICHÉES uniquement — pied de tableau.
|
||
// Source = assignments (cohérent avec hoursOf par tech), garde exclue.
|
||
const visStatByDay = computed(() => {
|
||
const vis = new Set(visibleTechs.value.map(t => t.id)); const tpl = tplByName.value; const agg = {}
|
||
for (const a of assignments.value) {
|
||
if (!vis.has(a.tech)) continue
|
||
const t = tpl[a.shift]; if (t && t.on_call) continue // garde = mise en dispo, pas travaillée
|
||
const o = agg[a.date] || (agg[a.date] = { hours: 0, staff: new Set() })
|
||
o.hours += Number(a.hours) || 0; o.staff.add(a.tech)
|
||
}
|
||
const out = {}; for (const k in agg) out[k] = { hours: _r1(agg[k].hours), staff: agg[k].staff.size }
|
||
return out
|
||
})
|
||
function visStat (iso) { return visStatByDay.value[iso] || { hours: 0, staff: 0 } }
|
||
// CHARGE estimée par jour = Σ heures estimées des jobs de cette date (assignés aux techs AFFICHÉS + pool non assigné,
|
||
// filtré compétence) → c'est le NUMÉRATEUR ; le dénominateur = heures dispo (visStat). Ratio = taux d'occupation prévu.
|
||
const estLoadByDay = computed(() => {
|
||
const vis = new Set(visibleTechs.value.map(t => t.id)); const out = {}
|
||
for (const key in occByTechDay.value) { // jobs déjà assignés (occupancy), seulement sur les techs affichés
|
||
const [tid, iso] = key.split('|'); if (!vis.has(tid)) continue
|
||
const blocks = (occByTechDay.value[key] || {}).blocks || []
|
||
out[iso] = (out[iso] || 0) + blocks.reduce((s, b) => s + Math.max(0, (b.e - b.s)), 0)
|
||
}
|
||
for (const j of (assignPanel.jobs || [])) { // + pool non assigné de cette date (même filtre compétence que la capacité)
|
||
if (skillFilter.value.length && !skillFilter.value.includes(j.required_skill)) continue
|
||
const iso = j.scheduled_date; if (!iso) continue
|
||
out[iso] = (out[iso] || 0) + (j.est_min ? j.est_min / 60 : Number(j.duration_h || j.dur || 0))
|
||
}
|
||
for (const k in out) out[k] = _r1(out[k]); return out
|
||
})
|
||
function estLoad (iso) { return estLoadByDay.value[iso] || 0 }
|
||
function loadClass (iso) { const est = estLoad(iso), dispo = visStat(iso).hours || 0; if (!dispo) return est > 0 ? 'cap-full' : 'cap-none'; const r = est / dispo; return r > 1 ? 'cap-full' : (r > 0.85 ? 'cap-low' : 'cap-ok') } // vert/orange/rouge selon est/dispo
|
||
// Barre verticale d'occupation de l'en-tête desktop (miroir de la bande mobile) : ratio charge estimée / heures de quart.
|
||
function hdrRatio (iso) { const cap = visStat(iso).hours || 0, load = estLoad(iso) || 0; return cap > 0 ? load / cap : (load > 0 ? 1 : 0) }
|
||
function hdrFillH (iso) { return (estLoad(iso) || 0) > 0 ? Math.max(12, Math.min(100, Math.round(hdrRatio(iso) * 100))) : 0 }
|
||
// Vue « jobs prévus en en-tête de jour » : jobs NON assignés ayant une date, groupés par jour, glissables sur une case
|
||
// tech×jour (réutilise onJobDragStart/onCellDrop → assigne + replanifie à la date de la case). Suit le filtre compétence.
|
||
const showDayJobs = ref(false)
|
||
// Charge LEGACY appliquée aux timelines : durées estimées des jobs assignés dans osTicket (datés dans la fenêtre affichée).
|
||
const showLegacyLoad = ref((() => { try { return localStorage.getItem('plan_legacy_load') !== '0' } catch (e) { return true } })()) // défaut ON
|
||
const legacyLoad = ref({}) // 'technician_id|iso' → { h, n, jobs } (durées estimées des jobs legacy datés, fenêtre affichée)
|
||
async function loadLegacyWindow () {
|
||
if (!showLegacyLoad.value) { legacyLoad.value = {}; return }
|
||
try { const r = await roster.legacyWindowLoad(start.value, days.value); legacyLoad.value = (r && r.load) || {} } catch (e) { /* non bloquant — legacy indispo */ }
|
||
}
|
||
watch(showLegacyLoad, (v) => { try { localStorage.setItem('plan_legacy_load', v ? '1' : '0') } catch (e) {} loadLegacyWindow() })
|
||
const unassignedByDay = computed(() => {
|
||
const out = {}
|
||
const pr = j => ({ urgent: 0, high: 1, moyenne: 2, medium: 2, normal: 2, low: 3, basse: 3 }[String(j.priority || '').toLowerCase()] ?? 2)
|
||
for (const j of (assignPanel.jobs || [])) {
|
||
if (skillFilter.value.length && !skillFilter.value.includes(j.required_skill)) continue
|
||
const iso = j.scheduled_date; if (!iso) continue
|
||
;(out[iso] = out[iso] || []).push(j)
|
||
}
|
||
for (const k in out) out[k].sort((a, b) => pr(a) - pr(b) || (b.est_min || 0) - (a.est_min || 0))
|
||
return out
|
||
})
|
||
const dailyStats = ref([])
|
||
const solverStats = ref(null)
|
||
const loading = ref(false); const generating = ref(false); const publishing = ref(false); const applying = ref(false)
|
||
const days = ref(7)
|
||
const start = ref(thisMonday()) // défaut = semaine COURANTE (cohérent avec « Auj. ») — pas la semaine suivante
|
||
const lastWeek = reactive({ start: start.value, days: days.value })
|
||
const showDemand = ref(false)
|
||
const drag = reactive({ on: false, ti: 0, di: 0, moved: false, base: [] })
|
||
const justDragged = ref(false)
|
||
const selection = ref([])
|
||
const activeCell = ref(null) // dernière case cliquée {id, name, iso} — pour copier/coller au clavier sans multi-sélection
|
||
const anchor = ref(null)
|
||
const demand = ref([]); const holidays = ref([]); const weekTemplates = ref([])
|
||
const statHolidays = ref([]) // fériés QC DÉTERMINISTES (calendrier hub /roster/holidays) — fusionnés dans isHoliday, tous navigateurs
|
||
const gardeRules = ref([]); const showGarde = ref(false)
|
||
const manualGarde = ref({}) // overrides manuels de garde : 'techId|iso' → 'on' | 'off' (touche « G »)
|
||
const newGardeRule = reactive({ dept: '', shift: '', shiftWeekend: '', weekdays: [], anchor: '', steps: [] })
|
||
const GARDE_DOW = [{ v: 1, l: 'L' }, { v: 2, l: 'M' }, { v: 3, l: 'M' }, { v: 4, l: 'J' }, { v: 5, l: 'V' }, { v: 6, l: 'S' }, { v: 0, l: 'D' }]
|
||
const history = ref([]); const future = ref([])
|
||
const search = ref(''); const groupFilter = ref(null); const maxHours = ref(40); const skillFilter = ref([])
|
||
// Ressources masquées (hors front-line : compta, etc.) — masquées ET ignorées du calcul ; localStorage.
|
||
const hiddenTechs = ref([]); const showHidden = ref(false)
|
||
function isHidden (id) { return hiddenTechs.value.includes(id) }
|
||
function toggleHidden (id) { const i = hiddenTechs.value.indexOf(id); if (i >= 0) hiddenTechs.value.splice(i, 1); else hiddenTechs.value.push(id); localStorage.setItem('roster-hidden-techs-v1', JSON.stringify(hiddenTechs.value)) }
|
||
const hiddenCount = computed(() => techs.value.filter(t => isHidden(t.id)).length)
|
||
const showShiftEditor = ref(false); const editTpls = ref([])
|
||
const showTeamEditor = ref(false); const editTechs = ref([])
|
||
const notifySms = ref(false)
|
||
const showLeave = ref(false); const leaveRows = ref([]); const leaveFilter = ref('Demandé')
|
||
const newLeave = reactive({ technician: '', availability_type: 'Congé', from_date: '', to_date: '', reason: '', long_term: 0 })
|
||
const newTpl = reactive({ template_name: '', start: '08:00', end: '16:00', color: '#1976d2', on_call: 0 })
|
||
function numToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') }
|
||
// Slider à 2 poignées pour le nouveau modèle (heures custom) ↔ newTpl.start/end
|
||
const newTplRange = computed({ get: () => ({ min: hToNum(newTpl.start) || 8, max: hToNum(newTpl.end) || 16 }), set: (v) => { newTpl.start = numToTime(v.min); newTpl.end = numToTime(v.max) } })
|
||
|
||
const LS_DEMAND = 'roster-demand-v1'; const LS_HOL = 'roster-holidays-v1'; const LS_TPL = 'roster-week-templates-v1'; const LS_GARDE = 'roster-garde-rules-v1'; const LS_GARDE_MANUAL = 'roster-garde-manual-v1'
|
||
|
||
// Date LOCALE du jour (PAS toISOString, qui bascule au lendemain le soir en UTC− → cause des « jours décalés »).
|
||
function todayISO () { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') }
|
||
function thisMonday () { return mondayISO(todayISO()) } // lundi de la semaine COURANTE — défaut au chargement ET bouton « Auj. »
|
||
function addDaysISO (iso, n) { const [y, m, d] = iso.split('-').map(Number); const dt = new Date(Date.UTC(y, m - 1, d)); dt.setUTCDate(dt.getUTCDate() + n); return dt.toISOString().slice(0, 10) }
|
||
const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
|
||
const dayList = computed(() => {
|
||
const [y, m, dd] = start.value.split('-').map(Number); const base = new Date(Date.UTC(y, m - 1, dd)); const out = []
|
||
for (let i = 0; i < days.value; i++) { const d = new Date(base); d.setUTCDate(d.getUTCDate() + i); const iso = d.toISOString().slice(0, 10); const dow = d.getUTCDay(); out.push({ iso, dow: FR_DOW[dow], dnum: iso.slice(8) + '/' + iso.slice(5, 7), weekend: dow === 0 || dow === 6 }) }
|
||
return out
|
||
})
|
||
function dowOf (iso) { const [y, m, d] = iso.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)).getUTCDay() }
|
||
const isWeekendIso = iso => { const d = dowOf(iso); return d === 0 || d === 6 } // samedi/dimanche → PAS de quart auto (techs de fin de semaine dédiés)
|
||
// File PLACEHOLDER (dispatch auto) : pas un vrai tech → « à assigner ». `__wknd__` (legacy) + `__hold__|<iso>|<n>` (tournées sans quart, tout jour).
|
||
const isHoldId = id => id === '__wknd__' || id === '__unassigned__' || id === '__remote__' || (typeof id === 'string' && id.startsWith('__hold__'))
|
||
// ── MODE JOUR (style Gaiia) : 1 seule colonne pleine largeur → timelines hautes + règle d'heures, idéal pour disposer
|
||
// les jobs (glisser depuis le panneau « jobs du jour » vers un tech, réordonner via l'éditeur de jour). ─────────────
|
||
const dayMode = computed(() => dayList.value.length === 1)
|
||
watch(dayMode, (on) => { if (on) showDayJobs.value = true }) // en mode Jour, affiche d'office le panneau de jobs à disposer
|
||
|
||
// ── VUE KANBAN (mode de vue, sur le socle hub) : colonne « À assigner » + 1 colonne/tech, cartes glissables entre lanes.
|
||
// Réutilise tout : occupation (avec équipes via job.assist), pool unassignedJobs, onCellDrop (= roster.assignJob hub). ──
|
||
const { prefs: planifPrefs, save: savePlanif } = useUserPrefs('planif', { view: 'grid' }) // préférence d'affichage SERVEUR (suit l'usager entre appareils)
|
||
const boardView = ref(['kanban', 'routes'].includes(planifPrefs.value.view) ? planifPrefs.value.view : 'grid') // 'grid' | 'kanban' | 'routes'
|
||
watch(boardView, (v) => { savePlanif({ view: v }); if (v === 'kanban') { kbSelIso.value = nowET.value.iso; if (!dayList.value.find(d => d.iso === kbSelIso.value)) { start.value = thisMonday(); loadWeek() } _kbDidScroll = false; nextTick(() => setTimeout(kbScrollToDefault, 160)) } else if (v === 'grid' && days.value < 7) { days.value = 7; loadWeek() } })
|
||
watch(() => planifPrefs.value.view, (v) => { if (v && v !== boardView.value) boardView.value = v }) // sync depuis le serveur (autre appareil) une fois chargé
|
||
const kbSelIso = ref('') // mode Jour : jour explicitement choisi ('' = auto → aujourd'hui)
|
||
const kanbanDay = computed(() => { // Jour : jour choisi, sinon AUJOURD'HUI (ET) si dans la fenêtre, sinon le 1er jour
|
||
for (const c of [kbSelIso.value, nowET.value.iso]) { if (!c) continue; const d = dayList.value.find(x => x.iso === c); if (d) return d }
|
||
return dayList.value[0] || null
|
||
})
|
||
// Sélecteur de date de la barre d'outils : en mode JOUR = le jour affiché (éditable) ; en SEMAINE = début de fenêtre.
|
||
const boardDate = computed({
|
||
get: () => (boardView.value === 'kanban' ? ((kanbanDay.value && kanbanDay.value.iso) || start.value) : start.value),
|
||
set: (v) => {
|
||
if (!v) return
|
||
if (boardView.value === 'kanban') { kbSelIso.value = v; if (!dayList.value.find(d => d.iso === v)) { start.value = mondayISO(v); loadWeek() } }
|
||
else if (v !== start.value) { start.value = v; onWeekChange() }
|
||
},
|
||
})
|
||
const kanbanPool = computed(() => { const f = skillFilter.value; return (assignPanel.jobs || []).filter(j => !f.length || f.includes(j.required_skill)) })
|
||
// Pool : recherche plein-texte + tri (priorité / ville / distance dépôt / compétence / durée), comme la fenêtre flottante.
|
||
const kbSearch = ref(''); const kbSort = ref('prio')
|
||
function kbCity (j) { if (j.municipalite) return j.municipalite; const m = String(j.address || '').match(/,\s*([^,]+?)\s*(?:,|$|\s[A-Z]\d[A-Z])/); return (m ? m[1] : '').trim() }
|
||
const kanbanPoolView = computed(() => {
|
||
let list = kanbanPool.value
|
||
const q = kbSearch.value.trim().toLowerCase()
|
||
if (q) list = list.filter(j => [j.subject, j.service_type, j.customer_name, j.address, j.required_skill].some(x => String(x || '').toLowerCase().includes(q)))
|
||
const pr = (j) => ({ urgent: 0, high: 1, medium: 2, low: 3 }[String(j.priority || '').toLowerCase()] ?? 2)
|
||
const dep = depot.value; const dist = (j) => (dep && dep.lat != null && j.latitude != null) ? (haversineKm(dep.lat, dep.lon, +j.latitude, +j.longitude) ?? 9e9) : 9e9
|
||
const s = kbSort.value
|
||
return [...list].sort((a, b) =>
|
||
s === 'city' ? (kbCity(a) || 'zz').localeCompare(kbCity(b) || 'zz') :
|
||
s === 'dist' ? dist(a) - dist(b) :
|
||
s === 'skill' ? String(a.required_skill || 'zz').localeCompare(String(b.required_skill || 'zz')) :
|
||
s === 'dur' ? (b.est_min || 0) - (a.est_min || 0) :
|
||
(pr(a) - pr(b) || (b.est_min || 0) - (a.est_min || 0)))
|
||
})
|
||
// Blocs d'une lane : depuis l'occupation BRUTE (occByTechDay = TOUS les jobs assignés ; robuste vs shift-gating qui
|
||
// faisait « disparaître » la carte). Timés (start_h) à leur heure ; non-timés en flow ; inclut les miroirs « assist ».
|
||
const KB_DAY_START = 8 // 8h AM (style Gaiia) : départ par défaut de la tournée
|
||
const kbMatrix = ref({}); const _kbMatPending = new Set() // cache matrice Mapbox par tournée (tech|jour) → 1 call/tournée
|
||
function kanbanLaneBlocks (techId) {
|
||
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return []
|
||
const o = occByTechDay.value[techId + '|' + iso] || { jobs: [] }
|
||
const dur = (j) => Number(j.dur) || 1
|
||
// Jobs réels (occupation) + jobs TERRAIN legacy (synthétique F, comme la grille semaine) : non draggables, clic = ouvre le ticket.
|
||
const base = (o.jobs || []).slice()
|
||
const lg = showLegacyLoad.value ? legacyLoad.value[techId + '|' + iso] : null
|
||
if (lg && Array.isArray(lg.jobs)) { for (const lj of lg.jobs) { if (!lj.field) continue; const d = Number(lj.est_h) || 0; if (d <= 0) continue; base.push({ name: 'LEG:' + lj.id, legacy: true, lid: lj.id, subject: lj.subject, dept: lj.dept, skill: lj.skill, dur: d, start_h: null, route_order: 9999 }) } }
|
||
if (!base.length) return []
|
||
// Ordre de tournée : route_order puis heure.
|
||
const jobs = base.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
|
||
// RDV CONFIRMÉS + miroirs ASSISTANT = verrouillés à leur heure (le renfort suit l'heure du lead) ; tout le reste COULE depuis 8h.
|
||
const fixed = jobs.filter(j => (j.booking_status === 'Confirmé' || j.assist) && j.start_h != null)
|
||
const out = fixed.map(j => ({ ...j, s: j.start_h, e: j.start_h + dur(j), fixed: true }))
|
||
let cursor = KB_DAY_START
|
||
const mat = (kbMatrix.value[techId + '|' + iso] || {}).map || null // matrice Mapbox RÉELLE (1 call/tournée) si chargée
|
||
const dep = depot.value
|
||
let prev = (dep && dep.lat != null) ? { lat: +dep.lat, lon: +dep.lon } : null
|
||
let prevName = (dep && dep.lat != null) ? 'DEPOT' : null
|
||
for (const j of jobs) {
|
||
if (j.assist) continue // miroir assistant : déjà placé (fixed) ; ne coule pas et ne compte pas dans le trajet de l'assistant
|
||
if (j.booking_status === 'Confirmé' && j.start_h != null) { if (j.lat != null) { prev = { lat: +j.lat, lon: +j.lon }; prevName = j.name } continue }
|
||
const d = dur(j); const blk = { ...j }
|
||
// Trajet d'approche : temps ROUTIER RÉEL (matrice Mapbox) si dispo, sinon repli à vol d'oiseau (~55 km/h).
|
||
let legMin = 0, legKm = 0
|
||
const real = (mat && prevName && j.name) ? mat[prevName + '>' + j.name] : null
|
||
if (real) { legMin = Math.min(120, real.min); legKm = real.km != null ? real.km : 0 }
|
||
else if (prev && j.lat != null && j.lon != null) { const km = haversineKm(prev.lat, prev.lon, +j.lat, +j.lon); if (km != null && km > 0.05) { legKm = km; legMin = Math.min(60, Math.round(km * 1.1)) } }
|
||
if (legMin >= 3) { blk.legS = cursor; blk.legMin = legMin; blk.legKm = Math.round(legKm * 10) / 10; blk.legReal = !!real; cursor += legMin / 60; blk.legE = cursor }
|
||
let guard = 0; while (guard++ < 50) { const c = fixed.find(f => cursor < (f.start_h + dur(f)) && (cursor + d) > f.start_h); if (c) cursor = c.start_h + dur(c); else break }
|
||
blk.s = cursor; blk.e = cursor + d; out.push(blk); cursor += d
|
||
if (j.lat != null && j.lon != null) { prev = { lat: +j.lat, lon: +j.lon }; prevName = j.name }
|
||
}
|
||
return out
|
||
}
|
||
// Matrice Mapbox RÉELLE par tournée (tech×jour) : 1 SEUL appel /tournée (≤25 coords) → temps de route réels pour tous les legs. Cache par signature (set de jobs) → pas de refetch inutile.
|
||
async function fetchKbMatrix (techId) {
|
||
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso || !MAPBOX_TOKEN) return
|
||
const key = techId + '|' + iso
|
||
const o = occByTechDay.value[key]
|
||
const jobs = (o && o.jobs ? o.jobs : []).filter(j => isFinite(+j.lat) && isFinite(+j.lon) && (+j.lat || +j.lon))
|
||
const sig = jobs.map(j => j.name).join(',')
|
||
if (jobs.length < 1) return
|
||
const cached = kbMatrix.value[key]; if ((cached && cached.sig === sig) || _kbMatPending.has(key)) return
|
||
const dep = depot.value; const pts = []
|
||
if (dep && isFinite(+dep.lat) && isFinite(+dep.lon)) pts.push({ name: 'DEPOT', lat: +dep.lat, lon: +dep.lon })
|
||
for (const j of jobs) pts.push({ name: j.name, lat: +j.lat, lon: +j.lon })
|
||
if (pts.length < 2) return
|
||
const use = pts.slice(0, 25) // Matrix = 25 coords max
|
||
_kbMatPending.add(key)
|
||
try {
|
||
const d = await roster.osrmTable(use.map(p => [p.lon, p.lat])) // OSRM auto-hébergé (via hub) — même forme que Mapbox Matrix
|
||
const dur = d.durations || [], dist = d.distances || []; const map = {}
|
||
for (let i = 0; i < use.length; i++) for (let k = 0; k < use.length; k++) { if (i === k) continue; const sec = dur[i] && dur[i][k]; if (sec == null) continue; const m = dist[i] && dist[i][k]; map[use[i].name + '>' + use[k].name] = { min: Math.max(2, Math.round(sec / 60)), km: m != null ? Math.round(m / 100) / 10 : null } }
|
||
kbMatrix.value = { ...kbMatrix.value, [key]: { sig, map } } // réactif → les legs se recalculent (temps routiers réels)
|
||
} catch (e) { /* repli haversine (géré dans kanbanLaneBlocks) */ } finally { _kbMatPending.delete(key) }
|
||
}
|
||
let _kbMatT = null // le watch déclencheur est enregistré PLUS BAS (après la déclaration de occByTechDay) pour éviter la zone morte temporelle
|
||
const kbColor = (j) => j.skill ? getTagColor(j.skill) : (j.required_skill ? getTagColor(j.required_skill) : (legacyDeptColor(j.legacy_dept) || '#90a4ae'))
|
||
async function reloadPool () { try { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } catch (e) { /* non bloquant */ } }
|
||
// ── Création de job NATIVE OPS (#17) — Dispatch déprécié → tout depuis Planification. Réutilise UnifiedCreateModal (work-order). ──
|
||
// Job OPS-natif = PAS de legacy_ticket_id → coexiste avec les jobs synchronisés de F (la sync F ne touche que les jobs à legacy_ticket_id). « Sync F transitoire » = coexistence, pas de push F pour l'instant.
|
||
const createJobOpen = ref(false)
|
||
const createJobCtx = ref({})
|
||
const showSuggestSlots = ref(false)
|
||
function openCreateJob () { createJobCtx.value = { scheduled_date: '' }; createJobOpen.value = true } // pool (non planifié) par défaut
|
||
function onSlotSelected (slot) { // un créneau choisi → pré-remplit tech+date+heure+adresse+coords+compétence
|
||
createJobCtx.value = { assigned_tech: slot.tech_id, scheduled_date: slot.date, start_time: slot.start_time, address: slot._address || '', duration_h: slot._duration || 1, _latitude: slot._latitude, _longitude: slot._longitude, tags: slot._skill ? [slot._skill] : [] }
|
||
createJobOpen.value = true
|
||
}
|
||
async function onJobCreated () { createJobOpen.value = false; await reloadPool(); await reloadOccupancy(); $q.notify({ type: 'positive', icon: 'add_task', message: 'Job créé — visible dans « À assigner »', timeout: 2600 }) }
|
||
// Drop sur la colonne « À assigner » = renvoyer au pool (désassigner, ERPNext only).
|
||
async function onKanbanUnassign (ev) {
|
||
ev.preventDefault()
|
||
const raw = (ev.dataTransfer && ev.dataTransfer.getData('text/plain')) || draggingJobName.value; draggingJobName.value = null
|
||
const names = (raw || '').split(',').filter(Boolean); onJobDragEnd(); if (!names.length) return
|
||
let ok = 0; for (const n of names) { try { await roster.unassignJobRoster(n); ok++ } catch (e) { err(e) } } // SÉQUENTIEL (frappe_pg)
|
||
if (ok) { $q.notify({ type: 'info', message: ok + ' job(s) renvoyé(s) au pool', timeout: 2200 }); await reloadOccupancy(); await reloadPool() }
|
||
}
|
||
// Clic droit sur une carte job → désassigner directement (retour au pool), sans devoir la glisser sur un autre tech.
|
||
async function unassignOne (b) {
|
||
const jn = b && (b.name || b.job); if (!jn || b.assist || b.legacy) return // Kanban=name, grille=job ; legacy/assist non désassignables
|
||
try { await roster.unassignJobRoster(jn); await reloadOccupancy(); await reloadPool(); $q.notify({ type: 'info', message: (b.subject || b.skill || 'Job') + ' renvoyé au pool', timeout: 2200 }) } catch (e) { err(e) }
|
||
}
|
||
// Ligne-curseur « maintenant » (Eastern Time, America/Toronto), rafraîchie chaque minute — style Gaiia.
|
||
const nowTick = ref(0); let _nowTimer = null
|
||
const nowET = computed(() => {
|
||
nowTick.value // dépendance réactive → recalcule à chaque tick (minute)
|
||
try {
|
||
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Toronto', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(new Date())
|
||
const o = {}; for (const p of parts) o[p.type] = p.value
|
||
const hh = o.hour === '24' ? 0 : Number(o.hour)
|
||
return { iso: `${o.year}-${o.month}-${o.day}`, h: hh + Number(o.minute) / 60 }
|
||
} catch (e) { return { iso: '', h: null } }
|
||
})
|
||
const nowHHMM = computed(() => { const n = nowET.value; if (n.h == null) return ''; const h = Math.floor(n.h); return String(h).padStart(2, '0') + ':' + String(Math.round((n.h - h) * 60)).padStart(2, '0') })
|
||
// ── Échelle d'heures du board en PIXELS (style Gaiia/dispatch) : 7h–18h REMPLIT la largeur dispo (mesurée),
|
||
// scroll horizontal pour les heures hors plage ; lanes hautes + rangées serrées = plus de place pour les détails. ──
|
||
const KB_VIS_FROM = 6; const KB_VIS_TO = 18; const KB_NAME_W = 150 // fenêtre de REMPLISSAGE 6–18h ; 0–6 et 18–24 restent SCROLLABLES (continuité)
|
||
const kbBoardEl = ref(null); const kbBoardW = ref(1100); let _kbRO = null
|
||
const kbScrollEl = ref(null); let _kbDidScroll = false
|
||
function kbScrollToDefault () { if (kbScrollEl.value) { kbScrollEl.value.scrollLeft = Math.max(0, (KB_VIS_FROM - kbAxis.value.min) * kbPxH.value); _kbDidScroll = true } } // cale la vue sur 6h (0–6 reste accessible en scrollant à gauche)
|
||
watch(kbBoardEl, (el) => { if (_kbRO) { _kbRO.disconnect(); _kbRO = null } if (el) { _kbRO = new ResizeObserver(es => { kbBoardW.value = es[0].contentRect.width; if (!_kbDidScroll) nextTick(kbScrollToDefault) }); _kbRO.observe(el); kbBoardW.value = el.clientWidth || 1100 } })
|
||
// Plage RENDUE : couvre au moins 7–18, étendue aux heures réelles des shifts (scroll pour le hors-plage).
|
||
// Vue calée sur 7–18h : on NE rend PAS 00–06h (ni la nuit/garde). Min fixe à 7 ; max étendu aux jobs réels mais plafonné à 21h.
|
||
const kbAxis = computed(() => ({ min: 0, max: 24 })) // rendu PLEINE JOURNÉE → scroll continu (0–6 à gauche, 18–24 à droite) ; 6–18 remplit la largeur
|
||
const kbPxH = computed(() => Math.max(46, (kbBoardW.value - KB_NAME_W - 4) / (KB_VIS_TO - KB_VIS_FROM))) // px/heure : 7–18 = largeur dispo
|
||
const kbInnerW = computed(() => Math.round((kbAxis.value.max - kbAxis.value.min) * kbPxH.value))
|
||
// Lane : fond BLANC + lignes verticales pâles toutes les 0,5 h (alignées sur l'axe : 0 = kbAxis.min).
|
||
const kbLaneStyle = computed(() => { const half = Math.max(8, kbPxH.value / 2); return { width: kbInnerW.value + 'px', backgroundColor: '#eef1f4', backgroundImage: 'repeating-linear-gradient(90deg, rgba(0,0,0,.05) 0 1px, transparent 1px ' + half + 'px)' } }) // gris pâle = indispo ; le quart (.kbb-shift) y crée une zone blanche
|
||
function kbPos (s, e) { const m = kbAxis.value.min, p = kbPxH.value; const L = (Math.max(m, s) - m) * p; const W = (Math.min(e, kbAxis.value.max) - Math.max(m, s)) * p; return { left: Math.round(L) + 'px', width: Math.max(6, Math.round(W)) + 'px' } }
|
||
const kbTicks = computed(() => { const a = kbAxis.value; const out = []; for (let h = a.min; h <= a.max; h++) out.push({ h, left: Math.round((h - a.min) * kbPxH.value) + 'px' }); return out })
|
||
// Bandes de quart (px) : réguliers + garde (brut depuis cellsOf/templates, sans pos %).
|
||
function kbShiftBands (techId) {
|
||
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return []
|
||
let s = Infinity, e = -Infinity
|
||
for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || t.on_call) continue; const ss = hToNum(t.start_time), ee = hToNum(t.end_time); if (ss != null) s = Math.min(s, ss); if (ee != null) e = Math.max(e, ee <= ss ? 24 : ee) }
|
||
// Allonge la zone blanche du quart pour COUVRIR les blocs qui débordent (jobs + trajets) ; garde = bande séparée.
|
||
const blks = kanbanLaneBlocks(techId)
|
||
if (!isFinite(s)) { if (!blks.length) return []; s = Math.min(...blks.map(b => b.s)); e = Math.max(...blks.map(b => b.e)) }
|
||
else for (const b of blks) { if (b.s < s) s = b.s; if (b.e > e) e = b.e }
|
||
return [{ s, e }]
|
||
}
|
||
const kbOnGarde = (techId) => kanbanDay.value ? onGarde(techId, kanbanDay.value.iso) : false
|
||
// Garde = shift SUR APPEL : bande jaune pâle sur la timeline (heures de garde) + téléphone rouge (pas une icône dans le nom).
|
||
function kbGardeBands (techId) {
|
||
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return []
|
||
const g = gardeEffective.value[techId + '|' + iso]; if (!g) return []
|
||
const t = tplByName.value[g]; if (!t) return []
|
||
const s = hToNum(t.start_time), e = hToNum(t.end_time); if (s == null || e == null) return []
|
||
return [{ s, e: e <= s ? 24 : e }]
|
||
}
|
||
// Ligne « maintenant » (px) — seulement si la colonne = aujourd'hui (ET) et dans la plage rendue.
|
||
const nowLeftPx = computed(() => { const n = nowET.value; if (n.h == null || !kanbanDay.value || kanbanDay.value.iso !== n.iso) return null; const a = kbAxis.value; if (n.h < a.min || n.h > a.max) return null; return (n.h - a.min) * kbPxH.value })
|
||
const nowLineStyle = computed(() => { const x = nowLeftPx.value; return x == null ? null : { left: (KB_NAME_W + Math.round(x)) + 'px' } }) // trait continu (inclut la colonne noms)
|
||
const nowLblStyle = computed(() => { const x = nowLeftPx.value; return x == null ? null : { left: Math.round(x) + 'px' } }) // étiquette dans la règle (après les noms)
|
||
// Export GPX du parcours GPS (jour courant) via Traccar — si le tech a un appareil associé.
|
||
const kbIsToday = computed(() => { const n = nowET.value; return !!(kanbanDay.value && n.iso && kanbanDay.value.iso === n.iso) })
|
||
function exportGpx (techId) {
|
||
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return
|
||
const from = new Date(iso + 'T00:00:00').toISOString(); const to = new Date(iso + 'T23:59:59').toISOString()
|
||
window.open(roster.gpxUrl(techId, from, to), '_blank')
|
||
}
|
||
// ── Détails d'un job : double-clic sur un bloc → grand volet DROIT (billet + commentaires) ; simple clic = éditeur de jour. ──
|
||
const jobDetail = reactive({ open: false, name: '', subject: '', customer: '', address: '', skill: '', time: '', detail: '', lid: null, dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null, geofence: null })
|
||
// Décode les entités HTML (« d'équipement » → « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés).
|
||
const _deEntEl = typeof document !== 'undefined' ? document.createElement('textarea') : null
|
||
function deEnt (s) { if (!s || String(s).indexOf('&') < 0 || !_deEntEl) return s || ''; _deEntEl.innerHTML = String(s); return _deEntEl.value }
|
||
async function openJobDetail (b, t) {
|
||
if (!b) return
|
||
jdShowTrack.value = false
|
||
Object.assign(jobDetail, { open: true, name: b.name || '', subject: deEnt(b.subject || b.name || 'Job'), customer: deEnt(b.customer || ''), address: deEnt(b.address || ''), skill: b.skill || '', time: (b.start || (b.s != null ? fmtH(b.s) : '')) + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : ''), detail: deEnt(b.detail || ''), lid: b.legacy_id || null, dept: b.dept || '', techId: (t && t.id) || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : null, lon: b.lon != null ? +b.lon : null, loading: !!b.legacy_id, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null })
|
||
// Géofencing (suivi façon colis) : timeline En route → Arrivé → Reparti, non bloquant.
|
||
jobDetail.geofence = null
|
||
if (b.name) roster.jobGeofence(b.name).then(g => { if (jobDetail.name === b.name) jobDetail.geofence = g }).catch(() => {})
|
||
if (b.legacy_id) { try { jobDetail.thread = await roster.ticketThread(b.legacy_id) } catch (e) { jobDetail.thread = { error: true, messages: [] } } finally { jobDetail.loading = false } }
|
||
if (jobDetail.canTeam) { jobDetail.teamLoading = true; try { const r = await roster.getJobTeam(b.name); jobDetail.team = r.assistants || [] } catch (e) { jobDetail.team = [] } finally { jobDetail.teamLoading = false } }
|
||
}
|
||
// Timeline géofencing pour l'affichage (façon suivi de colis) : 4 étapes + heure atteinte.
|
||
const GEO_STEPS = [{ k: 'assigned', label: 'Assigné', icon: 'assignment_ind' }, { k: 'en_route', label: 'En route', icon: 'directions_car' }, { k: 'on_site', label: 'Arrivé sur place', icon: 'location_on' }, { k: 'departed', label: 'Reparti', icon: 'check_circle' }]
|
||
const geoTimeline = computed(() => {
|
||
const gf = jobDetail.geofence; const events = (gf && gf.events) || []
|
||
const atOf = (k) => { const e = [...events].reverse().find(x => x.status === k); return e ? new Date(e.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', timeZone: 'America/Toronto' }) : null }
|
||
const order = { assigned: 0, en_route: 1, on_site: 2, departed: 3 }
|
||
const curIdx = order[(gf && gf.state) || 'assigned'] ?? 0
|
||
return GEO_STEPS.map((s, i) => ({ ...s, at: s.k === 'assigned' ? null : atOf(s.k), done: i <= curIdx, current: i === curIdx }))
|
||
})
|
||
// Options du sélecteur d'assistant (TechSelect, recherche par nom OU compétence) : tous les techs sauf le lead + l'équipe déjà là.
|
||
// Label = nom · compétences (searchable) ; CAPABLES d'abord (possèdent la compétence du job) ; value = id (scalaire).
|
||
const jdTeamOptions = computed(() => {
|
||
const taken = new Set([jobDetail.techId, ...(jobDetail.team || []).map(a => a.tech_id)])
|
||
const req = jobDetail.required_skill || jobDetail.skill || ''
|
||
return (techs.value || []).filter(t => !taken.has(t.id))
|
||
.map(t => { const sk = (t.skills || []); return { capable: !!(req && sk.includes(req)), label: t.name + (sk.length ? ' · ' + sk.slice(0, 4).join(', ') : ''), value: t.id } })
|
||
.sort((a, b) => (b.capable - a.capable) || a.label.localeCompare(b.label))
|
||
.map(({ label, value }) => ({ label, value }))
|
||
})
|
||
async function jdAddAssistant () {
|
||
const id = jobDetail.teamAdd; if (!id || !jobDetail.name) return
|
||
const tech = (techs.value || []).find(t => t.id === id); if (!tech) return
|
||
try {
|
||
await roster.addAssistant(jobDetail.name, { tech_id: tech.id, tech_name: tech.name, duration_h: 0, pinned: 1 })
|
||
jobDetail.teamAdd = null
|
||
const r = await roster.getJobTeam(jobDetail.name); jobDetail.team = r.assistants || []
|
||
await reloadOccupancy()
|
||
$q.notify({ type: 'positive', icon: 'group_add', message: tech.name + ' ajouté en renfort · bloc hachuré réservé dans son horaire', timeout: 2600 })
|
||
} catch (e) { err(e) }
|
||
}
|
||
async function jdRemoveAssistant (techId) {
|
||
if (!jobDetail.name) return
|
||
try { await roster.removeAssistant(jobDetail.name, techId); const r = await roster.getJobTeam(jobDetail.name); jobDetail.team = r.assistants || []; await reloadOccupancy(); $q.notify({ type: 'info', message: 'Assistant retiré', timeout: 1600 }) } catch (e) { err(e) }
|
||
}
|
||
let _kbClickT = null
|
||
function kbBlockClick (t) { clearTimeout(_kbClickT); _kbClickT = setTimeout(() => { if (kanbanDay.value) openDayFromCell(t, kanbanDay.value) }, 230) } // simple clic (différé) = éditeur de jour
|
||
function kbBlockDbl (b, t) { clearTimeout(_kbClickT); openJobDetail(b, t) } // double-clic = volet détails
|
||
// Clic DROIT sur le FOND d'une lane (hors job) → menu de quart (Jour/Soir/Garde/Absent/heures + créer un quart).
|
||
// Clic GAUCHE sur le fond = éditeur de jour (liste complète + carte) — voir le template (kbb-lane @click.self).
|
||
function onKbLaneMenu (t, ev) { if (!kanbanDay.value) return; openShiftMenu(t, kanbanDay.value, ev, -1, -1) }
|
||
// Drop d'une job sur une lane : assigne (onCellDrop → hub) PUIS crée un quart 8–16 auto si le tech n'a aucun quart régulier ce jour.
|
||
async function onKbDrop (ev, t) {
|
||
if (!kanbanDay.value) return
|
||
await onCellDrop(ev, t, kanbanDay.value) // si pas de quart → onCellDrop ouvre le dialogue (créer quart / absence) ; sinon assigne + toast Annuler
|
||
}
|
||
// ── Drop sur une lane SANS quart : demander quoi faire (créer un quart OU marquer absent) au lieu d'assigner en douce. ──
|
||
const dropAsk = reactive({ open: false, tech: null, day: null, names: [], mode: 'choice', preset: '8-16', cStart: '08:00', cEnd: '16:00', scope: 'day', from: '', to: '', absType: 'Congé' })
|
||
const shiftPresets = [{ label: 'Journée · 8 h–16 h', value: '8-16' }, { label: 'Avant-midi · 8 h–12 h', value: '8-12' }, { label: 'Après-midi · 12 h–16 h', value: '12-16' }, { label: 'Soir · 13 h–21 h', value: '13-21' }, { label: 'Personnalisé…', value: 'custom' }]
|
||
const absTypes = ['Congé', 'Maladie', 'Personnel', 'Formation', 'Absent']
|
||
const dropAskReturn = computed(() => { if (!dropAsk.open || dropAsk.mode !== 'absence') return ''; const days = dropAskDays(); return days.length ? addDaysISO(days[days.length - 1], 1) : '' })
|
||
function openDropAsk (names, t, d) { Object.assign(dropAsk, { open: true, tech: t, day: d, names: names.slice(), mode: 'choice', preset: '8-16', cStart: '08:00', cEnd: '16:00', scope: 'day', from: d.iso, to: d.iso, absType: 'Congé' }) }
|
||
function dropAskWindow () { if (dropAsk.preset === 'custom') return [hToNum(dropAsk.cStart), hToNum(dropAsk.cEnd)]; const [a, b] = dropAsk.preset.split('-').map(Number); return [a, b] }
|
||
async function dropAskCreateShift () {
|
||
const t = dropAsk.tech, d = dropAsk.day; const [min, max] = dropAskWindow()
|
||
if (!(max > min)) { $q.notify({ type: 'warning', message: 'La fin doit être après le début.' }); return }
|
||
const tpl = await ensureWindowTpl(min, max)
|
||
if (tpl) {
|
||
pushHistory(); addShift(t.id, t.name, d.iso, tpl)
|
||
// PERSISTE le quart tout de suite (sinon addShift reste local/Proposé → disparaît au rechargement, et le tech « n'a aucun quart »).
|
||
try { await roster.createShift({ tech: t.id, tech_name: t.name, date: d.iso, shift: tpl.name, zone: tpl.zone || '', hours: tpl.hours || 8 }) } catch (e) { err(e) }
|
||
}
|
||
const done = await assignNames(dropAsk.names, t, d)
|
||
dropAsk.open = false
|
||
notifyAssignUndo(done, t, d, tpl ? tpl.name : null) // « Annuler » → désassigne + retire le quart créé
|
||
}
|
||
function dropAskDays () {
|
||
if (dropAsk.scope === 'day') return [dropAsk.day.iso]
|
||
if (dropAsk.scope === 'week') { const m = mondayISO(dropAsk.day.iso); return Array.from({ length: 7 }, (_, i) => addDaysISO(m, i)) }
|
||
const out = []; let dd = dropAsk.from; let g = 0; while (dd && dropAsk.to && dd <= dropAsk.to && g++ < 400) { out.push(dd); dd = addDaysISO(dd, 1) }; return out
|
||
}
|
||
async function dropAskAbsence () {
|
||
const t = dropAsk.tech; const days = dropAskDays(); if (!days.length) { $q.notify({ type: 'warning', message: 'Plage de dates invalide.' }); return }
|
||
for (const dd of days) { try { await roster.setAbsence(t.id, dd, dropAsk.absType || 'Congé', false) } catch (e) { err(e) } } // séquentiel (frappe_pg)
|
||
await reloadAbsences(); await reloadOccupancy()
|
||
const retour = addDaysISO(days[days.length - 1], 1)
|
||
const noms = dropAsk.names.slice(); dropAsk.open = false
|
||
$q.notify({ type: 'warning', icon: 'event_busy', message: `${t.name} absent ${days.length} j (${dropAsk.absType}) · retour le ${retour}. ${noms.length} job(s) laissé(s) au pool.`, timeout: 8000, actions: [{ label: 'Assigner à un autre tech', color: 'white', handler: () => openAssignPanel() }] })
|
||
await checkAbsenceImpact(days.map(dd => t.id + '|' + dd)) // redistribue les jobs DÉJÀ assignés sur ces jours (dialogue existant)
|
||
}
|
||
// ── Absence sur PLAGE (comme l'ancien dispatch) : défaut = jour affiché ; options « ce jour / cette semaine / plage du..au ». ──
|
||
const absDialog = reactive({ open: false, techId: '', techName: '', from: '', to: '' })
|
||
function openAbsDialog () {
|
||
if (!menu.tech || !menu.day) return
|
||
absDialog.techId = menu.tech.id; absDialog.techName = menu.tech.name
|
||
absDialog.from = menu.day.iso; absDialog.to = menu.day.iso; absDialog.open = true; menu.show = false
|
||
}
|
||
function absWeek () { const m = mondayISO(absDialog.from || todayISO()); absDialog.from = m; absDialog.to = addDaysISO(m, 6) }
|
||
function absDays () { const out = []; let d = absDialog.from; if (!d || !absDialog.to || absDialog.to < d) return d ? [d] : out; let g = 0; while (d <= absDialog.to && g++ < 400) { out.push(d); d = addDaysISO(d, 1) }; return out }
|
||
async function applyAbs (remove) {
|
||
const days = absDays(); if (!days.length || !absDialog.techId) return
|
||
for (const d of days) { try { await roster.setAbsence(absDialog.techId, d, 'Congé', remove) } catch (e) { err(e) } } // séquentiel (frappe_pg)
|
||
await reloadAbsences(); await reloadOccupancy()
|
||
$q.notify({ type: 'info', message: remove ? ('Absence retirée (' + days.length + ' j)') : (days.length + ' jour(s) marqué(s) absent') })
|
||
absDialog.open = false
|
||
if (!remove) await checkAbsenceImpact(days.map(d => absDialog.techId + '|' + d))
|
||
}
|
||
|
||
// ── Mini-carte du volet détails : zoome sur le job + layer « tracé GPS réel » (Traccar) en option ──
|
||
const jdMapEl = ref(null); let _jdMap = null
|
||
const jdShowTrack = ref(false); const jdTrackMsg = ref('')
|
||
// Toggle Traccar disponible UNIQUEMENT aujourd'hui ou hier (ET) — sinon Traccar trop lourd/lent.
|
||
const kbCanTrack = computed(() => {
|
||
const iso = kanbanDay.value && kanbanDay.value.iso; const n = nowET.value.iso; if (!iso || !n) return false
|
||
const yest = new Date(Date.parse(n + 'T12:00:00Z') - 86400000).toISOString().slice(0, 10)
|
||
return iso === n || iso === yest
|
||
})
|
||
// Autres jobs (pool + occupation) À PROXIMITÉ (~3 km) du job affiché → points PÂLES sur la carte miniature pour le contexte.
|
||
function jdNearby () {
|
||
const la = jobDetail.lat, lo = jobDetail.lon
|
||
if (!isFinite(la) || !isFinite(lo)) return []
|
||
const out = []; const seen = new Set()
|
||
const add = (lat, lon, subject, name) => {
|
||
lat = +lat; lon = +lon
|
||
if (!isFinite(lat) || !isFinite(lon) || Math.abs(lat) < 0.01) return
|
||
if (name) { if (seen.has(name)) return; seen.add(name) }
|
||
if (name && name === jobDetail.name) return
|
||
if (Math.abs(lat - la) < 1e-5 && Math.abs(lon - lo) < 1e-5) return // même point que le job affiché
|
||
if (haversineKm(la, lo, lat, lon) > 3) return
|
||
out.push([lon, lat, subject || ''])
|
||
}
|
||
for (const j of (assignPanel.jobs || [])) add(j.latitude, j.longitude, j.subject, j.name)
|
||
const occ = occByTechDay.value || {}
|
||
for (const k in occ) for (const j of ((occ[k] || {}).jobs || [])) if (!j.cancelled) add(j.lat, j.lon, j.subject, j.name)
|
||
return out.slice(0, 80)
|
||
}
|
||
async function initJdMap () {
|
||
if (!MAPBOX_TOKEN || !jdMapEl.value) return
|
||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !jdMapEl.value) return
|
||
if (_jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null }
|
||
const lat = jobDetail.lat, lon = jobDetail.lon; const hasLL = isFinite(lat) && isFinite(lon) && (lat || lon)
|
||
_jdMap = new mapboxgl.Map({ container: jdMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: hasLL ? [lon, lat] : [-73.6756, 45.1599], zoom: hasLL ? 14 : 9 })
|
||
_jdMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
|
||
_jdMap.on('load', () => {
|
||
_jdMap.resize()
|
||
// Autres jobs à proximité = points PÂLES (contexte), ajoutés AVANT le marqueur du job → celui-ci reste au-dessus.
|
||
if (hasLL) {
|
||
_jdMap.addSource('jd-near', { type: 'geojson', data: { type: 'FeatureCollection', features: jdNearby().map(p => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [p[0], p[1]] }, properties: { t: p[2] } })) } })
|
||
_jdMap.addLayer({ id: 'jd-near', type: 'circle', source: 'jd-near', paint: { 'circle-radius': 5, 'circle-color': '#94a3b8', 'circle-opacity': 0.45, 'circle-stroke-color': '#fff', 'circle-stroke-width': 1 } })
|
||
_jdMap.on('mouseenter', 'jd-near', () => { _jdMap.getCanvas().style.cursor = 'pointer' })
|
||
_jdMap.on('mouseleave', 'jd-near', () => { _jdMap.getCanvas().style.cursor = '' })
|
||
_jdMap.on('click', 'jd-near', (e) => { const p = e.features[0].properties; new mapboxgl.Popup({ offset: 10 }).setLngLat(e.features[0].geometry.coordinates).setHTML('<div style="font-size:11px;color:#475569">' + String(p.t || '').replace(/[<>&]/g, '') + '</div>').addTo(_jdMap) })
|
||
}
|
||
if (hasLL) new mapboxgl.Marker({ color: '#1976d2' }).setLngLat([lon, lat]).addTo(_jdMap) // le point du job (au-dessus des points pâles)
|
||
_jdMap.addSource('jd-track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||
_jdMap.addLayer({ id: 'jd-track-halo', type: 'line', source: 'jd-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ff6f00', 'line-width': 8, 'line-opacity': 0.22 } })
|
||
_jdMap.addLayer({ id: 'jd-track-l', type: 'line', source: 'jd-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ef6c00', 'line-width': 3, 'line-opacity': 0.78 } })
|
||
if (jdShowTrack.value) loadJdTrack()
|
||
})
|
||
}
|
||
async function loadJdTrack () {
|
||
jdTrackMsg.value = '…'
|
||
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso || !jobDetail.techId || !_jdMap) { jdTrackMsg.value = ''; return }
|
||
const from = new Date(iso + 'T00:00:00').toISOString()
|
||
const to = (iso === nowET.value.iso) ? new Date().toISOString() : new Date(iso + 'T23:59:59').toISOString()
|
||
try {
|
||
const r = await roster.traccarTrack(jobDetail.techId, from, to); const coords = (r && r.coords) || []
|
||
const src = _jdMap.getSource('jd-track')
|
||
if (src) src.setData(coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} } : { type: 'FeatureCollection', features: [] })
|
||
jdTrackMsg.value = coords.length >= 2 ? (coords.length + ' points GPS') : 'Aucun tracé ce jour (ou pas d\'appareil).'
|
||
} catch (e) { jdTrackMsg.value = 'Tracé indisponible' }
|
||
}
|
||
watch(jdShowTrack, (on) => { if (!_jdMap) return; if (on) loadJdTrack(); else { const s = _jdMap.getSource('jd-track'); if (s) s.setData({ type: 'FeatureCollection', features: [] }); jdTrackMsg.value = '' } })
|
||
watch(() => jobDetail.open, (open) => { if (open) nextTick(() => setTimeout(initJdMap, 280)); else if (_jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null } })
|
||
|
||
const tplOptions = computed(() => templates.value.map(t => ({ label: t.template_name, value: t.name })))
|
||
const techOptions = computed(() => techs.value.map(t => ({ label: t.name, value: t.id })))
|
||
const tplByName = computed(() => Object.fromEntries(templates.value.map(t => [t.name, t])))
|
||
// ── CALQUE de garde LIVE ──────────────────────────────────────────────────
|
||
// La garde n'est PAS matérialisée pour l'affichage : on la recalcule à la volée depuis les règles
|
||
// (rotation déterministe ancrée + saut d'absent, via rotationTech). Éditer la séquence ou marquer
|
||
// une absence se reflète INSTANTANÉMENT, sans régénérer → fini la désync « la suite est bousillée ».
|
||
// Clé techId|iso → nom du shift de garde (semaine vs week-end). Vacances ⇒ substitut auto.
|
||
const gardeOverlay = computed(() => {
|
||
const map = {}; const rules = gardeRules.value; if (!rules.length) return map
|
||
for (const d of dayList.value) {
|
||
const dow = dowOf(d.iso); const weekend = (dow === 0 || dow === 6)
|
||
for (const rule of rules) {
|
||
if (!(rule.weekdays || []).includes(dow)) continue
|
||
const sh = (weekend && rule.shiftWeekend) ? rule.shiftWeekend : rule.shift
|
||
if (!tplByName.value[sh]) continue
|
||
const id = rotationTech(rule, d.iso); if (!id) continue
|
||
map[id + '|' + d.iso] = sh // si 2 règles visent le même tech/jour, la dernière gagne (rare)
|
||
}
|
||
}
|
||
return map
|
||
})
|
||
// Shift de garde à utiliser pour un ajout MANUEL un jour donné : celui des règles (semaine/WE), sinon 1er modèle on_call.
|
||
function gardeShiftForDay (iso) {
|
||
const dow = dowOf(iso); const weekend = (dow === 0 || dow === 6)
|
||
for (const rule of gardeRules.value) { if (!(rule.weekdays || []).includes(dow)) continue; const sh = (weekend && rule.shiftWeekend) ? rule.shiftWeekend : rule.shift; if (tplByName.value[sh]) return sh }
|
||
const oc = templates.value.find(t => t.on_call); return oc ? oc.name : null
|
||
}
|
||
// Garde EFFECTIVE = rotation (gardeOverlay) + overrides MANUELS (touche « G »/menu, localStorage) :
|
||
// manualGarde[key]='on' → ajoute la garde (shift du jour) ; 'off' → la retire (override d'une garde de règle).
|
||
// Permet de DÉPLACER la garde à la main (tech en congé → la placer ailleurs) SANS toucher aux règles.
|
||
const gardeEffective = computed(() => {
|
||
const base = { ...gardeOverlay.value }
|
||
for (const key in manualGarde.value) {
|
||
const v = manualGarde.value[key]
|
||
if (v === 'off') delete base[key]
|
||
else if (v === 'on') { const sh = gardeShiftForDay(key.split('|')[1]); if (sh) base[key] = sh }
|
||
}
|
||
return base
|
||
})
|
||
function onGarde (techId, iso) { return !!gardeEffective.value[techId + '|' + iso] }
|
||
// Nb de techs de garde par jour (garde EFFECTIVE) → ligne de pied cohérente avec la grille
|
||
const gardeCountByDate = computed(() => { const m = {}; for (const k in gardeEffective.value) { const iso = k.split('|')[1]; m[iso] = (m[iso] || 0) + 1 } return m })
|
||
function chip (color) { return { background: color || '#1976d2', color: '#fff' } }
|
||
|
||
// techs visibles (recherche + groupe + tri)
|
||
const groupOptions = computed(() => { const s = new Set(); for (const t of techs.value) if (t.group) s.add(t.group); return [...s].sort().map(g => ({ label: g, value: g })) })
|
||
// Compétences distinctes (chips de filtrage) + score de PRIORITÉ provisoire.
|
||
const allSkills = computed(() => [...new Set(techs.value.flatMap(t => t.skills || []))].sort())
|
||
// Édition des compétences dans la cellule du nom (popover) + suggestions ALIGNÉES sur les catégories de job.
|
||
const jobTypes = ref([]) // service_types distincts (catégories de job) → suggérés comme compétences
|
||
const skillByType = ref({}) // type de job → compétence requise déduite (politique booking) — sert au repli reqSkill
|
||
const skillDialog = ref(null) // tech dont on édite les compétences
|
||
const skillMenuTarget = ref(null) // élément cliqué = ancre du popover (près de la souris, sur la rangée)
|
||
const skillMenuShown = ref(false)
|
||
function openSkillEditor (t, ev) { skillDialog.value = t; skillMenuTarget.value = (ev && ev.currentTarget) || null; skillMenuShown.value = true }
|
||
function openSkillEditorFor (techId, ev) { const t = (visibleTechs.value || []).find(x => x.id === techId); if (t) openSkillEditor(t, ev) } // depuis la revue de l'optimiseur
|
||
// TagEditor (showLevel) ↔ tech : skills = libellés (CSV) · skill_levels = {compétence: niveau 1–5} (JSON).
|
||
// Par compétence : SCORE (maîtrise 1–5, qualité) + EFFICACITÉ (facteur vitesse ; défaut = efficacité globale).
|
||
function skillLevelOf (t, sk) { return (t.skill_levels && t.skill_levels[sk]) || 0 } // 0 = non défini (étoiles vides) ; pas de défaut trompeur
|
||
// Applique un horaire standard à CE tech sur la semaine affichée (presets type Dispatch).
|
||
async function applyWeekPreset (t, dows, min, max) {
|
||
const tpl = await ensureWindowTpl(min, max); if (!tpl) return
|
||
pushHistory()
|
||
for (const d of dayList.value) { if (dows.includes(dowOf(d.iso))) { clearLocal(t.id, d.iso); addShift(t.id, t.name, d.iso, tpl) } }
|
||
skillMenuShown.value = false
|
||
$q.notify({ type: 'positive', message: t.name + ' : horaire appliqué (semaine affichée) — pense à Publier', timeout: 2500 })
|
||
}
|
||
// ── Génération de quarts HEBDO par tech (modèles + N semaines) — écrit DIRECTEMENT les Shift Assignment (Publié). ──
|
||
const schedGenOpen = ref(false); const schedGenTechs = ref([])
|
||
function openSchedGen (t) { schedGenTechs.value = [{ id: t.id, name: t.name }]; skillMenuShown.value = false; schedGenOpen.value = true } // 1 tech
|
||
function openSchedGenBulk () { schedGenTechs.value = (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name })); schedGenOpen.value = true } // LOT : tous les techs visibles
|
||
const _hmNum = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 }
|
||
async function onScheduleApply ({ schedule, weeks, techIds, mode }) {
|
||
const targets = (schedGenTechs.value || []).filter(t => (techIds || []).includes(t.id)); if (!targets.length) return
|
||
// ── RÉCURRENT : le patron devient la SOURCE (weekly_schedule) → matérialisation auto (fériés/vacances sautés, manuels préservés) ──
|
||
if (mode === 'recurring') {
|
||
const patt = {}; for (const d of schedule) patt[d.dow] = d.on ? { start: d.start, end: d.end } : null
|
||
let okT = 0
|
||
for (const t of targets) { try { const r = await roster.setWeeklySchedule(t.id, patt); if (r && r.ok) okT++ } catch (e) {} }
|
||
let mat = null; try { mat = await roster.materializeShifts({ weeks }) } catch (e) { err(e) }
|
||
try { await loadWeek() } catch (e) {}
|
||
$q.notify({ type: okT ? 'positive' : 'warning', icon: 'event_repeat', message: 'Horaire récurrent défini · ' + okT + ' tech' + (mat ? ' → ' + mat.created + ' quart(s) sur ' + weeks + ' sem.' + (mat.skipped_holiday ? ' (' + mat.skipped_holiday + ' férié(s) sauté(s))' : '') : ''), timeout: 4800 })
|
||
schedGenTechs.value = []
|
||
return
|
||
}
|
||
// ── EXCEPTION : override ponctuel (source='manuel', préservé par la matérialisation) sur N semaines dès la semaine affichée ──
|
||
let created = 0, failed = 0; const tplByKey = {}
|
||
const base = start.value // lundi de la semaine affichée
|
||
for (const t of targets) {
|
||
for (let w = 0; w < weeks; w++) {
|
||
for (let i = 0; i < 7; i++) {
|
||
const day = schedule[i]; if (!day || !day.on) continue
|
||
const sh = _hmNum(day.start), eh = _hmNum(day.end); if (!(eh > sh)) { failed++; continue }
|
||
const key = sh + '-' + eh; let tpl = tplByKey[key]
|
||
if (!tpl) { tpl = await ensureWindowTpl(sh, eh); tplByKey[key] = tpl }
|
||
if (!tpl) { failed++; continue }
|
||
const iso = addDaysISO(base, w * 7 + i)
|
||
try { const r = await roster.createShift({ tech: t.id, tech_name: t.name, date: iso, shift: tpl.name, hours: tpl.hours || calcHours(day.start, day.end) }); if (r && (r.ok || r.existed)) created++; else failed++ } catch (e) { failed++ }
|
||
}
|
||
}
|
||
}
|
||
try { await loadWeek() } catch (e) {} // rafraîchit la grille (semaine affichée)
|
||
$q.notify({ type: created ? 'positive' : 'warning', icon: 'event_available', message: created + ' quart(s) publié(s) · ' + targets.length + ' tech' + (weeks > 1 ? ' (' + weeks + ' sem.)' : '') + (failed ? ' · ' + failed + ' ignoré(s)' : ''), timeout: 4000 })
|
||
schedGenTechs.value = []
|
||
}
|
||
function skillEffOf (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? Number(e) : (Number(t.efficiency) || 1) } // facteur (pour le calcul de priorité)
|
||
// Efficacité saisie en % de PERFORMANCE : + = plus VITE (moins de temps) · − = plus LENT. Conversion ↔ facteur.
|
||
function effPctOf (factor) { return Math.round((1 - (Number(factor) || 1)) * 100) }
|
||
function factorFromPct (pct) { const f = 1 - (Number(pct) || 0) / 100; return Math.max(0.1, Math.min(3, Math.round(f * 100) / 100)) }
|
||
function skillEffPct (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? effPctOf(e) : '' } // '' = hérite de la globale
|
||
function setSkillEffPct (t, sk, pct) { const m = { ...(t.skill_eff || {}) }; if (pct === '' || pct == null) delete m[sk]; else m[sk] = factorFromPct(pct); t.skill_eff = m; queueSkillSave(t) } // libre (ex. +80)
|
||
function setSkillLevel (t, sk, v) { if (!t.skill_levels) t.skill_levels = {}; t.skill_levels = { ...t.skill_levels, [sk]: v }; queueSkillSave(t) }
|
||
// Efficacité GLOBALE du tech, éditable/réinitialisable depuis le popover (débouncée via setTechEfficiency).
|
||
// Couleur du cercle (vitesse PAR compétence) : vite → vert · normal → bleu-gris · lent → rouge. Intensité ∝ écart.
|
||
function effColor (factor) { const p = effPctOf(factor); if (!p) return '#607d8b'; const a = Math.min(Math.abs(p), 30) / 30; const hue = p > 0 ? 122 : 4; return 'hsl(' + hue + ',' + Math.round(50 + a * 28) + '%,' + (44 - Math.round(a * 8)) + '%)' }
|
||
function skillEffColor (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e == null || e === '') ? '#607d8b' : effColor(Number(e)) } // neutre si pas d'override (pure par-compétence)
|
||
// Icône Material (offline, jeu Quasar par défaut) représentant une compétence/type — par mots-clés, repli 'bolt'.
|
||
// Icône de compétence pour q-icon (accepte les SYMBOLES SVG importés) : échelle=installation, casque=support,
|
||
// outils=réparation ; sinon ligature classique unique (skillIcon). NB: les pins carte (HTML brut) gardent skillIcon.
|
||
function tSkills (t) { return [...(t.skills || [])].sort((a, b) => a.localeCompare(b)) } // compétences triées du tech (pour l'affichage 1 + « +N »)
|
||
function skillSym (sk) {
|
||
const s = String(sk || '').toLowerCase()
|
||
if (/t[ée]l[ée]vis|\btv\b|iptv|t[ée]l[ée]\b/.test(s)) return 'live_tv' // télé/TV → TV — AVANT « install » (« Install/Reparation Télé » contient « install »)
|
||
if (/install/.test(s)) return symOutlinedToolsLadder
|
||
if (/support|service.?client|t[ée]l[ée]assist|\baide\b/.test(s)) return symOutlinedHeadsetMic
|
||
return skillIcon(sk) // réparation → 'build' (clé simple / wrench) via skillIcon ; autres → ligature unique
|
||
}
|
||
// Icône d'un bloc de tournée (vue JOUR et SEMAINE) : renfort → group ; sinon icône de COMPÉTENCE
|
||
// (skill/required_skill, ou dept pour les tickets legacy → ex. « Install/Reparation Télé » = TV).
|
||
// Le ticket générique n'est utilisé qu'en dernier recours (legacy sans skill ni dept).
|
||
function blkIcon (b) {
|
||
if (b && b.done) return 'check'
|
||
if (b && b.assist) return 'group'
|
||
const sk = b && (b.skill || b.required_skill || b.dept)
|
||
if (sk) return skillSym(sk)
|
||
return (b && b.legacy) ? 'confirmation_number' : skillSym('')
|
||
}
|
||
// Camion-nacelle (bucket truck) pour « monteur » — Material Symbols n'a pas d'équivalent → SVG custom au format
|
||
// q-icon Quasar (chemins séparés par && ; chaque chemin = d@@style). Aplats (carrosserie/roues/nacelle, roues
|
||
// évidées en evenodd) + bras articulé en trait épais. Hérite couleur (currentColor) et taille. viewBox 512×416.
|
||
const BUCKET_TRUCK = 'M120,300 H440 V322 H120 Z M120,276 H310 V300 H120 Z M206,226 H280 V300 H206 Z M56,64 H160 V144 H56 Z@@fill:currentColor'
|
||
+ '&&M300,300 V250 L322,222 H408 L438,260 V300 Z M330,252 H396 V284 H330 Z@@fill:currentColor;fill-rule:evenodd'
|
||
+ '&&M144,356 a40,40 0 1,0 80,0 a40,40 0 1,0 -80,0 Z M170,356 a14,14 0 1,1 28,0 a14,14 0 1,1 -28,0 Z M352,356 a40,40 0 1,0 80,0 a40,40 0 1,0 -80,0 Z M378,356 a14,14 0 1,1 28,0 a14,14 0 1,1 -28,0 Z@@fill:currentColor;fill-rule:evenodd'
|
||
+ '&&M243,250 L384,150 L120,104@@fill:none;stroke:currentColor;stroke-width:30;stroke-linecap:round;stroke-linejoin:round'
|
||
+ '|0 0 512 416'
|
||
function skillIcon (sk) {
|
||
const s = String(sk || '').toLowerCase()
|
||
if (/install/.test(s)) return 'construction'
|
||
if (/r[ée]par|d[ée]pann|bris/.test(s)) return 'build'
|
||
if (/fibre|fusion|soud|épissure|epissure/.test(s)) return 'cable'
|
||
if (/t[ée]l[ée]vis|\btv\b|iptv|t[ée]l[ée]\b/.test(s)) return 'live_tv'
|
||
if (/t[ée]l[ée]phon|voip|\b3cx\b/.test(s)) return 'call'
|
||
if (/r[ée]seau|net\s?admin|router|bgp|olt|acs/.test(s)) return 'dns'
|
||
if (/\bwifi\b|wi-?fi|mesh/.test(s)) return 'wifi' // wifi MAISON (couverture interne) → symbole wifi simple
|
||
if (/sans.?fil|wireless|\bfwa\b|\blte\b|radio|antenne|\btour\b|micro.?onde|point.?[àa].?point|\bptp\b|liaison/.test(s)) return 'cell_tower' // sans fil = PtP inter-bâtiments/tours → tour
|
||
if (/monteur|poteau|hauteur|nacelle|a[ée]rien|grimp/.test(s)) return BUCKET_TRUCK // monteur / aérien → camion-nacelle
|
||
if (/d[ée]sinstall|retrait|ramass|d[ée]mant/.test(s)) return 'delete_sweep'
|
||
if (/info|ordinateur|\bpc\b|cam[ée]ra/.test(s)) return 'computer'
|
||
if (/vente|sales|soumission/.test(s)) return 'sell'
|
||
if (/factur|paiement|compta/.test(s)) return 'receipt_long'
|
||
return 'bolt'
|
||
}
|
||
// Icône de compétence pour un MARQUEUR carte (DOM, police material-icons) : UNIQUEMENT des ligatures material (pas les SVG
|
||
// custom de skillSym) — sinon le glyphe ne s'affiche pas dans un <span class="material-icons"> hors q-icon.
|
||
function markerIcon (skill) {
|
||
const s = String(skill || '').toLowerCase()
|
||
if (/t[ée]l[ée]vis|\btv\b|iptv/.test(s)) return 'live_tv'
|
||
if (/install/.test(s)) return 'construction'
|
||
if (/monteur|poteau|hauteur|nacelle|a[ée]rien|grimp/.test(s)) return 'local_shipping'
|
||
const ic = skillIcon(skill)
|
||
return (typeof ic === 'string' && /^[a-z0-9_]+$/.test(ic)) ? ic : 'build'
|
||
}
|
||
function onTagsChange (t, items) {
|
||
const newLabels = (items || []).map(x => typeof x === 'string' ? x : x.tag).filter(Boolean)
|
||
const removed = (t.skills || []).filter(s => !newLabels.includes(s)) // compétences retirées → vérifier l'impact sur les jobs assignés
|
||
t.skills = newLabels; queueSkillSave(t)
|
||
if (removed.length) checkSkillImpact(t, removed)
|
||
}
|
||
// IROPS : un retrait de compétence peut invalider des jobs assignés qui l'exigent → proposer de redistribuer.
|
||
const skillImpactDialog = ref(null) // { tech, skill, jobs }
|
||
const skillImpactOpen = computed({ get: () => !!skillImpactDialog.value, set: v => { if (!v) skillImpactDialog.value = null } })
|
||
const redistributing = ref(false)
|
||
const impactCandidates = reactive({}) // jobName → [{tech,tech_name}] candidats classés
|
||
const impactPlan = reactive({}) // jobName → techId choisi | '__requeue'
|
||
const loadingCandidates = ref(false)
|
||
function candidateOptions (jobName) { return [...((impactCandidates[jobName] || []).map(x => ({ label: x.tech_name || x.tech, value: x.tech }))), { label: '→ À recontacter (client)', value: '__requeue' }] }
|
||
async function loadImpactCandidates () { // candidats classés par job + pré-sélection du meilleur
|
||
const d = skillImpactDialog.value; if (!d) return
|
||
loadingCandidates.value = true
|
||
for (const k in impactCandidates) delete impactCandidates[k]; for (const k in impactPlan) delete impactPlan[k]
|
||
for (const j of d.jobs) {
|
||
try { const r = await roster.jobCandidates(j.name, d.tech.id); impactCandidates[j.name] = r.candidates || []; impactPlan[j.name] = (r.candidates && r.candidates[0]) ? r.candidates[0].tech : '__requeue' }
|
||
catch (e) { impactCandidates[j.name] = []; impactPlan[j.name] = '__requeue' }
|
||
}
|
||
loadingCandidates.value = false
|
||
}
|
||
async function checkSkillImpact (t, removedSkills) {
|
||
for (const sk of removedSkills) {
|
||
try { const r = await roster.skillImpact(t.id, sk); if (r.jobs && r.jobs.length) { skillImpactDialog.value = { tech: t, kind: 'skill', skill: sk, jobs: r.jobs }; await loadImpactCandidates(); return } } catch (e) { /* non bloquant */ }
|
||
}
|
||
}
|
||
// Même principe pour une ABSENCE : jobs assignés tombant sur les jours d'absence → dialogue de redistribution.
|
||
async function checkAbsenceImpact (targets) {
|
||
const byTech = {}; for (const k of targets) { const [tid, iso] = k.split('|'); (byTech[tid] = byTech[tid] || []).push(iso) }
|
||
for (const tid in byTech) {
|
||
try { const r = await roster.absenceImpact(tid, byTech[tid]); if (r.jobs && r.jobs.length) { const t = techs.value.find(x => x.id === tid) || { id: tid, name: tid }; skillImpactDialog.value = { tech: t, kind: 'absence', jobs: r.jobs }; await loadImpactCandidates(); return } } catch (e) { /* non bloquant */ }
|
||
}
|
||
}
|
||
async function applyImpactPlan () { // applique le plan choisi (tech par job) puis RECHARGE (occupation à jour)
|
||
const d = skillImpactDialog.value; if (!d) return
|
||
redistributing.value = true
|
||
const plan = d.jobs.map(j => (!impactPlan[j.name] || impactPlan[j.name] === '__requeue') ? { job: j.name, requeue: true } : { job: j.name, tech: impactPlan[j.name] })
|
||
try { const r = await roster.redistributePlan(plan); $q.notify({ type: 'positive', message: (r.reassigned || 0) + ' réassigné(s) · ' + (r.requeued || 0) + ' à recontacter', timeout: 4500 }); skillImpactDialog.value = null; await loadWeek() }
|
||
catch (e) { err(e) } finally { redistributing.value = false }
|
||
}
|
||
async function doRedistribute (mode) { // « Tout à recontacter » (bascule simple)
|
||
const d = skillImpactDialog.value; if (!d) return
|
||
redistributing.value = true
|
||
try {
|
||
const r = await roster.redistributeSkillJobs(d.jobs.map(j => j.name), d.kind === 'skill' ? d.skill : '', mode) // absence → compétence par job côté hub
|
||
$q.notify({ type: 'positive', message: (r.reassigned || 0) + ' réassigné(s) · ' + (r.requeued || 0) + ' à recontacter', timeout: 4500 })
|
||
skillImpactDialog.value = null; await loadWeek() // refresh : occupation/jobs à jour (fix bar 4h résiduelle)
|
||
} catch (e) { err(e) } finally { redistributing.value = false }
|
||
}
|
||
// ── Panneau FLOTTANT « jobs à assigner » (multi-sélection + glisser-déposer + aperçu d'occupation) ──
|
||
const assignPanel = reactive({ open: false, x: 40, y: 130, w: 340, h: 560, jobs: [], loading: false, showMap: false, locating: false })
|
||
const draggingJobName = ref(null); const dropCell = ref(null); const dragHours = ref(0)
|
||
const selectedJobs = reactive({}) // jobName → true
|
||
const dropPreview = reactive({ key: null, addH: 0 })
|
||
const draggingSet = reactive(new Set()); let _dragGhost = null // jobs en cours de glissé (source estompée) + fantôme custom
|
||
async function openAssignPanel () { assignPanel.open = true; assignPanel.loading = true; for (const k in selectedJobs) delete selectedJobs[k]; try { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } catch (e) { err(e) } finally { assignPanel.loading = false } }
|
||
|
||
// Write-back legacy : aperçu (dryRun, 0 écriture) → confirmation → écrit ticket.assign_to dans osTicket.
|
||
const legacyPush = reactive({ open: false, loading: false, applying: false, preview: null, notify: true })
|
||
async function openLegacyPush () { legacyPush.open = true; legacyPush.loading = true; legacyPush.preview = null; try { legacyPush.preview = await roster.pushLegacyPreview() } catch (e) { err(e) } finally { legacyPush.loading = false } }
|
||
async function applyLegacyPush () { legacyPush.applying = true; try { const r = await roster.pushLegacyApply(legacyPush.notify); $q.notify({ type: r.errors ? 'warning' : 'positive', message: `Legacy : ${r.pushed} ticket(s) réassigné(s)` + (legacyPush.notify && r.notified != null ? ` · ${r.notified} avisé(s) par courriel (boîte du tech)` : '') + (r.mismatch ? ` · ${r.mismatch} ignoré(s) (nom ≠)` : '') + (r.errors ? ` · ${r.errors} erreur(s)` : '') }); if (legacyPush.notify && r.notify_errors && r.notify_errors.length) { $q.notify({ type: 'warning', icon: 'mark_email_unread', message: `⚠ ${r.notify_errors.length} avis NON envoyé(s) — ${(r.notify_errors[0] && r.notify_errors[0].error) || ''}`, timeout: 6000 }) } legacyPush.open = false } catch (e) { err(e) } finally { legacyPush.applying = false } }
|
||
// X sur une ligne de l'aperçu → désassigne le job dans Ops (retour au pool) → l'exclut du push, puis rafraîchit l'aperçu + la grille.
|
||
async function removeFromPush (s) {
|
||
if (!s || !s.job) { $q.notify({ type: 'warning', message: 'Job introuvable' }); return }
|
||
legacyPush.applying = true
|
||
try {
|
||
await roster.unassignJobRoster(s.job)
|
||
await reloadOccupancy()
|
||
legacyPush.preview = await roster.pushLegacyPreview()
|
||
$q.notify({ type: 'info', message: 'Job désassigné (retour au pool) — exclu du legacy', timeout: 2200 })
|
||
} catch (e) { err(e) } finally { legacyPush.applying = false }
|
||
}
|
||
const assignSort = ref('date') // défaut = date DUE (facilite le dispatch : aujourd'hui/en retard d'abord). Autres : group | skill | city | priority
|
||
const assignSortDir = ref('asc') // sens du tri (asc/desc) — surtout pour la date
|
||
const ASSIGN_PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
|
||
function jobCity (j) {
|
||
if (j.municipalite) return j.municipalite // municipalité CANONIQUE résolue côté hub (regroupe Ste-Clotilde / ste-clotilde / … en 1 groupe)
|
||
const a = String(j.location_label || j.service_location || '')
|
||
const parts = a.split(',').map(s => s.trim()).filter(Boolean)
|
||
if (parts.length >= 2) return parts[parts.length - 1] // dernier segment d'adresse = ville
|
||
const subj = String(j.subject || ''); if (subj.includes('|')) return subj.split('|')[0].trim() // sujets legacy « Ville | Nom »
|
||
return parts[0] || 'Sans ville'
|
||
}
|
||
// Chips-filtres par compétence/type (installation, réparation, tv…) dans le panneau d'assignation.
|
||
const assignTypeFilter = ref([]) // types sélectionnés (vide = tous)
|
||
const assignTypes = computed(() => { const m = {}; for (const j of assignPanel.jobs) { const k = j.required_skill || 'autre'; m[k] = (m[k] || 0) + 1 } return Object.entries(m).map(([k, n]) => ({ k, n })).sort((a, b) => b.n - a.n) })
|
||
// Chips « Trouver un créneau » : 4 compétences principales (les + fréquentes du pool, repli liste cœur) + durée PAR DÉFAUT par skill.
|
||
const SLOT_SKILL_DUR = { installation: 2, 'réparation': 1, reparation: 1, tv: 1, 'télévision': 1, television: 1, 'téléphonie': 0.5, telephone: 0.5, 'téléphone': 0.5, 'sans-fil': 1.5, fibre: 1.5, support: 0.5 }
|
||
const slotSkills = computed(() => {
|
||
const top = (assignTypes.value || []).map(t => t.k).filter(k => k && k.toLowerCase() !== 'autre').slice(0, 4)
|
||
return (top.length ? top : ['installation', 'réparation', 'tv']).map(k => ({ skill: k, dur: SLOT_SKILL_DUR[k.toLowerCase()] || 1, color: getTagColor(k) }))
|
||
})
|
||
// Filtre par DATE DUE (chips) — « n'afficher que les jobs d'une date précise »
|
||
const assignDateFilter = ref([]) // isos retenus (vide = toutes les dates)
|
||
const assignDates = computed(() => {
|
||
const t = todayISO(); const m = {}
|
||
for (const j of assignPanel.jobs) { const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'; m[d] = (m[d] || 0) + 1 }
|
||
// Toutes les dates PASSÉES → UN SEUL chip « ⏰ en retard » (déclutter). Les jours futurs + « Sans date » restent séparés.
|
||
const out = []; let overdueN = 0
|
||
for (const [iso, n] of Object.entries(m)) {
|
||
if (iso !== 'Sans date' && iso < t) { overdueN += n; continue }
|
||
out.push({ iso, n, label: iso === 'Sans date' ? 'Sans date' : fmtDueLabel(iso), overdue: false })
|
||
}
|
||
out.sort((a, b) => String(a.iso).localeCompare(String(b.iso)))
|
||
if (overdueN) out.unshift({ iso: '__overdue__', n: overdueN, label: '⏰ en retard', overdue: true })
|
||
return out
|
||
})
|
||
function toggleAssignDate (iso) { const s = new Set(assignDateFilter.value); s.has(iso) ? s.delete(iso) : s.add(iso); assignDateFilter.value = [...s] }
|
||
// Depuis l'aperçu « jobs sur la colonne du jour » → ouvre le panneau « À assigner » filtré sur ce jour.
|
||
async function openDayInPanel (iso) { await openAssignPanel(); assignSort.value = 'date'; assignDateFilter.value = [iso] }
|
||
// Un job correspond-il aux CHIPS de date sélectionnées ? (vide = toutes). '__overdue__' = toutes dates passées.
|
||
// Partagé par le filtre d'affichage ET le dispatch auto (« Suggérer n'utilise que les jobs des jours cochés »).
|
||
function jobMatchesDateChips (j) {
|
||
const df = assignDateFilter.value; if (!df.length) return true
|
||
const ds = new Set(df); const t = todayISO()
|
||
const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'
|
||
return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t)
|
||
}
|
||
const assignJobsFiltered = computed(() => {
|
||
let arr = assignPanel.jobs
|
||
const f = assignTypeFilter.value; if (f.length) { const set = new Set(f); arr = arr.filter(j => set.has(j.required_skill || 'autre')) }
|
||
if (assignDateFilter.value.length) arr = arr.filter(jobMatchesDateChips)
|
||
return arr
|
||
})
|
||
function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) }
|
||
// Date DUE (≠ date de création) : étiquette relative pour grouper/afficher (En retard / Aujourd'hui / future).
|
||
function isOverdue (d) { return !!d && d !== 'Sans date' && d < todayISO() }
|
||
function fmtDueLabel (d) { if (!d || d === 'Sans date') return 'Sans date due'; const t = todayISO(); if (d < t) return d + ' ⏰ en retard'; if (d === t) return "Aujourd'hui"; return d }
|
||
// Couleur d'un segment de capacité (heures-tech libres restantes) : rouge=plein, orange=presque plein, vert=ok, gris=aucun quart.
|
||
function capSegClass (s) { if (!s || s.cap <= 0) return 'cap-none'; if (s.free <= 0) return 'cap-full'; if (s.free < 1.5) return 'cap-low'; return 'cap-ok' }
|
||
const capFmt = (h) => (h === 0 ? '0' : (Number.isInteger(h) ? String(h) : h.toFixed(1)))
|
||
const fmtMin = (m) => { m = Math.round(Number(m) || 0); const h = Math.floor(m / 60), mm = m % 60; return h ? (h + 'h' + (mm ? String(mm).padStart(2, '0') : '')) : (mm + 'min') }
|
||
const assignGroups = computed(() => {
|
||
const jobs = assignJobsFiltered.value
|
||
if (assignSort.value === 'group') { // défaut : groupe parent-enfant (installation avant activation…), ordonné par step_order
|
||
const g = {}; for (const j of jobs) { const k = j.parent_job || j.name; (g[k] = g[k] || []).push(j) }
|
||
return Object.keys(g).map(k => ({ key: k, label: null, jobs: g[k].slice().sort((a, b) => (a.step_order || 0) - (b.step_order || 0)) }))
|
||
}
|
||
const keyOf = j => assignSort.value === 'skill' ? (j.required_skill || 'Sans compétence')
|
||
: assignSort.value === 'city' ? jobCity(j)
|
||
: assignSort.value === 'priority' ? (j.priority || 'low')
|
||
: (j.scheduled_date || 'Sans date')
|
||
const labelOf = k => assignSort.value === 'priority' ? (({ urgent: '🔴 Urgent', high: '🟠 Élevée', medium: '🔵 Moyenne', low: '⚪ Basse' })[k] || k) : assignSort.value === 'date' ? '📅 ' + fmtDueLabel(k) : k
|
||
const g = {}; for (const j of jobs) { const k = keyOf(j); (g[k] = g[k] || []).push(j) }
|
||
const dir = assignSortDir.value === 'desc' ? -1 : 1
|
||
const keys = Object.keys(g).sort((a, b) => (assignSort.value === 'priority' ? (ASSIGN_PRIO[a] ?? 9) - (ASSIGN_PRIO[b] ?? 9) : a.localeCompare(b)) * dir)
|
||
return keys.map(k => ({ key: k, label: labelOf(k), jobs: g[k] }))
|
||
})
|
||
// Repli des groupes — « tout replier, n'afficher que certains jobs groupés par date »
|
||
const assignCollapsed = ref(new Set())
|
||
function toggleCollapse (key) { const s = new Set(assignCollapsed.value); s.has(key) ? s.delete(key) : s.add(key); assignCollapsed.value = s }
|
||
const allCollapsed = computed(() => { const gs = assignGroups.value; return gs.length > 0 && gs.every(g => assignCollapsed.value.has(g.key)) })
|
||
function toggleCollapseAll () { assignCollapsed.value = allCollapsed.value ? new Set() : new Set(assignGroups.value.map(g => g.key)) }
|
||
function toggleGroupSelAll (grp) { const anyOff = grp.jobs.some(j => !selectedJobs[j.name]); grp.jobs.forEach(j => { if (anyOff) selectedJobs[j.name] = true; else delete selectedJobs[j.name] }); if (assignPanel.showMap) refreshAssignMap() }
|
||
// Terrain vs à distance : l'activation / config / netadmin ne va PAS à un tech sur site (heuristique skill + type/sujet).
|
||
function jobIsOnsite (j) {
|
||
const txt = ((j.required_skill || '') + ' ' + (j.service_type || '') + ' ' + (j.subject || '')).toLowerCase()
|
||
if (/activation|config|netadmin|à distance|a distance|distant|remote|provision/.test(txt)) return false
|
||
return true
|
||
}
|
||
const selectedNames = computed(() => assignPanel.jobs.filter(j => selectedJobs[j.name]).map(j => j.name))
|
||
const jobHours = (j) => Number(j.duration_h) || 1 // défaut système = 1h (cf. hub)
|
||
const selectedHours = computed(() => Math.round(assignPanel.jobs.filter(j => selectedJobs[j.name]).reduce((s, j) => s + jobHours(j), 0) * 10) / 10)
|
||
function toggleGroupSel (grp) { const anyOnsiteOff = grp.jobs.some(j => jobIsOnsite(j) && !selectedJobs[j.name]); for (const j of grp.jobs) selectedJobs[j.name] = anyOnsiteOff ? jobIsOnsite(j) : false } // (dé)sélectionne le groupe ; pré-coche les terrain
|
||
function groupSelected (grp) { return grp.jobs.some(j => selectedJobs[j.name]) }
|
||
function onJobDragStart (ev, job) {
|
||
const names = selectedJobs[job.name] ? selectedNames.value : [job.name] // job non coché → on glisse juste lui
|
||
draggingJobName.value = names.join(','); dragHours.value = Math.round(assignPanel.jobs.filter(j => names.includes(j.name)).reduce((s, j) => s + jobHours(j), 0) * 10) / 10
|
||
draggingSet.clear(); names.forEach(n => draggingSet.add(n)) // source estompée (feedback)
|
||
try {
|
||
ev.dataTransfer.setData('text/plain', names.join(',')); ev.dataTransfer.effectAllowed = 'move'
|
||
// Fantôme COMPACT et semi-transparent décalé sous le curseur → ne masque plus le badge d'occupation projetée.
|
||
const g = document.createElement('div')
|
||
g.textContent = (names.length > 1 ? names.length + ' jobs · ' : '') + dragHours.value + 'h'
|
||
g.style.cssText = 'position:fixed;top:-1000px;left:-1000px;padding:2px 9px;background:rgba(94,53,177,.78);color:#fff;font:600 11px sans-serif;border-radius:10px;white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,.3)'
|
||
document.body.appendChild(g); _dragGhost = g
|
||
ev.dataTransfer.setDragImage(g, -12, -10) // curseur en haut-gauche du fantôme → fantôme bas-droite, badge (haut) lisible
|
||
} catch (e) {}
|
||
}
|
||
function onJobDragEnd () { dropCell.value = null; dropPreview.key = null; draggingSet.clear(); if (_dragGhost) { _dragGhost.remove(); _dragGhost = null } }
|
||
function onCellDragOver (t, d) { dropCell.value = t.id + '|' + d.iso; dropPreview.key = t.id + '|' + d.iso; dropPreview.addH = dragHours.value }
|
||
async function onCellDrop (ev, t, d, opts = {}) {
|
||
dropCell.value = null; dropPreview.key = null
|
||
const raw = (ev.dataTransfer && ev.dataTransfer.getData('text/plain')) || draggingJobName.value; draggingJobName.value = null
|
||
const names = (raw || '').split(',').filter(Boolean); if (!names.length) return []
|
||
// Garde-fou : un job « On Hold » attend une tâche précédente → on REFUSE de l'assigner (≠ simple 🔒 visuel).
|
||
const statusBy = Object.fromEntries(assignPanel.jobs.map(j => [j.name, j.status]))
|
||
const blocked = names.filter(n => statusBy[n] === 'On Hold'); const assignable = names.filter(n => statusBy[n] !== 'On Hold')
|
||
if (blocked.length) $q.notify({ type: 'warning', message: blocked.length + ' job(s) en attente d\'une tâche précédente — non assigné(s). Termine d\'abord l\'étape requise.', timeout: 4000 })
|
||
if (!assignable.length) return []
|
||
// Pas de quart RÉGULIER ce jour → on N'ASSIGNE PAS en douce : on demande quoi faire (créer un quart OU marquer absent).
|
||
const hasShift = hasShiftDay(t.id, d.iso)
|
||
// Le tech a-t-il DÉJÀ du travail ce jour (job assigné, occupation serveur) ? Si oui, ne pas re-demander un quart :
|
||
// le 1er job a pu créer un quart NON publié (perdu au rechargement de `assignments`), mais l'occupation persiste côté serveur.
|
||
const occ = occByTechDay.value[t.id + '|' + d.iso]
|
||
const hasWork = !!(occ && (((+occ.h) || 0) > 0.01 || (occ.jobs || []).length))
|
||
if (!hasShift && !hasWork && !opts.forceAssign) { openDropAsk(assignable, t, d); return [] }
|
||
const done = await assignNames(assignable, t, d)
|
||
if (!opts.silent) notifyAssignUndo(done, t, d, null) // toast avec « Annuler » (désassigne)
|
||
return done
|
||
}
|
||
// Boucle d'assignation serveur (séquentielle, frappe_pg) — partagée par le drop direct et le dialogue « créer un quart ».
|
||
async function assignNames (names, t, d) {
|
||
const done = []
|
||
// OPTIMISTE : créditer la charge sur la barre du tech IMMÉDIATEMENT (occByTechDay.h → usedH → barres), réconcilié par reloadOccupancy.
|
||
const key = t.id + '|' + d.iso
|
||
const addH = names.reduce((s, n) => { const j = assignPanel.jobs.find(x => x.name === n); return s + (j ? (j.est_min ? j.est_min / 60 : Number(j.duration_h || 1)) : 0) }, 0)
|
||
const cur = occByTechDay.value[key] || { h: 0, blocks: [], jobs: [] }
|
||
occByTechDay.value = { ...occByTechDay.value, [key]: { ...cur, h: (cur.h || 0) + addH, jobs: [...(cur.jobs || []), ...names] } }
|
||
for (const jn of names) { try { await roster.assignJob(jn, t.id, d.iso); done.push(jn); delete selectedJobs[jn] } catch (e) { err(e) } }
|
||
assignPanel.jobs = assignPanel.jobs.filter(j => !names.includes(j.name))
|
||
await reloadOccupancy()
|
||
return done
|
||
}
|
||
// Toast d'assignation AVEC bouton « Annuler » → désassigne le(s) job(s) + retire le quart auto-créé. Corrige : l'undo local ne touchait que les quarts, pas l'assignation serveur.
|
||
function notifyAssignUndo (names, t, d, autoShiftName) {
|
||
if (!names || !names.length) return
|
||
$q.notify({ type: 'positive', message: names.length + ' job(s) assigné(s) à ' + t.name + ' · ' + d.dnum + (autoShiftName ? ' · quart 8–16 créé' : ''), timeout: 6500, actions: [{ label: 'Annuler', color: 'white', handler: () => undoDrop(names, t, d, autoShiftName) }] })
|
||
}
|
||
async function undoDrop (names, t, d, autoShiftName) {
|
||
let ok = 0; for (const n of (names || [])) { try { await roster.unassignJobRoster(n); ok++ } catch (e) { err(e) } } // SÉQUENTIEL (frappe_pg)
|
||
if (autoShiftName) { pushHistory(); removeShift(t.id, d.iso, autoShiftName) } // retire aussi le quart auto-créé par le drop
|
||
await reloadOccupancy(); await reloadPool()
|
||
$q.notify({ type: 'info', message: 'Annulé — ' + ok + ' job(s) renvoyé(s) au pool' + (autoShiftName ? ' + quart retiré' : ''), timeout: 2600 })
|
||
}
|
||
let _panelDrag = null // déplacement du panneau via son en-tête
|
||
function panelHeaderDown (ev) { _panelDrag = { dx: ev.clientX - assignPanel.x, dy: ev.clientY - assignPanel.y }; document.addEventListener('mousemove', panelMove); document.addEventListener('mouseup', panelUp) }
|
||
function panelMove (ev) { if (!_panelDrag) return; assignPanel.x = Math.max(0, ev.clientX - _panelDrag.dx); assignPanel.y = Math.max(0, ev.clientY - _panelDrag.dy) }
|
||
function panelUp () { _panelDrag = null; document.removeEventListener('mousemove', panelMove); document.removeEventListener('mouseup', panelUp) }
|
||
|
||
// ── Panneau d'assignation : redimensionnement (poignée coin) ──
|
||
let _panelResize = null
|
||
function panelResizeDown (ev) { _panelResize = { x: ev.clientX, y: ev.clientY, w: assignPanel.w, h: assignPanel.h }; document.addEventListener('mousemove', panelResizeMove); document.addEventListener('mouseup', panelResizeUp); ev.preventDefault() }
|
||
function panelResizeMove (ev) { if (!_panelResize) return; assignPanel.w = Math.max(300, Math.round(_panelResize.w + (ev.clientX - _panelResize.x))); assignPanel.h = Math.max(320, Math.round(_panelResize.h + (ev.clientY - _panelResize.y))); if (_assignMap) _assignMap.resize() }
|
||
function panelResizeUp () { _panelResize = null; document.removeEventListener('mousemove', panelResizeMove); document.removeEventListener('mouseup', panelResizeUp) }
|
||
|
||
// ── Étiquettes de GROUPE par adresse de service : une adresse avec ≥2 jobs = groupe lettré (A, B…), rang interne (A1, A2…). Lie liste ↔ pins. ──
|
||
function _normAddr (s) { return String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, ' ').trim() }
|
||
function _colLetter (n) { let s = ''; n++; while (n > 0) { const m = (n - 1) % 26; s = String.fromCharCode(65 + m) + s; n = Math.floor((n - 1) / 26) } return s }
|
||
function _jobLocKey (j) { const a = _normAddr(j.address); if (a) return a; return j.service_location ? 'sl:' + j.service_location : '' }
|
||
// Localisations des jobs géolocalisés : 1 LETTRE (A, B, C…) par ADRESSE = 1 pin (couleur = compétence). Lie la liste aux pins.
|
||
const assignLocations = computed(() => {
|
||
const by = {}; const order = []
|
||
for (const j of assignJobsFiltered.value) { // carte = MÊME filtre que la liste (date sélectionnée + compétence)
|
||
if (!_hasJobLL(j)) continue
|
||
const k = (Math.round(+j.latitude * 1e5) / 1e5) + ',' + (Math.round(+j.longitude * 1e5) / 1e5)
|
||
if (!by[k]) { by[k] = { lat: +j.latitude, lon: +j.longitude, jobs: [] }; order.push(k) }
|
||
by[k].jobs.push(j)
|
||
}
|
||
const stops = []; const byName = {}
|
||
order.forEach((k, i) => {
|
||
const v = by[k]; const letter = _colLetter(i)
|
||
const skJob = v.jobs.find(j => j.required_skill || j.skill) || {}; const sk = skJob.required_skill || skJob.skill || ''
|
||
const j0 = v.jobs[0]
|
||
stops.push({ lat: v.lat, lon: v.lon, letter, color: sk ? getTagColor(sk) : '#90a4ae', count: v.jobs.length, names: v.jobs.map(j => j.name), title: (j0.address || j0.subject || '').slice(0, 60) })
|
||
for (const j of v.jobs) byName[j.name] = letter
|
||
})
|
||
return { stops, byName }
|
||
})
|
||
function groupLabel (j) { return assignLocations.value.byName[j.name] || '' } // lettre du pin de ce job (repère liste ↔ carte)
|
||
|
||
// ── Carte des jobs à assigner (Mapbox GL) : 1 pin par adresse, lettre = groupe ; clic = sélectionne les jobs co-localisés ──
|
||
const assignMapEl = ref(null); let _assignMap = null, _assignMapRO = null
|
||
|
||
// ── Bascule vue SATELLITE (couche raster Mapbox superposée SOUS les pins ; pas de setStyle = pins conservés) ──
|
||
// Préférence partagée par les 2 cartes (panneau + journée), persistée. addSatLayer() est appelé dans chaque on('load')
|
||
// AVANT les couches de pins → le raster reste dessous. toggleSat() bascule la visibilité sur les cartes vivantes.
|
||
const satView = ref((() => { try { return localStorage.getItem('plan_sat') === '1' } catch (e) { return false } })())
|
||
function addSatLayer (map) {
|
||
try {
|
||
if (!map.getSource('sat')) map.addSource('sat', { type: 'raster', url: 'mapbox://mapbox.satellite', tileSize: 256 })
|
||
if (!map.getLayer('sat')) map.addLayer({ id: 'sat', type: 'raster', source: 'sat', layout: { visibility: satView.value ? 'visible' : 'none' } })
|
||
} catch (e) { /* style pas prêt — ignoré */ }
|
||
}
|
||
function applySat (map) { try { if (map && map.getLayer && map.getLayer('sat')) map.setLayoutProperty('sat', 'visibility', satView.value ? 'visible' : 'none') } catch (e) {} }
|
||
function toggleSat () { satView.value = !satView.value; try { localStorage.setItem('plan_sat', satView.value ? '1' : '0') } catch (e) {} applySat(_assignMap); applySat(_dayMap); applySat(_locMap) }
|
||
|
||
// Clic DROIT sur une carte → Google Street View à la position cliquée (nouvel onglet). Branché sur les 3 cartes.
|
||
function openStreetView (lng, lat) { window.open(`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${lat},${lng}`, '_blank', 'noopener') }
|
||
|
||
// ── SÉLECTEUR D'EMPLACEMENT (jobs « hors carte ») : carte Mapbox bornée au sud de Montréal (Valleyfield ↔ Lacolle ↔
|
||
// frontière US). Clic / glisser le repère OU recherche d'adresse RQA → coords + adresse, écrits sur le Dispatch Job. ──
|
||
const locMapEl = ref(null); let _locMap = null, _locMarker = null
|
||
const TERR_BOUNDS = [[-74.95, 44.95], [-73.0, 45.55]] // SW (Valleyfield/frontière) → NE
|
||
const locPicker = reactive({ open: false, mode: 'job', job: null, tech: null, techName: '', subject: '', lat: null, lon: null, address: '', search: '', results: [], searching: false, saving: false, autoMatched: false })
|
||
const batchLocating = ref(false)
|
||
function openLocPicker (j) {
|
||
locPicker.mode = 'job'; locPicker.job = j.name; locPicker.tech = null; locPicker.subject = j.subject || j.name
|
||
locPicker.lat = hasLL(j) ? +j.lat : null; locPicker.lon = hasLL(j) ? +j.lon : null
|
||
locPicker.address = j.address || ''; locPicker.search = j.address || ''; locPicker.results = []; locPicker.autoMatched = false
|
||
locPicker.open = true
|
||
// Job avec une adresse mais SANS GPS → faire correspondre tout de suite à une adresse existante (RQA) + coords.
|
||
if (!hasLL(j) && (j.address || '').trim().length >= 3) autoMatchAddress()
|
||
}
|
||
// Définir le DÉPÔT (point de départ par défaut des tournées) via le même sélecteur de carte.
|
||
function openDepotPicker () {
|
||
const d = depot.value || {}; locPicker.mode = 'depot'; locPicker.job = null; locPicker.tech = null; locPicker.subject = 'Point de départ (dépôt)'
|
||
locPicker.lat = hasLL(d) ? +d.lat : null; locPicker.lon = hasLL(d) ? +d.lon : null
|
||
locPicker.address = d.address || ''; locPicker.search = d.address || ''; locPicker.results = []
|
||
locPicker.open = true
|
||
}
|
||
// Définir le DOMICILE d'un tech (origine si plus proche du travail que le dépôt).
|
||
function openHomePicker (t) {
|
||
const h = (techHomes.value[t.id]) || {}; locPicker.mode = 'home'; locPicker.job = null; locPicker.tech = t.id; locPicker.techName = t.name || t.id; locPicker.subject = 'Domicile — ' + (t.name || t.id)
|
||
locPicker.lat = hasLL(h) ? +h.lat : null; locPicker.lon = hasLL(h) ? +h.lon : null
|
||
locPicker.address = h.address || ''; locPicker.search = h.address || ''; locPicker.results = []
|
||
locPicker.open = true
|
||
}
|
||
// Fixer le domicile (= point de départ) d'un tech à sa POSITION GPS LIVE actuelle (Traccar) — INLINE, sans ouvrir la carte.
|
||
const liveGpsBusy = ref(false)
|
||
async function setHomeFromLiveGps (t) {
|
||
if (!t || !t.id) return
|
||
liveGpsBusy.value = true
|
||
try {
|
||
const r = await roster.techLivePosition(t.id)
|
||
if (!r || !r.ok || r.lat == null) { $q.notify({ type: 'warning', message: (r && r.error) || 'Aucune position GPS live (appareil Traccar non associé ?)' }); return }
|
||
let address = ''
|
||
if (MAPBOX_TOKEN) { try { const g = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${r.lon},${r.lat}.json?access_token=${MAPBOX_TOKEN}&language=fr&country=ca&types=address,poi`); const jj = await g.json(); const f = (jj.features || [])[0]; if (f && f.place_name) address = f.place_name.replace(/, Canada$/, '') } catch (_) { /* géocodage inverse non bloquant */ } }
|
||
const homes = { ...techHomes.value, [t.id]: { address, lat: +(+r.lat).toFixed(6), lon: +(+r.lon).toFixed(6) } }
|
||
await roster.savePolicy({ tech_homes: homes }); techHomes.value = homes
|
||
if (_dayMap) refreshDayMap()
|
||
$q.notify({ type: 'positive', icon: 'my_location', message: 'Départ fixé à la position GPS actuelle — ' + (t.name || t.id) })
|
||
} catch (e) { err(e) } finally { liveGpsBusy.value = false }
|
||
}
|
||
// Variante DANS le sélecteur de carte (mode domicile) : amène le marqueur sur la position GPS live, ajustable ensuite.
|
||
async function pickerUseLiveGps () {
|
||
if (locPicker.mode !== 'home' || !locPicker.tech) return
|
||
liveGpsBusy.value = true
|
||
try {
|
||
const r = await roster.techLivePosition(locPicker.tech)
|
||
if (!r || !r.ok || r.lat == null) { $q.notify({ type: 'warning', message: (r && r.error) || 'Aucune position GPS live' }); return }
|
||
if (_locMap) { _locMap.easeTo({ center: [r.lon, r.lat], zoom: 15, duration: 400 }); placeLocMarker(r.lon, r.lat, true) } else { locPicker.lon = +r.lon; locPicker.lat = +r.lat; reverseGeocode(r.lon, r.lat) }
|
||
} catch (e) { err(e) } finally { liveGpsBusy.value = false }
|
||
}
|
||
// Appareils Traccar — pour ASSOCIER un tech à son GPS, inline (prérequis du GPS live + tracé). La page Dispatch
|
||
// gérait ça ; on le rapatrie dans Planif. Chargé une fois à l'ouverture de l'éditeur de jour.
|
||
const traccarDevices = ref([]); const devicesLoading = ref(false); let _devicesLoaded = false
|
||
const deviceFilter = ref('')
|
||
async function loadTraccarDevices () {
|
||
if (_devicesLoaded || devicesLoading.value) return
|
||
devicesLoading.value = true
|
||
try { const d = await roster.listTraccarDevices(); traccarDevices.value = Array.isArray(d) ? d : (d && d.devices) || []; _devicesLoaded = true } catch (e) { /* non bloquant */ } finally { devicesLoading.value = false }
|
||
}
|
||
const deviceOptions = computed(() => {
|
||
const q = deviceFilter.value.toLowerCase().trim()
|
||
return traccarDevices.value
|
||
.map(d => ({ label: d.name || d.uniqueId || ('#' + d.id), value: String(d.id), uniqueId: d.uniqueId || '', online: d.status === 'online' }))
|
||
.filter(o => !q || o.label.toLowerCase().includes(q) || (o.uniqueId && o.uniqueId.toLowerCase().includes(q)))
|
||
.sort((a, b) => (b.online ? 1 : 0) - (a.online ? 1 : 0) || a.label.localeCompare(b.label))
|
||
})
|
||
// Appareil ACTUELLEMENT relié au tech de l'éditeur (résout l'id → nom réel, ex. « 24 - Stephane M »). missing = id enregistré mais introuvable dans Traccar.
|
||
const jdDevice = computed(() => {
|
||
const id = dayEditor.tech && dayEditor.tech.traccar_device_id
|
||
if (!id) return null
|
||
const d = traccarDevices.value.find(x => String(x.id) === String(id))
|
||
if (d) return { value: String(d.id), label: d.name || d.uniqueId || ('#' + d.id), online: d.status === 'online', uniqueId: d.uniqueId || '' }
|
||
return { value: String(id), label: devicesLoading.value ? ('#' + id + '…') : ('#' + id + ' — introuvable'), online: false, missing: !devicesLoading.value }
|
||
})
|
||
// Suggestion AUTO quand non relié : appareil dont le nom/uniqueId contient un mot du nom du tech (≥3 lettres). Facilite « relier ».
|
||
const _normDev = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '')
|
||
const jdDeviceSuggest = computed(() => {
|
||
if (jdDevice.value) return null
|
||
const t = dayEditor.tech; if (!t) return null
|
||
const parts = _normDev(t.name).split(/[^a-z]+/).filter(p => p.length >= 3)
|
||
if (!parts.length) return null
|
||
let best = null
|
||
for (const d of traccarDevices.value) {
|
||
const dn = _normDev(d.name) + ' ' + _normDev(d.uniqueId)
|
||
const hit = parts.filter(p => dn.includes(p)).length
|
||
if (hit > (best ? best.hit : 0)) best = { hit, d }
|
||
}
|
||
if (best && best.hit >= 1) { const d = best.d; return { value: String(d.id), label: d.name || ('#' + d.id), online: d.status === 'online' } }
|
||
return null
|
||
})
|
||
async function onPickDevice (tech, deviceId) {
|
||
if (!tech || !tech.id) return
|
||
const prev = tech.traccar_device_id || ''; const v = deviceId == null ? '' : String(deviceId)
|
||
if (v === prev) return
|
||
tech.traccar_device_id = v // optimiste
|
||
try { await roster.setTechTraccarDevice(tech.id, v); $q.notify({ type: 'positive', icon: 'gps_fixed', message: v ? 'Appareil GPS associé — ' + (tech.name || tech.id) : 'Appareil GPS dissocié', timeout: 1800 }) }
|
||
catch (e) { tech.traccar_device_id = prev; err(e) }
|
||
}
|
||
async function initLocMap () {
|
||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !locMapEl.value) return
|
||
if (_locMap) { _locMap.resize(); return }
|
||
mapboxgl.accessToken = MAPBOX_TOKEN
|
||
_locMap = new mapboxgl.Map({ container: locMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [locPicker.lon || -73.95, locPicker.lat || 45.18], zoom: locPicker.lat != null ? 14 : 8.5 })
|
||
_locMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
|
||
_locMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
|
||
_locMap.on('load', () => {
|
||
_locMap.resize(); addSatLayer(_locMap)
|
||
if (locPicker.lat == null) _locMap.fitBounds(TERR_BOUNDS, { padding: 18, duration: 0 }) // zoom sur la région au sud de Montréal
|
||
else placeLocMarker(locPicker.lon, locPicker.lat, false)
|
||
})
|
||
_locMap.on('click', (e) => placeLocMarker(e.lngLat.lng, e.lngLat.lat, true))
|
||
}
|
||
function placeLocMarker (lon, lat, doReverse) {
|
||
locPicker.lon = +(+lon).toFixed(6); locPicker.lat = +(+lat).toFixed(6)
|
||
if (!_locMarker) {
|
||
_locMarker = new window.mapboxgl.Marker({ draggable: true, color: '#e8590c' }).setLngLat([lon, lat]).addTo(_locMap)
|
||
_locMarker.on('dragend', () => { const ll = _locMarker.getLngLat(); locPicker.lon = +ll.lng.toFixed(6); locPicker.lat = +ll.lat.toFixed(6); reverseGeocode(ll.lng, ll.lat) })
|
||
} else _locMarker.setLngLat([lon, lat])
|
||
if (doReverse) reverseGeocode(lon, lat)
|
||
}
|
||
async function reverseGeocode (lon, lat) {
|
||
if (!MAPBOX_TOKEN) return
|
||
try {
|
||
const r = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${lon},${lat}.json?access_token=${MAPBOX_TOKEN}&language=fr&country=ca&types=address,poi`)
|
||
const j = await r.json(); const f = (j.features || [])[0]
|
||
if (f && f.place_name) locPicker.address = f.place_name.replace(/, Canada$/, '')
|
||
} catch (e) { /* non bloquant */ }
|
||
}
|
||
async function locSearch () {
|
||
const q = (locPicker.search || '').trim(); if (q.length < 3) return
|
||
locPicker.searching = true; locPicker.autoMatched = false
|
||
try { const r = await addressApi.conformityCandidates(q); locPicker.results = (r.results || []).filter(x => isFinite(+x.latitude) && isFinite(+x.longitude)).slice(0, 8) } catch (e) { err(e) } finally { locPicker.searching = false }
|
||
}
|
||
// Correspondance AUTO (ouverture d'un job sans GPS) : cherche son adresse dans la base RQA et, si la
|
||
// correspondance est franche (1 seul candidat, ou score ≥ 0.6), l'applique (adresse canonique + coords).
|
||
// Sinon laisse la liste des candidats affichée pour un choix en 1 clic. Jamais bloquant.
|
||
async function autoMatchAddress () {
|
||
const q = (locPicker.search || '').trim(); if (q.length < 3) return
|
||
locPicker.searching = true; locPicker.autoMatched = false
|
||
try {
|
||
const r = await addressApi.conformityCandidates(q)
|
||
const cands = (r.results || []).filter(x => isFinite(+x.latitude) && isFinite(+x.longitude)).slice(0, 8)
|
||
locPicker.results = cands
|
||
const top = cands[0]
|
||
const strong = top && (cands.length === 1 || (top.similarity_score != null && +top.similarity_score >= 0.6))
|
||
if (strong) { pickLocResult(top, true); locPicker.autoMatched = true }
|
||
} catch (e) { /* non bloquant : recherche/situer à la main reste possible */ } finally { locPicker.searching = false }
|
||
}
|
||
function pickLocResult (rr, keepList = false) {
|
||
const lon = +rr.longitude, lat = +rr.latitude; if (!isFinite(lon) || !isFinite(lat)) return
|
||
locPicker.address = rr.address_full || [rr.numero, rr.rue, rr.ville, rr.code_postal].filter(Boolean).join(' ')
|
||
if (!keepList) { locPicker.results = []; locPicker.autoMatched = false }
|
||
if (_locMap) { _locMap.easeTo({ center: [lon, lat], zoom: 16, duration: 500 }); placeLocMarker(lon, lat, false) } else { locPicker.lon = lon; locPicker.lat = lat }
|
||
}
|
||
async function saveLocPicker () {
|
||
if (locPicker.lat == null || locPicker.lon == null) { $q.notify({ type: 'warning', message: 'Choisis un point sur la carte' }); return }
|
||
locPicker.saving = true
|
||
try {
|
||
if (locPicker.mode === 'depot') {
|
||
const obj = { label: 'Dépôt', address: locPicker.address || '', lat: locPicker.lat, lon: locPicker.lon }
|
||
await roster.savePolicy({ depot: obj }); depot.value = obj
|
||
locPicker.open = false; if (_dayMap) refreshDayMap()
|
||
$q.notify({ type: 'positive', icon: 'warehouse', message: 'Dépôt (point de départ) enregistré' })
|
||
} else if (locPicker.mode === 'home') {
|
||
const homes = { ...techHomes.value, [locPicker.tech]: { address: locPicker.address || '', lat: locPicker.lat, lon: locPicker.lon } }
|
||
await roster.savePolicy({ tech_homes: homes }); techHomes.value = homes
|
||
locPicker.open = false; if (_dayMap) refreshDayMap()
|
||
$q.notify({ type: 'positive', icon: 'home', message: 'Domicile enregistré — ' + locPicker.techName })
|
||
} else {
|
||
await roster.setJobLocation(locPicker.job, locPicker.lat, locPicker.lon, locPicker.address)
|
||
const j = dayEditor.list.find(x => x.name === locPicker.job); if (j) { j.lat = locPicker.lat; j.lon = locPicker.lon; if (locPicker.address) j.address = locPicker.address }
|
||
const pj = (assignPanel.jobs || []).find(x => x.name === locPicker.job); if (pj) { pj.latitude = locPicker.lat; pj.longitude = locPicker.lon; if (locPicker.address) pj.address = locPicker.address } // pool « à assigner » : latitude/longitude → rafraîchit le badge sans coords
|
||
locPicker.open = false
|
||
await reloadOccupancy(); if (_dayMap) refreshDayMap()
|
||
$q.notify({ type: 'positive', icon: 'place', message: 'Emplacement enregistré sur le job' })
|
||
}
|
||
} catch (e) { err(e) } finally { locPicker.saving = false }
|
||
}
|
||
// LOT : localiser d'un coup tous les jobs du jour qui ont une adresse mais pas de GPS — chaque adresse est
|
||
// mise en correspondance avec la base RQA (top match franc) puis écrite sur le Dispatch Job. Les adresses
|
||
// ambiguës sont laissées telles quelles (à situer à la main via 📍).
|
||
async function batchLocate () {
|
||
const targets = dayEditor.list.filter(j => !j.legacy && !hasLL(j) && (j.address || '').trim().length >= 3)
|
||
if (!targets.length) { $q.notify({ type: 'info', message: 'Aucun job sans coordonnées à localiser', timeout: 1800 }); return }
|
||
batchLocating.value = true
|
||
let ok = 0, ambiguous = 0
|
||
try {
|
||
for (const j of targets) {
|
||
try {
|
||
const r = await addressApi.conformityCandidates((j.address || '').trim())
|
||
const cands = (r.results || []).filter(x => isFinite(+x.latitude) && isFinite(+x.longitude))
|
||
const top = cands[0]
|
||
const strong = top && (cands.length === 1 || (top.similarity_score != null && +top.similarity_score >= 0.6))
|
||
if (strong) {
|
||
const lat = +top.latitude, lon = +top.longitude
|
||
const addr = top.address_full || [top.numero, top.rue, top.ville, top.code_postal].filter(Boolean).join(' ')
|
||
await roster.setJobLocation(j.name, lat, lon, addr)
|
||
j.lat = lat; j.lon = lon; j.address = addr; ok++
|
||
} else { ambiguous++ }
|
||
} catch (e) { ambiguous++ }
|
||
}
|
||
await reloadOccupancy(); if (_dayMap) refreshDayMap()
|
||
$q.notify({ type: ok ? 'positive' : 'warning', icon: 'place', timeout: 3500,
|
||
message: `${ok} job(s) localisé(s)` + (ambiguous ? ` · ${ambiguous} adresse(s) ambiguë(s) à situer à la main (📍)` : '') })
|
||
} catch (e) { err(e) } finally { batchLocating.value = false }
|
||
}
|
||
function destroyLocMap () { if (_locMarker) { try { _locMarker.remove() } catch (e) {} _locMarker = null } if (_locMap) { try { _locMap.remove() } catch (e) {} _locMap = null } }
|
||
// init/destroy via @show/@hide du q-dialog (le conteneur a sa taille finale à @show → carte fiable)
|
||
|
||
// ── Tableur « durées par caractéristique » (additif, hors transport) — éditable inline, seed + apprentissage ──
|
||
const jobChar = reactive({ open: false, loading: false, saving: false, items: [] })
|
||
async function openJobChar () { jobChar.open = true; jobChar.loading = true; try { const r = await roster.getJobChars(); jobChar.items = r.items || [] } catch (e) { err(e) } finally { jobChar.loading = false } }
|
||
function addJobCharRow () { jobChar.items.push({ id: 'c' + Date.now().toString(36), cat: 'addon', label: '', minutes: 30, per_qty: false, keywords: '', learned_min: null, samples: 0 }) }
|
||
async function saveJobChar () { jobChar.saving = true; try { const r = await roster.saveJobChars(jobChar.items); $q.notify({ type: r.ok ? 'positive' : 'negative', message: r.ok ? `Table enregistrée (${r.count} lignes)` : 'Échec d\'enregistrement' }); if (r.ok) jobChar.open = false } catch (e) { err(e) } finally { jobChar.saving = false } }
|
||
|
||
// Lien app terrain d'un tech : copie le lien (PWA /field?t=) + propose l'envoi SMS au tech (sans build natif requis).
|
||
async function techAppLinkMenu (t) {
|
||
try {
|
||
const r = await roster.techAppLink(t.id); if (!r || !r.ok) { err(new Error('lien app indisponible')); return }
|
||
try { await navigator.clipboard.writeText(r.url) } catch (e) {}
|
||
const actions = [{ label: 'OK', color: 'white' }]
|
||
if (r.phone) actions.unshift({ label: '📲 SMS au tech', color: 'yellow', handler: async () => { try { await sendSmsViaHub(r.phone, 'Ton app terrain Targo (interventions du jour) : ' + r.url, ''); $q.notify({ type: 'positive', message: 'Lien envoyé par SMS à ' + r.name }) } catch (e) { err(e) } } })
|
||
$q.notify({ message: '📱 Lien app copié' + (r.phone ? '' : ' (pas de téléphone au dossier)') + ' — ' + r.url, timeout: 9000, multiLine: true, actions })
|
||
} catch (e) { err(e) }
|
||
}
|
||
|
||
// ── Bloc Legacy sur la timeline : clic → détail du ticket (fil osTicket ingéré in-app, via ticketThread) ──
|
||
const legacyDetail = reactive({ open: false, lid: null, subject: '', dept: '', loading: false, thread: null })
|
||
async function openLegacyBlock (b) {
|
||
if (!b || !b.lid) return
|
||
legacyDetail.lid = b.lid; legacyDetail.subject = b.subject || ('#' + b.lid); legacyDetail.dept = b.dept || ''; legacyDetail.thread = null; legacyDetail.loading = true; legacyDetail.open = true
|
||
try { legacyDetail.thread = await roster.ticketThread(b.lid) } catch (e) { err(e); legacyDetail.thread = { error: true, messages: [] } } finally { legacyDetail.loading = false }
|
||
}
|
||
|
||
// ── Synchroniser les techniciens : rapport de réconciliation (staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech) ──
|
||
const techSync = reactive({ open: false, loading: false, applying: false, report: null, sel: {} })
|
||
const techSyncSelCount = computed(() => Object.values(techSync.sel).filter(Boolean).length)
|
||
async function openTechSync () {
|
||
techSync.open = true; techSync.loading = true; techSync.report = null; techSync.sel = {}
|
||
try {
|
||
const r = await roster.techSyncReport(); techSync.report = r
|
||
for (const s of (r.staff_missing_fiche || [])) techSync.sel[s.staff_id] = true // candidats cochés par défaut
|
||
} catch (e) { err(e) } finally { techSync.loading = false }
|
||
}
|
||
async function applyTechSync () {
|
||
const ids = Object.entries(techSync.sel).filter(([, v]) => v).map(([k]) => k)
|
||
if (!ids.length) return
|
||
techSync.applying = true
|
||
try {
|
||
const r = await roster.techSyncApply(ids)
|
||
$q.notify({ type: r.created ? 'positive' : 'info', icon: 'group_add', message: `${r.created} fiche(s) technicien créée(s)` + (r.results ? ` · ${r.results.filter(x => x.skipped).length} déjà présente(s)` : '') })
|
||
techSync.open = false
|
||
await loadBase() // recharge la liste des techniciens dans la grille
|
||
} catch (e) { err(e) } finally { techSync.applying = false }
|
||
}
|
||
const _hasJobLL = (j) => j && j.latitude != null && j.longitude != null && isFinite(+j.latitude) && isFinite(+j.longitude) && (Math.abs(+j.latitude) > 0.01 || Math.abs(+j.longitude) > 0.01)
|
||
const assignNoCoord = computed(() => assignJobsFiltered.value.filter(j => !_hasJobLL(j)).length) // « sans coords » = dans le filtre courant (cohérent avec la carte)
|
||
// LOT (pool « à assigner ») : faire correspondre l'adresse de chaque job sans coords à la base RQA → GPS, écrit
|
||
// sur le Dispatch Job + en mémoire (j.latitude/longitude) → les jobs apparaissent sur la carte du pool. Ambigus = à la main.
|
||
async function batchLocatePool () {
|
||
// MÊME périmètre que le badge assignNoCoord (jobs sans coords du filtre courant) → plus d'incohérence badge/notification.
|
||
const coordless = assignJobsFiltered.value.filter(j => !_hasJobLL(j))
|
||
if (!coordless.length) { $q.notify({ type: 'positive', message: 'Tous les jobs à assigner ont déjà des coordonnées 🎉', timeout: 2000 }); return }
|
||
const targets = coordless.filter(j => String(j.address || j.location_label || '').trim().length >= 3)
|
||
if (!targets.length) { // sans coords MAIS sans adresse exploitable → pas d'auto-localisation possible : on ouvre le sélecteur manuel sur le 1er
|
||
$q.notify({ type: 'warning', icon: 'wrong_location', timeout: 4000, message: `${coordless.length} job(s) sans adresse exploitable — à situer à la main sur la carte` })
|
||
openLocPicker(coordless[0]); return
|
||
}
|
||
const noAddr = coordless.length - targets.length
|
||
assignPanel.locating = true
|
||
let ok = 0, ambiguous = 0
|
||
try {
|
||
for (const j of targets) {
|
||
const q = String(j.address || j.location_label || '').trim()
|
||
try {
|
||
const r = await addressApi.conformityCandidates(q)
|
||
const cands = (r.results || []).filter(x => isFinite(+x.latitude) && isFinite(+x.longitude))
|
||
const top = cands[0]
|
||
const strong = top && (cands.length === 1 || (top.similarity_score != null && +top.similarity_score >= 0.6))
|
||
if (strong) {
|
||
const lat = +top.latitude, lon = +top.longitude
|
||
const addr = top.address_full || [top.numero, top.rue, top.ville, top.code_postal].filter(Boolean).join(' ')
|
||
await roster.setJobLocation(j.name, lat, lon, addr)
|
||
j.latitude = lat; j.longitude = lon; j.address = addr; ok++
|
||
} else { ambiguous++ }
|
||
} catch (e) { ambiguous++ }
|
||
}
|
||
if (_assignMap) refreshAssignMap()
|
||
$q.notify({ type: ok ? 'positive' : 'warning', icon: 'place', timeout: 4000,
|
||
message: `${ok} job(s) localisé(s)` + (ambiguous ? ` · ${ambiguous} adresse(s) ambiguë(s) — situer à la main (📍)` : '') + (noAddr ? ` · ${noAddr} sans adresse (📍)` : '') })
|
||
} catch (e) { err(e) } finally { assignPanel.locating = false }
|
||
}
|
||
function assignStops () { return assignLocations.value.stops }
|
||
function toggleAssignMap () { assignPanel.showMap = !assignPanel.showMap }
|
||
// ── Lasso / boîte de sélection sur la carte : glisse un rectangle → sélectionne TOUS les jobs dedans (amas compris) ──
|
||
const assignLasso = ref(false)
|
||
function toggleLasso () {
|
||
assignLasso.value = !assignLasso.value
|
||
if (_assignMap) { assignLasso.value ? _assignMap.dragPan.disable() : _assignMap.dragPan.enable(); _assignMap.getCanvas().style.cursor = assignLasso.value ? 'crosshair' : '' }
|
||
}
|
||
function setupBoxSelect (map) { // LASSO FREEFORM : tracé libre (souris ou doigt) → polygone de sélection
|
||
const canvas = map.getCanvasContainer()
|
||
let pts = null, svg = null, poly = null
|
||
const pos = (e) => { const r = canvas.getBoundingClientRect(); const t = (e.touches && e.touches[0]) || e; return { x: t.clientX - r.left, y: t.clientY - r.top } }
|
||
const onMove = (e) => {
|
||
if (!pts) return; if (e.cancelable) e.preventDefault(); pts.push(pos(e))
|
||
if (!svg) {
|
||
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||
svg.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:10'
|
||
poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline')
|
||
poly.setAttribute('fill', 'rgba(99,102,241,.15)'); poly.setAttribute('stroke', '#6366f1'); poly.setAttribute('stroke-width', '2'); poly.setAttribute('stroke-dasharray', '5 3')
|
||
svg.appendChild(poly); canvas.appendChild(svg)
|
||
}
|
||
poly.setAttribute('points', pts.map(q => q.x + ',' + q.y).join(' '))
|
||
}
|
||
const onUp = () => {
|
||
document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp)
|
||
document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onUp)
|
||
if (svg) { svg.remove(); svg = null; poly = null }
|
||
const path = pts; pts = null
|
||
if (path && path.length >= 3) selectInLasso(map, path)
|
||
}
|
||
const onDown = (e) => {
|
||
if (!assignLasso.value || (e.button != null && e.button !== 0)) return
|
||
e.preventDefault(); e.stopPropagation(); pts = [pos(e)]
|
||
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp)
|
||
document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchend', onUp)
|
||
}
|
||
canvas.addEventListener('mousedown', onDown, true)
|
||
canvas.addEventListener('touchstart', onDown, true)
|
||
}
|
||
// Point-in-polygon (ray casting) sur coordonnées écran.
|
||
function _inPoly (pt, poly) {
|
||
let inside = false
|
||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||
const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y
|
||
if (((yi > pt.y) !== (yj > pt.y)) && (pt.x < (xj - xi) * (pt.y - yi) / ((yj - yi) || 1e-9) + xi)) inside = !inside
|
||
}
|
||
return inside
|
||
}
|
||
async function selectInLasso (map, path) { // sélectionne pins + amas dont le CENTRE est dans le tracé
|
||
const names = new Set(); const clusterIds = []
|
||
try {
|
||
const feats = map.queryRenderedFeatures({ layers: ['aj-c', 'aj-cluster'] }) || []
|
||
for (const f of feats) {
|
||
const c = f.geometry && f.geometry.coordinates; if (!c) continue
|
||
const scr = map.project(c)
|
||
if (!_inPoly({ x: scr.x, y: scr.y }, path)) continue
|
||
if (f.properties && f.properties.cluster_id != null) clusterIds.push(f.properties.cluster_id)
|
||
else { try { JSON.parse(f.properties.names || '[]').forEach(n => names.add(n)) } catch (e) {} }
|
||
}
|
||
const src = map.getSource('aj')
|
||
await Promise.all(clusterIds.map(cid => new Promise(res => { try { src.getClusterLeaves(cid, 1000, 0, (err, leaves) => { if (!err && leaves) leaves.forEach(l => { try { JSON.parse(l.properties.names || '[]').forEach(n => names.add(n)) } catch (e) {} }); res() }) } catch (e) { res() } })))
|
||
} catch (e) {}
|
||
names.forEach(n => { selectedJobs[n] = true })
|
||
if (names.size) $q.notify({ type: 'positive', message: names.size + ' job(s) sélectionné(s) au lasso', timeout: 1800 })
|
||
else $q.notify({ type: 'info', message: 'Aucun job dans la zone', timeout: 1500 })
|
||
refreshAssignMap()
|
||
}
|
||
function focusAssignJob (j) { if (_assignMap && _hasJobLL(j)) _assignMap.easeTo({ center: [+j.longitude, +j.latitude], zoom: 13, duration: 400 }) }
|
||
// Carte → liste : déplie les détails du job + scrolle la ligne + flash visuel.
|
||
const flashJob = ref(null)
|
||
function focusAssignJobInList (name) {
|
||
if (!name) return
|
||
const job = assignPanel.jobs.find(j => j.name === name)
|
||
if (job) { job._showThread = true; loadThread(job) }
|
||
flashJob.value = name; setTimeout(() => { if (flashJob.value === name) flashJob.value = null }, 1800)
|
||
nextTick(() => { const el = document.querySelector('.assign-body [data-jobname="' + name + '"]'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center', behavior: 'smooth' }) })
|
||
}
|
||
async function initAssignMap () {
|
||
if (!MAPBOX_TOKEN || !assignMapEl.value || _assignMap) return
|
||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !assignMapEl.value) return
|
||
mapboxgl.accessToken = MAPBOX_TOKEN
|
||
_assignMap = new mapboxgl.Map({ container: assignMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.55, 45.2], zoom: 8 })
|
||
_assignMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
|
||
_assignMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
|
||
_assignMapRO = new ResizeObserver(() => { if (_assignMap) _assignMap.resize() }); _assignMapRO.observe(assignMapEl.value)
|
||
_assignMap.on('load', () => {
|
||
_assignMap.resize()
|
||
addSatLayer(_assignMap) // couche satellite (sous les pins), masquée par défaut
|
||
// Regroupement (clustering) natif Mapbox : les jobs proches fusionnent en une pastille portant le TOTAL de jobs.
|
||
// Résout le chevauchement des pastilles/texte au dézoom. jobsum = somme des jobs (pas juste le nb d'adresses).
|
||
_assignMap.addSource('aj', { type: 'geojson', cluster: true, clusterRadius: 44, clusterMaxZoom: 13, clusterProperties: { jobsum: ['+', ['get', 'count']], selsum: ['+', ['get', 'seln']] }, data: { type: 'FeatureCollection', features: [] } })
|
||
// Amas : bulle indigo dimensionnée par le nb de jobs ; contour DORÉ si l'amas contient de la sélection
|
||
_assignMap.addLayer({ id: 'aj-cluster', type: 'circle', source: 'aj', filter: ['has', 'point_count'], paint: { 'circle-color': '#6366f1', 'circle-opacity': 0.92, 'circle-radius': ['step', ['get', 'jobsum'], 15, 5, 19, 15, 25], 'circle-stroke-width': ['case', ['>', ['get', 'selsum'], 0], 4, 2.5], 'circle-stroke-color': ['case', ['>', ['get', 'selsum'], 0], '#fbbf24', '#fff'] } })
|
||
_assignMap.addLayer({ id: 'aj-cluster-count', type: 'symbol', source: 'aj', filter: ['has', 'point_count'], layout: { 'text-field': ['get', 'jobsum'], 'text-size': 13, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
|
||
// Pastilles individuelles : UNIQUEMENT hors amas ; contour DORÉ si sélectionnée (surlignage de la sélection)
|
||
_assignMap.addLayer({ id: 'aj-c', type: 'circle', source: 'aj', filter: ['!', ['has', 'point_count']], paint: { 'circle-radius': ['case', ['>', ['get', 'count'], 1], 12, 9], 'circle-color': ['get', 'color'], 'circle-stroke-width': ['case', ['get', 'sel'], 3.5, 2], 'circle-stroke-color': ['case', ['get', 'sel'], '#fbbf24', '#fff'] } })
|
||
_assignMap.addLayer({ id: 'aj-l', type: 'symbol', source: 'aj', filter: ['!', ['has', 'point_count']], layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
|
||
_assignMap.on('mouseenter', 'aj-c', () => { _assignMap.getCanvas().style.cursor = 'pointer' })
|
||
_assignMap.on('mouseleave', 'aj-c', () => { _assignMap.getCanvas().style.cursor = '' })
|
||
// Clic sur un amas → zoom d'expansion qui l'ÉCLATE (le « zoom automatique on clic » demandé).
|
||
_assignMap.on('mouseenter', 'aj-cluster', () => { _assignMap.getCanvas().style.cursor = 'zoom-in' })
|
||
_assignMap.on('mouseleave', 'aj-cluster', () => { _assignMap.getCanvas().style.cursor = '' })
|
||
_assignMap.on('click', 'aj-cluster', (e) => {
|
||
const f = _assignMap.queryRenderedFeatures(e.point, { layers: ['aj-cluster'] })[0]; if (!f) return
|
||
_assignMap.getSource('aj').getClusterExpansionZoom(f.properties.cluster_id, (err, zoom) => {
|
||
if (err) return
|
||
_assignMap.easeTo({ center: f.geometry.coordinates, zoom: Math.min((zoom || 14) + 0.3, 16), duration: 500 })
|
||
})
|
||
})
|
||
_assignMap.on('click', 'aj-c', (e) => {
|
||
const f = e.features[0]; const p = f.properties; const names = JSON.parse(p.names || '[]')
|
||
const allSel = names.length && names.every(n => selectedJobs[n]) // clic = BASCULE (sélectionne, ou désélectionne si déjà tout coché)
|
||
names.forEach(n => { if (allSel) delete selectedJobs[n]; else selectedJobs[n] = true })
|
||
if (!allSel && names[0]) focusAssignJobInList(names[0]) // + scrolle/expand la job dans la liste (à la sélection seulement)
|
||
new window.mapboxgl.Popup({ offset: 13 }).setLngLat(f.geometry.coordinates).setHTML(`<div style="font-size:12px;line-height:1.45"><b>${_esc(p.letter)}</b> · ${_esc(p.title)}<br>${allSel ? '✕ retiré de la sélection' : '✓ ' + p.count + ' job(s) sélectionné(s)'}<br><span style="color:#888">${allSel ? 're-clique pour re-sélectionner' : 'glisse-les sur un tech × jour'}</span></div>`).addTo(_assignMap)
|
||
})
|
||
setupBoxSelect(_assignMap) // lasso : glisser un rectangle → sélectionne les jobs dedans
|
||
refreshAssignMap()
|
||
})
|
||
}
|
||
function refreshAssignMap () {
|
||
if (!_assignMap || !_assignMap.isStyleLoaded()) { setTimeout(refreshAssignMap, 200); return }
|
||
const stops = assignStops(); const src = _assignMap.getSource('aj'); if (!src) return
|
||
src.setData({ type: 'FeatureCollection', features: stops.map(s => { const sel = (s.names || []).some(n => !!selectedJobs[n]); return { type: 'Feature', geometry: { type: 'Point', coordinates: [s.lon, s.lat] }, properties: { label: s.letter + (s.count > 1 ? '·' + s.count : ''), color: s.color, count: s.count, names: JSON.stringify(s.names), letter: s.letter, title: s.title, sel, seln: sel ? 1 : 0 } } }) })
|
||
if (stops.length === 1) _assignMap.easeTo({ center: [stops[0].lon, stops[0].lat], zoom: 11, duration: 400 })
|
||
else if (stops.length > 1) { const b = new window.mapboxgl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat]); stops.forEach(s => b.extend([s.lon, s.lat])); _assignMap.fitBounds(b, { padding: 40, maxZoom: 12, duration: 400 }) }
|
||
}
|
||
function destroyAssignMap () { assignLasso.value = false; if (_assignMapRO) { try { _assignMapRO.disconnect() } catch (e) {} _assignMapRO = null } if (_assignMap) { try { _assignMap.remove() } catch (e) {} _assignMap = null } }
|
||
// ── Grande carte des TOURNÉES proposées (revue Suggérer) : ligne domicile→arrêts par tech, 1 couleur/tech, 1 jour ──
|
||
// Carte de la revue = composant partagé <RouteMap> (P2). Les métriques réelles (OSRM) remontent ici pour la légende.
|
||
function onRouteMetrics (m) { for (const k in realRoutes) delete realRoutes[k]; Object.assign(realRoutes, m) }
|
||
// D v2 — routes RÉELLES : Mapbox Directions par tech (domicile→arrêts) → géométrie routière + distance/durée réelles. Cache par signature.
|
||
const _routeCache = new Map()
|
||
const realRoutes = reactive({}) // techId → { km, mins } (la légende préfère ces valeurs réelles quand présentes)
|
||
// Tournée routière RÉELLE d'un tech (OSRM auto-hébergé via hub, plus de Mapbox payant) : tracé + total + temps ENTRE arrêts. Cache par signature.
|
||
async function fetchTechRoute (home, stops) {
|
||
const pts = []; if (home) pts.push([+home.lon, +home.lat]); for (const s of stops) pts.push([+s.lon, +s.lat])
|
||
if (home) pts.push([+home.lon, +home.lat]) // RETOUR au point de départ : la tournée est une boucle domicile→arrêts→domicile
|
||
if (pts.length < 2 || pts.length > 52) return null
|
||
const sig = pts.map(p => p[0].toFixed(5) + ',' + p[1].toFixed(5)).join(';')
|
||
if (_routeCache.has(sig)) return _routeCache.get(sig)
|
||
try {
|
||
const d = await roster.osrmRoute(pts)
|
||
if (!d || !Array.isArray(d.geometry)) return null
|
||
const info = { geometry: { type: 'LineString', coordinates: d.geometry }, km: d.km, mins: d.mins, legs: d.legs || [], hasHome: !!home }
|
||
_routeCache.set(sig, info); return info
|
||
} catch (e) { return null }
|
||
}
|
||
watch(() => assignPanel.showMap, (on) => { if (on) { if (assignPanel.h < 600) assignPanel.h = 640; nextTick(initAssignMap) } else destroyAssignMap() })
|
||
watch(assignJobsFiltered, () => { if (assignPanel.showMap) nextTick(() => { _assignMap ? refreshAssignMap() : initAssignMap() }) }) // carte suit le filtre (date/compétence) + les jobs
|
||
watch(selectedJobs, () => { if (assignPanel.showMap && _assignMap) refreshAssignMap() }) // surlignage doré des pins/amas suit la sélection
|
||
watch(() => assignPanel.open, (o) => { if (!o) destroyAssignMap() })
|
||
|
||
// ── Timeline contextuelle d'une RESSOURCE (dispatch des jobs de la semaine visible) ──
|
||
// Réutilise les helpers de cellule (cellBands/cellBlocks/cellJobs/cellPct) → 0 nouvel appel réseau.
|
||
const timelineDlg = reactive({ open: false, tech: null })
|
||
function openTimeline (t) { timelineDlg.tech = t; timelineDlg.open = true }
|
||
// (Clic sur le progressbar → gotoDispatch : on ouvre le timeline ÉDITABLE du tableau Dispatch, drag-drop + suppression,
|
||
// plutôt qu'un popup maison — réutilisation max + cohérence. Le réordonnancement/priorité se fait là-bas.)
|
||
// Deep-link vers le tableau Dispatch focalisé sur la ressource + le jour cliqué (sinon 1er jour de la semaine).
|
||
function gotoDispatch (t, dateIso) {
|
||
const q = {}
|
||
if (t) q.tech = t.id
|
||
q.date = dateIso || (timelineDays.value[0] && timelineDays.value[0].iso) || start.value
|
||
router.push({ path: '/planification', query: q }) // page Dispatch retirée → reste sur Planification
|
||
}
|
||
// ── Éditeur de JOURNÉE (fenêtre contextuelle ciblée — clic sur le progressbar) ──
|
||
// Garde le contexte de la grille derrière. Timeline + réordonnancement DRAG-DROP + retrait d'un job.
|
||
const dayEditor = reactive({ open: false, tech: null, day: null, list: [], saving: false, dragIdx: null, travelMap: {}, routeReady: false })
|
||
const dayShift = ref({ min: 8, max: 16 }) // quart de l'éditeur de jour (q-range, révélé par « Modifier »)
|
||
const dayShiftEdit = ref(false) // false = affichage statique ; true = curseur d'édition révélé
|
||
// Jobs LEGACY (osTicket F) datés ce jour, PAS encore importés en Dispatch Job → comptés dans l'occupation mais absents
|
||
// de cellJobs (roster). On les liste en LECTURE SEULE dans l'éditeur de jour (sinon « Aucun job » alors que 88% occupé).
|
||
const dayLegacyJobs = computed(() => {
|
||
if (!showLegacyLoad.value || !dayEditor.tech || !dayEditor.day) return []
|
||
const lg = legacyLoad.value[dayEditor.tech.id + '|' + dayEditor.day.iso]
|
||
return lg && Array.isArray(lg.jobs) ? lg.jobs.filter(j => j.field) : []
|
||
})
|
||
function openDayEditor (t, d) {
|
||
dayEditor.tech = t; dayEditor.day = d
|
||
const _w = winOf(t.id, d.iso, false); dayShift.value = _w ? { min: _w.s, max: _w.e } : { min: 8, max: 16 } // init la bande de quart depuis le shift régulier (sinon 8-16 par défaut)
|
||
dayShiftEdit.value = false // toujours rouvrir en mode STATIQUE
|
||
// RDV confirmé (ou heure légacy précise) = heure FIXE → verrouillé ; sinon flexible (replanifiable par la tournée).
|
||
// Roster (Dispatch Jobs, ÉDITABLES) + jobs LEGACY (osTicket F, géocodés, LECTURE SEULE) dans la MÊME liste → pins sur la
|
||
// carte + tournée optimisable + legs de transport, via la logique existante. Le legacy ne se sauvegarde pas (F autoritaire).
|
||
const rosterItems = cellJobs(t.id, d.iso).map(j => ({ ...j, locked: j.booking_status === 'Confirmé', showDetail: false }))
|
||
const legacyItems = dayLegacyJobs.value.map(j => ({ name: 'LEG:' + j.id, legacy: true, readonly: true, legacy_id: j.id, lid: j.id, subject: j.subject, dept: j.dept, skill: j.skill, dur: Number(j.est_h) || 1, lat: j.lat, lon: j.lon, address: j.address || '', customer: j.customer || '', locked: false, showDetail: false }))
|
||
dayEditor.list = [...rosterItems, ...legacyItems]
|
||
dayOriginChoice.value = null // départ = auto (politique) à chaque ouverture
|
||
dayEditor.dragIdx = null; dayEditor.travelMap = {}; dayEditor.routeReady = false; dayEditor.open = true; dayShowTrack.value = false
|
||
loadDayRoute() // charge la matrice de temps routiers RÉELS (Mapbox) → packedDay les utilise dès l'arrivée (réactif)
|
||
loadTraccarDevices() // liste des appareils GPS (pour associer le tech, inline) — chargée une fois
|
||
}
|
||
// Quart MODIFIABLE depuis l'éditeur de jour : applique un gabarit d'heures (réutilise ensureWindowTpl + setCellReplace,
|
||
// comme la saisie rapide « 8-17 ») sur le tech/jour courant. Édition LOCALE → « Publier » pour enregistrer.
|
||
async function saveDayShift () {
|
||
const min = dayShift.value.min; const max = dayShift.value.max
|
||
if (!dayEditor.tech || !dayEditor.day || !(max > min)) return
|
||
const tpl = await ensureWindowTpl(min, max)
|
||
if (tpl) { pushHistory(); setCellReplace(dayEditor.tech.id, dayEditor.tech.name, dayEditor.day.iso, tpl); $q.notify({ type: 'positive', message: `Quart ${fmtH(min)}h–${fmtH(max)}h — publier pour enregistrer`, timeout: 2500 }) }
|
||
}
|
||
function dayShiftLabel () { // affichage statique du quart (lecture seule) dans l'éditeur de jour
|
||
const reg = dayEditor.tech && dayEditor.day && hasReg(dayEditor.tech.id, dayEditor.day.iso)
|
||
if (!reg) return 'Aucun quart planifié'
|
||
return `${fmtH(dayShift.value.min)}h–${fmtH(dayShift.value.max)}h · ${Math.round((dayShift.value.max - dayShift.value.min) * 10) / 10} h`
|
||
}
|
||
// ── Tracé GPS réel (Traccar) SUPERPOSÉ sur la carte de l'éditeur de jour (jobs + itinéraire planifié déjà présents) ──
|
||
// → permet de voir si le tech est réellement passé par ses jobs. Toggle dispo AUJOURD'HUI/HIER seulement (Traccar lourd).
|
||
const dayShowTrack = ref(false); const dayTrackMsg = ref('')
|
||
const dayCanTrack = computed(() => {
|
||
const iso = dayEditor.day && dayEditor.day.iso; const n = nowET.value.iso; if (!iso || !n) return false
|
||
const yest = new Date(Date.parse(n + 'T12:00:00Z') - 86400000).toISOString().slice(0, 10)
|
||
return iso === n || iso === yest
|
||
})
|
||
async function loadDayTrack () {
|
||
dayTrackMsg.value = '…'
|
||
const iso = dayEditor.day && dayEditor.day.iso; const tech = dayEditor.tech && dayEditor.tech.id
|
||
const src = _dayMap && _dayMap.getSource('day-track')
|
||
if (!iso || !tech || !src) { dayTrackMsg.value = ''; return }
|
||
const from = new Date(iso + 'T00:00:00').toISOString()
|
||
const to = (iso === nowET.value.iso) ? new Date().toISOString() : new Date(iso + 'T23:59:59').toISOString()
|
||
try {
|
||
const r = await roster.traccarTrack(tech, from, to); const coords = (r && r.coords) || []
|
||
src.setData(coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} } : { type: 'FeatureCollection', features: [] })
|
||
if (coords.length < 2) { dayTrackMsg.value = 'aucun tracé / appareil'; return }
|
||
// Comparaison RÉEL (GPS) vs PRÉVU (route Mapbox) — km + temps de conduite. Contextuel : seulement quand le tracé est affiché → pas de surcharge.
|
||
let pkm = 0, pmin = 0, hasPlan = false
|
||
for (let i = 0; i < dayEditor.list.length; i++) { const l = dayLeg(i); if (l) { if (l.km != null) { pkm += l.km; hasPlan = true } pmin += (l.min || 0) } }
|
||
const rkm = (r && r.km != null) ? r.km : null; const rmin = (r && r.moving_min != null) ? r.moving_min : null
|
||
const span = (r && r.span_min != null) ? r.span_min : null
|
||
if (rkm == null) { dayTrackMsg.value = coords.length + ' pts GPS'; return }
|
||
const onsite = (span != null && rmin != null) ? Math.max(0, span - rmin) : null // sur place (arrêté) = durée GPS totale − conduite
|
||
let msg = 'réel ' + rkm + ' km' + (rmin != null ? ' · route ' + fmtMin(rmin) : '') + (onsite != null ? ' · sur place ' + fmtMin(onsite) : '')
|
||
if (hasPlan) {
|
||
const dkm = Math.round((rkm - pkm) * 10) / 10
|
||
const flag = (pkm > 0 && rkm > pkm * 1.25) ? '⚠ ' : '✓ ' // ⚠ = beaucoup plus de km que prévu (détour/trafic/route à revoir)
|
||
msg = flag + msg + ' · prévu ' + (Math.round(pkm * 10) / 10) + ' km / ' + Math.round(pmin) + ' min (' + (dkm >= 0 ? '+' : '') + dkm + ' km)'
|
||
}
|
||
dayTrackMsg.value = msg
|
||
} catch (e) { dayTrackMsg.value = 'indispo' }
|
||
}
|
||
watch(dayShowTrack, (on) => { if (!_dayMap) return; if (on) loadDayTrack(); else { const s = _dayMap.getSource('day-track'); if (s) s.setData({ type: 'FeatureCollection', features: [] }); dayTrackMsg.value = '' } })
|
||
// Matrice des temps de trajet ROUTIERS RÉELS entre tous les jobs du jour (Mapbox Matrix, 1 requête).
|
||
// Indépendante de l'ordre → le réordonnancement réutilise la matrice SANS nouvelle requête (recalcul instantané).
|
||
// Repli silencieux sur l'haversine si Mapbox indispo ou coords manquantes.
|
||
async function loadDayRoute () {
|
||
const key = (dayEditor.tech && dayEditor.tech.id) + '|' + (dayEditor.day && dayEditor.day.iso)
|
||
const jobsLL = dayEditor.list.filter(j => j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon))
|
||
const origin = dayOrigin.value // l'origine (dépôt/domicile) entre dans la matrice → trajet origine→1er job RÉEL
|
||
const pts = ((origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? [origin, ...jobsLL] : jobsLL).slice(0, 25) // Matrix = 25 coords max
|
||
if (pts.length < 2) { dayEditor.travelMap = {}; dayEditor.routeReady = false; return }
|
||
try {
|
||
const d = await roster.osrmTable(pts.map(j => [+j.lon, +j.lat])) // OSRM auto-hébergé (via hub) — même forme que Mapbox Matrix
|
||
const dur = d.durations || [], dist = d.distances || []
|
||
if (key !== ((dayEditor.tech && dayEditor.tech.id) + '|' + (dayEditor.day && dayEditor.day.iso))) return // l'éditeur a changé de cible entre-temps
|
||
const map = {}
|
||
for (let i = 0; i < pts.length; i++) for (let k = 0; k < pts.length; k++) {
|
||
if (i === k) continue
|
||
const sec = dur[i] && dur[i][k]; const m = dist[i] && dist[i][k]
|
||
if (sec == null) continue
|
||
map[pts[i].name + '>' + pts[k].name] = { min: Math.max(2, Math.round(sec / 60)), km: m != null ? Math.round(m / 100) / 10 : null, real: true }
|
||
}
|
||
dayEditor.travelMap = map; dayEditor.routeReady = true
|
||
} catch (e) { dayEditor.travelMap = {}; dayEditor.routeReady = false } // repli haversine
|
||
}
|
||
const dayOcc = () => (dayEditor.tech && dayEditor.day) ? cellOcc(dayEditor.tech.id, dayEditor.day.iso) : null
|
||
const dayBands = () => (dayEditor.tech && dayEditor.day) ? cellBands(dayEditor.tech.id, dayEditor.day.iso) : []
|
||
// Blocs RECALCULÉS depuis la SÉQUENCE éditée (packedDay) → l'ordre + les durées + le transport se reflètent + plus d'overlap.
|
||
const dayBlocks = () => packedDay.value.map(p => ({ s: p.startMin, e: p.endMin, skill: p.skill, legacy: p.legacy })) // packedDay inclut déjà les jobs legacy fusionnés
|
||
// Réordonnancement : flèches ↑↓ (fiable) + drag-drop basé sur le DROP (robuste, pas de splice live jittery)
|
||
function moveDayJob (i, dir) { const j = i + dir; const l = dayEditor.list; if (j < 0 || j >= l.length) return; const [x] = l.splice(i, 1); l.splice(j, 0, x) }
|
||
// Figer une job en 1re / dernière position (ou détacher). Réordonne pour honorer les positions figées.
|
||
function setPin (j, pin) { j.pin = (j.pin === pin ? null : pin); reorderByPins() }
|
||
function reorderByPins () {
|
||
const l = dayEditor.list
|
||
const first = l.filter(x => x.pin === 'first')
|
||
const last = l.filter(x => x.pin === 'last')
|
||
const mid = l.filter(x => x.pin !== 'first' && x.pin !== 'last')
|
||
dayEditor.list = [...first, ...mid, ...last]
|
||
}
|
||
function dayDragStart (i, ev) { dayEditor.dragIdx = i; try { ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.setData('text/plain', String(i)) } catch (e) {} }
|
||
function dayDropOn (i) { const from = dayEditor.dragIdx; if (from == null || from === i) { dayEditor.dragIdx = null; return } const l = dayEditor.list; const [x] = l.splice(from, 1); l.splice(i, 0, x); dayEditor.dragIdx = null }
|
||
function dayDragEnd () { dayEditor.dragIdx = null }
|
||
// Durée éditable en MINUTES (pas de 5) — best practice de précision
|
||
function jobMinutes (j) { return Math.round((Number(j.dur) || 0) * 60) }
|
||
// Couleur du « réel/estimé » sur la timeline : vert si dans l'estimé, rouge si dépassé > 10 %, neutre entre.
|
||
function ratioStyle (j) {
|
||
const est = jobMinutes(j); const act = j.actual_min
|
||
if (act == null || !est) return {}
|
||
if (act > est * 1.1) return { color: '#c62828', fontWeight: 700 }
|
||
if (act <= est) return { color: '#2e7d32', fontWeight: 700 }
|
||
return {}
|
||
}
|
||
function setJobMinutes (j, min) { const m = Math.max(5, Math.round((Number(min) || 0) / 5) * 5); j.dur = Math.round(m / 60 * 100) / 100 }
|
||
// Temps de transport estimé entre 2 jobs (haversine via coords Service Location) — provisoire, en attendant la géoloc live (Capacitor)
|
||
function haversineKm (la1, lo1, la2, lo2) { if ([la1, lo1, la2, lo2].some(v => v == null)) return null; const R = 6371; const r = x => x * Math.PI / 180; const dLa = r(la2 - la1); const dLo = r(lo2 - lo1); const s = Math.sin(dLa / 2) ** 2 + Math.cos(r(la1)) * Math.cos(r(la2)) * Math.sin(dLo / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(s)) }
|
||
function travelBetween (a, b) {
|
||
if (!a || !b) return null
|
||
const hit = dayEditor.travelMap && dayEditor.travelMap[a.name + '>' + b.name]
|
||
if (hit) return hit // temps routier RÉEL (Mapbox Matrix)
|
||
const km = haversineKm(a.lat, a.lon, b.lat, b.lon); if (km == null) return null
|
||
return { km: Math.round(km * 10) / 10, min: Math.max(5, Math.round(km / 40 * 60) + 5), real: false } // repli : 40 km/h + 5 min tampon (vol d'oiseau)
|
||
}
|
||
function dayLeg (i) { if (i === 0) { const o = dayOrigin.value; return (o && dayEditor.list[0]) ? travelBetween(o, dayEditor.list[0]) : null } return travelBetween(dayEditor.list[i - 1], dayEditor.list[i]) } // i=0 = trajet origine (dépôt/domicile) → 1er job ; sinon job précédent → courant
|
||
const fmtHM = (h) => { if (h == null) return '—'; const m = Math.round(h * 60); const hh = Math.floor(m / 60), mm = m % 60; return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') } // heure décimale → HH:MM (padded, pour start_time)
|
||
function dayShiftStartH () { const t = dayEditor.tech, d = dayEditor.day; if (!t || !d) return 8; const w = winOf(t.id, d.iso, false); return w ? w.s : 8 }
|
||
// PLANIFICATEUR DE TOURNÉE : recalcule les heures depuis l'ordre de la liste + durées + transport.
|
||
// Job verrouillé (RDV fixe) → garde son heure ; flexible → enchaîné après le précédent (+ transport). Plus d'overlap.
|
||
const packedDay = computed(() => {
|
||
const list = dayEditor.list; const out = []; let cursor = dayShiftStartH()
|
||
const origin = dayOrigin.value
|
||
if (origin && list.length) { const l0 = (travelBetween(origin, list[0]) || {}).min || 0; cursor += l0 / 60 } // départ origine → 1er job (le tech quitte l'origine à l'heure du shift)
|
||
for (let i = 0; i < list.length; i++) {
|
||
const j = list[i]; const dur = Number(j.dur) || 1
|
||
const start = (j.locked && j.start_h != null) ? j.start_h : cursor
|
||
const end = start + dur
|
||
out.push({ ...j, startMin: start, endMin: end })
|
||
const trH = (i < list.length - 1 ? (travelBetween(j, list[i + 1]) || {}).min || 0 : 0) / 60
|
||
cursor = Math.max(cursor, end) + trH
|
||
}
|
||
return out
|
||
})
|
||
// Coords valides : non nulles, finies, ET pas ~0,0 (placeholder « pas de coords » — golfe de Guinée).
|
||
const hasLL = (j) => j && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon) && (Math.abs(+j.lat) > 0.01 || Math.abs(+j.lon) > 0.01)
|
||
const dayNoCoord = computed(() => dayEditor.list.filter(j => !hasLL(j)).length)
|
||
|
||
// ── Origine de tournée : point de départ = DÉPÔT par défaut, OU DOMICILE du tech s'il est plus proche du travail ──
|
||
// (politique dispatch, lue côté client). La 1re job devient la plus proche de cette origine via « Optimiser depuis le départ ».
|
||
const depot = ref(null) // { label, address, lat, lon }
|
||
const techHomes = ref({}) // { techId: { address, lat, lon } }
|
||
// Point de DÉPART d'un tech : son DOMICILE s'il est défini, SINON le bureau TARGO (dépôt) — politique par défaut.
|
||
function techOrigin (techId) {
|
||
const h = techHomes.value[techId]
|
||
if (h && isFinite(+h.lat) && isFinite(+h.lon) && (Math.abs(+h.lat) > 0.01)) return { lat: +h.lat, lon: +h.lon, kind: 'home', address: h.address || '' }
|
||
const d = depot.value
|
||
if (d && isFinite(+d.lat) && isFinite(+d.lon)) return { lat: +d.lat, lon: +d.lon, kind: 'depot', address: d.address || '' }
|
||
return null
|
||
}
|
||
async function loadDispatchPolicy () {
|
||
try { const d = await roster.getPolicy(); depot.value = (d.policy && d.policy.depot) || null; techHomes.value = (d.policy && d.policy.tech_homes) || {} } catch (e) { /* non bloquant */ }
|
||
}
|
||
// Point de départ de la tournée : choix MANUEL dans le dialogue (null = auto/politique). Permet de basculer Bureau↔Domicile
|
||
// pour un tech « dispatch maison » même sans job géolocalisé (sinon la carte n'aurait aucun pin de départ à afficher).
|
||
const dayOriginChoice = ref(null)
|
||
const dayHasDepot = computed(() => { const dp = depot.value; return !!(dp && hasLL(dp)) })
|
||
const dayHasHome = computed(() => { const t = dayEditor.tech; const hm = t && techHomes.value[t.id]; return !!(hm && hasLL(hm)) })
|
||
const dayOrigin = computed(() => {
|
||
const t = dayEditor.tech; if (!t) return null
|
||
const dp = depot.value; const dep = (dp && hasLL(dp)) ? { name: '__origin__', lat: +dp.lat, lon: +dp.lon, label: dp.label || 'Dépôt', address: dp.address || '', kind: 'depot' } : null
|
||
const hm = techHomes.value[t.id]; const home = (hm && hasLL(hm)) ? { name: '__origin__', lat: +hm.lat, lon: +hm.lon, label: 'Domicile', address: hm.address || '', kind: 'home' } : null
|
||
if (dayOriginChoice.value === 'depot' && dep) return dep // départ forcé manuellement (toggle du dialogue) — prioritaire sur l'auto
|
||
if (dayOriginChoice.value === 'home' && home) return home
|
||
const jobs = dayEditor.list.filter(hasLL)
|
||
if (!jobs.length) return dep || home || null
|
||
if (dep && home) { const nd = (o) => Math.min(...jobs.map(j => haversineKm(o.lat, o.lon, +j.lat, +j.lon) ?? 9e9)); return nd(home) < nd(dep) ? home : dep } // domicile gagne s'il est plus proche
|
||
return dep || home || null
|
||
})
|
||
// Affiche la carte même sans job géolocalisé → au moins le point de départ (dépôt/domicile) reste visible.
|
||
const dayShowMap = computed(() => dayEditor.list.length > 0 || !!dayOrigin.value)
|
||
// Bascule manuelle du départ (dépôt ↔ domicile) depuis le dialogue → recalcule pins + itinéraire.
|
||
function setDayOrigin (kind) { dayOriginChoice.value = kind; if (_dayMap) refreshDayMap(); loadDayRoute() }
|
||
|
||
// ── Carte INTERACTIVE de la journée (Mapbox GL) : itinéraire ROUTIER réel (API Directions) + pins
|
||
// numérotés dans l'ordre de tournée. Navigable : zoom molette + boutons (NavigationControl), déplacement. ──
|
||
const dayMapEl = ref(null)
|
||
let _dayMap = null; let _dayMapRO = null; let _dirTimer = null; let _dayStopMarkers = []
|
||
function ensureMapbox () {
|
||
return 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
||
function dayStops () { // arrêts géolocalisés, dans l'ordre de tournée (packedDay) + infos pour le popup
|
||
return packedDay.value.filter(hasLL).map((j, i) => ({
|
||
lon: +j.lon, lat: +j.lat, label: String(i + 1), color: j.skill ? getTagColor(j.skill) : '#1976d2', skill: j.skill || '',
|
||
subject: j.subject || '', customer: j.customer || '', address: j.address || '', time: fmtHM(j.startMin) + '–' + fmtHM(j.endMin),
|
||
}))
|
||
}
|
||
// Segments de DÉPLACEMENT (pointillés) = l'espace entre 2 jobs dans la barre timeline.
|
||
const dayTravelSegs = () => { const p = packedDay.value; const out = []; const o = dayOrigin.value; if (o && p.length) { const s = dayShiftStartH(), e = p[0].startMin; if (e - s > 0.02) out.push({ s, e }) } for (let i = 0; i < p.length - 1; i++) { const s = p[i].endMin, e = p[i + 1].startMin; if (e - s > 0.02) out.push({ s, e }) } return out }
|
||
// Centre la carte sur un job (clic sur la ligne de la liste).
|
||
function focusDayJob (j) { if (_dayMap && hasLL(j)) _dayMap.easeTo({ center: [+j.lon, +j.lat], zoom: 14, duration: 500 }) }
|
||
// Fil legacy (messages + réponses du ticket osTicket) chargé à la demande, mis en cache sur le job (j._thread).
|
||
async function loadThread (j) {
|
||
const id = j && (j.legacy_id || j.legacy_ticket_id); if (!id || j._thread) return
|
||
j._thread = { loading: true, messages: [] }
|
||
try { const r = await roster.ticketThread(id); j._thread = { loading: false, messages: r.messages || [], count: r.count || 0, status: r.status || '' } }
|
||
catch (e) { j._thread = { loading: false, error: true, messages: [] } }
|
||
}
|
||
function toggleJobDetail (j) { j.showDetail = !j.showDetail; if (j.showDetail) loadThread(j); focusDayJob(j) } // tournée : clic = déplie détails + fil + centre carte
|
||
function toggleAssignThread (j) { j._showThread = !j._showThread; if (j._showThread) loadThread(j) } // panneau d'assignation : icône fil
|
||
// Fermer un ticket inutile dans le legacy (depuis le panneau) → sort du dispatch (Completed). Acteur = utilisateur Authentik.
|
||
function closeLegacyTicket (j) {
|
||
if (!j || !j.legacy_ticket_id) return
|
||
$q.dialog({ title: 'Fermer le ticket', message: 'Fermer le ticket legacy <b>#' + j.legacy_ticket_id + '</b> ?<br><span class="text-grey-7">' + (j.subject || '').replace(/[<>]/g, '') + '</span><br>→ il sortira du dispatch (Completed).', html: true, cancel: 'Annuler', ok: { label: 'Fermer', color: 'negative', unelevated: true }, persistent: true }).onOk(async () => {
|
||
try {
|
||
const r = await roster.closeLegacyTicket(j.legacy_ticket_id)
|
||
if (r && r.ok) { assignPanel.jobs = assignPanel.jobs.filter(x => x.name !== j.name); $q.notify({ type: 'positive', message: 'Ticket #' + j.legacy_ticket_id + ' fermé dans le legacy' }) }
|
||
else $q.notify({ type: 'warning', message: 'Échec fermeture : ' + ((r && r.error) || '?') })
|
||
} catch (e) { err(e) }
|
||
})
|
||
}
|
||
// Fermeture EN LOT des jobs cochés (1 seul appel) — option « fermer » au lieu de « réassigner ».
|
||
function bulkCloseSelected () {
|
||
const sel = assignPanel.jobs.filter(j => selectedJobs[j.name] && j.legacy_ticket_id)
|
||
if (!sel.length) { $q.notify({ type: 'warning', message: 'Aucun ticket legacy coché' }); return }
|
||
$q.dialog({ title: 'Fermer en lot', message: 'Fermer <b>' + sel.length + '</b> ticket(s) dans le legacy ?<br>→ ils sortent du dispatch (Completed).', html: true, cancel: 'Annuler', ok: { label: 'Fermer ' + sel.length, color: 'negative', unelevated: true }, persistent: true }).onOk(async () => {
|
||
try {
|
||
const r = await roster.batchCloseLegacy(sel.map(j => j.legacy_ticket_id))
|
||
if (r && r.ok) {
|
||
const done = new Set((r.results || []).filter(x => x.ok).map(x => String(x.t)))
|
||
assignPanel.jobs = assignPanel.jobs.filter(j => !done.has(String(j.legacy_ticket_id)))
|
||
for (const k in selectedJobs) delete selectedJobs[k]
|
||
$q.notify({ type: 'positive', message: r.closed + ' ticket(s) fermé(s)' + (r.locked ? ' · ' + r.locked + ' verrouillé(s)' : '') + (r.skipped ? ' · ' + r.skipped + ' déjà fermé(s)' : '') })
|
||
} else $q.notify({ type: 'warning', message: 'Échec : ' + ((r && r.error) || '?') })
|
||
} catch (e) { err(e) }
|
||
})
|
||
}
|
||
function fmtDT (iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString('fr-CA', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' } }
|
||
async function initDayMap () {
|
||
if (!MAPBOX_TOKEN || !dayMapEl.value || _dayMap) return
|
||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !dayMapEl.value) return
|
||
mapboxgl.accessToken = MAPBOX_TOKEN
|
||
_dayMap = new mapboxgl.Map({ container: dayMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.6756, 45.1599], zoom: 9 })
|
||
_dayMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right') // boutons zoom +/-
|
||
_dayMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
|
||
_dayMapRO = new ResizeObserver(() => { if (_dayMap) _dayMap.resize() }); _dayMapRO.observe(dayMapEl.value)
|
||
_dayMap.on('load', () => {
|
||
_dayMap.resize()
|
||
addSatLayer(_dayMap) // couche satellite (sous l'itinéraire et les pins), masquée par défaut
|
||
_dayMap.addSource('day-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||
_dayMap.addLayer({ id: 'day-route-halo', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 9, 'line-opacity': 0.2 } })
|
||
_dayMap.addLayer({ id: 'day-route', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 4, 'line-opacity': 0.85 } })
|
||
// Tracé GPS RÉEL (Traccar) — orange semi-transparent, par-dessus l'itinéraire planifié (bleu) ; sous les pins.
|
||
_dayMap.addSource('day-track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||
_dayMap.addLayer({ id: 'day-track-halo', type: 'line', source: 'day-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ff6f00', 'line-width': 9, 'line-opacity': 0.2 } })
|
||
_dayMap.addLayer({ id: 'day-track-l', type: 'line', source: 'day-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ef6c00', 'line-width': 3.5, 'line-opacity': 0.8 } })
|
||
_dayMap.addSource('day-stops', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||
_dayMap.addLayer({ id: 'day-stops-c', type: 'circle', source: 'day-stops', paint: { 'circle-radius': ['case', ['>', ['get', 'count'], 1], 12, 9], 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' } }) // groupe même adresse = un peu plus gros ; rayon réduit pour moins de chevauchement
|
||
_dayMap.addLayer({ id: 'day-stops-l', type: 'symbol', source: 'day-stops', layout: { 'text-field': ['get', 'label'], 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-size': 12, 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
|
||
// Clic sur un pin → popup avec les détails du job ; curseur main au survol.
|
||
_dayMap.on('mouseenter', 'day-stops-c', () => { _dayMap.getCanvas().style.cursor = 'pointer' })
|
||
_dayMap.on('mouseleave', 'day-stops-c', () => { _dayMap.getCanvas().style.cursor = '' })
|
||
_dayMap.on('click', 'day-stops-c', (e) => {
|
||
const f = e.features[0]; const p = f.properties
|
||
new window.mapboxgl.Popup({ offset: 14 }).setLngLat(f.geometry.coordinates)
|
||
.setHTML(`<div style="font-size:12px;line-height:1.5">${p.popup || ('<b>' + _esc(p.label) + '</b>')}${p.address ? '<br><span style="color:#888">📍 ' + _esc(p.address) + '</span>' : ''}</div>`)
|
||
.addTo(_dayMap)
|
||
})
|
||
refreshDayMap()
|
||
})
|
||
}
|
||
function refreshDayMap () {
|
||
if (!_dayMap || !_dayMap.isStyleLoaded()) { setTimeout(refreshDayMap, 200); return }
|
||
const stops = dayStops()
|
||
const origin = dayOrigin.value
|
||
// Regroupe les arrêts à la MÊME adresse (coords arrondies) → 1 pin « style Gaiia » : icône vectorielle du TYPE de la job
|
||
// PRINCIPALE (1re en ordre de tournée) + son n°, et — si plusieurs jobs à l'adresse — une mini barre segmentée au-dessus
|
||
// du n° (1 segment coloré par job) qui distingue la principale (l'icône) des secondaires. Le popup liste tous les jobs.
|
||
const grp = {}; const gord = []
|
||
for (const s of stops) { const k = s.lon.toFixed(5) + ',' + s.lat.toFixed(5); if (!grp[k]) { grp[k] = { lon: s.lon, lat: s.lat, items: [] }; gord.push(k) } grp[k].items.push(s) }
|
||
// Marqueurs DOM custom (styles inline car ils sortent du scope CSS du composant) ; les couches circle/symbol restent vides.
|
||
_dayStopMarkers.forEach(m => { try { m.remove() } catch (e) {} }); _dayStopMarkers = []
|
||
const sSrc = _dayMap.getSource('day-stops'); if (sSrc) sSrc.setData({ type: 'FeatureCollection', features: [] })
|
||
const seg = (items) => items.length > 1 ? `<div style="display:flex;gap:1.5px;margin-bottom:2px;padding:1.5px;background:#fff;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,.3)">${items.map(i => `<span style="display:block;width:7px;height:3px;border-radius:1px;background:${i.color}"></span>`).join('')}</div>` : ''
|
||
// L'icône peut être une ligature Material OU un chemin SVG custom (format q-icon « d@@style&&…|viewBox », ex. BUCKET_TRUCK) :
|
||
// sur un pin (HTML brut), on rend alors un <svg> inline (blanc) au lieu de la ligature.
|
||
const iconMarkup = (icon) => /^[Mm][\s\d.-]/.test(String(icon))
|
||
? (() => { const [def, vb = '0 0 24 24'] = String(icon).split('|'); const paths = def.split('&&').map(p => { const [d, style = ''] = p.split('@@'); return `<path d="${d}" style="${style.replace(/currentColor/g, '#fff')}"/>` }).join(''); return `<svg viewBox="${vb}" width="14" height="14" style="display:block;fill:#fff">${paths}</svg>` })()
|
||
: `<i class="material-icons" style="font-size:14px;line-height:1">${icon}</i>`
|
||
const pinHtml = (color, icon, num, items, round) => `${seg(items || [])}<div style="display:flex;align-items:center;gap:1px;color:#fff;background:${color};border:2px solid #fff;border-radius:${round ? '50%' : '11px'};padding:${round ? '3px' : '1px 5px 1px 3px'};box-shadow:0 1px 4px rgba(0,0,0,.45)">${iconMarkup(icon)}${num ? `<span style="font-size:10px;font-weight:800;line-height:1;padding:0 1px">${num}</span>` : ''}</div><div style="width:0;height:0;margin:-1px auto 0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid ${color}"></div>`
|
||
const addPin = (lon, lat, html, popup, addr) => {
|
||
const el = document.createElement('div'); el.style.cssText = 'display:flex;flex-direction:column;align-items:center;cursor:pointer;line-height:0'; el.innerHTML = html
|
||
el.addEventListener('click', (ev) => { ev.stopPropagation(); new window.mapboxgl.Popup({ offset: 16 }).setLngLat([lon, lat]).setHTML(`<div style="font-size:12px;line-height:1.5">${popup}${addr ? '<br><span style="color:#888">📍 ' + _esc(addr) + '</span>' : ''}</div>`).addTo(_dayMap) })
|
||
_dayStopMarkers.push(new window.mapboxgl.Marker({ element: el, anchor: 'bottom' }).setLngLat([lon, lat]).addTo(_dayMap))
|
||
}
|
||
for (const k of gord) {
|
||
const g = grp[k]; const f = g.items[0]
|
||
const popup = g.items.map(i => `<b>${_esc(i.label)}.</b> ${_esc(i.subject)}${i.time ? ' · ' + _esc(i.time) : ''}${i.customer ? ' · ' + _esc(i.customer) : ''}`).join('<br>')
|
||
addPin(g.lon, g.lat, pinHtml(f.color, skillIcon(f.skill), f.label, g.items, false), popup, f.address) // n° = job principale ; barre = N jobs à l'adresse
|
||
}
|
||
if (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) { // pin de départ (dépôt/domicile) : icône maison/entrepôt
|
||
const oc = origin.kind === 'home' ? '#00897b' : '#37474f'
|
||
addPin(+origin.lon, +origin.lat, pinHtml(oc, origin.kind === 'home' ? 'home' : 'warehouse', '', null, true), '<b>Départ</b> — ' + _esc(origin.label || ''), origin.address || '')
|
||
}
|
||
const all = (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? [...stops, { lon: +origin.lon, lat: +origin.lat }] : stops
|
||
if (all.length === 1) _dayMap.easeTo({ center: [all[0].lon, all[0].lat], zoom: 13, duration: 400 })
|
||
else if (all.length > 1) {
|
||
const b = new window.mapboxgl.LngLatBounds([all[0].lon, all[0].lat], [all[0].lon, all[0].lat])
|
||
all.forEach(s => b.extend([s.lon, s.lat]))
|
||
_dayMap.fitBounds(b, { padding: 45, maxZoom: 14, duration: 400 })
|
||
}
|
||
fetchDayRouteGeom(stops, origin)
|
||
}
|
||
async function fetchDayRouteGeom (stops, origin) { // itinéraire ROUTIER réel (Directions) → tracé sur la carte, DEPUIS l'origine
|
||
const rSrc = _dayMap && _dayMap.getSource('day-route'); if (!rSrc) return
|
||
const ordered = (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? [{ lon: +origin.lon, lat: +origin.lat }, ...(stops || [])] : (stops || [])
|
||
if (ordered.length < 2) { rSrc.setData({ type: 'FeatureCollection', features: [] }); return }
|
||
try {
|
||
const d = await roster.osrmRoute(ordered.slice(0, 50).map(s => [+s.lon, +s.lat])) // OSRM auto-hébergé (via hub) — l'U-turn aux waypoints est permis par défaut
|
||
const src = _dayMap && _dayMap.getSource('day-route')
|
||
if (d && Array.isArray(d.geometry) && d.geometry.length && src) src.setData({ type: 'Feature', geometry: { type: 'LineString', coordinates: d.geometry }, properties: {} })
|
||
} catch (e) { /* repli : pas de tracé routier (les pins restent visibles) */ }
|
||
}
|
||
function destroyDayMap () {
|
||
if (_dirTimer) { clearTimeout(_dirTimer); _dirTimer = null }
|
||
if (_dayMapRO) { _dayMapRO.disconnect(); _dayMapRO = null }
|
||
_dayStopMarkers.forEach(m => { try { m.remove() } catch (e) {} }); _dayStopMarkers = []
|
||
if (_dayMap) { try { _dayMap.remove() } catch (e) {} _dayMap = null }
|
||
}
|
||
// (ré)init à l'ouverture du dialogue (après l'anim) ; refresh débouncé au réordonnancement ; destruction à la fermeture.
|
||
watch(() => dayEditor.open, (open) => { if (open) nextTick(() => setTimeout(initDayMap, 250)); else destroyDayMap() })
|
||
watch(() => dayEditor.list.map(j => j.name).join(','), () => { if (_dayMap) { clearTimeout(_dirTimer); _dirTimer = setTimeout(refreshDayMap, 500) } })
|
||
const dayTotalH = () => Math.round(dayEditor.list.reduce((s, j) => s + (Number(j.dur) || 0), 0) * 10) / 10
|
||
// Chrono (boucle de capture) : début/fin réels → durée mesurée (alimente l'apprentissage des durées par type×tech)
|
||
async function startJobChrono (j) { try { const r = await roster.startJob(j.name); j.actual_start = r.actual_start || '1'; j.actual_end = ''; $q.notify({ type: 'positive', icon: 'play_arrow', message: 'Job démarré (chrono)', timeout: 1500 }) } catch (e) { err(e) } }
|
||
async function finishJobChrono (j) { try { const r = await roster.finishJob(j.name); j.actual_end = '1'; j.actual_min = r.actual_minutes; $q.notify({ type: 'positive', icon: 'check', message: `Terminé · ${r.actual_minutes} min réels`, timeout: 2500 }); reloadOccupancy() } catch (e) { err(e) } }
|
||
async function removeFromDay (j) {
|
||
// TAMPON : on désassigne SEULEMENT dans OPS (ERPNext), comme le clic-droit « Renvoyer au pool ». AUCUNE écriture dans F ici.
|
||
// Le retour au pool dans F (legacy) ne part qu'à « Publier au legacy » (action explicite). Évite tout write-back surprise.
|
||
try {
|
||
await roster.unassignJobRoster(j.name)
|
||
dayEditor.list = dayEditor.list.filter(x => x.name !== j.name); await loadWeek()
|
||
$q.notify({ type: 'info', message: 'Retiré du tech (retour au pool OPS). À publier dans F via « Publier au legacy ».', timeout: 2800 })
|
||
} catch (e) { err(e) }
|
||
}
|
||
// « Optimiser depuis le départ » : ordonne par PLUS-PROCHE-VOISIN à partir de l'origine (dépôt/domicile)
|
||
// → la 1re job devient la plus proche de l'origine. Non destructif : l'utilisateur Enregistre ensuite.
|
||
function optimizeFromOrigin () {
|
||
const origin = dayOrigin.value
|
||
// Positions FIGÉES respectées : les « 1er » restent en tête (dans leur ordre), les « dernier » en queue ; on optimise le MILIEU.
|
||
const first = dayEditor.list.filter(j => j.pin === 'first')
|
||
const last = dayEditor.list.filter(j => j.pin === 'last')
|
||
const middle = dayEditor.list.filter(j => j.pin !== 'first' && j.pin !== 'last')
|
||
const withLL = middle.filter(hasLL)
|
||
const noLL = middle.filter(j => !hasLL(j))
|
||
if (withLL.length < 2 && !first.length && !last.length) { $q.notify({ type: 'info', message: 'Pas assez d’arrêts géolocalisés à optimiser' }); return }
|
||
const remaining = withLL.slice(); const ordered = []
|
||
// Point de départ de l'optim : dernière job figée-en-1er (si géoloc), sinon l'origine (domicile/dépôt), sinon le 1er arrêt du milieu.
|
||
let cur = (first.length && hasLL(first[first.length - 1])) ? first[first.length - 1]
|
||
: (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? origin
|
||
: remaining.shift()
|
||
if (cur && !first.includes(cur) && cur !== origin) ordered.push(cur)
|
||
while (remaining.length) {
|
||
let bi = 0, bd = Infinity
|
||
for (let i = 0; i < remaining.length; i++) { const lg = travelBetween(cur, remaining[i]); const d = lg ? lg.min : 9e9; if (d < bd) { bd = d; bi = i } }
|
||
cur = remaining[bi]; ordered.push(remaining.splice(bi, 1)[0])
|
||
}
|
||
dayEditor.list = [...first, ...ordered, ...noLL, ...last]
|
||
const pinNote = (first.length || last.length) ? ' (positions figées respectées)' : ''
|
||
$q.notify({ type: 'positive', icon: 'route', message: 'Tournée optimisée' + pinNote + (origin ? ' depuis ' + (origin.kind === 'home' ? 'le domicile' : 'le dépôt') : ''), timeout: 2000 })
|
||
}
|
||
async function saveDayOrder () {
|
||
dayEditor.saving = true
|
||
const packed = packedDay.value // heures recalculées par la tournée → on les persiste (start_time)
|
||
// Les jobs LEGACY (F autoritaire) ne sont PAS écrits — on ne persiste que les Dispatch Jobs roster (route_order séquentiel).
|
||
const updates = dayEditor.list.map((j, i) => ({ j, i })).filter(x => !x.j.legacy).map(({ j, i }, k) => ({ job: j.name, route_order: k + 1, priority: j.priority, duration_h: Number(j.dur) || 1, start_time: fmtHM(packed[i].startMin) }))
|
||
try { const r = await roster.reorderJobs(updates); dayEditor.open = false; await loadWeek(); $q.notify({ type: 'positive', message: 'Tournée enregistrée — ordre · heures · durées (' + (r.updated || 0) + ')', timeout: 2400 }) } catch (e) { err(e) } finally { dayEditor.saving = false }
|
||
}
|
||
const timelineDays = computed(() => {
|
||
const t = timelineDlg.tech; if (!t) return []
|
||
const out = []
|
||
for (const d of dayList.value) {
|
||
const shift = hasReg(t.id, d.iso) || onGarde(t.id, d.iso)
|
||
const jobs = shift ? cellJobs(t.id, d.iso) : rawCellJobs(t.id, d.iso) // hors quart : jobs bruts
|
||
if (!jobs.length && !shift) continue // on saute les jours vides
|
||
const o = cellOcc(t.id, d.iso)
|
||
const usedH = shift ? (o ? o.usedH : 0) : Math.round(jobs.reduce((s, j) => s + (j.dur || 0), 0) * 10) / 10
|
||
out.push({ iso: d.iso, label: d.dow + ' ' + d.dnum, weekend: d.weekend, bands: cellBands(t.id, d.iso), blocks: cellBlocks(t.id, d.iso), jobs, pct: shift ? cellPct(t.id, d.iso) : null, usedH, offShift: !shift && jobs.length > 0 })
|
||
}
|
||
return out
|
||
})
|
||
// Sauvegarde DEBOUNCÉE + silencieuse (succès) : coalesce les clics rapides → 1 seul appel (évite « load fail » concurrents).
|
||
let _skillSaveTimer = null
|
||
function queueSkillSave (t) { if (_skillSaveTimer) clearTimeout(_skillSaveTimer); _skillSaveTimer = setTimeout(() => { _skillSaveTimer = null; doSaveSkillData(t) }, 500) }
|
||
async function doSaveSkillData (t) {
|
||
const sset = new Set(t.skills || [])
|
||
const lv = {}; for (const k in (t.skill_levels || {})) if (sset.has(k)) lv[k] = t.skill_levels[k]
|
||
const ef = {}; for (const k in (t.skill_eff || {})) if (sset.has(k)) ef[k] = t.skill_eff[k]
|
||
t.skill_levels = lv; t.skill_eff = ef
|
||
try { await roster.setTechSkills(t.id, (t.skills || []).join(','), lv, ef) } catch (e) { $q.notify({ type: 'negative', message: 'Échec sauvegarde compétences — réessaie', timeout: 1800 }) }
|
||
}
|
||
// ── Catalogue de compétences (réutilise TagEditor) : couleurs persistées + création/suppression ──
|
||
// Palette élargie (incl. roses/magentas) + sélecteur HTML natif pour toute couleur.
|
||
const TAG_PALETTE = [
|
||
'#6366f1', '#3b82f6', '#0ea5e9', '#06b6d4', '#14b8a6', '#10b981', '#22c55e', '#84cc16',
|
||
'#eab308', '#f59e0b', '#f97316', '#ef4444', '#f43f5e', '#fb7185', '#ec4899', '#f472b6',
|
||
'#db2777', '#d946ef', '#a855f7', '#8b5cf6', '#78716c', '#64748b', '#94a3b8', '#111827',
|
||
]
|
||
function hashColor (label) { let h = 0; for (const c of String(label)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return TAG_PALETTE[h % TAG_PALETTE.length] }
|
||
const customTags = ref([]) // [{label,color}] créés à la volée (localStorage)
|
||
function saveCustomTags () { localStorage.setItem('roster-skill-tags-v1', JSON.stringify(customTags.value)) }
|
||
function getTagColor (label) { const ct = customTags.value.find(x => x.label === label); return (ct && ct.color) || hashColor(label) }
|
||
// Teinte PASTEL (≈80 % blanc + 20 % couleur) → fond doux des blocs (moins chargé), avec contour/icône en couleur pleine.
|
||
function pastelColor (hex) {
|
||
const h = String(hex || '').replace('#', ''); if (h.length < 6) return '#eef1f4'
|
||
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16)
|
||
const mix = (c) => Math.round(255 * 0.80 + (isNaN(c) ? 200 : c) * 0.20)
|
||
return `rgb(${mix(r)},${mix(g)},${mix(b)})`
|
||
}
|
||
function hexA (hex, a) { const h = String(hex || '').replace('#', ''); if (h.length < 6) return `rgba(120,130,145,${a})`; return `rgba(${parseInt(h.slice(0, 2), 16)},${parseInt(h.slice(2, 4), 16)},${parseInt(h.slice(4, 6), 16)},${a})` }
|
||
// Fond HACHURÉ (renfort/assistant) : rayures diagonales de la couleur de compétence sur blanc → « réservé sur une autre job ».
|
||
function hatchBg (hex) { const c = hexA(hex, 0.22); return `repeating-linear-gradient(45deg, ${c} 0, ${c} 5px, #ffffff 5px, #ffffff 11px)` }
|
||
// Couleur d'une carte job = COULEUR DE SA COMPÉTENCE (éditable via le gestionnaire de tags → cohérent + simple).
|
||
// required_skill est renseigné côté hub (skill explicite, sinon déduit du type legacy). Repli : couleur du type.
|
||
function panelJobColor (j) { return j.required_skill ? getTagColor(j.required_skill) : (legacyDeptColor(j.legacy_dept) || '#90a4ae') }
|
||
const tagCatalog = computed(() => {
|
||
const m = new Map()
|
||
for (const ct of customTags.value) m.set(ct.label, { name: ct.label, label: ct.label, color: ct.color || hashColor(ct.label), category: 'Custom' })
|
||
for (const s of [...allSkills.value, ...jobTypes.value, 'installation', 'réparation', 'support', 'fibre', 'aérien', 'épissure']) if (s && !m.has(s)) m.set(s, { name: s, label: s, color: getTagColor(s), category: jobTypes.value.includes(s) ? 'Type de job' : 'Compétence' })
|
||
return [...m.values()]
|
||
})
|
||
function onCreateRosterTag ({ label, color }) { if (label && !customTags.value.some(x => x.label === label)) { customTags.value.push({ label, color: color || hashColor(label) }); saveCustomTags() } }
|
||
function onUpdateRosterTag ({ name, color }) { const ct = customTags.value.find(x => x.label === name); if (ct) ct.color = color; else customTags.value.push({ label: name, color }); saveCustomTags() }
|
||
// (onDeleteRosterTag retiré : la suppression de tag passe par deleteTagGlobal du gestionnaire ci-dessous)
|
||
// ── Gestionnaire global de compétences (tags) : renommer / supprimer PARTOUT, recolorer, voir l'usage ──
|
||
const showTagManager = ref(false)
|
||
const managedTags = computed(() => {
|
||
const labels = new Set([...techs.value.flatMap(t => t.skills || []), ...customTags.value.map(c => c.label)])
|
||
return [...labels].filter(Boolean).sort((a, b) => a.localeCompare(b)).map(l => ({ label: l, color: getTagColor(l), count: techs.value.filter(t => (t.skills || []).includes(l)).length }))
|
||
})
|
||
async function renameTagGlobal (oldL, newL) { // remplace le label sur TOUS les techs (skills + niveaux + eff) + catalogue
|
||
newL = String(newL || '').trim(); if (!newL || newL === oldL) return
|
||
for (const t of techs.value) { // SÉQUENTIEL (pas de Promise.all sur erp)
|
||
if (!(t.skills || []).includes(oldL)) continue
|
||
t.skills = t.skills.map(s => s === oldL ? newL : s)
|
||
if (t.skill_levels && t.skill_levels[oldL] != null) { const m = { ...t.skill_levels, [newL]: t.skill_levels[oldL] }; delete m[oldL]; t.skill_levels = m }
|
||
if (t.skill_eff && t.skill_eff[oldL] != null) { const m = { ...t.skill_eff, [newL]: t.skill_eff[oldL] }; delete m[oldL]; t.skill_eff = m }
|
||
try { await roster.setTechSkills(t.id, t.skills.join(','), t.skill_levels || {}, t.skill_eff || {}) } catch (e) { err(e) }
|
||
}
|
||
const ct = customTags.value.find(c => c.label === oldL); if (ct) ct.label = newL; else customTags.value.push({ label: newL, color: getTagColor(oldL) })
|
||
saveCustomTags(); await syncSkillByType({ [oldL]: newL }, null) // cohérence avec la table type de job → compétence (booking)
|
||
$q.notify({ type: 'positive', message: '« ' + oldL +' » renommée « ' + newL + ' » partout', timeout: 2500 })
|
||
}
|
||
// Propage rename/delete d'un tag vers la table booking skill_by_type (Copilote #56) → cohérence globale du filtre.
|
||
async function syncSkillByType (renames, deletes) {
|
||
try {
|
||
const d = await roster.getPolicy(); const map = { ...((d.policy && d.policy.booking && d.policy.booking.skill_by_type) || {}) }; let changed = false
|
||
for (const k in map) { if (deletes && deletes.includes(map[k])) { delete map[k]; changed = true } else if (renames && renames[map[k]]) { map[k] = renames[map[k]]; changed = true } }
|
||
if (changed) await roster.savePolicy({ booking: { skill_by_type: map } })
|
||
} catch (e) { /* non bloquant */ }
|
||
}
|
||
async function deleteTagGlobal (tg) {
|
||
if (tg.count && !window.confirm('Supprimer « ' + tg.label + ' » de ' + tg.count + ' technicien(s) ?')) return
|
||
for (const t of techs.value) {
|
||
if (!(t.skills || []).includes(tg.label)) continue
|
||
t.skills = t.skills.filter(s => s !== tg.label)
|
||
if (t.skill_levels) { const m = { ...t.skill_levels }; delete m[tg.label]; t.skill_levels = m }
|
||
if (t.skill_eff) { const m = { ...t.skill_eff }; delete m[tg.label]; t.skill_eff = m }
|
||
try { await roster.setTechSkills(t.id, t.skills.join(','), t.skill_levels || {}, t.skill_eff || {}) } catch (e) { err(e) }
|
||
}
|
||
customTags.value = customTags.value.filter(c => c.label !== tg.label); saveCustomTags()
|
||
await syncSkillByType(null, [tg.label]) // retire aussi le tag de la table type de job → compétence (booking)
|
||
$q.notify({ type: 'info', message: '« ' + tg.label + ' » supprimée partout', timeout: 2000 })
|
||
}
|
||
function toggleSkill (sk) { const i = skillFilter.value.indexOf(sk); if (i >= 0) skillFilter.value.splice(i, 1); else skillFilter.value.push(sk) }
|
||
function techHasSkill (t) { const sk = (t.skills || []); return !skillFilter.value.length || skillFilter.value.every(f => sk.includes(f)) } // ET : toutes les compétences cochées requises
|
||
// Score de priorité (0 = meilleur) sur les techs qualifiés. DEUX dimensions DISTINCTES + coût :
|
||
// • COMPÉTENCE = niveau 1–5 dans la/les compétence(s) filtrée(s) (qualité ; haut = mieux)
|
||
// • VITESSE = efficiency (<1 = plus rapide = mieux) — un tech peut être très compétent MAIS lent
|
||
// • COÛT = coût chargé $/h. Pondéré compétence 0,4 · vitesse 0,3 · coût 0,3 (sans filtre : vitesse/coût 50/50).
|
||
function techCompetence (t) { const f = skillFilter.value; if (!f.length) return null; const ls = f.map(s => (t.skill_levels && t.skill_levels[s]) || 1); return ls.reduce((a, b) => a + b, 0) / ls.length }
|
||
// Vitesse retenue = efficacité PAR compétence filtrée (skillEffOf, fallback global) ; sinon efficacité globale.
|
||
function techSpeed (t) { const f = skillFilter.value; if (!f.length) return Number(t.efficiency) || 1; const es = f.map(s => skillEffOf(t, s)); return es.reduce((a, b) => a + b, 0) / es.length }
|
||
// PROXIMITÉ (à venir) : on gère le secteur MANUELLEMENT pour l'instant (zones). Ce hook renverra
|
||
// plus tard une distance normalisée 0..1 (0 = sur place) job↔tech (lat/lng de la Service Location vs
|
||
// secteur/base du tech). Tant qu'il retourne null, le score reste inchangé (poids redistribué).
|
||
// eslint-disable-next-line no-unused-vars
|
||
function techProximity (t /*, job */) { return null } // TODO proximité : brancher une fois lat/lng dispo
|
||
const priorityScores = computed(() => {
|
||
const cands = techs.value.filter(t => !isHidden(t.id) && techHasSkill(t)); const m = {}
|
||
if (!cands.length) return m
|
||
const effs = cands.map(techSpeed); const costs = cands.map(t => Number(t.cost_h) || 0)
|
||
const eMin = Math.min(...effs); const eMax = Math.max(...effs); const cMin = Math.min(...costs); const cMax = Math.max(...costs)
|
||
const norm = (v, lo, hi) => (hi > lo ? (v - lo) / (hi - lo) : 0)
|
||
for (const t of cands) {
|
||
const eff = norm(techSpeed(t), eMin, eMax); const cost = norm(Number(t.cost_h) || 0, cMin, cMax)
|
||
const comp = techCompetence(t)
|
||
const prox = techProximity(t) // null pour l'instant → ignoré (secteur manuel)
|
||
let s
|
||
if (comp == null) s = 0.5 * eff + 0.5 * cost
|
||
else { const compN = (Math.min(5, Math.max(1, comp)) - 1) / 4; s = 0.4 * (1 - compN) + 0.3 * eff + 0.3 * cost } // compétence ⊕ vitesse(par-skill) ⊕ coût
|
||
if (prox != null) s = 0.8 * s + 0.2 * prox // quand la proximité arrivera : 20% du score (réservé)
|
||
m[t.id] = s
|
||
}
|
||
return m
|
||
})
|
||
function techScore (t) { const v = priorityScores.value[t.id]; return v == null ? 0 : v }
|
||
function techRank (t) { if (!skillFilter.value.length) return null; const i = visibleTechs.value.findIndex(x => x.id === t.id); return i >= 0 ? i + 1 : null }
|
||
const visibleTechs = computed(() => {
|
||
const q = (search.value || '').trim().toLowerCase() // clearable (X) met search=null → null-safe (sinon le computed plante et le champ « ne se vide pas »)
|
||
const list = techs.value.filter(t => (showHidden.value || !isHidden(t.id)) && (!groupFilter.value || t.group === groupFilter.value) && techHasSkill(t) && (!q || (t.name || '').toLowerCase().includes(q) || (t.group || '').toLowerCase().includes(q)))
|
||
// Filtre par compétence actif → on TRIE par priorité (meilleur score d'abord) ; sinon ordre équipe/nom.
|
||
if (skillFilter.value.length) return list.slice().sort((a, b) => techScore(a) - techScore(b) || (a.name || '').localeCompare(b.name || ''))
|
||
return list.slice().sort((a, b) => (a.group || '~').localeCompare(b.group || '~') || (a.name || '').localeCompare(b.name || ''))
|
||
})
|
||
|
||
const cellsByTechDay = computed(() => { const m = {}; for (const a of assignments.value) { const t = (m[a.tech] || (m[a.tech] = {})); (t[a.date] || (t[a.date] = [])).push(a) } return m })
|
||
function cellsOf (techId, iso) { return (cellsByTechDay.value[techId] && cellsByTechDay.value[techId][iso]) || [] }
|
||
function isPaused (t) { return t.status === 'En pause' }
|
||
function hoursOf (techId) { let h = 0; for (const a of assignments.value) { if (a.tech !== techId) continue; const t = tplByName.value[a.shift]; if (t && t.on_call) continue; h += Number(a.hours) || 0 } return h } // garde exclue (mise en dispo, pas travaillée)
|
||
|
||
const serverSet = ref(new Set())
|
||
const currentSet = computed(() => new Set(assignments.value.map(a => a.tech + '|' + a.date + '|' + a.shift)))
|
||
const diffKeys = computed(() => { const cur = currentSet.value; const srv = serverSet.value; const d = []; for (const k of cur) if (!srv.has(k)) d.push(k); for (const k of srv) if (!cur.has(k)) d.push(k); return d })
|
||
const dirty = computed(() => diffKeys.value.length > 0)
|
||
const dirtyCount = computed(() => diffKeys.value.length)
|
||
const dirtyCells = computed(() => new Set(diffKeys.value.map(k => k.slice(0, k.lastIndexOf('|')))))
|
||
function isCellDirty (techId, iso) { return dirtyCells.value.has(techId + '|' + iso) }
|
||
|
||
const holSet = computed(() => new Set(holidays.value))
|
||
const statHolSet = computed(() => new Set(statHolidays.value.map(h => h.date)))
|
||
const statHolName = computed(() => { const m = {}; for (const h of statHolidays.value) m[h.date] = h.name; return m })
|
||
function isHoliday (iso) { return holSet.value.has(iso) || statHolSet.value.has(iso) } // manuel OU férié QC déterministe
|
||
function holidayLabel (iso) { return statHolName.value[iso] || (holSet.value.has(iso) ? 'Congé' : '') }
|
||
// toggle = ajoute/retire un congé MANUEL (les fériés QC restent auto, non togglables ici)
|
||
function toggleHoliday (iso) { holidays.value = holSet.value.has(iso) ? holidays.value.filter(x => x !== iso) : [...holidays.value, iso]; localStorage.setItem(LS_HOL, JSON.stringify(holidays.value)) }
|
||
// Préavis de disponibilité avant un férié (rec 2) : aperçu + envoi SMS aux techs non encore prévenus (confirmation requise).
|
||
const holNoticePreview = ref([])
|
||
const nextHolidayNotify = computed(() => (holNoticePreview.value || []).find(h => h.to_notify > 0) || null)
|
||
function sendHolidayPreavis (h) {
|
||
if (!h) return
|
||
$q.dialog({ title: 'Préavis de férié', message: `Envoyer un SMS à ${h.to_notify} technicien(s) pour ${h.name} (${h.date}) — leur demander s'ils travaillent et de proposer des dates de remplacement ?`, cancel: true, ok: { label: 'Envoyer', color: 'deep-orange' } }).onOk(async () => {
|
||
try { const r = await roster.sendHolidayNotice(h.date); $q.notify({ type: r && r.ok ? 'positive' : 'negative', message: r && r.ok ? `Préavis envoyé à ${r.sent} tech(s)` : ((r && r.error) || 'Échec'), timeout: 2600 }); const p = await roster.holidayNotice(40); holNoticePreview.value = p.holidays || [] } catch (e) { err(e) }
|
||
})
|
||
}
|
||
const selSet = computed(() => new Set(selection.value))
|
||
function isSelected (techId, iso) { return selSet.value.has(techId + '|' + iso) }
|
||
|
||
const statByDate = computed(() => Object.fromEntries(dailyStats.value.map(s => [s.date, s])))
|
||
function stat (iso) { const s = statByDate.value[iso] || {}; const g = gardeCountByDate.value[iso]; return g != null ? { ...s, on_call: g } : s } // on_call = calque live (cohérent avec la grille)
|
||
const hasOnCall = computed(() => Object.keys(gardeEffective.value).length > 0 || dailyStats.value.some(s => s.on_call > 0))
|
||
|
||
// Micro-timeline 24 h par cellule : fenêtre(s) du shift = bande neutre, jobs pris = trait coloré.
|
||
const occByTechDay = ref({})
|
||
// Déclencheur matrice Mapbox du Kanban (défini ICI, après occByTechDay/visibleTechs, pour éviter la zone morte temporelle).
|
||
watch([boardView, kanbanDay, occByTechDay], () => {
|
||
if (boardView.value !== 'kanban') return
|
||
clearTimeout(_kbMatT); _kbMatT = setTimeout(() => { for (const t of visibleTechs.value) fetchKbMatrix(t.id) }, 350) // 1 call/tournée visible, débouncé + caché
|
||
})
|
||
const absByTechDay = ref({}) // tech|date → type d'absence (En pause / Congé / Maladie…) → hachuré
|
||
function isAbsent (techId, iso) { return !!absByTechDay.value[techId + '|' + iso] }
|
||
function absenceLabel (techId, iso) { return absByTechDay.value[techId + '|' + iso] || 'Absent' }
|
||
async function reloadAbsences () { try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) {} }
|
||
// Bascule absence d'1 jour sur des cases (clic + « A » ou menu). Si toutes absentes → retire ; sinon marque.
|
||
async function toggleAbsentCells (targets) {
|
||
if (!targets || !targets.length) return
|
||
const allAbsent = targets.every(k => { const [tid, iso] = k.split('|'); return isAbsent(tid, iso) })
|
||
for (const k of targets) { const [tid, iso] = k.split('|'); try { await roster.setAbsence(tid, iso, 'Congé', allAbsent) } catch (e) { err(e) } }
|
||
await reloadAbsences()
|
||
$q.notify({ type: 'info', message: allAbsent ? 'Absence retirée' : (targets.length + ' absence(s) marquée(s)') })
|
||
if (!allAbsent) await checkAbsenceImpact(targets) // marquage → vérifier les jobs assignés impactés (IROPS)
|
||
}
|
||
function saveManualGarde () { localStorage.setItem(LS_GARDE_MANUAL, JSON.stringify(manualGarde.value)) }
|
||
// Override manuel : ne stocke QUE les écarts vs règles (want===défaut-règle → on retire l'override) → carte minimale.
|
||
function setGardeCell (m, key, want) { const ruleHas = !!gardeOverlay.value[key]; if (want === ruleHas) delete m[key]; else m[key] = want ? 'on' : 'off' }
|
||
// Bascule la garde sur des cases (clic + « G » ou menu). Toutes déjà de garde → retire ; sinon ajoute.
|
||
function toggleGardeCells (targets) {
|
||
if (!targets || !targets.length) return
|
||
const allG = targets.every(k => { const [tid, iso] = k.split('|'); return onGarde(tid, iso) })
|
||
const want = !allG // tout en garde ⇒ on retire ; sinon on place
|
||
if (want && !templates.value.some(t => t.on_call)) { $q.notify({ type: 'warning', message: 'Aucun modèle de garde (🛡️) — créez-en un dans « Types de shift »' }); return }
|
||
const m = { ...manualGarde.value }; let n = 0
|
||
for (const k of targets) { const [tid, iso] = k.split('|'); if (onGarde(tid, iso) !== want) { setGardeCell(m, k, want); n++ } }
|
||
manualGarde.value = m; saveManualGarde()
|
||
$q.notify({ type: 'info', message: want ? (n + ' garde(s) placée(s)') : (n + ' garde(s) retirée(s)') })
|
||
}
|
||
function hToNum (t) { if (!t) return null; const p = String(t).split(':'); return Number(p[0]) + (Number(p[1]) || 0) / 60 }
|
||
function fmtH (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return mm ? (hh + ':' + String(mm).padStart(2, '0')) : ('' + hh) }
|
||
// Axe ADAPTATIF : se cale sur l'amplitude réelle des shifts réguliers + la garde AFFICHÉE (calque),
|
||
// pour que les quarts de garde de soir/nuit (17h–minuit, 8h–minuit) soient visibles sur l'échelle.
|
||
const axisBounds = computed(() => {
|
||
let lo = Infinity; let hi = -Infinity
|
||
const grow = (t) => { if (!t) return; const s = hToNum(t.start_time); const e = hToNum(t.end_time); if (s != null) lo = Math.min(lo, s); if (e != null) hi = Math.max(hi, e <= s ? 24 : e) }
|
||
for (const techId in cellsByTechDay.value) { const day = cellsByTechDay.value[techId]; for (const iso in day) for (const a of day[iso]) { const t = tplByName.value[a.shift]; if (!t || t.on_call) continue; grow(t) } }
|
||
// Garde NON incluse dans l'axe (rendue comme bande « sur appel ») → l'axe reste FOCALISÉ sur les heures de travail.
|
||
if (!isFinite(lo) || !isFinite(hi)) return { min: 6, max: 19 }
|
||
lo = Math.max(0, Math.min(6, Math.floor(lo))); hi = Math.min(24, Math.max(19, Math.ceil(hi))) // focus 6–19, étendu seulement si un quart régulier déborde
|
||
return { min: lo, max: hi }
|
||
})
|
||
// Graduations horaires pour la règle d'en-tête (alignées sur l'axe adaptatif)
|
||
const axisTicks = computed(() => {
|
||
const b = axisBounds.value; const span = (b.max - b.min) || 24
|
||
const step = span > 13 ? 4 : (span > 7 ? 3 : 2); const out = []
|
||
for (let h = Math.ceil(b.min / step) * step; h <= b.max; h += step) out.push({ h, left: ((h - b.min) / span * 100) + '%' })
|
||
return out
|
||
})
|
||
function pos (s, e) { const b = axisBounds.value; const span = (b.max - b.min) || 24; const L = Math.max(0, (s - b.min) / span * 100); const R = Math.min(100, (e - b.min) / span * 100); return { left: L + '%', width: Math.max(1.5, R - L) + '%' } }
|
||
// Barre de temps PÂLE : bleu très pâle le matin → violet pâle le soir (repère discret du « quand »)
|
||
function todColor (h) { const t = Math.max(0, Math.min(1, (h - 6) / 15)); return 'hsl(' + Math.round(210 + t * 60) + ',45%,' + Math.round(91 - t * 8) + '%)' }
|
||
function bandGradient (s, e) { return 'linear-gradient(to right, ' + todColor(s) + ', ' + todColor(e) + ')' }
|
||
// Bandes = shifts réguliers (dégradé) + GARDE en CALQUE LIVE (calculée depuis les règles, pointillé ambre).
|
||
// La garde n'est PAS stockée : on l'ignore dans cellsOf (on_call) et on la recalcule → temps réel, pas de désync.
|
||
function pushBand (out, s, e, opt) { if (s == null || e == null) return; if (e <= s) { out.push({ ...pos(s, 24), ...opt }); out.push({ ...pos(0, e), ...opt }) } else out.push({ ...pos(s, e), ...opt }) }
|
||
function cellBands (techId, iso) {
|
||
const out = []
|
||
for (const a of cellsOf(techId, iso)) {
|
||
const t = tplByName.value[a.shift]; if (!t || t.on_call) continue // garde stockée ignorée → gérée par le calque live
|
||
const s = hToNum(t.start_time); const e = hToNum(t.end_time); if (s == null || e == null) continue
|
||
pushBand(out, s, e, { oncall: false, bg: bandGradient(s, e <= s ? 24 : e) })
|
||
}
|
||
const gShift = gardeEffective.value[techId + '|' + iso] // garde EFFECTIVE (règles + overrides manuels « G »)
|
||
if (gShift) { const t = tplByName.value[gShift]; if (t) pushBand(out, hToNum(t.start_time), hToNum(t.end_time), { oncall: true }) }
|
||
return out
|
||
}
|
||
// Barre de statut OPAQUE selon l'occupation : vert (peu) → orange (plein) → rouge (surbooké).
|
||
function occColor (pct) { if (pct == null) return '#9e9e9e'; if (pct >= 100) return '#e53935'; const t = Math.max(0, Math.min(1, pct / 100)); return 'hsl(' + Math.round(122 - t * 90) + ',68%,44%)' }
|
||
// Bloc = 1 job, coloré par la COULEUR DE SA COMPÉTENCE (palette skills éditable). Repli : couleur d'occupation.
|
||
// OPS-only = job dispatché dans OPS mais PAS encore dans F (autoritaire) : aucun legacy_id et pas un bloc synthétique F.
|
||
function blkOpsOnly (b) { return !!(b && !b.legacy && !b.legacy_id) }
|
||
// Couleur d'un bloc : dépt F (legacyDeptColor) si connu, sinon compétence OPS. PARTAGÉ grille + kanban (bordure, icône, remplissage).
|
||
function blkColor (b) { if (b && b.done) return '#6b7280'; return (b && (b.dept || b.legacy_dept) ? legacyDeptColor(b.dept || b.legacy_dept) : null) || (b && b.skill ? getTagColor(b.skill) : '#90a4ae') }
|
||
// Remplissage d'un bloc. F autoritaire (synthétique legacy OU job lié à un ticket F) = couleur PLEINE du dépt.
|
||
// OPS-only (pas encore publié dans F) = PÂLE + contour POINTILLÉ. assist = hachuré.
|
||
function blkFill (blk) {
|
||
const c = blkColor(blk)
|
||
if (blk && blk.done) return { background: '#e4e7eb', borderColor: '#aab2bd' } // COMPLÉTÉ = gris neutre (se distingue nettement des blocs colorés actifs) + ✓
|
||
if (blk && blk.assist) return { background: hatchBg(c), borderColor: c, borderStyle: 'dashed' }
|
||
if (blkOpsOnly(blk)) return { background: pastelColor(c), borderColor: c, borderStyle: 'dotted', borderWidth: '2px', opacity: '.72' }
|
||
return { background: pastelColor(c), borderColor: c }
|
||
}
|
||
function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), ...blkFill(blk) } } // F = couleur dépt pleine ; OPS-non-publié = pâle pointillé ; assist = hachuré
|
||
// Fenêtre des shifts (garde=true → seulement les quarts de garde ; garde=false → réguliers)
|
||
function winOf (techId, iso, garde) { let s = Infinity; let e = -Infinity; for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || (!!t.on_call) !== garde) continue; const st = hToNum(t.start_time); const en = hToNum(t.end_time); if (st != null) s = Math.min(s, st); if (en != null) e = Math.max(e, en) } return isFinite(s) ? { s, e } : null }
|
||
const occCells = computed(() => {
|
||
const m = {}; const ct = cellsByTechDay.value
|
||
for (const techId in ct) for (const iso in ct[techId]) {
|
||
const cells = ct[techId][iso]; if (!cells.length) continue
|
||
let bookableH = 0; let hasGarde = false; let hasReg = false
|
||
for (const a of cells) { const t = tplByName.value[a.shift]; if (t && t.on_call) hasGarde = true; else { hasReg = true; bookableH += Number(a.hours) || 0 } }
|
||
const o = occByTechDay.value[techId + '|' + iso] || { h: 0, blocks: [], jobs: [] }
|
||
let usedH = o.h || 0; const jobs = o.jobs || []
|
||
// Fenêtre du quart (début/fin). Les jobs HORODATÉS (o.blocks) restent à leur heure = « lock ». Les jobs sans heure
|
||
// (ingérés du legacy) + legacy résiduel coulent EN ORDRE (route_order, o.jobs déjà trié) depuis la fin des horodatés,
|
||
// MIS À L'ÉCHELLE pour TENIR dans la fenêtre du quart → plus de débordement ; la SURCHARGE reste signalée par le %.
|
||
let winS = Infinity, winE = -Infinity
|
||
for (const a of cells) { const t = tplByName.value[a.shift]; if (t && !t.on_call) { const s = hToNum(t.start_time), e = hToNum(t.end_time); if (s != null) winS = Math.min(winS, s); if (e != null) winE = Math.max(winE, e <= s ? 24 : e) } }
|
||
if (!isFinite(winS)) { winS = 8; winE = 17 } else if (!(winE > winS)) winE = winS + 8
|
||
// VERROUILLÉ = RDV CONFIRMÉS + miroirs assistant (à leur heure réelle). Tout le reste COULE — même un job
|
||
// « à planifier » qui porte une heure incidente (héritée du legacy) — pour remplir le quart sans trou.
|
||
// (Cohérent avec la vue Jour/Kanban : avant, la grille verrouillait TOUT job ayant un start_time → trous + faux débordements.)
|
||
const dur1 = (j) => Number(j.dur) || (j.est_min ? j.est_min / 60 : 1)
|
||
const ordered = (jobs || []).slice().sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
|
||
const locked = []
|
||
for (const j of ordered) { if (j.cancelled) continue; if ((j.booking_status === 'Confirmé' || j.assist) && j.start_h != null) { const d = dur1(j); locked.push({ s: j.start_h, e: j.start_h + d, skill: j.skill, job: j.name, assist: !!j.assist, done: !!j.done, subject: j.subject, legacy_id: j.legacy_id, legacy_dept: j.legacy_dept }) } }
|
||
const flow = [] // jobs « à planifier » (heure ignorée) + legacy résiduel, dans l'ordre de tournée. (Annulés exclus du timeline : bruit.)
|
||
for (const j of ordered) { if (j.cancelled) continue; if ((j.booking_status === 'Confirmé' && j.start_h != null) || j.assist) continue; const d = dur1(j); if (d > 0) flow.push({ dur: d, skill: j.skill, job: j.name, subject: j.subject, done: !!j.done, legacy_id: j.legacy_id, legacy_dept: j.legacy_dept }) }
|
||
const lg = showLegacyLoad.value ? legacyLoad.value[techId + '|' + iso] : null
|
||
// Seuls les jobs TERRAIN (j.field) deviennent des blocs horaires + comptent dans la charge. L'admin/bureau (officeN) est listé en pastille, pas en bloc → pas de fausse surcharge.
|
||
if (lg) { for (const j of (lg.jobs || [])) { if (!j.field) continue; const d = Number(j.est_h) || 0; if (d > 0) flow.push({ dur: d, legacy: true, subject: j.subject, dept: j.dept, skill: j.skill, lid: j.id }) }; usedH += (lg.h || 0) }
|
||
const scale = 1 // PAS de compression : les blocs gardent leur taille réelle et on ALLONGE la zone (shiftE) pour les couvrir ; surcharge = triangle
|
||
const synth = []
|
||
let cursor = winS // les jobs souples coulent depuis le DÉBUT du quart et remplissent les trous AUTOUR des RDV confirmés
|
||
for (const f of flow) {
|
||
const w = f.dur * scale
|
||
let guard = 0; while (guard++ < 80) { const c = locked.find(b => cursor < b.e - 0.001 && (cursor + w) > b.s + 0.001); if (c) cursor = c.e; else break } // saute par-dessus un RDV qu'il chevaucherait
|
||
synth.push({ s: cursor, e: cursor + w, skill: f.skill, legacy: f.legacy, subject: f.subject, dept: f.dept, lid: f.lid, job: f.job, done: f.done, legacy_id: f.legacy_id, legacy_dept: f.legacy_dept }); cursor += w
|
||
}
|
||
const blocks = [...locked, ...synth]
|
||
usedH = Math.round(usedH * 10) / 10
|
||
// Zone de quart ÉTENDUE (blanche) pour COUVRIR les blocs qui débordent ; surcharge = blocs hors quart OU usedH > dispo.
|
||
const _maxE = blocks.length ? Math.max(...blocks.map(b => b.e)) : winE
|
||
const _minS = blocks.length ? Math.min(...blocks.map(b => b.s)) : winS
|
||
const shiftS = Math.min(winS, _minS); const shiftE = Math.max(winE, _maxE)
|
||
const over = (_maxE > winE + 0.01) || (_minS < winS - 0.01) || (bookableH > 0 && usedH > bookableH + 0.01)
|
||
m[techId + '|' + iso] = { bookableH, usedH, hasGarde, hasReg, pct: bookableH > 0 ? Math.round(usedH / bookableH * 100) : null, blocks, jobs, legacyH: lg ? lg.h : 0, officeN: lg ? (lg.officeN || 0) : 0, shiftS, shiftE, winS, winE, over }
|
||
}
|
||
// Tickets assignés dans F à des techs SANS quart planifié ce jour → on les affiche quand même (sinon invisibles).
|
||
if (showLegacyLoad.value) {
|
||
for (const key in legacyLoad.value) {
|
||
if (m[key]) continue // déjà couvert par un quart
|
||
const _r = occByTechDay.value[key]; if (_r && ((_r.jobs || []).some(j => !j.cancelled) || _r.h > 0.01)) continue // jobs RÉELS présents → laissés à la branche C (blocs + noShift) ; pas de doublon legacy qui masque le réel
|
||
const lg = legacyLoad.value[key]; if (!lg || (!(lg.h > 0) && !(lg.officeN > 0))) continue
|
||
const winS = 8, winE = 17; const synth = []; let cursor = winS
|
||
for (const j of (lg.jobs || [])) { if (!j.field) continue; const d = Number(j.est_h) || 0; if (d <= 0) continue; synth.push({ s: cursor, e: cursor + d, skill: j.skill, legacy: true, subject: j.subject, dept: j.dept, lid: j.id }); cursor += d }
|
||
const maxE = synth.length ? Math.max(...synth.map(b => b.e)) : winE
|
||
// noShift (amber) seulement s'il y a du TERRAIN sans quart (autoritaire pour dispatcher). Office-only → pas d'amber (aucun quart requis), juste la pastille admin.
|
||
m[key] = { bookableH: 0, usedH: Math.round((lg.h || 0) * 10) / 10, hasGarde: false, hasReg: false, pct: null, blocks: synth, jobs: [], legacyH: lg.h || 0, noShift: synth.length > 0, officeN: lg.officeN || 0, shiftS: winS, shiftE: Math.max(winE, maxE), winS, winE, over: false }
|
||
}
|
||
}
|
||
// Jobs RÉELS (Dispatch Job) assignés à un tech SANS quart publié ce jour (ex. Louis-Paul Bourdon) → on les affiche QUAND MÊME :
|
||
// sinon ils comptent dans la charge (occByTechDay) mais sont INVISIBLES sur le timeline ET la carte. Fenêtre synthétique 8-17, drapeau noShift.
|
||
const visIds = new Set(visibleTechs.value.map(t => t.id))
|
||
for (const key in occByTechDay.value) {
|
||
if (m[key]) continue // déjà couvert par un quart (ou la branche legacy)
|
||
if (!visIds.has(key.split('|')[0])) continue // respecte les filtres équipe/compétence
|
||
const o = occByTechDay.value[key]; const jobs = (o && o.jobs) || []
|
||
const usedH = Math.round(((o && o.h) || 0) * 10) / 10
|
||
if (!(usedH > 0.01) && !jobs.some(j => !j.cancelled)) continue // aucun travail réel (que des annulés) → cellule vide, pas de faux « sans quart »
|
||
const winS = 8, winE = 17
|
||
const dur1 = (j) => Number(j.dur) || (j.est_min ? j.est_min / 60 : 1)
|
||
const ordered = jobs.slice().sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
|
||
const synth = []; let cursor = winS
|
||
for (const j of ordered) { if (j.cancelled) continue; const d = dur1(j); if (d <= 0) continue; synth.push({ s: cursor, e: cursor + d, skill: j.skill, job: j.name, subject: j.subject, done: !!j.done, legacy_id: j.legacy_id, legacy_dept: j.legacy_dept }); cursor += d }
|
||
const maxE = synth.length ? Math.max(...synth.map(b => b.e)) : winE
|
||
m[key] = { bookableH: 0, usedH, hasGarde: false, hasReg: false, pct: null, blocks: synth, jobs, legacyH: 0, noShift: true, officeN: 0, shiftS: winS, shiftE: Math.max(winE, maxE), winS, winE, over: false }
|
||
}
|
||
return m
|
||
})
|
||
function cellOcc (techId, iso) { return occCells.value[techId + '|' + iso] || null }
|
||
// Occupation tech-jour pour les vues MOBILES : occCells (avec quart) SINON occupation brute (jobs assignés SANS quart publié) —
|
||
// sinon un tech dont le quart auto a été perdu « disparaît » de la charge alors qu'il a des jobs (ex. Louis-Paul Bourdon).
|
||
function techDayOcc (techId, iso) {
|
||
const c = occCells.value[techId + '|' + iso]
|
||
if (c) return c
|
||
const raw = occByTechDay.value[techId + '|' + iso]
|
||
if (raw && ((+raw.h) || 0) > 0.01) return { usedH: +raw.h || 0, bookableH: 0, over: false, jobs: raw.jobs || [] }
|
||
return null
|
||
}
|
||
// Le tech a-t-il un quart RÉGULIER (non sur-appel) ce jour ? Sert au signalement « sans quart » (▲) ET au dialogue de création.
|
||
// Même logique que la garde de onCellDrop — un seul endroit (hoisté → utilisable plus haut).
|
||
function hasShiftDay (techId, iso) {
|
||
return cellsOf(techId, iso).some(a => { const tp = tplByName.value[a.shift]; return tp && !tp.on_call })
|
||
}
|
||
|
||
// ── Vue mobile « bande de jours + heat » (téléphone) : charge agrégée par jour + par tech, lecture rapide. ──
|
||
const todayIso = computed(() => (nowET.value && nowET.value.iso) || new Date().toISOString().slice(0, 10))
|
||
// heatColor → composables/useHelpers.js (source unique, partagé avec <OccupancyStrip> + tableau de bord)
|
||
const _CAP_H = 8 // capacité indicative par tech-jour (h) pour normaliser les barres
|
||
const mobileSelIso = ref(null)
|
||
// Bande de jours mobile = fenêtre LONGUE (4 semaines) → carrousel horizontal CONTINU (sans flèches).
|
||
// L'occupation est chargée sur la même fenêtre (voir getOccupancy mobile) pour que tous ces jours aient des données.
|
||
const MOBILE_STRIP_DAYS = 28
|
||
const mobileStripDays = computed(() => {
|
||
const [y, m, dd] = start.value.split('-').map(Number); const base = new Date(Date.UTC(y, m - 1, dd)); const out = []
|
||
for (let i = 0; i < MOBILE_STRIP_DAYS; i++) { const d = new Date(base); d.setUTCDate(d.getUTCDate() + i); const iso = d.toISOString().slice(0, 10); const dow = d.getUTCDay(); out.push({ iso, dow: FR_DOW[dow], dnum: iso.slice(8) + '/' + iso.slice(5, 7), weekend: dow === 0 || dow === 6 }) }
|
||
return out
|
||
})
|
||
const mobileSelDay = computed(() => {
|
||
const isos = mobileStripDays.value.map(d => d.iso)
|
||
if (mobileSelIso.value && isos.includes(mobileSelIso.value)) return mobileSelIso.value
|
||
if (isos.includes(todayIso.value)) return todayIso.value
|
||
return isos[0] || todayIso.value
|
||
})
|
||
const mobileSelDayObj = computed(() => mobileStripDays.value.find(d => d.iso === mobileSelDay.value) || { iso: mobileSelDay.value })
|
||
const selDayLabel = computed(() => { const d = mobileSelDayObj.value; return d && d.dow ? d.dow + ' ' + d.dnum : mobileSelDay.value })
|
||
// Charge d'un tech : heures occupées / capacité. Dénominateur = heures du QUART RÉEL s'il est planifié ;
|
||
// SINON journée nominale ESTIMÉE à 8h (le tech a du travail mais aucun quart → on le signale avec noShift=▲).
|
||
// 3h sans quart → 3/8 ≈ 37% (vert) ; rouge seulement si réellement au-dessus de la capacité.
|
||
function techLoad (o, hasShift) {
|
||
const h = +(o && o.usedH) || 0
|
||
const book = +(o && o.bookableH) || 0
|
||
const cap = (hasShift && book > 0) ? book : 8
|
||
return { h: Math.round(h * 10) / 10, cap: Math.round(cap * 10) / 10, ratio: cap > 0 ? h / cap : 0, over: h > cap + 0.01, noShift: !hasShift }
|
||
}
|
||
// Barre globale du jour = Σ durées jobs estimées (numérateur) / Σ durées des quarts (dénominateur) = couverture du jour.
|
||
const mobileDayLoads = computed(() => {
|
||
const ts = visibleTechs.value || []
|
||
const pool = assignPanel.jobs || []
|
||
return mobileStripDays.value.map(d => {
|
||
let assignedH = 0, capH = 0, over = 0, noShift = 0
|
||
for (const t of ts) {
|
||
const o = techDayOcc(t.id, d.iso); if (!o) continue
|
||
const u = +o.usedH || 0; const bk = +o.bookableH || 0; const hasSh = hasShiftDay(t.id, d.iso)
|
||
const cap = (hasSh && bk > 0) ? bk : 8 // quart réel sinon estimation 8h (cohérent avec les barres par tech)
|
||
assignedH += u; capH += cap; if (u > cap + 0.01) over++
|
||
if (!hasSh && u > 0.01) noShift++ // tech avec du travail mais aucun quart planifié
|
||
}
|
||
// Numérateur = TOUT le travail DÛ ce jour à répartir = jobs déjà assignés ce jour (assignedH) + jobs du POOL (non assignés) dont la date prévue est ce jour.
|
||
let pendingH = 0
|
||
for (const j of pool) { if (j.scheduled_date !== d.iso) continue; pendingH += (j.est_min ? j.est_min / 60 : Number(j.duration_h || 1)) }
|
||
const jobsH = assignedH + pendingH
|
||
const ratio = capH > 0 ? jobsH / capH : (jobsH > 0 ? 1 : 0)
|
||
return { iso: d.iso, dow: d.dow, dnum: d.dnum, weekend: d.weekend, h: Math.round(jobsH * 10) / 10, cap: Math.round(capH * 10) / 10, pending: Math.round(pendingH * 10) / 10, ratio, over, noShift }
|
||
})
|
||
})
|
||
const mobileTechLoads = computed(() => {
|
||
const iso = mobileSelDay.value
|
||
return (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name, _t: t, ...techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso)) })).sort((a, b) => b.h - a.h)
|
||
})
|
||
const pmShowIdle = ref(false) // mobile : replier les techniciens à 0 h (sinon on scrolle 50+ barres vides)
|
||
const mobileTechBusy = computed(() => mobileTechLoads.value.filter(t => t.h > 0))
|
||
const mobileTechIdle = computed(() => mobileTechLoads.value.filter(t => !(t.h > 0)))
|
||
// Compétences requises par la sélection (pour auto-filtrer les techs candidats).
|
||
const assignSkills = computed(() => {
|
||
const set = new Set()
|
||
for (const n of selectedNames.value) { const j = (assignPanel.jobs || []).find(x => x.name === n); if (j && j.required_skill) set.add(j.required_skill) }
|
||
return [...set]
|
||
})
|
||
// Filtre compétence ACTIF dans la feuille d'assignation : démarre = compétences des jobs sélectionnés (auto-coché),
|
||
// mais chaque chip est DÉCOCHABLE → décocher relâche le filtre ; tout décoché ⇒ TOUS les techs s'affichent.
|
||
const assignSkillActive = ref(new Set())
|
||
watch(assignSkills, (sk) => { assignSkillActive.value = new Set(sk) }, { immediate: true }) // reset à chaque changement de sélection
|
||
function toggleAssignSkill (s) { const set = new Set(assignSkillActive.value); if (set.has(s)) set.delete(s); else set.add(s); assignSkillActive.value = set }
|
||
// Mode assignation : techs CAPABLES selon les chips compétence ACTIVES (décochables) ; aucune active ⇒ tous. Libres/avec-quart d'abord.
|
||
const mobileTechAssign = computed(() => {
|
||
const iso = mobileSelDay.value; const act = assignSkillActive.value
|
||
const capable = (t) => !act.size || (t.skills || []).some(s => act.has(s))
|
||
// Candidats CAPABLES : ceux qui ONT déjà un quart d'abord (prêts, pas de création requise), puis les moins chargés.
|
||
// Les « sans quart » (▲) restent listés en dessous — le dispatcher peut quand même les toucher et accepter la création d'un quart.
|
||
return (visibleTechs.value || []).filter(capable).map(t => ({ id: t.id, name: t.name, _t: t, ...techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso)) })).sort((a, b) => (a.noShift ? 1 : 0) - (b.noShift ? 1 : 0) || a.h - b.h)
|
||
})
|
||
|
||
// ── TAP-TO-ASSIGNER (desktop) : clic sur une job du pool → techs CLASSÉS par pertinence pour CETTE job, 1 clic assigne ──
|
||
// Jour cible = date prévue de la job (sinon 1er jour visible). Classement : capable (compétence) → a un quart → plus proche → moins chargé.
|
||
function jobTargetDay (job) {
|
||
const d = job && job.scheduled_date
|
||
const t = todayISO(); const isos = (dayList.value || []).map(x => x.iso)
|
||
// date valide ET pas dans le passé → respectée. En retard/sans date → aujourd'hui (jamais le passé).
|
||
if (d && d !== 'Sans date' && /^\d{4}-\d{2}-\d{2}$/.test(d) && d >= t) return d
|
||
if (isos.includes(t)) return t
|
||
return isos.find(iso => iso >= t) || isos[0] || t
|
||
}
|
||
function tomorrowISO () { const d = new Date(todayISO() + 'T12:00:00'); d.setDate(d.getDate() + 1); return d.toISOString().slice(0, 10) }
|
||
// Replanifie EN LOT la sélection à une date (reprogrammation réelle optimiste + updateJob). setJobDate/applyJobDate (unitaires + undo) plus bas.
|
||
function setSelDate (iso) {
|
||
const names = selectedNames.value.slice(); let n = 0
|
||
for (const nm of names) { const j = (assignPanel.jobs || []).find(x => x.name === nm); if (j && iso !== j.scheduled_date) { j.scheduled_date = iso; roster.updateJob(j.name, { scheduled_date: iso }).catch(e => err(e)); n++ } }
|
||
if (n) $q.notify({ type: 'info', icon: 'event', message: n + ' job(s) reportés au ' + fmtDueLabel(iso), timeout: 3000 })
|
||
}
|
||
// Niveau requis PERSISTANT d'un job (store hub → survit aux rechargements/sessions) — piloté par la pastille « niv ».
|
||
function setJobReqLevel (job, n) { const lv = Math.max(1, Math.min(5, Number(n) || 1)); job.required_level = lv; roster.setJobLevel(job.name, lv).catch(e => err(e)) }
|
||
function techsForJob (job) {
|
||
const iso = jobTargetDay(job)
|
||
const reqSkill = job && job.required_skill
|
||
const jlat = +(job && job.lat); const jlon = +(job && job.lon)
|
||
const hasJC = isFinite(jlat) && isFinite(jlon) && (jlat || jlon)
|
||
return (visibleTechs.value || []).map(t => {
|
||
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso))
|
||
const capable = !reqSkill || (t.skills || []).includes(reqSkill)
|
||
const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
|
||
const distKm = (hasJC && home) ? haversineKm(home.lat, home.lon, jlat, jlon) : null
|
||
return { id: t.id, name: t.name, _t: t, capable, distKm, ...load }
|
||
}).sort((a, b) =>
|
||
(Number(b.capable) - Number(a.capable)) || // capables d'abord
|
||
((a.noShift ? 1 : 0) - (b.noShift ? 1 : 0)) || // avec quart d'abord
|
||
((a.distKm == null ? 9e9 : a.distKm) - (b.distKm == null ? 9e9 : b.distKm)) || // plus proche
|
||
(a.h - b.h) // moins chargé
|
||
)
|
||
}
|
||
async function quickAssign (job, tech) {
|
||
const iso = jobTargetDay(job)
|
||
const dObj = (dayList.value || []).find(d => d.iso === iso) || { iso }
|
||
draggingJobName.value = job.name
|
||
await onCellDrop({}, tech._t || tech, dObj) // réutilise le chemin d'assignation (garde On Hold + dialogue sans-quart + toast Annuler)
|
||
}
|
||
// ── B : assignation EN LOT de la sélection (bouton « Assigner à un tech ») ──
|
||
// Techs classés pour la SÉLECTION : capable de TOUTES les compétences requises → avec quart → proche du centroïde → moins chargé.
|
||
function techsForSelection () {
|
||
const jobs = (assignPanel.jobs || []).filter(j => selectedJobs[j.name])
|
||
if (!jobs.length) return []
|
||
const reqSkills = [...new Set(jobs.map(j => j.required_skill).filter(Boolean))]
|
||
const ll = jobs.filter(j => isFinite(+j.lat) && isFinite(+j.lon) && (+j.lat || +j.lon))
|
||
const cLat = ll.length ? ll.reduce((s, j) => s + (+j.lat), 0) / ll.length : null
|
||
const cLon = ll.length ? ll.reduce((s, j) => s + (+j.lon), 0) / ll.length : null
|
||
const iso = jobTargetDay(jobs[0]) // jour de référence pour la charge (approx.)
|
||
return (visibleTechs.value || []).map(t => {
|
||
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso))
|
||
const capable = !reqSkills.some(s => !(t.skills || []).includes(s)) // capable de TOUTES les compétences de la sélection
|
||
const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
|
||
const distKm = (cLat != null && home) ? haversineKm(home.lat, home.lon, cLat, cLon) : null
|
||
return { id: t.id, name: t.name, _t: t, capable, distKm, ...load }
|
||
}).sort((a, b) =>
|
||
(Number(b.capable) - Number(a.capable)) ||
|
||
((a.noShift ? 1 : 0) - (b.noShift ? 1 : 0)) ||
|
||
((a.distKm == null ? 9e9 : a.distKm) - (b.distKm == null ? 9e9 : b.distKm)) ||
|
||
(a.h - b.h)
|
||
)
|
||
}
|
||
// Assigne TOUTE la sélection au tech, chaque job gardant SA date cible (regroupés par jour → 1 passage/jour, garde-fous réutilisés).
|
||
async function quickAssignSelection (tech) {
|
||
const names = selectedNames.value.slice(); if (!names.length) return
|
||
const t = tech._t || tech
|
||
const byDay = {}
|
||
for (const jn of names) { const job = (assignPanel.jobs || []).find(x => x.name === jn); if (!job) continue; const iso = jobTargetDay(job); (byDay[iso] = byDay[iso] || []).push(jn) }
|
||
for (const iso of Object.keys(byDay)) {
|
||
const dObj = (dayList.value || []).find(d => d.iso === iso) || { iso, dnum: iso.slice(8) }
|
||
draggingJobName.value = byDay[iso].join(',')
|
||
await onCellDrop({}, t, dObj) // On Hold + dialogue sans-quart + toast Annuler réutilisés
|
||
}
|
||
if (assignPanel.showMap) refreshAssignMap() // rafraîchit le surlignage carte
|
||
}
|
||
// Sélectionne / désélectionne TOUS les jobs d'un secteur (municipalité canonique) — depuis l'en-tête de secteur du pool.
|
||
function selectAllSector (city) {
|
||
const jobs = poolJobs.value.filter(j => (jobCity(j) || 'Sans secteur') === city && j.status !== 'On Hold')
|
||
const allSel = jobs.length && jobs.every(j => selectedJobs[j.name])
|
||
jobs.forEach(j => { if (allSel) delete selectedJobs[j.name]; else selectedJobs[j.name] = true })
|
||
if (assignPanel.showMap) refreshAssignMap()
|
||
}
|
||
// ── D : SUGGESTION de répartition (glouton proximité + charge + compétence), REVUE en surcouche avant d'appliquer ──
|
||
const suggestDlg = reactive({ open: false, mode: 'config', plan: [], building: false, applying: false, fromSel: false, techSel: {}, makeShifts: true, strategy: 'optimize', view: 'list', jobTime: {} }) // défaut = solveur VRP (1re génération optimise déjà, plus de placeholders) · strategy: optimize|smart|best|balance|enough
|
||
const prevOpen = reactive({}) // revue : sections « déjà assigné(s) » dépliées (clé techId|iso)
|
||
// ── Revue : détail du ticket au clic + temps révisable + temps de route ENTRE arrêts (OSRM) ──
|
||
const durOverride = reactive({}) // jobName → heures révisées par le dispatcher (survit aux Ré-optimiser de la session)
|
||
const legsByKey = reactive({}) // 'techId|iso' → { homeTo: min|null, after: { jobName: min } } — temps de route réels entre arrêts
|
||
function openEntryDetail (e, g) { // clic sur un job de la revue → même fiche détail que la grille (adresse, détail, fil du ticket F)
|
||
const j = (assignPanel.jobs || []).find(x => x.name === e.jobName) || {}
|
||
openJobDetail({
|
||
name: e.jobName, subject: e.subject || j.subject, customer: e.customer || j.customer_name || '',
|
||
address: j.address || j.location_label || '', skill: e.skill || j.required_skill || '', dur: e.dur,
|
||
detail: j.legacy_detail || '', legacy_id: j.legacy_ticket_id || null, dept: j.legacy_dept || '',
|
||
lat: e.lat, lon: e.lon,
|
||
}, (g && !g.placeholder) ? { id: g.techId, name: g.techName } : null)
|
||
}
|
||
// Clic sur une pastille de la carte → fiche détail COMPLÈTE du ticket (même dialog que la grille). rid = techId de la tournée.
|
||
function onReviewStopClick (stop, rid) { // carte de la REVUE : l'arrêt porte l'entrée du plan
|
||
if (!stop) return
|
||
const e = stop.entry || { jobName: stop.name, subject: stop.subject, skill: '', lat: stop.lat, lon: stop.lon, dur: 0 }
|
||
const g = suggestGroups.value.find(x => x.techId === rid) || { techId: rid, techName: (techById.value[rid] || {}).name || '', placeholder: false }
|
||
openEntryDetail(e, g)
|
||
}
|
||
function onRoutesStopClick (stop, rid) { // onglet TOURNÉES : l'arrêt porte le job d'occupation
|
||
if (!stop) return
|
||
const j = stop.job || {}
|
||
openJobDetail({
|
||
name: stop.name || j.name, subject: stop.subject || j.subject, customer: j.customer || j.customer_name || '',
|
||
address: j.address || j.location_label || '', skill: j.skill || '', dur: j.dur,
|
||
detail: j.legacy_detail || '', legacy_id: j.legacy_id || j.legacy_ticket_id || null, dept: j.legacy_dept || j.dept || '',
|
||
lat: stop.lat, lon: stop.lon,
|
||
}, { id: rid, name: (techById.value[rid] || {}).name || '' })
|
||
}
|
||
function setEntryDur (e, v) { // temps révisé : plan (réactif) + mémoire de session + persistance ERPNext duration_h
|
||
const h = Math.round(Math.max(0.25, Math.min(24, Number(v) || 0)) * 4) / 4
|
||
if (!h) return
|
||
e.dur = h; durOverride[e.jobName] = h
|
||
const j = (assignPanel.jobs || []).find(x => x.name === e.jobName)
|
||
if (j) patchJob(j, { duration_h: h }, 'Temps révisé : ' + h + ' h — Ré-optimiser pour recalculer les tournées')
|
||
}
|
||
// Temps de route réels entre les arrêts de chaque tournée (1 appel OSRM par tech×jour, cache par signature dans fetchTechRoute).
|
||
async function loadLegTimes () {
|
||
for (const g of suggestGroups.value) {
|
||
if (g.placeholder) continue
|
||
const h = techOrigin(g.techId) // domicile, sinon bureau TARGO
|
||
for (const d of g.dayList) {
|
||
const geo = d.entries.filter(e => e.lat != null && e.lon != null)
|
||
if (geo.length < (h ? 1 : 2)) continue
|
||
const info = await fetchTechRoute(h, geo.map(e => ({ lat: +e.lat, lon: +e.lon })))
|
||
if (!info || !info.legs || !info.legs.length) continue
|
||
const legs = info.legs; const out = { homeTo: null, after: {}, returnTo: null, kind: h ? h.kind : null }
|
||
let li = 0
|
||
if (h) { out.homeTo = legs[0] ? legs[0].mins : null; li = 1 }
|
||
for (let k = 0; k < geo.length - 1; k++) { const L = legs[li + k]; if (L) out.after[geo[k].jobName] = L.mins }
|
||
if (h) { const rl = legs[li + geo.length - 1]; if (rl) out.returnTo = rl.mins } // dernier segment = dernier arrêt → point de départ
|
||
legsByKey[g.techId + '|' + d.iso] = out
|
||
}
|
||
}
|
||
}
|
||
// Temps de route affiché ENTRE deux lignes : réel (OSRM) si connu, sinon estimation haversine, sinon rien.
|
||
function legInfo (g, d, ei) {
|
||
const e = d.entries[ei]; const nx = d.entries[ei + 1]
|
||
if (!e || !nx) return null
|
||
const real = (legsByKey[g.techId + '|' + d.iso] || {}).after
|
||
if (real && real[e.jobName] != null) return { mins: real[e.jobName], real: true }
|
||
if (e.lat != null && e.lon != null && nx.lat != null && nx.lon != null) return { mins: estTravelMin(haversineKm(+e.lat, +e.lon, +nx.lat, +nx.lon)), real: false }
|
||
return null
|
||
}
|
||
function legHome (g, d) { const v = (legsByKey[g.techId + '|' + d.iso] || {}).homeTo; return v != null ? v : null }
|
||
function legReturn (g, d) { const v = (legsByKey[g.techId + '|' + d.iso] || {}).returnTo; return v != null ? v : null } // temps de retour au départ
|
||
// Contraintes horaires par job (⚡ Optimiser) : AM/PM (fenêtre dure) + « tôt » (urgence → début de journée). Session (clé = jobName, survit aux ré-optimisations).
|
||
const AMPM_WIN = { am: [480, 720], pm: [720, 960] } // 8-12 / 12-16 (heure d'horloge)
|
||
function jobTimeOf (name) { return suggestDlg.jobTime[name] || {} }
|
||
function toggleJobAmpm (name, v) { const c = suggestDlg.jobTime[name] || {}; suggestDlg.jobTime[name] = { ...c, ampm: c.ampm === v ? null : v } }
|
||
// 🖥 « SANS DÉPLACEMENT » (à distance, ex. config boîtier tel via ACS) : persistant (store hub), exclu des tournées par l'optimiseur.
|
||
const poolRemote = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = !!j.remote; return m })
|
||
function isJobRemote (name) { return !!poolRemote.value[name] }
|
||
async function toggleJobRemote (e) {
|
||
const j = (assignPanel.jobs || []).find(x => x.name === e.jobName); const next = !(j ? j.remote : e.remote)
|
||
if (j) j.remote = next; e.remote = next // optimiste
|
||
try { await roster.setJobRemote(e.jobName, next) } catch (er) { if (j) j.remote = !next; e.remote = !next; err(er); return }
|
||
$q.notify({ type: 'info', icon: 'computer', message: next ? '🖥 Sans déplacement — Ré-optimiser pour l\'exclure des tournées' : 'Déplacement requis rétabli — Ré-optimiser', timeout: 2600 })
|
||
}
|
||
// 📦 Grouper les tâches MÊME ADRESSE en « projet » (parent_job ERPNext) — parent = la plus longue, les autres deviennent sous-tâches.
|
||
async function groupSameAddr (e, g, d) {
|
||
const key = x => (x.lat != null && x.lon != null) ? (+x.lat).toFixed(4) + ',' + (+x.lon).toFixed(4) : null
|
||
const k = key(e); if (!k) return
|
||
const grp = d.entries.filter(x => key(x) === k)
|
||
if (grp.length < 2) { $q.notify({ type: 'info', message: 'Une seule tâche à cette adresse dans ce plan.' }); return }
|
||
const parent = grp.slice().sort((a, b) => (b.dur || 0) - (a.dur || 0))[0]
|
||
const children = grp.filter(x => x !== parent).map(x => x.jobName)
|
||
try {
|
||
const r = await roster.groupJobs(parent.jobName, children)
|
||
if (!r || !r.ok) throw new Error((r && r.error) || 'échec du groupement')
|
||
grp.forEach(x => { x.grouped = true })
|
||
$q.notify({ type: 'positive', icon: 'inventory_2', message: `📦 Projet créé : « ${(parent.subject || '').slice(0, 42)} » + ${children.length} sous-tâche(s)`, timeout: 3500 })
|
||
} catch (er) { err(er) }
|
||
}
|
||
// PRIORITÉ du job — MÊME drapeau que les conversations (priorityMeta) + champ ERPNext STANDARD (low/medium/high) via patchJob, comme la fiche du pool. Une seule source.
|
||
const poolPriority = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = String(j.priority || '').toLowerCase(); return m })
|
||
function jobPriorityOf (name) { return poolPriority.value[name] || '' } // medium → priorityMeta = contour gris (non classé)
|
||
// ── SLA sur les jobs (RÉUTILISE useSla des tickets) : échéance de RÉSOLUTION = création + politique[priorité]. Compute-on-read, aucune migration ERPNext. ──
|
||
const poolByName = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = j; return m })
|
||
const { slaFor: _slaForTicket } = useSla()
|
||
const PRIO_CAP = { high: 'High', 'élevée': 'High', elevee: 'High', urgent: 'Urgent', medium: 'Medium', moyenne: 'Medium', normal: 'Medium', low: 'Low', basse: 'Low' }
|
||
function jobSlaBadge (job) { // { state, color, text, icon, label } pour at_risk/breached SEULEMENT (états actionnables) ; null sinon
|
||
if (!job || !job.creation) return null
|
||
const prio = PRIO_CAP[String(job.priority || jobPriorityOf(job.name) || 'medium').toLowerCase()] || 'Medium'
|
||
// job → ticket synthétique : first_responded_on renseigné → on SAUTE la bande « réponse » et on prend la bande RÉSOLUTION depuis la création.
|
||
const s = _slaForTicket({ issue_type: job.job_type || undefined, priority: prio, creation: job.creation, first_responded_on: job.creation, status: (String(job.status || '').toLowerCase() === 'completed' ? 'Closed' : job.status) })
|
||
if (!s || (s.state !== 'at_risk' && s.state !== 'breached')) return null
|
||
const b = SLA_BADGE[s.state]
|
||
return { state: s.state, color: b.color, text: b.text, icon: b.icon, label: s.label, short: s.state === 'breached' ? 'SLA dépassé' : 'SLA proche' }
|
||
}
|
||
function entrySlaBadge (e) { return jobSlaBadge(poolByName.value[e.jobName]) } // revue : retrouve le job du pool pour l'échéance
|
||
function setEntryPriority (name, lvl) { // écrit la priorité ERPNext du job (low/medium/high) — réutilise patchJob (optimiste + updateJob)
|
||
const j = (assignPanel.jobs || []).find(x => x.name === name); if (!j) return
|
||
patchJob(j, { priority: lvl || 'medium' }, 'Priorité : ' + priorityMeta(lvl).label)
|
||
}
|
||
// Réglages de l'optimiseur VRP (curseur distance↔spécialité, vitesse repli, budget de calcul) — persistés localement, ajustables sans redéploiement.
|
||
const solverOpts = reactive({ rankWeight: 2, overtimeCoef: 20, speedKmh: 45, maxSeconds: 8 }) // un COÛT par critère (pas de slider)
|
||
try { const s = JSON.parse(localStorage.getItem('ops.solverOpts') || 'null'); if (s && typeof s === 'object') Object.assign(solverOpts, s) } catch (e) { /* défauts */ }
|
||
watch(solverOpts, v => { try { localStorage.setItem('ops.solverOpts', JSON.stringify(v)) } catch (e) {} }, { deep: true })
|
||
function jobDur (j) { return (j && (j.est_min ? j.est_min / 60 : Number(j.duration_h || 1))) || 1 }
|
||
// Construit un plan job→tech×jour SANS rien écrire (proposition). Chaque job garde sa date si elle est dans la fenêtre.
|
||
function buildSuggestion () {
|
||
const useSel = selectedNames.value.length > 0
|
||
suggestDlg.fromSel = useSel
|
||
const jobs = suggestJobs() // lasso si actif, sinon pool filtré par les chips de date (jours cochés)
|
||
const days = (dayList.value || []); const dayIsos = days.map(d => d.iso)
|
||
const techs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id]) // seulement les techs choisis (disponibles ce jour)
|
||
if (!jobs.length || !dayIsos.length || !techs.length) { suggestDlg.plan = []; return }
|
||
const pr = j => ({ high: 0, urgent: 0, élevée: 0, medium: 1, moyenne: 1, normal: 1, low: 2, basse: 2 }[String(j.priority || '').toLowerCase()] ?? 1)
|
||
const today = todayISO()
|
||
const win = suggestWindow.value; const winSet = new Set(win); const winMax = win.length ? win[win.length - 1] : today // fenêtre : dates sélectionnées ou auj.+demain
|
||
const isOver = j => (j.scheduled_date && j.scheduled_date !== 'Sans date' && j.scheduled_date < today) ? 0 : 1
|
||
const sorted = [...jobs].sort((a, b) => pr(a) - pr(b) || isOver(a) - isOver(b) || String(a.scheduled_date || '9').localeCompare(String(b.scheduled_date || '9')) || jobDur(b) - jobDur(a))
|
||
const proj = {} // techId|iso → { h, cap, pts:[[lat,lon]] } — charge PROJETÉE, initialisée depuis l'occupation actuelle
|
||
const cell = (tid, iso) => { const k = tid + '|' + iso; if (!proj[k]) { const load = techLoad(techDayOcc(tid, iso), hasShiftDay(tid, iso)); proj[k] = { h: load.h || 0, cap: load.cap || 8, pts: [], cities: new Set() } } return proj[k] }
|
||
// Poids selon la STRATÉGIE : smart · best (meilleurs d'abord, cascade) · balance (round-robin) · enough (juste ce qu'il faut → réserve les experts)
|
||
const strat = suggestDlg.strategy || 'smart'
|
||
// rank = ORDRE de la compétence chez le tech (0 = compétence PRINCIPALE) → un job va d'abord aux spécialistes de CETTE compétence, épargnant les polyvalents.
|
||
const W = strat === 'best' ? { dist: 0.4, skill: 15, eff: 65, cost: 20, load: -0.25, overqual: 0, rank: 10 }
|
||
: strat === 'balance' ? { dist: 0.6, skill: 6, eff: 30, cost: 8, load: 24, overqual: 0, rank: 8 }
|
||
: strat === 'enough' ? { dist: 1.4, skill: 0, eff: 25, cost: 10, load: 2, overqual: 22, rank: 16 } // « juste ce qu'il faut » : capable mais le moins sur-qualifié possible
|
||
: { dist: 1.6, skill: 6, eff: 30, cost: 7, load: 0.6, overqual: 0, rank: 14 }
|
||
const costs = techs.map(t => Number(t.cost_h) || 0); const cMin = Math.min(...costs); const cRange = (Math.max(...costs) - cMin) || 1 // normalisation coût 0..1
|
||
const plan = []
|
||
const holdByDay = {} // jobs SANS tech de quart → regroupés par jour puis découpés en tournées PLACEHOLDER (pass 2)
|
||
for (const j of sorted) {
|
||
const reqSkill = j.required_skill || skillByType.value[j.service_type] || '' // compétence explicite, sinon déduite du TYPE de job
|
||
const reqLevel = Number(j.required_level) || 1 // niveau imposé PAR LE JOB (persistant) — plus de défaut global par compétence
|
||
const jlat = +(j.lat != null ? j.lat : j.latitude), jlon = +(j.lon != null ? j.lon : j.longitude); const hasLL = isFinite(jlat) && isFinite(jlon) && (jlat || jlon) // pool hub = latitude/longitude (bruts ERP) ; grille = lat/lon
|
||
const jcity = jobCity(j) || '' // municipalité — sert de repli de proximité quand le job n'a pas de coordonnées
|
||
const dur = Number(durOverride[j.name]) > 0 ? Number(durOverride[j.name]) : jobDur(j) // temps révisé par le dispatcher prioritaire
|
||
const sched = (j.scheduled_date && j.scheduled_date !== 'Sans date' && /^\d{4}-\d{2}-\d{2}$/.test(j.scheduled_date)) ? j.scheduled_date : null
|
||
let cand
|
||
if (j._reDate && winSet.has(j._reDate)) cand = [j._reDate] // date re-choisie (dans la fenêtre)
|
||
else if (sched && winSet.has(sched)) cand = [sched] // daté DANS la fenêtre → ce jour
|
||
else if (sched && sched > winMax) continue // daté APRÈS la fenêtre → PAS dispatché maintenant (on ne planifie pas loin)
|
||
else cand = win // en retard / sans date / avant la fenêtre → placé dans la fenêtre
|
||
if (!cand.length) continue
|
||
let best = null
|
||
for (const t of techs) {
|
||
// SKILL = filtre DUR : un tech sans la compétence requise n'est JAMAIS candidat (ex. Josée-Anne ne fait ni réparation ni installation).
|
||
if (reqSkill && !(t.skills || []).includes(reqSkill)) continue
|
||
const capable = true
|
||
const skillRank = reqSkill ? Math.max(0, (t.skills || []).indexOf(reqSkill)) : 0 // ORDRE de la compétence chez le tech : 0 = principale (spécialiste) ; 1,2… = secondaire (polyvalent)
|
||
const lvl = reqSkill ? (skillLevelOf(t, reqSkill) || 0) : 3 // maîtrise 0-5 (3 = neutre sans compétence requise)
|
||
const eff = reqSkill ? skillEffOf(t, reqSkill) : (Number(t.efficiency) || 1) // vitesse : <1 = plus rapide pour cette compétence
|
||
const effDur = dur * (eff > 0 ? eff : 1) // durée EFFECTIVE = un tech rapide prend moins de capacité pour le même job
|
||
const costN = ((Number(t.cost_h) || 0) - cMin) / cRange
|
||
const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut) — départ de tournée
|
||
for (const iso of cand) {
|
||
const c = cell(t.id, iso)
|
||
const noShift = !hasShiftDay(t.id, iso)
|
||
const over = Math.max(0, (c.h + effDur) - c.cap)
|
||
// PROXIMITÉ : vraie distance km si géolocalisé ; SINON repli par VILLE (regroupe la même municipalité, pénalise le mélange lointain type Ste-Barbe + Napierville)
|
||
let prox
|
||
if (hasLL && c.pts.length) prox = Math.min(...c.pts.map(p => haversineKm(p[0], p[1], jlat, jlon))) // proche des jobs déjà planifiés (route serrée)
|
||
else if (hasLL && home && isFinite(+home.lat) && isFinite(+home.lon)) prox = haversineKm(+home.lat, +home.lon, jlat, jlon) // 1er job → depuis le domicile
|
||
else if (jcity && c.cities.has(jcity)) prox = 0 // même ville qu'un job déjà planifié ce jour = même tournée
|
||
else if (c.cities.size) prox = 40 // le tech a déjà une AUTRE ville ce jour → forte pénalité (évite de mélanger des villes éloignées)
|
||
else prox = 15 // 1er job du tech ce jour, sans coords → neutre
|
||
const under = Math.max(0, reqLevel - lvl) // sous le niveau requis pour ce job (maîtrise insuffisante — compétence déjà garantie par le filtre dur)
|
||
let score = over * 120 + (noShift ? 60 : 0) + under * 200 // surcharge / hors-quart / SOUS-qualifié
|
||
score += prox * W.dist // proximité (km réels OU regroupement par ville)
|
||
score += c.h * W.load // load>0 = étale (round-robin) · load<0 = remplit les 1ers (cascade)
|
||
score += (5 - lvl) * W.skill // (best/smart) maîtrise faible pénalisée → avantage aux experts
|
||
score += skillRank * (W.rank || 0) // ORDRE de la compétence : le spécialiste (rang 0) passe avant le polyvalent → épargne les moins spécialisés
|
||
score += Math.max(0, lvl - reqLevel) * (W.overqual || 0) // (« juste ce qu'il faut ») sur-qualification pénalisée → garde les experts pour les jobs exigeants
|
||
score += (eff - 1) * W.eff // rapide (eff<1) récompensé · lent (eff>1) pénalisé
|
||
score += costN * W.cost // moins cher préféré
|
||
if (!best || score < best.score) best = { t, iso, score, noShift, capable, over, lvl, eff, effDur, skillRank }
|
||
}
|
||
}
|
||
// Commit sur un VRAI tech seulement s'il a un quart ce jour-là. Sinon → réservé pour une file PLACEHOLDER (pass 2), à assigner ensuite à un vrai tech.
|
||
if (best && !best.noShift) {
|
||
const c = cell(best.t.id, best.iso); c.h += best.effDur; if (hasLL) c.pts.push([jlat, jlon]); if (jcity) c.cities.add(jcity) // capacité + ville pour la proximité des suivants
|
||
plan.push({ jobName: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || j.location_label || '', techId: best.t.id, techName: best.t.name, iso: best.iso, dur, skill: reqSkill || '', capable: best.capable, noShift: false, over: best.over > 0, lat: hasLL ? jlat : null, lon: hasLL ? jlon : null, lvl: best.lvl, eff: best.eff, reqLevel })
|
||
} else {
|
||
const holdIso = best ? best.iso : (sched && winSet.has(sched) ? sched : (cand.find(d => !isWeekendIso(d)) || cand[0]))
|
||
;(holdByDay[holdIso] = holdByDay[holdIso] || []).push({ j, dur, reqSkill, reqLevel, jlat, jlon, hasLL, jcity, capable: !best || best.capable })
|
||
}
|
||
}
|
||
// PASS 2 — jobs sans tech de quart → files PLACEHOLDER par jour, groupées PAR COMPÉTENCE puis découpées en tournées ~8 h par PROXIMITÉ
|
||
// (km réels, sinon même ville ; >60 km = nouvelle tournée). Une tournée = 1 compétence + 1 secteur → assignable à un tech qui a CETTE compétence.
|
||
const HOLD_CAP = 8
|
||
for (const iso of Object.keys(holdByDay)) {
|
||
const bySkill = {}; for (const it of holdByDay[iso]) { const k = it.reqSkill || '__any__'; (bySkill[k] = bySkill[k] || []).push(it) } // 1 groupe par compétence
|
||
const dLbl = (FR_DOW[dowOf(iso)] || '') + ' ' + iso.slice(8) // ex. « ven 03 »
|
||
let ti = 0
|
||
for (const sk of Object.keys(bySkill)) {
|
||
const items = bySkill[sk].slice().sort((a, b) => (a.jcity || '').localeCompare(b.jcity || '')) // départ groupé par ville → tournées cohérentes
|
||
const queues = []
|
||
for (const it of items) {
|
||
let bq = null, bd = Infinity
|
||
for (const q of queues) {
|
||
if (q.h + it.dur > HOLD_CAP + 0.01) continue // tournée pleine (~8 h)
|
||
let d
|
||
if (it.hasLL && q.pts.length) d = Math.min(...q.pts.map(p => haversineKm(p[0], p[1], it.jlat, it.jlon)))
|
||
else if (it.jcity && q.cities.has(it.jcity)) d = 0
|
||
else if (q.cities.size || q.pts.length) d = 40
|
||
else d = 15
|
||
if (d < bd) { bd = d; bq = q }
|
||
}
|
||
if (!bq || bd > 60) { bq = { h: 0, pts: [], cities: new Set(), jobs: [] }; queues.push(bq) } // trop loin ou tout plein → nouvelle tournée
|
||
bq.h += it.dur; if (it.hasLL) bq.pts.push([it.jlat, it.jlon]); if (it.jcity) bq.cities.add(it.jcity); bq.jobs.push(it)
|
||
}
|
||
const skLbl = sk === '__any__' ? '' : ' ' + sk
|
||
queues.forEach((q) => {
|
||
ti++
|
||
const hid = `__hold__|${iso}|${ti}`
|
||
const label = `⏳ ${dLbl}${skLbl} — tournée ${ti} (à assigner)`
|
||
for (const it of q.jobs) plan.push({ jobName: it.j.name, subject: it.j.subject || it.j.service_type || it.j.name, customer: it.j.customer_name || it.j.location_label || '', techId: hid, techName: label, placeholder: true, iso, dur: it.dur, skill: it.reqSkill || '', capable: it.capable, noShift: false, over: false, lat: it.hasLL ? it.jlat : null, lon: it.hasLL ? it.jlon : null, lvl: 0, eff: 1, reqLevel: it.reqLevel })
|
||
})
|
||
}
|
||
}
|
||
suggestDlg.plan = plan
|
||
}
|
||
function openSuggest () {
|
||
// Jour de base = la sélection de la vue active (strip Tournées, Jour kanban, ou bande mobile) → « Suggérer se base sur la journée sélectionnée »
|
||
suggestDay.value = (boardView.value === 'routes' && routesDay.value) || (boardView.value === 'kanban' && kbSelIso.value) || mobileSelIso.value || routesDay.value || kbSelIso.value || todayISO()
|
||
suggestDlg.fromSel = selectedNames.value.length > 0
|
||
// Étape « disponibilité » d'abord : défaut = techs AVEC un quart sur un des jours de la fenêtre (= disponibles) ; sinon tous les visibles.
|
||
const win = suggestWindow.value // fenêtre = chips de date cochés, sinon le jour du sélecteur
|
||
const techs = visibleTechs.value || []
|
||
const withShift = techs.filter(t => win.some(iso => hasShiftDay(t.id, iso)))
|
||
const availPool = withShift.length ? withShift : techs
|
||
// Pré-sélection par COMPÉTENCE : parmi les dispos, ceux dont les compétences couvrent au moins un skill requis
|
||
// par les jobs du périmètre (jour + filtres). Toujours modifiable (le dialogue liste tous les techs, cochables) ;
|
||
// repli sur tous les dispos si aucun skill requis connu ou aucun match.
|
||
const needed = new Set((suggestJobs() || []).map(j => j.required_skill).filter(Boolean))
|
||
const skilled = needed.size ? availPool.filter(t => (t.skills || []).some(s => needed.has(s))) : []
|
||
const base = skilled.length ? skilled : availPool
|
||
const sel = {}; base.forEach(t => { sel[t.id] = true }); suggestDlg.techSel = sel
|
||
suggestDlg.plan = []; suggestDlg.mode = 'config'; suggestDlg.open = true
|
||
}
|
||
const suggestSelCount = computed(() => Object.values(suggestDlg.techSel).filter(Boolean).length)
|
||
// Dispatch auto : la FENÊTRE = les CHIPS de date cochées dans le pool (jours à répartir). Sinon (aucun chip) → le jour choisi dans le sélecteur.
|
||
// Les chips « ⏰ en retard » / « Sans date » ne sont pas des jours calendaires → les jobs correspondants sont placés AUJOURD'HUI. Non destructif (revue avant d'appliquer).
|
||
const suggestDay = ref('')
|
||
const suggestWindow = computed(() => {
|
||
const f = assignDateFilter.value || []
|
||
if (!f.length) return [suggestDay.value || todayISO()] // aucun chip coché → sélecteur de jour du dialogue
|
||
const days = new Set(f.filter(iso => iso !== '__overdue__' && iso !== 'Sans date')) // jours concrets cochés (auj./futur)
|
||
if (f.includes('__overdue__') || f.includes('Sans date') || !days.size) days.add(todayISO()) // en retard / sans date → placés aujourd'hui
|
||
return [...days].sort()
|
||
})
|
||
// Jobs que « Suggérer » doit répartir : la sélection lasso si active, sinon « ce que je vois » = le pool filtré
|
||
// par les CHIPS de TYPE **et** de DATE (assignJobsFiltered). Ex. décocher « téléphonie » → non réparti aux techs terrain.
|
||
const suggestJobs = () => {
|
||
if (selectedNames.value.length) return (assignPanel.jobs || []).filter(j => j.status !== 'On Hold' && selectedJobs[j.name]) // sélection lasso explicite
|
||
// Vue Tournées : périmètre = EXACTEMENT la bande filtrée (jour sélectionné + compétence + secteur + priorité) → « ce que je filtre = ce que Suggérer répartit ».
|
||
if (boardView.value === 'routes') return (routeDayUnassignedView.value || []).filter(j => j.status !== 'On Hold')
|
||
let jobs = (assignJobsFiltered.value || []).filter(j => j.status !== 'On Hold') // pool tel qu'affiché (type + date)
|
||
// Par DÉFAUT (aucun chip de date coché) : ne répartir QUE les jobs DATÉS sur le(s) jour(s) de la fenêtre — jamais les
|
||
// jobs en retard/sans date/d'autres jours (souvent faits mais pas encore fermés) qui pollueraient la simulation.
|
||
// (Pour dispatcher les retards, cocher explicitement le chip « ⏰ en retard ».)
|
||
if (!assignDateFilter.value.length) { const win = new Set(suggestWindow.value); jobs = jobs.filter(j => win.has(j.scheduled_date)) }
|
||
return jobs
|
||
}
|
||
const suggestJobCount = computed(() => suggestJobs().length)
|
||
// Y a-t-il un filtre restreignant le dispatch ? (lasso, chips de date ou de type)
|
||
const suggestFiltered = computed(() => !!(selectedNames.value.length || assignDateFilter.value.length || assignTypeFilter.value.length))
|
||
// Libellé du périmètre : « sélectionnés » (lasso) · « des jours/types cochés » (chips) — décrit ce qui limite le jeu de jobs.
|
||
const suggestScope = computed(() => {
|
||
if (selectedNames.value.length) return 'sélectionnés'
|
||
const parts = []
|
||
if (assignDateFilter.value.length) parts.push('jours')
|
||
if (assignTypeFilter.value.length) parts.push('types')
|
||
return parts.length ? ('des ' + parts.join(' + ') + ' cochés') : 'du pool'
|
||
})
|
||
const windowDayLabel = iso => iso === todayISO() ? "Aujourd'hui" : (iso === tomorrowISO() ? 'Demain' : (fmtDueLabel(iso) || iso))
|
||
const suggestWindowLabel = computed(() => {
|
||
const w = suggestWindow.value; if (!w.length) return '—'
|
||
if ((assignDateFilter.value || []).length) return w.map(windowDayLabel).join(', ')
|
||
return w.length === 1 ? windowDayLabel(w[0]) : (windowDayLabel(w[0]) + ' + ' + windowDayLabel(w[w.length - 1]))
|
||
})
|
||
function suggestSelectTechs (which) { // 'all' | 'shift' | 'none'
|
||
const techs = visibleTechs.value || []; const sel = {}
|
||
if (which === 'all') techs.forEach(t => { sel[t.id] = true })
|
||
else if (which === 'shift') techs.forEach(t => { if ((dayList.value || []).some(d => hasShiftDay(t.id, d.iso))) sel[t.id] = true })
|
||
suggestDlg.techSel = sel
|
||
}
|
||
// « Groupe de techs par compétence » : sélectionner d'un coup tous les techs d'une compétence (ex : tous les monteurs).
|
||
const suggestSkillGroups = computed(() => {
|
||
const m = {}
|
||
for (const t of (visibleTechs.value || [])) for (const s of (t.skills || [])) m[s] = (m[s] || 0) + 1
|
||
return Object.entries(m).map(([skill, n]) => ({ skill, n })).sort((a, b) => b.n - a.n)
|
||
})
|
||
function suggestSkillOn (skill) { const techs = (visibleTechs.value || []).filter(t => (t.skills || []).includes(skill)); return !!techs.length && techs.every(t => suggestDlg.techSel[t.id]) }
|
||
function suggestSelectSkill (skill) { // (dé)sélectionne TOUT le groupe de techs ayant cette compétence
|
||
const sel = { ...suggestDlg.techSel }
|
||
const techs = (visibleTechs.value || []).filter(t => (t.skills || []).includes(skill))
|
||
const allOn = techs.length && techs.every(t => sel[t.id])
|
||
techs.forEach(t => { if (allOn) delete sel[t.id]; else sel[t.id] = true })
|
||
suggestDlg.techSel = sel
|
||
}
|
||
// Tri des techs par QUALITÉ (maîtrise ↑ · vitesse ↑ · coût ↓) → « les meilleurs en premier » dans la liste de dispo.
|
||
function techQuality (t) {
|
||
const lvls = Object.values(t.skill_levels || {}); const avgLvl = lvls.length ? lvls.reduce((a, b) => a + b, 0) / lvls.length : 0
|
||
const eff = Number(t.efficiency) || 1
|
||
return (5 - avgLvl) * 2 + (eff - 1) * 5 + ((Number(t.cost_h) || 0) / 100) // plus BAS = meilleur
|
||
}
|
||
const suggestTechsRanked = computed(() => [...(visibleTechs.value || [])].sort((a, b) => techQuality(a) - techQuality(b)))
|
||
async function runSuggestion () {
|
||
if (!suggestSelCount.value) { $q.notify({ type: 'warning', message: 'Choisis au moins un technicien disponible.', timeout: 2500 }); return }
|
||
suggestDlg.building = true
|
||
try {
|
||
if (suggestDlg.strategy === 'optimize') await optimizeSuggestion() // solveur VRP OR-Tools (routes + compétences + temps sur place)
|
||
else buildSuggestion() // heuristique gloutonne (rapide, local)
|
||
} catch (e) { err(e); buildSuggestion() } finally { suggestDlg.building = false }
|
||
suggestDlg.view = 'list'; suggestMapDay.value = suggestWindow.value[0] || '' // carte des tournées : 1er jour de la fenêtre par défaut
|
||
suggestDlg.mode = 'review'
|
||
loadLegTimes().catch(() => {}) // temps de route réels entre arrêts (OSRM, non bloquant)
|
||
}
|
||
// Ré-optimiser depuis la revue : relance le solveur VRP en tenant compte des contraintes AM/PM/⚡ posées sur les jobs.
|
||
// Tech malade/absent : le désélectionne puis RÉ-OPTIMISE → ses jobs sont répartis équitablement (le solveur équilibre la charge) sur les techs restants.
|
||
async function markTechSick (g) {
|
||
if (!g || isHoldId(g.techId)) return
|
||
suggestDlg.techSel[g.techId] = false
|
||
$q.notify({ type: 'info', icon: 'sick', message: g.techName + ' retiré (malade/absent) — redistribution de ses ' + g.jobs + ' job(s)…', timeout: 2200 })
|
||
await reoptimize()
|
||
}
|
||
async function reoptimize () {
|
||
suggestDlg.strategy = 'optimize'; suggestDlg.building = true
|
||
try { await optimizeSuggestion() } catch (e) { err(e) } finally { suggestDlg.building = false }
|
||
suggestDlg.view = 'list'; suggestMapDay.value = suggestWindow.value[0] || ''
|
||
loadLegTimes().catch(() => {}) // recalcule les temps entre arrêts après ré-optimisation
|
||
}
|
||
// Fenêtre de quart (min depuis minuit) d'un tech un jour donné, depuis ses cellules + le modèle. null si aucun quart régulier.
|
||
function shiftWindowMin (techId, iso) {
|
||
let s = Infinity, e = -Infinity
|
||
for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || t.on_call) continue; const ss = hToNum(t.start_time), ee = hToNum(t.end_time); if (ss != null) s = Math.min(s, ss); if (ee != null) e = Math.max(e, ee <= ss ? 24 : ee) }
|
||
if (!isFinite(s) || !isFinite(e) || e <= s) return null
|
||
return { start: Math.round(s * 60), end: Math.round(e * 60) }
|
||
}
|
||
// « Optimiser » = P4 : l'ORCHESTRATION COMPLÈTE vit au hub (/roster/optimize-plan — pool, quarts, occupation, mêmes règles pour le cron
|
||
// auto-plan) avec règles d'adresse : même adresse = 1 arrêt ; job à l'adresse d'un job DÉJÀ assigné → épinglé sur CE tech (coordination).
|
||
// Repli : l'ancienne orchestration locale si le hub est indisponible.
|
||
async function optimizeSuggestion () {
|
||
suggestDlg.makeShifts = true // quarts 8-16 assumés (hub assumed_shift) → créés au Publier
|
||
const selTechs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id])
|
||
const job_windows = {}
|
||
for (const n in suggestDlg.jobTime) { const w = AMPM_WIN[(suggestDlg.jobTime[n] || {}).ampm]; if (w) job_windows[n] = { tw_start_min: w[0], tw_end_min: w[1] } }
|
||
try {
|
||
const r = await roster.optimizePlanHub({
|
||
dates: suggestWindow.value, tech_ids: selTechs.map(t => t.id),
|
||
job_names: suggestJobs().map(j => j.name), // ne répartir QUE les jobs des jours cochés (ou la sélection lasso)
|
||
opts: { rank_weight: solverOpts.rankWeight, speed_kmh: solverOpts.speedKmh, max_seconds: solverOpts.maxSeconds, overtime_coef: solverOpts.overtimeCoef },
|
||
dur_overrides: { ...durOverride }, job_windows,
|
||
})
|
||
if (!r || !r.ok || !Array.isArray(r.plan)) throw new Error((r && r.error) || 'réponse invalide')
|
||
const out = []
|
||
for (const e of r.plan) {
|
||
const t = techById.value[e.techId] || {}
|
||
out.push({
|
||
...e, techName: e.techName || t.name || e.techId, placeholder: false, over: false,
|
||
capable: e.capable !== false, noShift: !!(r.assumed_shift && r.assumed_shift[e.techId + '|' + e.iso]),
|
||
lvl: e.skill ? (skillLevelOf(t, e.skill) || 0) : 0, eff: e.skill ? skillEffOf(t, e.skill) : (Number(t.efficiency) || 1),
|
||
})
|
||
}
|
||
for (const e of (r.unassigned || [])) out.push({ ...e, techId: '__unassigned__', techName: '⚠️ Non assignés — ajoute des techs ou assigne à la main', placeholder: true, noShift: false, capable: true, lvl: 0, eff: 1 })
|
||
for (const e of (r.remote || [])) out.push({ ...e, techId: '__remote__', techName: '🖥 Sans déplacement — à faire à distance (assigner à un agent)', placeholder: true, noShift: false, capable: true, lvl: 0, eff: 1 })
|
||
suggestDlg.plan = out
|
||
return
|
||
} catch (e) {
|
||
$q.notify({ type: 'warning', message: 'Optimisation serveur indisponible — repli local', timeout: 2500 })
|
||
}
|
||
await optimizeSuggestionLocal()
|
||
}
|
||
// Ancienne orchestration CLIENT (repli d'urgence si le hub est injoignable) — même solveur, sans les règles d'adresse.
|
||
async function optimizeSuggestionLocal () {
|
||
buildSuggestion() // pose les jours + skill/dur/coords/reqLevel sur chaque entrée + les placeholders
|
||
suggestDlg.makeShifts = true // on SUPPOSE un quart 8-16 pour chaque tech sélectionné → à créer au moment du Publier
|
||
const plan = suggestDlg.plan
|
||
const byDay = {}; const unassigned = [] // PAS de faux « tech placeholder » : on n'utilise QUE les techs sélectionnés ; le reste = 1 seule liste « non assignés »
|
||
for (const e of plan) {
|
||
if (e.placeholder && isWeekendIso(e.iso)) { unassigned.push(e); continue } // week-end : pas de quart auto → non assigné
|
||
;(byDay[e.iso] = byDay[e.iso] || []).push(e)
|
||
}
|
||
const selTechs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id])
|
||
// Priorité du job → « priority_boost » (pénalité de drop) : URGENT jamais laissé de côté ; BASSE lâchée en 1er si la capacité manque.
|
||
const poolPr = Object.fromEntries((assignPanel.jobs || []).map(j => [j.name, String(j.priority || '').toLowerCase()]))
|
||
const prBoost = p => ({ high: 400000, 'élevée': 400000, urgent: 400000, medium: 0, moyenne: 0, normal: 0, low: -60000, basse: -60000 }[p] ?? 0) // HAUTE jamais lâchée ; basse lâchée en 1er
|
||
const prEarly = p => ({ high: 10, 'élevée': 10, urgent: 10 }[p] || 0) // HAUTE = servie tôt (début de journée)
|
||
const out = []
|
||
for (const iso of Object.keys(byDay)) {
|
||
const entries = byDay[iso]
|
||
const byName = Object.fromEntries(entries.map(e => [e.jobName, e]))
|
||
const jobs = entries.map(e => {
|
||
const jt = suggestDlg.jobTime[e.jobName] || {}
|
||
const win = AMPM_WIN[jt.ampm] // AM/PM → fenêtre horaire dure
|
||
const p = poolPr[e.jobName] // priorité (surcharge persistante incluse via buildUnassigned) : urgent/high jamais lâché + servi tôt
|
||
return { id: e.jobName, lat: e.lat, lon: e.lon, service_min: Math.max(5, Math.round((e.dur || 1) * 60)), skill: e.skill || '', priority_boost: prBoost(p), urgent_weight: prEarly(p), ...(win ? { tw_start_min: win[0], tw_end_min: win[1] } : {}) }
|
||
})
|
||
const vehicles = []
|
||
for (const t of selTechs) {
|
||
// Fenêtre = quart réel ; SINON quart 8-16 supposé (créé au Publier). Jamais de quart auto le week-end → pas de véhicule.
|
||
const w = shiftWindowMin(t.id, iso) || (isWeekendIso(iso) ? null : { start: 480, end: 960 })
|
||
if (!w) continue
|
||
// RÉDUIT la capacité par la charge DÉJÀ assignée ce jour (sinon surcharge, ex. « déjà 8.8h » + 12h → 20/16h).
|
||
const occ = techDayOcc(t.id, iso); const usedMin = occ ? Math.round((occ.usedH || 0) * 60) : 0
|
||
const startMin = Math.min(w.end - 15, w.start + usedMin)
|
||
const overtimeMin = Math.round(0.2 * (w.end - w.start)) // plafond ~120 % du quart nominal ; au-delà de 100 % = pénalisé (équilibrage)
|
||
const o = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
|
||
vehicles.push({ id: t.id, name: t.name, skills: t.skills || [], home_lat: o ? o.lat : null, home_lon: o ? o.lon : null, shift_start_min: startMin, shift_end_min: w.end, overtime_min: overtimeMin })
|
||
}
|
||
if (!vehicles.length) { unassigned.push(...entries); continue } // aucun tech éligible ce jour → non assignés
|
||
let res
|
||
try { res = await roster.optimizeRoutes({ jobs, vehicles, max_seconds: solverOpts.maxSeconds, rank_weight: solverOpts.rankWeight, speed_kmh: solverOpts.speedKmh, overtime_coef: solverOpts.overtimeCoef }) } catch (e) { unassigned.push(...entries); continue }
|
||
if (!res || res.status !== 'OK') { unassigned.push(...entries); continue } // solveur absent/erreur → non assignés (ne jamais perdre de jobs)
|
||
const placed = new Set()
|
||
for (const rt of (res.routes || [])) {
|
||
const t = selTechs.find(x => x.id === rt.vehicle) || { id: rt.vehicle, name: rt.vehicle_name }
|
||
for (const st of (rt.stops || [])) { const e0 = byName[st.job_id]; if (!e0) continue; placed.add(st.job_id); out.push({ ...e0, techId: t.id, techName: t.name, placeholder: false, noShift: !hasShiftDay(t.id, iso), capable: true }) } // noShift → pastille « Créer quart »
|
||
}
|
||
for (const jid of (res.unassigned || [])) { const e0 = byName[jid]; if (e0 && !placed.has(jid)) unassigned.push(e0) }
|
||
}
|
||
// UN SEUL groupe « Non assignés » (aucun faux tech) : capacité/compétence insuffisante avec les techs sélectionnés → en ajouter, ou assigner à la main via le menu ⇄ (classé proximité + charge).
|
||
for (const e of unassigned) out.push({ ...e, techId: '__unassigned__', techName: '⚠️ Non assignés — ajoute des techs ou assigne à la main', placeholder: true, noShift: false })
|
||
suggestDlg.plan = out
|
||
}
|
||
// D v2 — ordonne une tournée par PLUS-PROCHE-VOISIN depuis le domicile du tech + distance totale (haversine, sans appel réseau).
|
||
function nnOrder (entries, home) {
|
||
const pts = entries.filter(e => e.lat != null && e.lon != null)
|
||
const noLL = entries.filter(e => e.lat == null || e.lon == null)
|
||
if (pts.length <= 1) return { ordered: [...pts, ...noLL], km: 0 }
|
||
const rem = [...pts]; const out = []
|
||
let cur = (home && isFinite(+home.lat) && isFinite(+home.lon)) ? { lat: +home.lat, lon: +home.lon } : rem[0]
|
||
let km = 0
|
||
while (rem.length) { let bi = 0, bd = Infinity; for (let i = 0; i < rem.length; i++) { const d = haversineKm(cur.lat, cur.lon, rem[i].lat, rem[i].lon); if (d < bd) { bd = d; bi = i } } if (isFinite(bd)) km += bd; cur = rem[bi]; out.push(rem.splice(bi, 1)[0]) }
|
||
return { ordered: [...out, ...noLL], km: Math.round(km * 10) / 10 }
|
||
}
|
||
const estTravelMin = km => Math.round(km / 45 * 60) // ~45 km/h moyen (route + arrêts) → estimation « ≈ »
|
||
// Regroupé par tech puis par jour, chaque jour ordonné en tournée (D v2) + km/temps de route estimés.
|
||
const suggestGroups = computed(() => {
|
||
const byTech = {}
|
||
for (const e of suggestDlg.plan) {
|
||
const g = byTech[e.techId] || (byTech[e.techId] = { techId: e.techId, techName: e.techName, days: {}, hours: 0, jobs: 0 })
|
||
const d = g.days[e.iso] || (g.days[e.iso] = { iso: e.iso, entries: [], hours: 0 })
|
||
d.entries.push(e); d.hours += e.dur; g.hours += e.dur; g.jobs++
|
||
}
|
||
const dnumOf = iso => { const d = (dayList.value || []).find(x => x.iso === iso); return d ? (d.dow + ' ' + d.dnum) : iso.slice(5) }
|
||
// Ville d'un job d'occupation/legacy : sujets hétérogènes (« Ville | Client » OU « Type | Ville | Client ») → on prend le 1er segment
|
||
// qui RESSEMBLE à une ville (≤4 mots, sans chiffres, sans mots-métier), sinon la fin d'adresse, sinon le suffixe « … - Ville ».
|
||
const OCC_STOP = /bris|fibre|install|r[ée]par|modem|internet|panne|coupure|remplac|ramass|retrait|d[ée]mant|note|t[ée]l[ée]|\btv\b|wifi|borne|activation|transfert|[ée]quipement|technicien|est\.|time|urgent|\bsca\b|drop|bo[iî]tier|d[ée]roulage|registre|camping/i
|
||
const cityLike = s => { s = String(s || '').trim(); if (!s || /\d/.test(s)) return ''; if (s.split(/\s+/).length > 4) return ''; if (OCC_STOP.test(s)) return ''; return s }
|
||
const cityOfOcc = j => {
|
||
const parts = String(j.address || '').split(',').map(x => x.trim()).filter(Boolean)
|
||
if (parts.length >= 2) { const c = cityLike(parts[parts.length - 1]); if (c) return c }
|
||
for (const seg of String(j.subject || '').split('|')) {
|
||
const c = cityLike(seg); if (c) return c
|
||
if (seg.includes(' - ')) { const c2 = cityLike(seg.split(' - ').pop()); if (c2) return c2 }
|
||
}
|
||
return ''
|
||
}
|
||
return Object.values(byTech).map(g => {
|
||
const home = isHoldId(g.techId) ? null : techOrigin(g.techId); const isPh = isHoldId(g.techId); let tkm = 0 // départ = domicile, sinon bureau TARGO
|
||
const days = Object.values(g.days).sort((a, b) => a.iso.localeCompare(b.iso)).map(d => {
|
||
const r = nnOrder(d.entries, home); tkm += r.km
|
||
// Jobs DÉJÀ assignés à ce tech ce jour (occupation OPS + tickets legacy F terrain) → visibles + secteur où il est déjà
|
||
let prevJobs = []
|
||
if (!isPh) {
|
||
prevJobs = (((techDayOcc(g.techId, d.iso) || {}).jobs) || []).filter(j => !j.cancelled)
|
||
const lg = legacyLoad.value[g.techId + '|' + d.iso]
|
||
for (const j of ((lg && lg.jobs) || [])) if (j.field) prevJobs.push({ name: 'LEG:' + j.id, subject: j.subject, skill: j.skill || '', dur: Number(j.est_h) || 0, legacy: true })
|
||
}
|
||
const prevCities = [...new Set(prevJobs.map(cityOfOcc).filter(c => c && c !== 'Sans ville'))]
|
||
return { ...d, entries: r.ordered, km: r.km, mins: estTravelMin(r.km), label: dnumOf(d.iso), prevJobs, prevCities }
|
||
})
|
||
const sectors = [...new Set(days.flatMap(d => d.prevCities))] // secteur(s) où le tech a DÉJÀ des jobs → s'ajuster en conséquence
|
||
// Occupation : capacité (quarts réels ou 8h) + charge DÉJÀ présente sur ces jours, + les heures proposées → barre projetée.
|
||
// Occupation PAR JOUR (le vrai plafond = 8h/jour, PAS la somme des 2 jours) : on affiche le JOUR le plus chargé (contrainte qui lie).
|
||
const placeholder = isPh
|
||
let worstPct = 0, worstProj = 0, worstCap = 8, existing = 0, over = false
|
||
if (!placeholder) for (const d of days) {
|
||
const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(g.techId, d.iso))
|
||
const dcap = l.cap || 8; const dproj = (l.h || 0) + (d.hours || 0); existing += (l.h || 0)
|
||
const pct = dcap ? dproj / dcap * 100 : 0
|
||
if (pct > worstPct) { worstPct = pct; worstProj = dproj; worstCap = dcap }
|
||
if (dproj > dcap * 1.001) over = true
|
||
}
|
||
const noShiftDays = placeholder ? [] : days.filter(d => !isWeekendIso(d.iso) && !hasShiftDay(g.techId, d.iso)).map(d => d.iso) // jours (hors WE) sans quart → alerte + créer quart
|
||
const skills = [...new Set(days.flatMap(d => d.entries).map(e => e.skill).filter(Boolean))] // compétences requises par cette file → cible d'assignation doit les couvrir
|
||
return {
|
||
...g, dayList: days, km: Math.round(tkm * 10) / 10, mins: estTravelMin(tkm),
|
||
cap: Math.round(worstCap * 10) / 10, existing: Math.round(existing * 10) / 10, projected: Math.round(worstProj * 10) / 10,
|
||
fillPct: Math.min(100, Math.round(worstPct)), over: !placeholder && over, placeholder, noShiftDays, skills, sectors
|
||
}
|
||
}).sort((a, b) => b.jobs - a.jobs)
|
||
})
|
||
// Carte des tournées (grande carte dans la revue) : 1 couleur par tech, domicile + arrêts ordonnés, POUR UN SEUL JOUR.
|
||
// Couleur de tournée par index : ANGLE D'OR (137,5°) sur la roue HSL → couleurs consécutives toujours très éloignées,
|
||
// nombre illimité de techs sans collision visuelle (remplace la palette fixe de 12 qui se ressemblait).
|
||
const routeColor = (i) => `hsl(${Math.round((i * 137.508) % 360)}, 70%, 40%)`
|
||
const suggestMapDay = ref('')
|
||
const suggestRoutes = computed(() => {
|
||
const day = suggestMapDay.value || (suggestWindow.value[0] || '')
|
||
const out = []; let ci = 0
|
||
for (const g of suggestGroups.value) {
|
||
if (g.placeholder) continue
|
||
const d = (g.dayList || []).find(x => x.iso === day); if (!d || !d.entries.length) continue
|
||
// MÊME ADRESSE = 1 ARRÊT sur la carte : les entrées aux mêmes coordonnées (~10 m) partagent un seul pin numéroté.
|
||
const stops = []; const seen = new Map()
|
||
for (const e of d.entries) {
|
||
if (e.lat == null || e.lon == null || !isFinite(+e.lat) || !isFinite(+e.lon) || Math.abs(+e.lat) < 0.01) continue // 0,0 / invalides exclus (sinon la carte dézoome jusqu'au golfe de Guinée)
|
||
const k = (+e.lat).toFixed(4) + ',' + (+e.lon).toFixed(4)
|
||
const ex = seen.get(k)
|
||
if (ex) { ex.n++; ex.subject = ex.n + ' jobs — ' + ex.first } else { const s = { lat: +e.lat, lon: +e.lon, seq: stops.length + 1, subject: e.subject, first: e.subject, n: 1, name: e.jobName, entry: e, icon: markerIcon(e.skill) }; seen.set(k, s); stops.push(s) }
|
||
}
|
||
if (!stops.length) continue
|
||
const home = techOrigin(g.techId) // domicile, sinon bureau TARGO
|
||
out.push({ techId: g.techId, techName: g.techName, color: routeColor(ci), km: d.km, mins: d.mins, home: home ? { lat: home.lat, lon: home.lon } : null, stops })
|
||
ci++
|
||
}
|
||
return out
|
||
})
|
||
// Carte de la revue = <RouteMap> (réactif sur suggestRoutes) — il ne reste qu'à défauter le jour affiché à l'ouverture de la vue carte.
|
||
watch(() => suggestDlg.view, (v) => { if (v === 'map' && !suggestMapDay.value) suggestMapDay.value = suggestWindow.value[0] || '' })
|
||
// ── P3 — Onglet « Tournées » : tournées RÉELLES (jobs assignés, occupation) de la journée choisie, 1 couleur/tech, tracés OSRM (RouteMap). ──
|
||
const routesDay = ref(null)
|
||
// Sélecteur de jour (Tournées) = bande d'occupation sur 2 SEMAINES (14 j depuis le lundi affiché) par défaut.
|
||
// Données via /roster/capacity (14 j) — DÉCOUPLÉ de la fenêtre de charge du grid (7 j) → barres réelles même en semaine 2.
|
||
const routeCapacity = ref({})
|
||
async function loadRouteCapacity () { try { const r = await roster.getCapacity(start.value, 14); routeCapacity.value = r.capacity || {} } catch (e) { routeCapacity.value = {} } }
|
||
watch([boardView, start], () => { if (boardView.value === 'routes') loadRouteCapacity() }, { immediate: true })
|
||
const routeStripDays = computed(() => {
|
||
const out = []
|
||
for (let i = 0; i < 14; i++) {
|
||
const iso = addDaysISO(start.value, i); const c = routeCapacity.value[iso] || {}
|
||
const h = +c.due_h || 0; const cap = +c.cap_h || 0; const dw = dowOf(iso)
|
||
out.push({ iso, dow: FR_DOW[dw], dnum: iso.slice(8) + '/' + iso.slice(5, 7), weekend: dw === 0 || dw === 6, holiday: isHoliday(iso), h, cap, ratio: cap > 0 ? h / cap : (h > 0 ? 1 : 0), over: !!c.overbooked, today: iso === todayISO(), unassigned: (unassignedByDay.value[iso] || []).length })
|
||
}
|
||
return out
|
||
})
|
||
// Jobs DUS le jour sélectionné (Tournées) mais NON assignés — mis en évidence + « Suggérer ». Respecte le filtre de compétence (unassignedByDay).
|
||
const routeDayUnassigned = computed(() => unassignedByDay.value[routesDay.value] || [])
|
||
// Chips SECTEUR (par ville) des non-assignés du jour → filtre la liste ET la carte. Reset au changement de jour.
|
||
const routeSectorSel = ref(new Set())
|
||
const routePrioSel = ref(new Set())
|
||
watch(routesDay, () => { routeSectorSel.value = new Set(); routePrioSel.value = new Set() })
|
||
const routeSectors = computed(() => { const m = {}; for (const j of routeDayUnassigned.value) { const c = jobCity(j) || 'Sans secteur'; m[c] = (m[c] || 0) + 1 } return Object.entries(m).map(([sector, n]) => ({ sector, n })).sort((a, b) => b.n - a.n) })
|
||
function toggleRouteSector (s) { const set = new Set(routeSectorSel.value); if (set.has(s)) set.delete(s); else set.add(s); routeSectorSel.value = set }
|
||
// Chips PRIORITÉ (normalisée high/medium/low) — filtrent la liste + la carte comme les secteurs, et (avec eux) le périmètre de « Suggérer ».
|
||
const NORM_PRIO = { high: 'high', urgent: 'high', 'élevée': 'high', elevee: 'high', medium: 'medium', moyenne: 'medium', normal: 'medium', low: 'low', basse: 'low' }
|
||
const normPrio = j => NORM_PRIO[String(j.priority || 'medium').toLowerCase()] || 'medium'
|
||
const routePrios = computed(() => { const m = {}; for (const j of routeDayUnassigned.value) { const p = normPrio(j); m[p] = (m[p] || 0) + 1 } return POOL_PRIOS.filter(pp => m[pp.value]).map(pp => ({ ...pp, n: m[pp.value] })) })
|
||
function toggleRoutePrio (p) { const set = new Set(routePrioSel.value); if (set.has(p)) set.delete(p); else set.add(p); routePrioSel.value = set }
|
||
const routeDayUnassignedView = computed(() => {
|
||
const sel = routeSectorSel.value; const ps = routePrioSel.value
|
||
if (!sel.size && !ps.size) return routeDayUnassigned.value
|
||
return routeDayUnassigned.value.filter(j => (!sel.size || sel.has(jobCity(j) || 'Sans secteur')) && (!ps.size || ps.has(normPrio(j))))
|
||
})
|
||
const _jlat = j => (j.latitude != null ? +j.latitude : (j.lat != null ? +j.lat : null))
|
||
const _jlon = j => (j.longitude != null ? +j.longitude : (j.lon != null ? +j.lon : null))
|
||
// Gouttes carte = non-assignés du jour (filtrés secteur) qui ont des coordonnées ; couleur = priorité
|
||
const routeUnassignedPins = computed(() => routeDayUnassignedView.value.filter(j => { const la = _jlat(j); const lo = _jlon(j); return la != null && lo != null && isFinite(la) && isFinite(lo) && Math.abs(la) > 0.01 }).map(j => ({ lat: _jlat(j), lon: _jlon(j), subject: j.subject || j.service_type || j.name, name: j.name, color: prioColor(j.priority), city: jobCity(j), icon: skillSym(j.required_skill || j.service_type), job: j })))
|
||
|
||
// ── Deep-link (?day=YYYY-MM-DD & ?skill=…) + action « planifier des quarts » du slot-finder ──
|
||
const route = useRoute()
|
||
function matchSkill (skill) { return skill ? (allSkills.value || []).find(s => String(s).toLowerCase() === String(skill).toLowerCase()) : null }
|
||
function focusDay (iso, skill) { // cadre la semaine sur le jour, le sélectionne (grille/mobile/tournées), filtre compétence si connue
|
||
if (iso && /^\d{4}-\d{2}-\d{2}$/.test(iso)) { start.value = mondayISO(iso); mobileSelIso.value = iso; routesDay.value = iso }
|
||
const m = matchSkill(skill); if (m) skillFilter.value = [m]
|
||
}
|
||
// « Aucun créneau » → « Planifier des quarts » : ouvre la timeline des techs (filtrée compétence) au bon jour pour créer des quarts.
|
||
function onPlanShifts ({ skill, date } = {}) {
|
||
showSuggestSlots.value = false
|
||
boardView.value = 'grid' // vue timeline = voir/créer les quarts
|
||
focusDay(date, skill)
|
||
const m = matchSkill(skill)
|
||
$q.notify({ type: 'info', icon: 'event_busy', timeout: 4500, message: m ? `Techs « ${m} » — crée un quart ce jour pour ouvrir des créneaux` : (skill ? `Aucun tech n'a « ${skill} » comme compétence — ajoute-la à un tech ou crée un quart` : 'Crée un quart pour ouvrir des créneaux') })
|
||
}
|
||
const routesMapRef = ref(null)
|
||
const dayRouteMetrics = reactive({})
|
||
const hiddenRouteTechs = ref(new Set()) // chips : techs RETIRÉS de la carte (couleur/badge conservés, réactivables d'un clic)
|
||
function toggleRouteTech (id) { const s = new Set(hiddenRouteTechs.value); if (s.has(id)) s.delete(id); else s.add(id); hiddenRouteTechs.value = s }
|
||
const allDayRoutes = computed(() => { // TOUTES les tournées du jour (couleurs STABLES par index, même quand on masque)
|
||
const iso = routesDay.value; if (!iso) return []
|
||
const out = []; let ci = 0
|
||
for (const t of (visibleTechs.value || [])) {
|
||
const occ = techDayOcc(t.id, iso)
|
||
const jobs = ((occ && occ.jobs) || []).filter(j => !j.cancelled && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon) && Math.abs(+j.lat) > 0.01)
|
||
if (!jobs.length) continue
|
||
jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // ordre de tournée réel
|
||
const h = techOrigin(t.id) // domicile, sinon bureau TARGO
|
||
out.push({ id: t.id, name: t.name, color: routeColor(ci++), home: h ? { lat: h.lat, lon: h.lon } : null, stops: jobs.map((j, i) => ({ lat: +j.lat, lon: +j.lon, seq: i + 1, subject: j.subject, name: j.name, job: j, icon: markerIcon(j.skill) })) })
|
||
}
|
||
return out
|
||
})
|
||
const dayRoutes = computed(() => allDayRoutes.value.filter(r => !hiddenRouteTechs.value.has(r.id))) // ce que la carte affiche
|
||
|
||
// ── Positions GPS LIVE des techs (onglet Tournées) : polling doux (25 s) tant que l'onglet est ouvert ; couleur = celle de la tournée du tech ──
|
||
const livePosRaw = ref([]) // [{ techId, techName, lat, lon, time, speed }]
|
||
const showLivePos = ref(true) // afficher/masquer les positions GPS live sur la carte des tournées
|
||
let _livePosTimer = null
|
||
async function refreshLivePositions () { try { const r = await roster.techPositions(); livePosRaw.value = (r && r.positions) || [] } catch (e) { /* non bloquant */ } }
|
||
function startLivePositions () { if (_livePosTimer) return; refreshLivePositions(); _livePosTimer = setInterval(refreshLivePositions, 25000) }
|
||
function stopLivePositions () { if (_livePosTimer) { clearInterval(_livePosTimer); _livePosTimer = null } }
|
||
// Positions pour la carte : masque les techs cachés (chips), colore selon leur tournée du jour (sinon gris neutre).
|
||
const dayLivePositions = computed(() => {
|
||
const colorByTech = {}; for (const r of (allDayRoutes.value || [])) colorByTech[r.id] = r.color
|
||
const visIds = new Set((visibleTechs.value || []).map(t => t.id)) // filtre de compétence : seules les positions des techs capables (+ non masqués) sur la carte
|
||
return (livePosRaw.value || [])
|
||
.filter(p => visIds.has(p.techId) && !hiddenRouteTechs.value.has(p.techId))
|
||
.map(p => ({ techId: p.techId, name: p.techName, lat: p.lat, lon: p.lon, time: p.time, speed: p.speed, color: colorByTech[p.techId] || '#455a64' }))
|
||
})
|
||
watch(boardView, (v) => { if (v === 'routes') startLivePositions(); else stopLivePositions() }, { immediate: true })
|
||
watch(routesDay, () => { for (const k in dayRouteMetrics) delete dayRouteMetrics[k]; hiddenRouteTechs.value = new Set() })
|
||
watch(boardView, (v) => { if (v === 'routes' && (!routesDay.value || !dayList.value.some(d => d.iso === routesDay.value))) { const t = todayISO(); routesDay.value = ((dayList.value || []).find(d => d.iso >= t) || (dayList.value || [])[0] || {}).iso || t } }, { immediate: true })
|
||
// Un tech « couvre » une file s'il possède TOUTES les compétences requises par ses jobs (compétences vides ignorées).
|
||
const covers = (t, skills) => (skills || []).filter(Boolean).every(s => (t.skills || []).includes(s))
|
||
// Existe-t-il AU MOINS un tech capable ? Sinon (données de compétences incomplètes) on ne bloque personne (évite de piéger une file inassignable).
|
||
const anyCovers = (skills) => !((skills || []).filter(Boolean).length) || (visibleTechs.value || []).some(t => covers(t, skills))
|
||
// Liste de techs triée CAPABLES d'abord (couvrent la/les compétence(s)), puis alphabétique — pour les menus d'assignation.
|
||
function capableTechsFirst (skills) { return [...(visibleTechs.value || [])].sort((a, b) => (covers(b, skills) - covers(a, skills)) || String(a.name || '').localeCompare(String(b.name || ''))) }
|
||
// Une entrée de la revue → objet job-like pour réutiliser techsForJob (classement proximité + occupation).
|
||
function techsForEntry (e) { return techsForJob({ required_skill: e.skill, lat: e.lat, lon: e.lon, scheduled_date: e.iso }) }
|
||
const techById = computed(() => { const m = {}; for (const t of (visibleTechs.value || [])) m[t.id] = t; return m })
|
||
// Réassigne UNE entrée : quand la cible est un VRAI tech, on sort du placeholder + on (re)calcule quart manquant ET compétence.
|
||
function reassignEntry (e, techId, techName) {
|
||
e.techId = techId; e.techName = techName
|
||
const hold = isHoldId(techId)
|
||
e.placeholder = hold
|
||
e.noShift = hold ? false : !hasShiftDay(techId, e.iso)
|
||
e.capable = hold ? true : covers(techById.value[techId] || {}, [e.skill]) // hors compétence → marqué (icône ⚠)
|
||
}
|
||
function moveSuggestEntry (entry, techId, techName) { reassignEntry(entry, techId, techName) } // déplacer 1 job vers un autre tech (ou une file placeholder)
|
||
function moveSuggestGroup (g, techId, techName) { // déplace/fusionne TOUTE la file d'un tech (ou d'un placeholder) vers un autre tech
|
||
if (!techId || techId === g.techId) return
|
||
const targetHad = suggestDlg.plan.some(e => e.techId === techId) // la cible avait déjà une file → c'est une FUSION
|
||
let n = 0; for (const e of suggestDlg.plan) if (e.techId === g.techId) { reassignEntry(e, techId, techName); n++ }
|
||
$q.notify({ type: 'info', icon: targetHad ? 'merge' : 'drive_file_move', message: `${n} job(s) ${targetHad ? 'fusionnés dans' : '→'} ${techName}`, timeout: 2800 })
|
||
}
|
||
// Files déjà présentes dans le plan (pour signaler « fusionner » dans le menu de déplacement).
|
||
const planCountByTech = computed(() => { const m = {}; for (const e of suggestDlg.plan) m[e.techId] = (m[e.techId] || 0) + 1; return m })
|
||
// Crée LOCALEMENT le quart 8-16 pour les jours sans quart de cette file (persisté via Publier, comme l'option makeShifts). PAS d'écriture immédiate.
|
||
async function createShiftForGroup (g) {
|
||
const days = g.noShiftDays || []; if (!days.length) return
|
||
const tpl = await ensureWindowTpl(8, 16); if (!tpl) { $q.notify({ type: 'negative', message: 'Modèle de quart 8-16 introuvable' }); return }
|
||
pushHistory()
|
||
for (const iso of days) setCellReplace(g.techId, g.techName, iso, tpl) // local → recalcule hasShiftDay/occupation ; Publier pour enregistrer
|
||
$q.notify({ type: 'positive', icon: 'event_available', message: `${days.length} quart(s) 8-16 créé(s) pour ${g.techName} — Publier pour enregistrer`, timeout: 3500 })
|
||
}
|
||
function removeSuggestEntry (entry) { suggestDlg.plan = suggestDlg.plan.filter(e => e !== entry) } // retiré du plan → reste au pool
|
||
// Applique : regroupe par tech×jour → réutilise assignNames (assignation serveur + occupation optimiste + retrait du pool).
|
||
async function applySuggestion () {
|
||
suggestDlg.applying = true
|
||
const groups = {}
|
||
for (const e of suggestDlg.plan) { const k = e.techId + '|' + e.iso; (groups[k] = groups[k] || { techId: e.techId, iso: e.iso, names: [] }).names.push(e.jobName) }
|
||
// Quart 8–16 pour les tech×jour SANS quart (si demandé) → capacité + fini le « hors quart ». Local → Publier pour enregistrer.
|
||
let shiftsMade = 0
|
||
if (suggestDlg.makeShifts) {
|
||
const tpl = await ensureWindowTpl(8, 16)
|
||
if (tpl) { let first = true; for (const k of Object.keys(groups)) { const g = groups[k]; if (isHoldId(g.techId) || isWeekendIso(g.iso)) continue; if (!hasShiftDay(g.techId, g.iso)) { const t = (visibleTechs.value || []).find(x => x.id === g.techId); if (t) { if (first) { pushHistory(); first = false } setCellReplace(g.techId, t.name, g.iso, tpl); shiftsMade++ } } } } // JAMAIS de quart auto le week-end
|
||
}
|
||
let total = 0
|
||
for (const k of Object.keys(groups)) {
|
||
const g = groups[k]; const t = (visibleTechs.value || []).find(x => x.id === g.techId); if (!t) continue
|
||
const d = (dayList.value || []).find(x => x.iso === g.iso) || { iso: g.iso, dnum: g.iso.slice(8) }
|
||
try { const done = await assignNames(g.names, t, d); total += (done || []).length } catch (e) { err(e) }
|
||
}
|
||
suggestDlg.applying = false; suggestDlg.open = false
|
||
const pending = suggestDlg.plan.filter(e => e.placeholder).length // jobs en file PLACEHOLDER (pas assignés — à confier à un vrai tech avant d'appliquer)
|
||
if (total || pending) $q.notify({ type: pending ? 'warning' : 'positive', message: (total ? `${total} job(s) répartis` : '') + (shiftsMade ? ` · ${shiftsMade} quart(s) 8–16 créé(s)` : '') + (pending ? ` · ⏳ ${pending} job(s) en file à assigner — assigne les tournées placeholder à un tech (bouton 👤+) puis applique` : (total ? ' — Publier pour enregistrer' : '')), timeout: 7000 })
|
||
await reloadPool()
|
||
}
|
||
// Mobile : assignation AU TOUCHER (le glisser HTML5 ne marche pas au doigt) + MULTI-sélection.
|
||
// Tap un (ou plusieurs) job(s) du pool → tap un tech = assigne TOUTE la sélection. Réutilise selectedJobs/selectedNames.
|
||
// Chips de filtre du pool (par compétence) — multi-sélection.
|
||
const poolSkillSel = ref(new Set())
|
||
const poolSort = ref('smart') // smart (défaut, jour sélectionné d'abord) | date | prio | dur
|
||
const poolSortOpts = [{ value: 'smart', label: 'Pertinence' }, { value: 'sector', label: 'Secteur' }, { value: 'date', label: 'Date prévue' }, { value: 'prio', label: 'Priorité' }, { value: 'dur', label: 'Durée' }]
|
||
const poolSkills = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) { const s = j.required_skill; if (s) m[s] = (m[s] || 0) + 1 } return Object.entries(m).map(([skill, n]) => ({ skill, n })).sort((a, b) => b.n - a.n) })
|
||
function togglePoolSkill (s) { const set = new Set(poolSkillSel.value); if (set.has(s)) set.delete(s); else set.add(s); poolSkillSel.value = set }
|
||
// Pool : filtré par chips + trié selon poolSort. Défaut « Pertinence » = dû le JOUR SÉLECTIONNÉ → en retard → aujourd'hui → futur → sans date ; puis priorité ; puis date.
|
||
const poolJobs = computed(() => {
|
||
const t = todayISO(); const sel = mobileSelDay.value; const fs = poolSkillSel.value
|
||
const pr = j => ({ urgent: 0, high: 1, élevée: 1, moyenne: 2, medium: 2, normal: 2, low: 3, basse: 3 }[String(j.priority || '').toLowerCase()] ?? 2)
|
||
const dueRank = j => { const d = j.scheduled_date; if (d && d === sel) return 0; if (!d || d === 'Sans date') return 4; if (d < t) return 1; if (d === t) return 2; return 3 }
|
||
const durMin = j => (+j.est_min || (+j.duration_h || 1) * 60)
|
||
const byDate = (a, b) => { const da = a.scheduled_date && a.scheduled_date !== 'Sans date' ? a.scheduled_date : '9999-99-99'; const db = b.scheduled_date && b.scheduled_date !== 'Sans date' ? b.scheduled_date : '9999-99-99'; return String(da).localeCompare(String(db)) }
|
||
let arr = assignPanel.jobs || []
|
||
if (fs.size) arr = arr.filter(j => fs.has(j.required_skill))
|
||
const secOf = j => jobCity(j) || 'zz Sans secteur'; const mrcOf = j => String(j.mrc || 'zz') // secteur=municipalité canonique ; ordonné par MRC pour regrouper les voisines
|
||
const mode = poolSort.value
|
||
const cmp = mode === 'date' ? (a, b) => byDate(a, b) || pr(a) - pr(b)
|
||
: mode === 'prio' ? (a, b) => pr(a) - pr(b) || byDate(a, b)
|
||
: mode === 'dur' ? (a, b) => durMin(b) - durMin(a) || dueRank(a) - dueRank(b)
|
||
: mode === 'sector' ? (a, b) => mrcOf(a).localeCompare(mrcOf(b)) || secOf(a).localeCompare(secOf(b)) || dueRank(a) - dueRank(b) || pr(a) - pr(b)
|
||
: (a, b) => dueRank(a) - dueRank(b) || pr(a) - pr(b) || byDate(a, b)
|
||
return [...arr].sort(cmp)
|
||
})
|
||
// Comptes par secteur (municipalité) pour les en-têtes du regroupement « Secteur ».
|
||
const poolSectorCounts = computed(() => { const m = {}; for (const j of poolJobs.value) { const k = jobCity(j) || 'Sans secteur'; const e = m[k] || (m[k] = { n: 0, mins: 0 }); e.n++; e.mins += (+j.est_min || (+j.duration_h || 1) * 60) } return m })
|
||
// initials → composables/useFormatters (source unique ; 2 premiers mots · '?')
|
||
function toggleSel (j) {
|
||
if (j.status === 'On Hold') { $q.notify({ type: 'warning', message: 'En attente d\'une tâche précédente — non assignable', timeout: 2500 }); return }
|
||
if (selectedJobs[j.name]) delete selectedJobs[j.name]; else selectedJobs[j.name] = true
|
||
}
|
||
// ── Actions rapides du pool (mobile, façon Gmail : glisser + icônes directes ★/📝) ──
|
||
// ⚠ Le champ Dispatch Job.priority = Select { low | medium | high } UNIQUEMENT (pas de « urgent » → 417). L'étoile = « high » (le plus prioritaire dispo).
|
||
const POOL_PRIOS = [{ value: 'high', label: 'Élevée', color: '#ef4444' }, { value: 'medium', label: 'Moyenne', color: '#f59e0b' }, { value: 'low', label: 'Basse', color: '#9e9e9e' }]
|
||
const POOL_STATUSES = [{ value: 'open', label: 'Ouvert', icon: 'inbox', color: 'grey-7' }, { value: 'On Hold', label: 'En attente', icon: 'pause_circle', color: 'orange-7' }, { value: 'Cancelled', label: 'Annulé', icon: 'cancel', color: 'red-6' }]
|
||
const jobSheet = reactive({ open: false, job: null })
|
||
const noteDialog = reactive({ open: false, job: null, text: '' })
|
||
const snoozeDate = ref('') // date du sélecteur « Reporter », pré-réglée à +1 semaine à l'ouverture de la feuille
|
||
const sheetNoteText = ref('') // brouillon de la note importante, éditée INLINE dans la feuille (input + bouton Enregistrer)
|
||
// Patch optimiste : applique localement, persiste côté hub, réconcilie le pool sur échec.
|
||
async function patchJob (j, patch, okMsg) {
|
||
if (!j) return
|
||
Object.assign(j, patch)
|
||
try { await roster.updateJob(j.name, patch); if (okMsg) $q.notify({ type: 'positive', message: okMsg, timeout: 1600 }) }
|
||
catch (e) { err(e); await reloadPool() }
|
||
}
|
||
function toggleUrgent (j) { const on = j.priority === 'high'; patchJob(j, { priority: on ? 'medium' : 'high' }, on ? 'Priorité normale' : '⭐ Prioritaire') }
|
||
function setJobPriority (j, p) { patchJob(j, { priority: p }, 'Priorité : ' + ((POOL_PRIOS.find(x => x.value === p) || {}).label || p)) }
|
||
function setJobStatus (j, s) { patchJob(j, { status: s }, 'Statut : ' + ((POOL_STATUSES.find(x => x.value === s) || {}).label || s)) }
|
||
function setJobSkill (j, sk) { patchJob(j, { required_skill: sk }, 'Compétence : ' + (sk || '—')) }
|
||
// Reporter : +N jours (base = date actuelle sinon aujourd'hui).
|
||
function snoozeJob (j, days) {
|
||
if (!j) return
|
||
const base = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : todayISO()
|
||
applyJobDate(j, addDaysISO(base, days), j.scheduled_date || '')
|
||
}
|
||
// Reporter à une date PRÉCISE (depuis le sélecteur).
|
||
function setJobDate (j, iso) { if (!j || !iso || iso === j.scheduled_date) return; applyJobDate(j, iso, j.scheduled_date || '') }
|
||
// Pose la date prévue (optimiste) + toast Annuler (rétablit l'ancienne).
|
||
function applyJobDate (j, next, prev) {
|
||
j.scheduled_date = next
|
||
roster.updateJob(j.name, { scheduled_date: next })
|
||
.then(() => $q.notify({ type: 'info', icon: 'event', message: 'Reporté au ' + fmtDueLabel(next), timeout: 4500, actions: [{ label: 'Annuler', color: 'white', handler: () => patchJob(j, { scheduled_date: prev || null }) }] }))
|
||
.catch(e => { err(e); reloadPool() })
|
||
}
|
||
// Ouvre la feuille d'options ; pré-règle le sélecteur de date à AUJOURD'HUI + 1 semaine (pas date due + 1 sem.).
|
||
// Fil complet du ticket affiché dans les détails (le plus récent EN PREMIER). Chargé à l'ouverture via legacy_ticket_id.
|
||
const sheetThread = reactive({ loading: false, ticket: null, messages: [], contact: null })
|
||
// Validation « service livré » (à la demande) : statut service+modem du client/adresse du job → croise tech-présent (geofence) + service en ligne.
|
||
const serviceCheck = reactive({ loading: false, done: false, online: null, found: null, label: '', rxPower: null })
|
||
async function checkJobService (j) {
|
||
if (!j || !j.name) return
|
||
serviceCheck.loading = true; serviceCheck.done = false
|
||
try {
|
||
const r = await roster.jobServiceStatus(j.name)
|
||
if (!r || r.ok === false) { serviceCheck.found = null; serviceCheck.online = null; serviceCheck.label = (r && r.error) || 'Indisponible' }
|
||
else if (r.found === false) { serviceCheck.found = false; serviceCheck.online = null; serviceCheck.label = r.ambiguous ? 'Client ambigu — préciser' : 'Aucun service trouvé' }
|
||
else {
|
||
const s = r.summary || {}
|
||
serviceCheck.found = true
|
||
serviceCheck.online = s.online === true ? true : (s.online === false ? false : null)
|
||
serviceCheck.rxPower = s.rxPower != null ? s.rxPower : null
|
||
serviceCheck.label = (s.label || (serviceCheck.online ? 'En ligne' : 'Statut inconnu')) + (serviceCheck.rxPower != null ? ' · ' + serviceCheck.rxPower + ' dBm' : '')
|
||
}
|
||
} catch (e) { serviceCheck.found = null; serviceCheck.online = null; serviceCheck.label = 'Erreur' }
|
||
finally { serviceCheck.loading = false; serviceCheck.done = true }
|
||
}
|
||
// Mappe un message de ticket osTicket {author, fromClient} vers le resolver comms partagé (nom/initiales/couleur cohérents Boîte↔feuille).
|
||
function pmIdentity (m) {
|
||
const generic = !m || !m.author || m.author === 'Système / client'
|
||
if (m && m.fromClient) return messageIdentity({ from: 'customer', fromName: generic ? '' : m.author }, { customerName: sheetThread.contact && sheetThread.contact.name })
|
||
return messageIdentity({ fromName: (m && m.author) || '' }, {})
|
||
}
|
||
const postDraft = ref('') // brouillon de RÉPONSE au client
|
||
const noteMode = ref(false) // bascule 📌 « Note importante » (true = note épinglée au job j.notes ; false = réponse client via Outbox)
|
||
const postBusy = ref(false)
|
||
// CHAMP UNIQUE du composeur : édite la note épinglée (sheetNoteText → j.notes) en mode note, sinon la réponse (postDraft).
|
||
const composerText = computed({
|
||
get: () => noteMode.value ? sheetNoteText.value : postDraft.value,
|
||
set: (v) => { if (noteMode.value) sheetNoteText.value = v; else postDraft.value = v },
|
||
})
|
||
// Canal de réponse client déduit du contact (courriel prioritaire, sinon SMS) — gouverne l'option « Réponse ».
|
||
const replyChannel = computed(() => {
|
||
const c = sheetThread.contact
|
||
if (c && c.email) return { label: 'courriel', ok: true }
|
||
if (c && c.phone) return { label: 'SMS', ok: true }
|
||
return { label: '', ok: false }
|
||
})
|
||
async function submitComposer () {
|
||
const j = jobSheet.job; if (!j) return
|
||
if (noteMode.value) { // 📌 Note importante → épinglée au job (j.notes) ; pour un ticket legacy, miroir en note interne osTicket
|
||
const txt = (sheetNoteText.value || '')
|
||
patchJob(j, { notes: txt }, 'Note importante enregistrée')
|
||
if (j.legacy_ticket_id && txt.trim()) { try { await roster.postTicket(j.legacy_ticket_id, txt.trim(), false) } catch (e) { /* miroir best-effort */ } }
|
||
return
|
||
}
|
||
const txt = (postDraft.value || '').trim(); if (!txt) return
|
||
postBusy.value = true
|
||
try {
|
||
// Réponse AU CLIENT : résout la conversation du JOB (osTicket OU ERPNext-natif) puis envoie par le MÊME chemin que la Boîte → Outbox + miroir.
|
||
const c = await roster.jobConversation(j.name)
|
||
if (!c || !c.ok || !c.convToken) { $q.notify({ type: 'negative', message: (c && c.error) || 'Contact client introuvable' }); postBusy.value = false; return }
|
||
const email = (authStore.user && authStore.user !== 'authenticated') ? authStore.user : ''
|
||
await roster.sendConvMessage(c.convToken, txt, email)
|
||
const via = c.channel === 'email' ? 'courriel' : (c.channel === 'sms' ? 'SMS' : 'message')
|
||
postDraft.value = ''
|
||
$q.notify({ type: 'positive', icon: 'send', message: 'Réponse envoyée au client par ' + via, timeout: 2400 })
|
||
await loadSheetThread(j)
|
||
} catch (e) { err(e) }
|
||
postBusy.value = false
|
||
}
|
||
// « Fil complet » : ouvre la conversation comms (composeur riche : canned ::, signatures, Unlayer) en pleine page — réutilise ConversationPanel.
|
||
async function openFullThread () {
|
||
const j = jobSheet.job; if (!j || !j.name) return
|
||
try {
|
||
const c = await roster.jobConversation(j.name)
|
||
if (c && c.ok && c.convToken) { jobSheet.open = false; router.push('/communications/c/' + c.convToken) }
|
||
else $q.notify({ type: 'negative', message: (c && c.error) || 'Conversation introuvable' })
|
||
} catch (e) { err(e) }
|
||
}
|
||
async function loadSheetThread (j) {
|
||
const tok = j && j.name
|
||
sheetThread.messages = []; sheetThread.contact = null; sheetThread.ticket = (j && j.legacy_ticket_id) || null; sheetThread.loading = !!(j && j.name)
|
||
if (!j || !j.name) return
|
||
try {
|
||
const r = await roster.jobThread(j.name) // unifié : contact+fil depuis osTicket si legacy_ticket_id, sinon depuis la fiche client ERPNext
|
||
if (jobSheet.job && jobSheet.job.name === tok) {
|
||
sheetThread.messages = (r && r.ok && Array.isArray(r.messages)) ? [...r.messages].reverse() : [] // reverse = plus récent en haut
|
||
sheetThread.contact = (r && r.ok && r.contact) || null
|
||
if (!replyChannel.value.ok && !noteMode.value) noteMode.value = true // aucun canal pour répondre → bascule sur Note importante
|
||
}
|
||
} catch (e) { /* fil indisponible */ }
|
||
if (jobSheet.job && jobSheet.job.name === tok) sheetThread.loading = false
|
||
}
|
||
function openJobSheet (j, opts) { jobSheet.job = j; snoozeDate.value = addDaysISO(todayISO(), 7); sheetNoteText.value = j.notes || ''; postDraft.value = ''; noteMode.value = !!(opts && opts.note); serviceCheck.done = false; serviceCheck.loading = false; jobSheet.open = true; loadSheetThread(j) }
|
||
// Auto-save de la note à la FERMETURE de la feuille (X · clic extérieur · bouton Fermer · Échap) — uniquement si modifiée. Plus de bouton Enregistrer.
|
||
function onJobSheetHide () { const j = jobSheet.job; if (j && (sheetNoteText.value || '') !== (j.notes || '')) patchJob(j, { notes: sheetNoteText.value || '' }, 'Note enregistrée') }
|
||
function openNote (j) { noteDialog.job = j; noteDialog.text = j.notes || ''; noteDialog.open = true }
|
||
function saveNote () { const j = noteDialog.job; if (j) patchJob(j, { notes: noteDialog.text || '' }, 'Note enregistrée'); noteDialog.open = false }
|
||
// Swipe MAISON, sûr pour le tap : on ne « glisse » qu'au drag horizontal franc (|dx|>10 et dominant) ; sinon le clic natif du bouton (toggleSel) reste intact.
|
||
const swipe = reactive({ name: null, x0: 0, y0: 0, dx: 0, active: false })
|
||
let swipeGuard = false
|
||
function swipeStart (e, j) { if (e.pointerType === 'mouse' && e.button !== 0) return; swipe.name = j.name; swipe.x0 = e.clientX; swipe.y0 = e.clientY; swipe.dx = 0; swipe.active = false }
|
||
function swipeMove (e) {
|
||
if (swipe.name == null) return
|
||
const dx = e.clientX - swipe.x0; const dy = e.clientY - swipe.y0
|
||
if (!swipe.active) {
|
||
if (Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy) * 1.4) { swipe.active = true; try { e.currentTarget.setPointerCapture(e.pointerId) } catch (_) {} }
|
||
else if (Math.abs(dy) > 10) { swipe.name = null; return } // défilement vertical → on lâche
|
||
}
|
||
if (swipe.active) { e.preventDefault(); swipe.dx = Math.max(-120, Math.min(120, dx)) }
|
||
}
|
||
function swipeEnd (e, j) {
|
||
if (swipe.name == null) return
|
||
const active = swipe.active; const dx = swipe.dx
|
||
swipe.name = null; swipe.active = false; swipe.dx = 0
|
||
if (!active) return // c'était un tap → onJobClick ouvre les DÉTAILS
|
||
swipeGuard = true; setTimeout(() => { swipeGuard = false }, 380) // empêcher le clic « fantôme » post-glissement
|
||
if (Math.abs(dx) > 55) toggleSel(j) // glissé G/D → SÉLECTIONNE pour assignation (la feuille de techs s'ouvre)
|
||
}
|
||
function swipeReset () { swipe.name = null; swipe.active = false; swipe.dx = 0 }
|
||
function onJobClick (j) { if (swipeGuard) { swipeGuard = false; return } openJobSheet(j) } // TAP = détails (thread + options) ; SWIPE = sélectionner pour assignation
|
||
function swipeStyle (j) { return (swipe.active && swipe.name === j.name) ? { transform: 'translateX(' + swipe.dx + 'px)' } : {} }
|
||
function clearSel () { for (const k in selectedJobs) delete selectedJobs[k] }
|
||
// Tâches « sœurs » : même adresse + même client + même jour qu'une tâche sélectionnée (clé exigeant les 3 non vides), non sélectionnées.
|
||
function jobBatchKey (j) {
|
||
const a = String(j.address || j.service_location || '').trim().toLowerCase()
|
||
const c = String(j.customer_name || j.customer || '').trim().toLowerCase()
|
||
const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : ''
|
||
return (a && c && d) ? (a + '|' + c + '|' + d) : null
|
||
}
|
||
function siblingJobs (names) {
|
||
const keys = new Set((assignPanel.jobs || []).filter(j => names.includes(j.name)).map(jobBatchKey).filter(Boolean))
|
||
if (!keys.size) return []
|
||
return (assignPanel.jobs || []).filter(j => !names.includes(j.name) && j.status !== 'On Hold' && keys.has(jobBatchKey(j)))
|
||
}
|
||
async function onTechTap (t) {
|
||
const tech = t._t || t
|
||
if (!selectedNames.value.length) { openDayFromCell(tech, mobileSelDayObj.value); return }
|
||
let names = selectedNames.value.slice()
|
||
// Même adresse/client/jour → proposer d'assigner tout le groupe au même tech (moins de clics).
|
||
const sibs = siblingJobs(names)
|
||
if (sibs.length) {
|
||
const ex = sibs[0]
|
||
const include = await new Promise(resolve => $q.dialog({
|
||
title: 'Assigner le groupe ?',
|
||
message: `${sibs.length} autre(s) tâche(s) à « ${String(ex.address || ex.service_location || '').slice(0, 60)} » (${ex.customer_name || ex.customer || ''} · ${fmtDueLabel(ex.scheduled_date)}) — les assigner aussi à ${tech.name} ?`,
|
||
cancel: { label: 'Non, juste la sélection', flat: true }, ok: { label: 'Oui, tout assigner', color: 'primary', unelevated: true }, persistent: true,
|
||
}).onOk(() => resolve(true)).onCancel(() => resolve(false)).onDismiss(() => resolve(false)))
|
||
if (include) names = [...new Set([...names, ...sibs.map(s => s.name)])]
|
||
}
|
||
draggingJobName.value = names.join(','); await onCellDrop({}, tech, mobileSelDayObj.value) // onCellDrop = garde On Hold + dialogue sans-quart + toast Annuler
|
||
}
|
||
function hasReg (techId, iso) { return cellsOf(techId, iso).some(a => { const t = tplByName.value[a.shift]; return t && !t.on_call }) } // a au moins un shift régulier (garde exclue)
|
||
function cellBlocks (techId, iso) { const o = cellOcc(techId, iso); return o ? o.blocks : [] }
|
||
function cellOfficeN (techId, iso) { const o = cellOcc(techId, iso); return o ? (o.officeN || 0) : 0 } // nb de tickets admin/bureau (non-terrain) assignés à ce tech ce jour
|
||
function officeJobs (techId, iso) { const lg = showLegacyLoad.value ? legacyLoad.value[techId + '|' + iso] : null; return lg ? (lg.jobs || []).filter(j => !j.field) : [] } // détail des tickets admin (pour la pastille)
|
||
// Bloc « sans déplacement » (vue semaine) : un SEUL bloc pointillé par jour. Largeur = somme des estimés
|
||
// des tickets office (est_min ou duration_h), PLANCHER 1h dès qu'il y a au moins un ticket, sur l'échelle du timeline.
|
||
function officeBlockH (techId, iso) {
|
||
const jobs = officeJobs(techId, iso); if (!jobs.length) return 0
|
||
let h = 0; for (const j of jobs) h += (j.est_min ? j.est_min / 60 : Number(j.duration_h || 0))
|
||
return Math.max(1, h)
|
||
}
|
||
function officeBlockStyle (techId, iso) {
|
||
const h = officeBlockH(techId, iso); if (!h) return { display: 'none' }
|
||
const occ = cellOcc(techId, iso); const startH = (occ && occ.shiftS != null) ? occ.shiftS : 8
|
||
return pos(startH, startH + h) // ancré au DÉBUT du quart (≈8h) → occupe le matin, pris en compte par le dispatch
|
||
}
|
||
function fmtOfficeH (techId, iso) { return fmtMin(Math.round(officeBlockH(techId, iso) * 60)) }
|
||
function cellGardeBands (techId, iso) { return cellBands(techId, iso).filter(b => b.oncall) } // garde seulement (la bande blanche du quart régulier vient de occCells.shiftS/E étendu)
|
||
function cellPct (techId, iso) { const o = cellOcc(techId, iso); return o ? o.pct : null }
|
||
// Explique PRÉCISÉMENT pourquoi le triangle de surcharge apparaît (heures vs quart + débordements) — pour le tooltip.
|
||
function overWhy (techId, iso) {
|
||
const o = cellOcc(techId, iso); if (!o || !o.over) return ''
|
||
const p = []
|
||
if (o.bookableH > 0 && o.usedH > o.bookableH + 0.01) p.push(`${o.usedH} h de travail prévu pour ${o.bookableH} h de quart${o.pct != null ? ' (' + o.pct + '%)' : ''}`)
|
||
if (o.winE != null && o.shiftE > o.winE + 0.01) p.push(`des jobs finissent après le quart (${fmtH(o.winE)} → ${fmtH(o.shiftE)})`)
|
||
if (o.winS != null && o.shiftS < o.winS - 0.01) p.push(`des jobs commencent avant le quart (dès ${fmtH(o.shiftS)})`)
|
||
if (!o.bookableH) p.push('jobs assignés sans quart régulier publié')
|
||
return p.length ? p.join(' · ') : 'jobs hors du quart prévu'
|
||
}
|
||
// Charge AGRÉGÉE par tech sur les JOURNÉES VISIBLES (dayList) : Σ occupé / Σ offrable → % d'occupation de la semaine affichée.
|
||
const techLoads = computed(() => {
|
||
const out = {}
|
||
for (const t of visibleTechs.value) {
|
||
let used = 0, book = 0, leg = 0
|
||
for (const d of dayList.value) { const o = cellOcc(t.id, d.iso); if (o) { used += o.usedH || 0; book += o.bookableH || 0; leg += o.legacyH || 0 } }
|
||
out[t.id] = { used: Math.round(used * 10) / 10, book: Math.round(book * 10) / 10, leg: Math.round(leg * 10) / 10, pct: book > 0 ? Math.round(used / book * 100) : null }
|
||
}
|
||
return out
|
||
})
|
||
function cellJobs (techId, iso) { const o = cellOcc(techId, iso); return o ? (o.jobs || []) : [] } // jobs du jour, déjà triés priorité→heure côté hub
|
||
function rawCellJobs (techId, iso) { const o = occByTechDay.value[techId + '|' + iso]; return o ? (o.jobs || []) : [] } // jobs BRUTS (inclut les jours SANS quart publié)
|
||
function offShiftJobs (techId, iso) { return (hasReg(techId, iso) || onGarde(techId, iso)) ? [] : rawCellJobs(techId, iso).filter(j => !j.cancelled) } // jobs RÉELS (annulés exclus) assignés un jour sans quart publié
|
||
const offShiftWeekCount = computed(() => { let n = 0; for (const t of visibleTechs.value) for (const d of dayList.value) n += offShiftJobs(t.id, d.iso).length; return n }) // total jobs hors quart sur la période visible
|
||
function prioColor (p) { return p === 'high' ? '#ef4444' : p === 'medium' ? '#f59e0b' : '#9e9e9e' }
|
||
// Aperçu en survol de drop : occupation projetée si on dépose la sélection ici.
|
||
function isDropTarget (techId, iso) { return dropPreview.key === techId + '|' + iso }
|
||
function projPct (techId, iso) { const o = cellOcc(techId, iso); if (!o || !o.bookableH) return null; return Math.round((o.usedH + dropPreview.addH) / o.bookableH * 100) }
|
||
function cellTip (techId, iso) {
|
||
const parts = []
|
||
if (hasReg(techId, iso)) parts.push(cellInterval(techId, iso))
|
||
const o = cellOcc(techId, iso); if (o && o.bookableH) parts.push(o.usedH + ' h occupé / ' + o.bookableH + ' h offrable (' + o.pct + ' %)')
|
||
const g = gardeEffective.value[techId + '|' + iso]; if (g) { const t = tplByName.value[g]; const nm = (t && t.template_name) || 'Garde'; const hrs = t ? (' ' + (t.start_time || '').slice(0, 5) + '–' + (t.end_time || '').slice(0, 5)) : ''; parts.push('🛡️ ' + nm + hrs) }
|
||
return parts.join(' · ')
|
||
}
|
||
function cellInterval (techId, iso) {
|
||
return cellsOf(techId, iso).filter(a => { const t = tplByName.value[a.shift]; return !(t && t.on_call) }).map(a => { const t = tplByName.value[a.shift]; const nm = (t && t.template_name) ? t.template_name.split(' ')[0] + ' ' : ''; return (t && t.start_time) ? (nm + t.start_time.slice(0, 5) + '–' + (t.end_time || '').slice(0, 5)) : (a.shift_name || a.shift) }).join(' + ')
|
||
}
|
||
|
||
// coût de main-d'œuvre (coût chargé × heures)
|
||
const costByTech = computed(() => Object.fromEntries(techs.value.map(t => [t.id, t.cost_h || 0])))
|
||
const costByDate = computed(() => { const m = {}; for (const a of assignments.value) { const t = tplByName.value[a.shift]; if (t && t.on_call) continue; m[a.date] = (m[a.date] || 0) + (Number(a.hours) || 0) * (costByTech.value[a.tech] || 0) } return m }) // garde exclue du coût de main-d'œuvre
|
||
const weekCost = computed(() => Object.values(costByDate.value).reduce((s, v) => s + v, 0))
|
||
function dayCost (iso) { return Math.round(costByDate.value[iso] || 0) }
|
||
|
||
const shiftName = (s) => { const t = tplByName.value[s]; return t ? (t.template_name || s) : s }
|
||
const covRows = computed(() => { const seen = {}; const rows = []; for (const c of coverageData.value) { const key = c.shift + '|' + c.zone; if (!seen[key]) { seen[key] = true; rows.push({ key, label: shiftName(c.shift) + ' · ' + c.zone }) } } return rows })
|
||
const covByKeyDay = computed(() => { const m = {}; for (const c of coverageData.value) m[c.shift + '|' + c.zone + '|' + c.date] = c; return m })
|
||
const gapByDay = computed(() => { const m = {}; for (const c of coverageData.value) m[c.date] = (m[c.date] || 0) + (c.shortfall || 0); return m })
|
||
// Détail du manque de couverture d'un jour (par quart · zone) pour l'infobulle du badge rouge.
|
||
function gapDetail (iso) { return coverageData.value.filter(c => c.date === iso && c.shortfall > 0).map(c => `${shiftName(c.shift)} · ${c.zone || '—'} : ${c.assigned}/${c.required} (−${c.shortfall})`).join('\n') }
|
||
function covCell (key, iso) { return covByKeyDay.value[key + '|' + iso] }
|
||
function covText (key, iso) { const c = covCell(key, iso); return c ? (c.assigned + '/' + c.required) : '' }
|
||
function covStyle (key, iso) { const c = covCell(key, iso); if (!c) return {}; return c.shortfall > 0 ? { background: '#ffcdd2', color: '#b71c1c', fontWeight: 700 } : { background: '#c8e6c9', color: '#1b5e20' } }
|
||
|
||
// undo / redo
|
||
function snap () { return JSON.parse(JSON.stringify(assignments.value)) }
|
||
function pushHistory () { history.value.push(snap()); if (history.value.length > 40) history.value.shift(); future.value = [] }
|
||
function undo () { if (!history.value.length) return; future.value.push(snap()); assignments.value = history.value.pop() }
|
||
function redo () { if (!future.value.length) return; history.value.push(snap()); assignments.value = future.value.pop() }
|
||
|
||
// garde anti-perte
|
||
function guard (fn) { if (dirty.value && !window.confirm(DIRTY_MSG)) return; fn() }
|
||
function onWeekChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { start.value = lastWeek.start; return } loadWeek() }
|
||
function onDaysChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { days.value = lastWeek.days; return } loadWeek() }
|
||
function navWeek (dir) { guard(() => { start.value = addDaysISO(start.value, dir * days.value); loadWeek() }) }
|
||
function navDay (dir) { guard(() => {
|
||
if (boardView.value === 'kanban') { // mode Jour : on déplace le JOUR sélectionné (pas la fenêtre)
|
||
const cur = (kanbanDay.value && kanbanDay.value.iso) || nowET.value.iso
|
||
const next = addDaysISO(cur, dir); kbSelIso.value = next
|
||
if (!dayList.value.find(d => d.iso === next)) { start.value = next; loadWeek() } // hors fenêtre → recentre
|
||
} else { start.value = addDaysISO(start.value, dir); loadWeek() }
|
||
}) }
|
||
function navToday () { guard(() => { kbSelIso.value = nowET.value.iso; start.value = dayMode.value ? todayISO() : thisMonday(); loadWeek() }) } // mode Jour → aujourd'hui ; sinon lundi courant
|
||
|
||
// chargement
|
||
async function loadBase () { const tr = await roster.listTechnicians(); techs.value = tr.technicians || []; const tp = await roster.listTemplates(); templates.value = tp.templates || [] }
|
||
async function refreshTemplates () { const tp = await roster.listTemplates(); templates.value = tp.templates || [] }
|
||
|
||
// cadence / efficacité par tech
|
||
// Libellé orienté PERFORMANCE/vitesse (≠ libellé "facteur temps") : facteur 0,9 = plus rapide, 1,2 = plus lent.
|
||
function effSuffix (e) { const d = Math.round((1 - Number(e)) * 100); if (!d) return 'normal'; return d > 0 ? ('+' + d + '% vite') : ('−' + Math.abs(d) + '% lent') }
|
||
// RÔLE dérivé des compétences (tags) : support→casque · installation→échelle · réparation/terrain→handyman.
|
||
const ROLE_ICON = { support: symOutlinedHeadsetMic, install: symOutlinedToolsLadder, repair: symOutlinedHandyman }
|
||
const ROLE_LABEL = { support: 'Support / service client', install: 'Installation', repair: 'Réparation / terrain' }
|
||
function techRole (t) {
|
||
const s = (t.skills || []).map(x => String(x).toLowerCase())
|
||
if (s.some(x => x.includes('support') || x.includes('service'))) return 'support'
|
||
if (s.some(x => x.includes('install'))) return 'install'
|
||
if (s.some(x => x.includes('répar') || x.includes('repar') || x.includes('fusion') || x.includes('terrain'))) return 'repair'
|
||
return null
|
||
}
|
||
function roleIcon (t) { const r = techRole(t); return r ? ROLE_ICON[r] : null }
|
||
function roleLabel (t) { const r = techRole(t); return r ? ROLE_LABEL[r] : '' }
|
||
function openTeamEditor () { editTechs.value = techs.value.map(t => ({ id: t.id, name: t.name, group: t.group, skills: (t.skills || []).join(', '), efficiency: t.efficiency || 1, salary: t.cost_salary_h || 0, charges: t.cost_charges_pct || 0, other: t.cost_other_h || 0 })); showTeamEditor.value = true }
|
||
async function saveSkills (t) { try { await roster.setTechSkills(t.id, t.skills || ''); const tt = techs.value.find(x => x.id === t.id); if (tt) tt.skills = (t.skills || '').split(',').map(s => s.trim()).filter(Boolean); $q.notify({ type: 'positive', message: t.name + ' : compétences enregistrées' }) } catch (e) { err(e) } }
|
||
|
||
// congés / disponibilités
|
||
async function openLeave () { showLeave.value = true; await loadLeave() }
|
||
async function loadLeave () { try { leaveRows.value = (await roster.listAvailability(leaveFilter.value)).availability || [] } catch (e) { err(e) } }
|
||
async function approveLeave (l, reject) { try { await roster.approveAvailability(l.name, { reject, approver: 'ops' }); $q.notify({ type: reject ? 'info' : 'positive', message: (l.technician_name || l.technician) + (reject ? ' refusé' : ' approuvé') }); await loadLeave() } catch (e) { err(e) } }
|
||
async function createLeave () {
|
||
if (!newLeave.technician || !newLeave.from_date || !newLeave.to_date) { $q.notify({ type: 'warning', message: 'Technicien + dates requis' }); return }
|
||
const t = techs.value.find(x => x.id === newLeave.technician)
|
||
try { await roster.requestAvailability({ technician: newLeave.technician, technician_name: t ? t.name : '', availability_type: newLeave.availability_type, from_date: newLeave.from_date, to_date: newLeave.to_date, reason: newLeave.reason, long_term: newLeave.long_term }); $q.notify({ type: 'positive', message: 'Demande créée' }); newLeave.from_date = ''; newLeave.to_date = ''; newLeave.reason = ''; newLeave.long_term = 0; await loadLeave() } catch (e) { err(e) }
|
||
}
|
||
async function saveEff (t) { const eff = Number(t.efficiency) || 1; try { await roster.setTechEfficiency(t.id, eff); const tt = techs.value.find(x => x.id === t.id); if (tt) tt.efficiency = eff; $q.notify({ type: 'positive', message: t.name + ' : cadence ' + eff }) } catch (e) { err(e) } }
|
||
function loadedCost (t) { return Math.round(((Number(t.salary) || 0) * (1 + (Number(t.charges) || 0) / 100) + (Number(t.other) || 0)) * 100) / 100 }
|
||
async function saveCost (t) { try { await roster.setTechCost(t.id, { salary: t.salary, charges: t.charges, other: t.other }); const tt = techs.value.find(x => x.id === t.id); if (tt) { tt.cost_salary_h = Number(t.salary) || 0; tt.cost_charges_pct = Number(t.charges) || 0; tt.cost_other_h = Number(t.other) || 0; tt.cost_h = loadedCost(t) } $q.notify({ type: 'positive', message: t.name + ' : ' + loadedCost(t) + ' $/h chargé' }) } catch (e) { err(e) } }
|
||
|
||
// éditeur de types de shift (intervalle d'heures)
|
||
function calcHours (st, et) { if (!st || !et) return 0; const [h1, m1] = st.split(':').map(Number); const [h2, m2] = et.split(':').map(Number); let mins = (h2 * 60 + m2) - (h1 * 60 + m1); if (mins < 0) mins += 1440; return Math.round(mins / 60 * 100) / 100 }
|
||
function openShiftEditor () { editTpls.value = templates.value.map(t => ({ name: t.name, template_name: t.template_name, start: (t.start_time || '08:00:00').slice(0, 5), end: (t.end_time || '16:00:00').slice(0, 5), color: t.color || '#1976d2', on_call: t.on_call ? 1 : 0 })); showShiftEditor.value = true }
|
||
async function saveShiftTpl (t) { try { await roster.updateTemplate(t.name, { start_time: t.start + ':00', end_time: t.end + ':00', hours: calcHours(t.start, t.end), color: t.color, on_call: t.on_call ? 1 : 0 }); await refreshTemplates(); $q.notify({ type: 'positive', message: t.template_name + ' enregistré (' + calcHours(t.start, t.end) + ' h)' }) } catch (e) { err(e) } }
|
||
async function addShiftTpl () { const nm = (newTpl.template_name || '').trim() || (fmtH(hToNum(newTpl.start)) + 'h–' + fmtH(hToNum(newTpl.end)) + 'h'); try { await roster.createTemplate({ template_name: nm, start_time: newTpl.start + ':00', end_time: newTpl.end + ':00', hours: calcHours(newTpl.start, newTpl.end), color: newTpl.color, default_required: 1, on_call: newTpl.on_call ? 1 : 0 }); newTpl.template_name = ''; newTpl.on_call = 0; await refreshTemplates(); openShiftEditor(); $q.notify({ type: 'positive', message: 'Type « ' + nm + ' » ajouté' }) } catch (e) { err(e) } }
|
||
async function delShiftTpl (t) { if (!window.confirm('Supprimer le type « ' + t.template_name + ' » ?')) return; try { await roster.deleteShiftTemplate(t.name); await refreshTemplates(); editTpls.value = editTpls.value.filter(x => x.name !== t.name); $q.notify({ type: 'info', message: 'Type supprimé' }) } catch (e) { err(e) } }
|
||
function snapshotServer (list) { serverSet.value = new Set(list.map(a => a.tech + '|' + a.date + '|' + a.shift)) }
|
||
async function loadWeek () {
|
||
loading.value = true
|
||
try {
|
||
const a = await roster.listAssignments(start.value, days.value); assignments.value = a.assignments || []
|
||
snapshotServer(assignments.value); history.value = []; future.value = []; solverStats.value = null
|
||
lastWeek.start = start.value; lastWeek.days = days.value
|
||
await loadStats()
|
||
} catch (e) { err(e) } finally { loading.value = false }
|
||
}
|
||
async function loadStats () {
|
||
try { const s = await roster.getStats(start.value, days.value); dailyStats.value = s.stats || [] } catch (e) { /* non bloquant */ }
|
||
try { const o = await roster.getOccupancy(start.value, $q.screen.lt.md ? MOBILE_STRIP_DAYS : days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ }
|
||
try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) { /* non bloquant */ }
|
||
loadLegacyWindow() // charge legacy datée de la fenêtre (durées estimées appliquées aux timelines) — non bloquant
|
||
}
|
||
// Refresh CIBLÉ + rapide de l'occupation (recharge les blocs/jobs des cellules → timeline à jour) après une assignation,
|
||
// sans le loadWeek complet (assignations + couverture + stats), bien plus lent.
|
||
async function reloadOccupancy () { try { const o = await roster.getOccupancy(start.value, $q.screen.lt.md ? MOBILE_STRIP_DAYS : days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ } }
|
||
|
||
async function doGenerate () {
|
||
generating.value = true
|
||
try {
|
||
const res = await roster.generate(start.value, days.value)
|
||
if (res.status !== 'OPTIMAL' && res.status !== 'FEASIBLE') { err(new Error(res.error || res.message || ('solveur: ' + res.status))); return }
|
||
pushHistory(); assignments.value = res.assignments || []; coverageData.value = res.coverage_report || []
|
||
solverStats.value = { assignments: (res.assignments || []).length, shortfall: res.total_shortfall || 0, spread: res.spread_hours || 0, ms: res.solve_ms || 0 }
|
||
$q.notify({ type: 'positive', message: 'Horaire généré : ' + solverStats.value.assignments + ' assignations (non publié)' })
|
||
} catch (e) { err(e) } finally { generating.value = false }
|
||
}
|
||
async function doPublish () {
|
||
publishing.value = true
|
||
try {
|
||
// Réécriture de semaine : efface la période + recrée la grille (anti-doublons).
|
||
const r = await roster.publishWeek(start.value, days.value, assignments.value, notifySms.value)
|
||
$q.notify({ type: r.errors ? 'warning' : 'positive', message: `Publié : ${r.created} assignations` + (r.deleted ? ` (${r.deleted} remplacées)` : '') + (r.errors ? ` · ${r.errors} erreurs` : '') + (r.notified ? ` · ${r.notified} SMS` : '') })
|
||
await loadWeek()
|
||
} catch (e) { err(e) } finally { publishing.value = false }
|
||
}
|
||
|
||
// demande
|
||
function loadLS () { try { demand.value = JSON.parse(localStorage.getItem(LS_DEMAND) || '[]') } catch { demand.value = [] } try { holidays.value = JSON.parse(localStorage.getItem(LS_HOL) || '[]') } catch { holidays.value = [] } try { weekTemplates.value = JSON.parse(localStorage.getItem(LS_TPL) || '[]') } catch { weekTemplates.value = [] } try { gardeRules.value = JSON.parse(localStorage.getItem(LS_GARDE) || '[]') } catch { gardeRules.value = [] } try { manualGarde.value = JSON.parse(localStorage.getItem(LS_GARDE_MANUAL) || '{}') } catch { manualGarde.value = {} } try { customTags.value = JSON.parse(localStorage.getItem('roster-skill-tags-v1') || '[]') } catch { customTags.value = [] } try { hiddenTechs.value = JSON.parse(localStorage.getItem('roster-hidden-techs-v1') || '[]') } catch { hiddenTechs.value = [] } }
|
||
|
||
// ── Rotation de garde par département (récurrence + rotation) ────────────────
|
||
const GARDE_EPOCH = '2026-01-05' // lundi de référence pour l'index de semaine
|
||
const gardeTemplateOptions = computed(() => templates.value.slice().sort((a, b) => (b.on_call ? 1 : 0) - (a.on_call ? 1 : 0)).map(t => ({ label: t.template_name + (t.on_call ? ' 🛡️' : ''), value: t.name })))
|
||
const groupNames = computed(() => [...new Set(techs.value.map(t => t.group).filter(Boolean))].sort())
|
||
const editingGardeId = ref(null); const gardePick = ref(null)
|
||
function d2ms (iso) { const a = iso.split('-').map(Number); return Date.UTC(a[0], a[1] - 1, a[2]) }
|
||
function mondayISO (iso) { return addDaysISO(iso, -((dowOf(iso) + 6) % 7)) }
|
||
function weekNo (iso) { return Math.round((d2ms(mondayISO(iso)) - d2ms(GARDE_EPOCH)) / (7 * 86400000)) } // n° de semaine absolu (réf. lundi)
|
||
function openGarde () { if (!newGardeRule.anchor) newGardeRule.anchor = mondayISO(start.value); showGarde.value = true }
|
||
// Séquence = étapes {tech, weeks}. Ajouter à la suite (doublons OK), réordonner, retirer.
|
||
function addTechToSeq () { if (gardePick.value) { newGardeRule.steps.push({ tech: gardePick.value, weeks: 1 }); gardePick.value = null } }
|
||
function moveTech (i, dir) { const a = newGardeRule.steps; const j = i + dir; if (j < 0 || j >= a.length) return; const x = a[i]; a.splice(i, 1); a.splice(j, 0, x) }
|
||
function editGardeRule (r) {
|
||
Object.assign(newGardeRule, { dept: r.dept || '', shift: r.shift, shiftWeekend: r.shiftWeekend || '', weekdays: [...(r.weekdays || [])], anchor: r.anchor || mondayISO(start.value), steps: ruleSteps(r) })
|
||
editingGardeId.value = r.id
|
||
}
|
||
const WD_SEMAINE = [1, 2, 3, 4, 5]; const WD_FINSEM = [6, 0]
|
||
function isSetActive (set) { return set.length && set.every(v => newGardeRule.weekdays.includes(v)) }
|
||
function toggleWeekdaysSet (set) { if (isSetActive(set)) newGardeRule.weekdays = newGardeRule.weekdays.filter(v => !set.includes(v)); else newGardeRule.weekdays = [...new Set([...newGardeRule.weekdays, ...set])] }
|
||
function toggleGardeDow (v) { const i = newGardeRule.weekdays.indexOf(v); if (i >= 0) newGardeRule.weekdays.splice(i, 1); else newGardeRule.weekdays.push(v) }
|
||
// ── Moteur de rotation : on PARCOURT la séquence semaine par semaine depuis l'ANCRAGE ──
|
||
function cycleWeeks (steps) { return (steps || []).reduce((s, x) => s + (Number(x.weeks) || 1), 0) }
|
||
function stepTechAt (steps, w) { for (const s of steps) { const n = Number(s.weeks) || 1; if (w < n) return s.tech; w -= n } return steps[0] && steps[0].tech }
|
||
// Rétrocompat : règle au nouveau format (steps) OU à l'ancien (techs[]+periodWeeks). Collapse les doublons consécutifs.
|
||
function ruleSteps (r) {
|
||
if (r.steps && r.steps.length) return r.steps.map(s => ({ tech: s.tech, weeks: Number(s.weeks) || 1 }))
|
||
const p = r.periodWeeks || 1; const out = []
|
||
for (const t of (r.techs || [])) { const last = out[out.length - 1]; if (last && last.tech === t) last.weeks += p; else out.push({ tech: t, weeks: p }) }
|
||
return out
|
||
}
|
||
function ruleAnchor (r) { return r.anchor || GARDE_EPOCH } // ancrage stable si non défini (vieilles règles)
|
||
function rotationTech (rule, iso) {
|
||
const steps = ruleSteps(rule); const cyc = cycleWeeks(steps); if (!cyc) return null
|
||
const w0 = (((weekNo(iso) - weekNo(ruleAnchor(rule))) % cyc) + cyc) % cyc
|
||
for (let k = 0; k < cyc; k++) { const id = stepTechAt(steps, (w0 + k) % cyc); if (id && !isAbsent(id, iso)) return id } // saut d'absent
|
||
return stepTechAt(steps, w0)
|
||
}
|
||
// Aperçu : qui est de garde, semaine par semaine, depuis l'ancrage — reflète la file en cours d'édition (ignore absences)
|
||
const gardePreview = computed(() => {
|
||
const rule = newGardeRule; const cyc = cycleWeeks(rule.steps); if (!cyc || !rule.weekdays.length) return []
|
||
const anchor = rule.anchor || mondayISO(start.value); const out = []
|
||
for (let i = 0; i < Math.min(14, cyc + 4); i++) {
|
||
const ws = addDaysISO(mondayISO(anchor), i * 7); const w0 = (((weekNo(ws) - weekNo(anchor)) % cyc) + cyc) % cyc
|
||
const id = stepTechAt(rule.steps, w0)
|
||
out.push({ week: ws, name: (techs.value.find(t => t.id === id) || {}).name || id })
|
||
}
|
||
return out
|
||
})
|
||
function saveGarde () { localStorage.setItem(LS_GARDE, JSON.stringify(gardeRules.value)) }
|
||
function addGardeRule () {
|
||
if (!newGardeRule.shift || !newGardeRule.steps.length || !newGardeRule.weekdays.length) { $q.notify({ type: 'warning', message: 'Shift, jours et au moins un tech requis' }); return }
|
||
const rule = { id: editingGardeId.value || Date.now(), dept: newGardeRule.dept || '—', shift: newGardeRule.shift, shiftWeekend: newGardeRule.shiftWeekend || '', weekdays: [...newGardeRule.weekdays], anchor: newGardeRule.anchor || mondayISO(start.value), steps: newGardeRule.steps.map(s => ({ tech: s.tech, weeks: Number(s.weeks) || 1 })) }
|
||
if (editingGardeId.value) gardeRules.value = gardeRules.value.map(r => r.id === editingGardeId.value ? rule : r)
|
||
else gardeRules.value = [...gardeRules.value, rule]
|
||
saveGarde(); editingGardeId.value = null; newGardeRule.steps = []; newGardeRule.weekdays = []
|
||
$q.notify({ type: 'positive', message: 'Règle enregistrée — clique « Générer la garde » pour l\'appliquer' })
|
||
}
|
||
function removeGardeRule (i) { gardeRules.value = gardeRules.value.filter((_, j) => j !== i); saveGarde(); if (editingGardeId.value && !gardeRules.value.some(r => r.id === editingGardeId.value)) editingGardeId.value = null }
|
||
function gardeDowLabel (r) { return (r.weekdays || []).map(w => (GARDE_DOW.find(x => x.v === w) || {}).l).join('') }
|
||
function gardeSeqLabel (r) { return ruleSteps(r).map(s => ((techs.value.find(t => t.id === s.tech) || {}).name || s.tech) + (s.weeks > 1 ? ' ×' + s.weeks : '')).join(' → ') }
|
||
// Génère les gardes de la semaine affichée selon les règles (rotation par département)
|
||
const gardeHorizon = ref(8) // nb de semaines à matérialiser (évènement récurrent)
|
||
// Génère la garde sur un HORIZON (plusieurs semaines) et l'écrit directement (publié) → navigable semaine par semaine.
|
||
async function applyGardeRules () {
|
||
if (!gardeRules.value.length && !Object.keys(manualGarde.value).length) { $q.notify({ type: 'info', message: 'Aucune règle — ajoute-en une' }); return }
|
||
if (dirty.value && !window.confirm('Les modifications non publiées de la grille seront rechargées. Continuer ?')) return
|
||
const weeks = gardeHorizon.value || 8; const wk0 = mondayISO(start.value)
|
||
// Construit la même garde EFFECTIVE que l'affichage, mais sur tout l'horizon : rotation par règles…
|
||
const horizon = new Set(); for (let i = 0; i < weeks * 7; i++) horizon.add(addDaysISO(wk0, i))
|
||
const map = {}
|
||
for (const iso of horizon) {
|
||
const dow = dowOf(iso); const weekend = (dow === 0 || dow === 6)
|
||
for (const rule of gardeRules.value) {
|
||
if (!rule.weekdays.includes(dow)) continue
|
||
const sh = (weekend && rule.shiftWeekend) ? rule.shiftWeekend : rule.shift
|
||
if (!tplByName.value[sh]) continue
|
||
const id = rotationTech(rule, iso); if (!id) continue
|
||
map[id + '|' + iso] = sh
|
||
}
|
||
}
|
||
// … + overrides MANUELS « G » dans l'horizon (déplacements faits à la main) → publiés aussi.
|
||
for (const key in manualGarde.value) { const iso = key.split('|')[1]; if (!horizon.has(iso)) continue; const v = manualGarde.value[key]; if (v === 'off') delete map[key]; else if (v === 'on') { const sh = gardeShiftForDay(iso); if (sh) map[key] = sh } }
|
||
const list = []
|
||
for (const key in map) { const [id, iso] = key.split('|'); const sh = map[key]; const tpl = tplByName.value[sh]; const t = techs.value.find(x => x.id === id); list.push({ tech: id, tech_name: t ? t.name : id, date: iso, shift: sh, hours: (tpl && tpl.hours) || 8, zone: (tpl && tpl.zone) || '' }) }
|
||
const shifts = [...new Set([...gardeRules.value.flatMap(r => [r.shift, r.shiftWeekend].filter(Boolean)), ...Object.values(map)])]
|
||
try {
|
||
const r = await roster.applyGardeHorizon(wk0, weeks, list, shifts)
|
||
showGarde.value = false; await loadWeek()
|
||
$q.notify({ type: 'positive', message: `Garde publiée sur ${weeks} sem. : ${r.created} assignations` + (r.deleted ? ` (${r.deleted} remplacées)` : '') + '. La grille la montrait déjà en direct ; c\'est maintenant visible par dispatch et les techs.', timeout: 6000 })
|
||
} catch (e) { err(e) }
|
||
}
|
||
function saveDemand () { localStorage.setItem(LS_DEMAND, JSON.stringify(demand.value)) }
|
||
function addDemand () { demand.value = [...demand.value, { shift: templates.value[0] && templates.value[0].name, zone: 'Montréal', skills: '', job_h: 0, weekday: 1, weekend: 0, holiday: 0 }]; saveDemand() }
|
||
function removeDemand (i) { demand.value = demand.value.filter((_, j) => j !== i); saveDemand() }
|
||
async function applyDemand () {
|
||
if (!demand.value.length) { $q.notify({ type: 'warning', message: 'Aucune ligne de demande' }); return }
|
||
applying.value = true
|
||
try {
|
||
await roster.clearRequirements(start.value, days.value)
|
||
const reqs = []
|
||
for (const d of dayList.value) {
|
||
const slot = isHoliday(d.iso) ? 'holiday' : (d.weekend ? 'weekend' : 'weekday')
|
||
for (const row of demand.value) {
|
||
const n = Number(row[slot]) || 0; if (n <= 0 || !row.shift) continue
|
||
const jobH = Number(row.job_h) || 0
|
||
const sh = (tplByName.value[row.shift] && tplByName.value[row.shift].hours) || 8
|
||
const count = jobH > 0 ? Math.max(1, Math.ceil(n * jobH / sh)) : n // mode jobs → effectif
|
||
reqs.push({ requirement_date: d.iso, shift_template: row.shift, zone: row.zone || '', required_count: count, required_skills: row.skills || '' })
|
||
}
|
||
}
|
||
if (reqs.length) await roster.bulkRequirements(reqs)
|
||
await loadWeek(); $q.notify({ type: 'positive', message: 'Demande appliquée : ' + reqs.length + ' besoins' })
|
||
} catch (e) { err(e) } finally { applying.value = false }
|
||
}
|
||
|
||
// modèles de semaine
|
||
function saveTemplate () {
|
||
$q.dialog({ title: 'Nouveau modèle', message: "Nom du modèle d'horaire", prompt: { model: '', type: 'text' }, cancel: true }).onOk(name => {
|
||
if (!name) return; const byDow = {}
|
||
for (const a of assignments.value) { const dow = dowOf(a.date); (byDow[dow] || (byDow[dow] = {}))[a.tech] = a.shift }
|
||
weekTemplates.value = [...weekTemplates.value, { name, byDow }]; localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value))
|
||
$q.notify({ type: 'positive', message: 'Modèle « ' + name + ' » enregistré' })
|
||
})
|
||
}
|
||
function deleteTemplate (i) { weekTemplates.value = weekTemplates.value.filter((_, j) => j !== i); localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value)) }
|
||
// Modèle par défaut (★) — un seul à la fois, appliqué en 1 clic
|
||
const defaultTemplate = computed(() => weekTemplates.value.find(t => t.default) || null)
|
||
function setDefaultTemplate (i) { weekTemplates.value = weekTemplates.value.map((t, j) => ({ ...t, default: j === i ? !t.default : false })); localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value)) }
|
||
function applyDefault () { const d = defaultTemplate.value; if (!d) { $q.notify({ type: 'info', message: 'Aucun modèle par défaut — marque-en un avec ★ dans Modèles' }); return } applyTemplate(d) }
|
||
function countPatternDays (tm, techId) { let n = 0; for (const d of dayList.value) { const map = tm.byDow[dowOf(d.iso)]; if (map && map[techId]) n++ } return n }
|
||
// Application « consciente des absences » : on n'assigne pas un tech absent ce jour-là.
|
||
// Absent toute la semaine (≈ congé permanent: maternité/blessure) → flag « à remplacer ».
|
||
// Absent quelques jours (≈ vacances) → on saute juste ces jours, le reste du patron tient.
|
||
function applyTemplate (tm) {
|
||
pushHistory()
|
||
const skipped = {}; let applied = 0
|
||
for (const d of dayList.value) {
|
||
const map = tm.byDow[dowOf(d.iso)]; if (!map) continue
|
||
for (const techId in map) {
|
||
if (isAbsent(techId, d.iso)) { skipped[techId] = (skipped[techId] || 0) + 1; continue }
|
||
const tpl = tplByName.value[map[techId]]; if (!tpl) continue
|
||
const t = techs.value.find(x => x.id === techId); setCellReplace(techId, t ? t.name : techId, d.iso, tpl); applied++
|
||
}
|
||
}
|
||
const fullOut = []; const partial = []
|
||
for (const techId in skipped) {
|
||
const t = techs.value.find(x => x.id === techId); const name = (t ? t.name : techId)
|
||
const type = absByTechDay.value[techId + '|' + (dayList.value.find(d => isAbsent(techId, d.iso)) || {}).iso] || ''
|
||
const lbl = name + (type ? ' (' + type + ')' : '')
|
||
if (/longue durée/i.test(type) || skipped[techId] >= countPatternDays(tm, techId)) fullOut.push(lbl); else partial.push(lbl)
|
||
}
|
||
let msg = 'Modèle « ' + tm.name + ' » appliqué (' + applied + ' assignations)'
|
||
if (partial.length) msg += ' · absence partielle ignorée : ' + partial.join(', ')
|
||
if (fullOut.length) msg += ' · ABSENT toute la semaine — à remplacer : ' + fullOut.join(', ')
|
||
$q.notify({ type: fullOut.length ? 'warning' : 'positive', message: msg, timeout: fullOut.length ? 9000 : 4500, multiLine: true })
|
||
}
|
||
|
||
// édition + sélection
|
||
const menu = reactive({ show: false, target: null, tech: null, day: null, x: 0, y: 0 })
|
||
const menuAnchorEl = ref(null) // ancre 1px positionnée au CURSEUR → le menu s'ouvre là où on clique
|
||
const menuRange = ref({ min: 8, max: 16 }); const quickEntry = ref('')
|
||
function rect (sti, sdi, eti, edi) {
|
||
const t0 = Math.min(sti, eti), t1 = Math.max(sti, eti), d0 = Math.min(sdi, edi), d1 = Math.max(sdi, edi)
|
||
const out = []
|
||
for (let i = t0; i <= t1; i++) for (let j = d0; j <= d1; j++) out.push(visibleTechs.value[i].id + '|' + dayList.value[j].iso)
|
||
return out
|
||
}
|
||
function onDown (ti, di, ev) { if (ev.button !== 0 || ev.shiftKey || ev.ctrlKey || ev.metaKey) return; drag.on = true; drag.ti = ti; drag.di = di; drag.moved = false; drag.base = [] }
|
||
function onEnter (ti, di) { if (!drag.on) return; drag.moved = true; selection.value = [...new Set([...drag.base, ...rect(drag.ti, drag.di, ti, di)])] }
|
||
function onUp () { if (drag.on) { drag.on = false; if (drag.moved) justDragged.value = true } }
|
||
function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a => a.shift === tpl.name)) return; assignments.value = [...assignments.value, { tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }] }
|
||
function setCellReplace (techId, techName, iso, tpl) { const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept }
|
||
function removeShift (techId, iso, shift) { assignments.value = assignments.value.filter(a => !(a.tech === techId && a.date === iso && a.shift === shift)) }
|
||
function clearLocal (techId, iso) { assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)) }
|
||
// Ouvre le menu d'horaire (Jour/Soir/Garde/Absent + heures) ancré sur la cellule.
|
||
function openShiftMenu (t, d, ev, ti, di) {
|
||
selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d
|
||
if (ev && ev.clientX != null) { menu.x = ev.clientX; menu.y = ev.clientY; menu.target = menuAnchorEl.value } else { menu.target = ev && ev.currentTarget } // ancre au curseur si dispo
|
||
const wr = winOf(t.id, d.iso, false); quickEntry.value = ''
|
||
menuRange.value = wr ? { min: wr.s, max: wr.e } : { min: 8, max: 16 }
|
||
menu.show = true
|
||
}
|
||
// Y a-t-il une bande timeline cliquable sur cette case ? (shift régulier OU garde, et pas absent/pause)
|
||
// Clic gauche sur le FOND de cellule (zone sans quart ni job) → menu d'horaire (POSER / modifier un quart).
|
||
// Le clic sur la BANDE de quart/garde ou un BLOC de job ouvre la tournée (openDayFromCell, via @click.stop).
|
||
function onCellClick (t, d, ev, ti, di) {
|
||
if (justDragged.value) { justDragged.value = false; return }
|
||
activeCell.value = { id: t.id, name: t.name, iso: d.iso } // mémorise la case pour Cmd+C/V
|
||
if (ev.shiftKey && anchor.value) { selectBlock(ti, di); return }
|
||
if (ev.ctrlKey || ev.metaKey) { const k = t.id + '|' + d.iso; selection.value = selSet.value.has(k) ? selection.value.filter(x => x !== k) : [...selection.value, k]; anchor.value = { ti, di }; return }
|
||
// Clic = LISTE DE TÂCHES du tech (tournée du jour) dès que la cellule a du contenu (quart, jobs terrain, ou tickets sans déplacement).
|
||
// Cellule vide = menu d'horaire (on garde la pose de quart au clic). La pose en lot reste : glisser-sélectionner + saisie rapide / clic droit.
|
||
const occ = cellOcc(t.id, d.iso)
|
||
if (hasReg(t.id, d.iso) || onGarde(t.id, d.iso) || (occ && (occ.noShift || occ.officeN))) openDayFromCell(t, d)
|
||
else openShiftMenu(t, d, ev, ti, di)
|
||
}
|
||
// Clic sur la BANDE de quart/garde ou un BLOC de job → ouvre la tournée du jour. Guardé contre les fins de drag.
|
||
function openDayFromCell (t, d) { if (justDragged.value) { justDragged.value = false; return } activeCell.value = { id: t.id, name: t.name, iso: d.iso }; openDayEditor(t, d) }
|
||
// Clic DROIT sur une cellule → menu d'horaire aussi (bonus / accessibilité).
|
||
function onCellContext (t, d, ev, ti, di) {
|
||
if (justDragged.value) { justDragged.value = false; return }
|
||
activeCell.value = { id: t.id, name: t.name, iso: d.iso }
|
||
openShiftMenu(t, d, ev, ti, di)
|
||
}
|
||
function selectBlock (ti, di) { const a = anchor.value; const t0 = Math.min(a.ti, ti); const t1 = Math.max(a.ti, ti); const d0 = Math.min(a.di, di); const d1 = Math.max(a.di, di); const add = []; for (let i = t0; i <= t1; i++) for (let j = d0; j <= d1; j++) add.push(visibleTechs.value[i].id + '|' + dayList.value[j].iso); selection.value = [...new Set([...selection.value, ...add])] }
|
||
function maybeSelectCol (di) { const ks = visibleTechs.value.map(t => t.id + '|' + dayList.value[di].iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] }
|
||
function maybeSelectRow (ti) { const ks = dayList.value.map(d => visibleTechs.value[ti].id + '|' + d.iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] }
|
||
function isRowSelected (ti) { const t = visibleTechs.value[ti]; if (!t || !dayList.value.length) return false; return dayList.value.every(d => selSet.value.has(t.id + '|' + d.iso)) } // rangée entière sélectionnée ?
|
||
const menuCellShifts = computed(() => (menu.tech && menu.day) ? cellsOf(menu.tech.id, menu.day.iso) : [])
|
||
const menuIsAbsent = computed(() => (menu.tech && menu.day) ? isAbsent(menu.tech.id, menu.day.iso) : false)
|
||
function toggleAbsentMenu () { if (menu.tech && menu.day) { toggleAbsentCells([menu.tech.id + '|' + menu.day.iso]); menu.show = false } }
|
||
const menuIsGarde = computed(() => (menu.tech && menu.day) ? onGarde(menu.tech.id, menu.day.iso) : false)
|
||
function toggleGardeMenu () { if (menu.tech && menu.day) { toggleGardeCells([menu.tech.id + '|' + menu.day.iso]); menu.show = false } }
|
||
function removeShiftFromMenu (a) { pushHistory(); removeShift(a.tech, a.date, a.shift) }
|
||
function clearOne () { if (menu.tech && menu.day) { pushHistory(); clearLocal(menu.tech.id, menu.day.iso); menu.show = false } }
|
||
// Menu : copier / coller (marche au clic, sans Cmd+clic) + ajuster l'horaire au slider
|
||
function copyFromMenu () { if (!menu.tech || !menu.day) return; cellClipboard.value = cellsOf(menu.tech.id, menu.day.iso).map(a => a.shift); $q.notify({ type: 'positive', message: cellClipboard.value.length ? (cellClipboard.value.length + ' shift(s) copié(s) — ouvre une autre case puis Coller') : 'Case vide copiée (Coller la videra)' }) }
|
||
function pasteFromMenu () { if (!menu.tech || !menu.day) return; pushHistory(); if (!cellClipboard.value.length) { clearLocal(menu.tech.id, menu.day.iso) } else { for (const name of cellClipboard.value) { const tpl = tplByName.value[name]; if (tpl) addShift(menu.tech.id, menu.tech.name, menu.day.iso, tpl) } } menu.show = false }
|
||
// Applique une fenêtre [min,max] à la case du menu : trouve/crée un modèle auto-nommé puis remplace.
|
||
// Trouve OU crée un modèle de shift régulier pour une plage horaire (réutilisé par menu + barre de sélection).
|
||
async function ensureWindowTpl (min, max) {
|
||
const s = numToTime(min); const e = numToTime(max); const nm = fmtH(min) + 'h–' + fmtH(max) + 'h'
|
||
let tpl = templates.value.find(t => t.template_name === nm)
|
||
if (!tpl) { try { await roster.createTemplate({ template_name: nm, start_time: s + ':00', end_time: e + ':00', hours: calcHours(s, e), color: '#1976d2', default_required: 1, on_call: 0 }); await refreshTemplates(); tpl = templates.value.find(t => t.template_name === nm) } catch (e2) { err(e2); return null } }
|
||
return tpl
|
||
}
|
||
async function applyWindow (min, max) {
|
||
if (!menu.tech || !menu.day || max <= min) return
|
||
const tpl = await ensureWindowTpl(min, max)
|
||
if (tpl) { pushHistory(); setCellReplace(menu.tech.id, menu.tech.name, menu.day.iso, tpl); menu.show = false }
|
||
}
|
||
function quickShift (min, max) { return applyWindow(min, max) }
|
||
async function applyMenuRange () { return applyWindow(menuRange.value.min, menuRange.value.max) }
|
||
// Saisie rapide d'heures : « 8-17 » · « 8:30-16 » · « 830-16 » · « 85 » (=8→17, dernier chiffre en pm si ≤ début).
|
||
function parseHM (tok) { tok = String(tok).trim().toLowerCase().replace(/h/g, ':').replace(/[^\d:]/g, ''); if (!tok) return null; if (tok.includes(':')) { const [h, m] = tok.split(':'); return Number(h) + (Number(m || 0)) / 60 } if (tok.length >= 3) return Number(tok.slice(0, -2)) + Number(tok.slice(-2)) / 60; return Number(tok) }
|
||
function parseQuickShift (str) {
|
||
const s = (str || '').trim().toLowerCase(); if (!s) return null
|
||
if (/[-–—]|to|→|\s/.test(s)) { const p = s.split(/[-–—]|to|→|\s+/).filter(Boolean); if (p.length < 2) return null; const a = parseHM(p[0]); const b = parseHM(p[1]); return (a == null || b == null || b <= a || b > 24) ? null : { min: a, max: b } }
|
||
if (/^\d{2}$/.test(s)) { const a = Number(s[0]); let b = Number(s[1]); if (b <= a) b += 12; return (b <= a || b > 24) ? null : { min: a, max: b } } // « 85 » = 8→17
|
||
return null
|
||
}
|
||
function applyQuick () { const r = parseQuickShift(quickEntry.value); if (!r) { $q.notify({ type: 'warning', message: 'Format : 8-17 · 8:30-16 · 85' }); return } quickEntry.value = ''; applyWindow(r.min, r.max) }
|
||
function assignBulk (tpl) { pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); const t = techs.value.find(x => x.id === tid); addShift(tid, t ? t.name : tid, iso, tpl) } selection.value = [] }
|
||
// ── Barre de sélection : mêmes 4 actions que le menu de cellule ──
|
||
async function bulkWindow (min, max) { if (!selection.value.length) return; const tpl = await ensureWindowTpl(min, max); if (tpl) assignBulk(tpl) }
|
||
function bulkQuick () { const r = parseQuickShift(quickEntry.value); if (!r) { $q.notify({ type: 'warning', message: 'Format : 8-17 · 8:30-16 · 85' }); return } quickEntry.value = ''; bulkWindow(r.min, r.max) }
|
||
function bulkGarde () { const t = selection.value.slice(); toggleGardeCells(t); selection.value = [] }
|
||
function bulkAbsent () { const t = selection.value.slice(); toggleAbsentCells(t); selection.value = [] }
|
||
function clearBulk () { pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); clearLocal(tid, iso) } selection.value = [] }
|
||
// Copier-coller une case (bâtir l'horaire vite) : copie les shifts de la 1re case sélectionnée → colle dans les autres
|
||
const cellClipboard = ref([])
|
||
function copyCell () {
|
||
const k = selection.value[0] || (activeCell.value && (activeCell.value.id + '|' + activeCell.value.iso))
|
||
if (!k) { $q.notify({ type: 'warning', message: 'Clique ou sélectionne une case d\'abord' }); return }
|
||
const [tid, iso] = k.split('|'); cellClipboard.value = cellsOf(tid, iso).map(a => a.shift)
|
||
$q.notify({ type: 'positive', message: cellClipboard.value.length ? (cellClipboard.value.length + ' shift(s) copié(s) — sélectionne des cases puis Coller (ou Cmd+V)') : 'Case vide copiée (Coller videra les cases)' })
|
||
}
|
||
function pasteCells () {
|
||
const targets = selection.value.length ? selection.value.slice() : (activeCell.value ? [activeCell.value.id + '|' + activeCell.value.iso] : [])
|
||
if (!targets.length) { $q.notify({ type: 'warning', message: 'Sélectionne les cases cibles' }); return }
|
||
pushHistory()
|
||
for (const k of targets) { const [tid, iso] = k.split('|'); const t = techs.value.find(x => x.id === tid); if (!cellClipboard.value.length) { clearLocal(tid, iso); continue } for (const name of cellClipboard.value) { const tpl = tplByName.value[name]; if (tpl) addShift(tid, t ? t.name : tid, iso, tpl) } }
|
||
if (selection.value.length) selection.value = []
|
||
}
|
||
|
||
async function togglePause (t) { try { const paused = !isPaused(t); await roster.pauseTechnician(t.id, paused); t.status = paused ? 'En pause' : 'Disponible'; $q.notify({ type: 'info', message: t.name + (paused ? ' en pause' : ' réactivé') }) } catch (e) { err(e) } }
|
||
function err (e) { $q.notify({ type: 'negative', message: '' + (e.message || e) }) }
|
||
|
||
function onKey (e) {
|
||
const tag = (e.target && e.target.tagName) || ''
|
||
if (/INPUT|TEXTAREA|SELECT/.test(tag) || (e.target && e.target.isContentEditable)) return // ne pas intercepter quand on tape dans un champ
|
||
const k = e.key.toLowerCase()
|
||
if ((e.ctrlKey || e.metaKey) && k === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return }
|
||
if ((e.ctrlKey || e.metaKey) && k === 'c' && (selection.value.length || activeCell.value)) { e.preventDefault(); menu.show = false; copyCell(); return }
|
||
if ((e.ctrlKey || e.metaKey) && k === 'v' && (selection.value.length || activeCell.value)) { e.preventDefault(); menu.show = false; pasteCells(); return }
|
||
if ((k === 'delete' || k === 'backspace') && (selection.value.length || activeCell.value)) {
|
||
e.preventDefault(); menu.show = false
|
||
const targets = selection.value.length ? selection.value.slice() : [activeCell.value.id + '|' + activeCell.value.iso]
|
||
pushHistory()
|
||
for (const key of targets) { const [tid, iso] = key.split('|'); clearLocal(tid, iso) }
|
||
if (selection.value.length) selection.value = []
|
||
}
|
||
if (k === 'a' && !e.altKey && (selection.value.length || activeCell.value)) { // « A » = bascule absent
|
||
e.preventDefault(); menu.show = false
|
||
const targets = selection.value.length ? selection.value.slice() : [activeCell.value.id + '|' + activeCell.value.iso]
|
||
toggleAbsentCells(targets); if (selection.value.length) selection.value = []
|
||
}
|
||
if (k === 'g' && !e.altKey && !e.ctrlKey && !e.metaKey && (selection.value.length || activeCell.value)) { // « G » = bascule garde (manuel)
|
||
e.preventDefault(); menu.show = false
|
||
const targets = selection.value.length ? selection.value.slice() : [activeCell.value.id + '|' + activeCell.value.iso]
|
||
toggleGardeCells(targets); if (selection.value.length) selection.value = []
|
||
}
|
||
}
|
||
function onUnload (e) { if (dirty.value) { e.preventDefault(); e.returnValue = '' } }
|
||
// ── Veille Legacy → Ops (SSE topic 'dispatch') : reflète fermeture / réassignation hors-Ops / activité en direct ──
|
||
let _legacyRefreshT = null
|
||
function scheduleLegacyRefresh () {
|
||
clearTimeout(_legacyRefreshT)
|
||
_legacyRefreshT = setTimeout(async () => {
|
||
try { await reloadOccupancy(); if (assignPanel.open) { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } } catch (e) { /* non bloquant */ }
|
||
}, 700)
|
||
}
|
||
function onLegacyUpdate (data) {
|
||
const ch = (data && data.changes) || []
|
||
if (!ch.length) return
|
||
const closed = ch.filter(c => c.kind === 'closed').length
|
||
const taken = ch.filter(c => c.kind === 'taken')
|
||
const moved = ch.filter(c => c.kind === 'reassigned' && !c.pool)
|
||
const stat = ch.filter(c => c.kind === 'status').length
|
||
if (closed) $q.notify({ type: 'info', icon: 'task_alt', message: `Legacy : ${closed} ticket(s) fermé(s) — retiré(s) du planning`, timeout: 3500 })
|
||
if (taken.length) $q.notify({ type: 'info', icon: 'person_remove', message: `Legacy : ${taken.length} ticket(s) pris par un tech dans Legacy — retiré(s) du pool`, timeout: 4000 })
|
||
for (const m of moved) $q.notify({ type: 'warning', icon: 'swap_horiz', message: `Ticket #${m.ticket} réassigné HORS-Ops (staff ${m.to_staff}) — à vérifier`, timeout: 6000 })
|
||
if (stat) $q.notify({ type: 'info', icon: 'sync', message: `Legacy : ${stat} changement(s) de statut`, timeout: 3000 })
|
||
for (const c of ch) { if (c.job) flashJob.value = c.job }
|
||
scheduleLegacyRefresh()
|
||
}
|
||
const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } })
|
||
|
||
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } if (route.query.day || route.query.skill) focusDay(route.query.day, route.query.skill); await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } if ($q.screen.lt.md) { try { await reloadPool() } catch (e) { /* */ } } else openAssignPanel(); /* mobile : charge le pool pour la liste « À assigner » (assignation au toucher), pas de panneau flottant ; desktop : panneau ouvert */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ })
|
||
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload); if (_nowTimer) clearInterval(_nowTimer); stopLivePositions(); if (_kbRO) _kbRO.disconnect() })
|
||
onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* Barre d'actions de sélection : flottante (fixed) → hors flux, aucune incidence sur la hauteur de la grille. */
|
||
.sel-actions { position: fixed; left: 50%; transform: translateX(-50%); bottom: 18px; z-index: 4000; display: flex; align-items: center; flex-wrap: wrap; gap: 2px; max-width: 94vw; padding: 6px 12px; background: #e0f2f1; color: #00695c; border: 1px solid #4db6ac; border-radius: 9px; box-shadow: 0 6px 22px rgba(0,0,0,.20); }
|
||
.garde-editor { background: #faf8f6; border: 1px solid #e8e0d8; } /* sous-panneau éditeur de garde */
|
||
/* Panneau flottant « jobs à assigner » (déplaçable, glisser-déposer) */
|
||
.assign-panel { position: fixed; z-index: 5000; width: 320px; max-height: 72vh; background: #fff; border: 1px solid #cfd8dc; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,.24); display: flex; flex-direction: column; }
|
||
.assign-hdr { display: flex; align-items: center; gap: 5px; padding: 6px 10px; background: #5e35b1; color: #fff; border-radius: 8px 8px 0 0; cursor: move; font-weight: 600; font-size: 13px; user-select: none; }
|
||
.assign-sortbar { display: flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 11px; color: #555; background: #f3f0fa; border-bottom: 1px solid #e0e0e0; }
|
||
.assign-sortbar select { font-size: 11px; border: 1px solid #cfc4e8; border-radius: 5px; padding: 1px 4px; background: #fff; color: #333; flex: 1; }
|
||
.assign-body { overflow: auto; padding: 5px; flex: 1 1 auto; min-height: 60px; }
|
||
.assign-map-wrap { flex: 0 0 auto; padding: 0 5px 4px; position: relative; }
|
||
.map-sat-btn { position: absolute; top: 7px; left: 11px; z-index: 3; background: rgba(255,255,255,.9); }
|
||
.map-lasso-btn { position: absolute; top: 44px; left: 11px; z-index: 3; background: rgba(255,255,255,.9); }
|
||
.lasso-box { position: absolute; top: 0; left: 0; background: rgba(99,102,241,.14); border: 1.5px dashed #6366f1; border-radius: 3px; pointer-events: none; z-index: 4; }
|
||
.loc-map-wrap { position: relative; }
|
||
.loc-map { height: 360px; border-radius: 6px; overflow: hidden; border: 1px solid #cfd8dc; }
|
||
.loc-map-hint { position: absolute; bottom: 8px; left: 8px; background: rgba(255,255,255,.88); font-size: 11px; padding: 2px 7px; border-radius: 4px; color: #555; pointer-events: none; }
|
||
.loc-results { max-height: 190px; overflow: auto; border-radius: 6px; }
|
||
.loc-pick-link { cursor: pointer; text-decoration: underline; font-weight: 600; }
|
||
/* Bande de capacité restante par jour (AM/PM/Soir) dans l'en-tête */
|
||
.cap-strip { display: flex; gap: 3px; justify-content: center; align-items: center; margin-top: 2px; }
|
||
/* Barre verticale d'occupation (miroir de la bande mobile) dans l'en-tête desktop */
|
||
.hdr-vbar { width: 7px; height: 22px; background: #eef2f7; border-radius: 4px; overflow: hidden; display: inline-flex; align-items: flex-end; flex: 0 0 auto; }
|
||
.hdr-vfill { width: 100%; border-radius: 4px; transition: height .3s; }
|
||
.cap-seg { font-size: 9px; font-weight: 700; line-height: 1; min-width: 15px; padding: 2px 2px; border-radius: 3px; text-align: center; cursor: default; }
|
||
.cap-ok { background: #c8e6c9; color: #1b5e20; }
|
||
.cap-low { background: #ffe0b2; color: #e65100; }
|
||
.cap-full { background: #ffcdd2; color: #b71c1c; }
|
||
.cap-none { background: #eceff1; color: #b0bec5; }
|
||
.load-cell { min-width: 38px; display: inline-block; } /* pastille « est/dispo » au pied de tableau */
|
||
/* Liste des jobs prévus (non assignés) en en-tête de jour — glissables sur une case tech */
|
||
.dayjobs-hdr { width: 234px; max-width: 234px; margin: 3px auto 0; max-height: 132px; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; gap: 2px; text-align: left; }
|
||
.dayjobs-more { font-size: 10px; font-weight: 600; color: #6366f1; text-align: center; padding: 2px 0; cursor: pointer; border-radius: 4px; }
|
||
.dayjobs-more:hover { background: #eef2ff; }
|
||
.dayjob { width: 100%; box-sizing: border-box; font-size: 10px; line-height: 1.2; background: #fff; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 5px; cursor: grab; overflow: hidden; display: flex; align-items: center; gap: 4px; box-shadow: 0 1px 1px rgba(0,0,0,.05); }
|
||
.dayjob:hover { border-color: #90a4ae; background: #f5f7fa; }
|
||
.dayjob.dragging { opacity: .4; }
|
||
.dayjob.hold { opacity: .6; font-style: italic; }
|
||
.dj-est { font-weight: 700; color: #00695c; flex: 0 0 auto; font-size: 9px; }
|
||
.dj-sub { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* min-width:0 = indispensable pour l'ellipsis dans un flex */
|
||
.dayjobs-empty { font-size: 9px; color: #cfd8dc; text-align: center; padding: 2px 0; }
|
||
/* bloc LEGACY sur la timeline : coloré par type (comme les jobs importés) + anneau blanc pointillé pour le distinguer */
|
||
.tl-blk-legacy { box-shadow: inset 0 0 0 1.5px rgba(255,255,255,.9); } /* legacy = anneau blanc inset */
|
||
.tl-blk-done { opacity: 0.85; } /* job COMPLÉTÉ → fond gris neutre (blkFill) + ✓ ; léger estompage pour le recul visuel */
|
||
.ovl-warn { position: absolute; top: 0; right: 1px; z-index: 3; pointer-events: auto; filter: drop-shadow(0 0 1.5px #fff) drop-shadow(0 0 1px #fff); } /* triangle orange = surcharge ; halo blanc → ressort sur n'importe quel fond */
|
||
.cap-warn { margin-left: 1px; }
|
||
.de-actual { font-size: 11px; font-weight: 700; color: #00695c; background: #e0f2f1; border-radius: 4px; padding: 2px 5px; white-space: nowrap; }
|
||
.chrono-run { animation: chronopulse 1.4s ease-in-out infinite; }
|
||
@keyframes chronopulse { 0%, 100% { opacity: 1 } 50% { opacity: .4 } }
|
||
.jc-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||
.jc-table th { text-align: left; color: #607d8b; font-weight: 600; padding: 3px 4px; border-bottom: 1px solid #e0e0e0; }
|
||
.jc-table td { padding: 2px 4px; vertical-align: middle; }
|
||
.jc-table tr.jc-base td:first-child { border-left: 3px solid #1565c0; }
|
||
.jc-table tr.jc-addon td:first-child { border-left: 3px solid #00897b; }
|
||
.jc-table tr.jc-modifier td:first-child { border-left: 3px solid #8e24aa; }
|
||
.jc-learn { font-size: 10px; color: #2e7d32; white-space: nowrap; }
|
||
.assign-map { height: 230px; border-radius: 6px; overflow: hidden; border: 1px solid #cfd8dc; }
|
||
.assign-map-cap { font-size: 10px; color: #607d8b; padding: 2px 2px 0; line-height: 1.3; }
|
||
.assign-chips { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 6px; border-bottom: 1px solid #eee; flex: 0 0 auto; }
|
||
.assign-chip-f { font-size: 10px; border: 1.5px solid #cfd8dc; border-radius: 11px; padding: 1px 8px; cursor: pointer; user-select: none; line-height: 1.6; background: #fff; color: #455a64; }
|
||
.assign-chip-f.clear { border-color: #b0bec5; color: #607d8b; font-weight: 700; }
|
||
.assign-chip-f:hover { filter: brightness(0.95); }
|
||
.assign-resize { position: absolute; right: 1px; bottom: 1px; width: 16px; height: 16px; cursor: nwse-resize; background: linear-gradient(135deg, transparent 45%, #b0bec5 45%, #b0bec5 55%, transparent 55%, transparent 70%, #b0bec5 70%, #b0bec5 80%, transparent 80%); border-radius: 0 0 8px 0; z-index: 2; }
|
||
.assign-grp-badge { display: inline-block; background: #cfd8dc; color: #455a64; font-weight: 700; border-radius: 4px; padding: 0 5px; font-size: 10px; line-height: 16px; }
|
||
.assign-grp { margin-bottom: 6px; border-radius: 7px; padding: 2px; }
|
||
.assign-grp-lbl { font-size: 11px; font-weight: 700; color: #37474f; padding: 3px 6px 2px; border-bottom: 1px solid #eee; margin-bottom: 2px; position: sticky; top: 0; background: #fff; z-index: 1; }
|
||
.assign-grp.grp-hl { background: #ede7f6; box-shadow: inset 0 0 0 1px #b39ddb; } /* groupe lié surligné dès qu'un membre est coché */
|
||
.assign-grp-hdr { font-size: 10px; font-weight: 700; color: #5e35b1; padding: 2px 6px; cursor: pointer; display: flex; align-items: center; gap: 3px; }
|
||
.assign-grp-hdr:hover { text-decoration: underline; }
|
||
.assign-job { border: 1px solid #e0e0e0; border-radius: 6px; padding: 3px 7px; margin: 3px 0; cursor: grab; background: #fafafa; font-size: 12px; }
|
||
.assign-job:hover { border-color: #5e35b1; background: #f3e9fb; }
|
||
.assign-job:active { cursor: grabbing; }
|
||
.assign-job.sel { border-color: #00897b; background: #e0f2f1; box-shadow: inset 0 0 0 1px #00897b; } /* sélectionné = à dispatcher */
|
||
.assign-job.child { margin-left: 14px; border-left: 3px solid #b39ddb; }
|
||
.assign-job.blocked { opacity: .65; }
|
||
.assign-job.job-flash { animation: jobflash 1.7s ease-out; }
|
||
@keyframes jobflash { 0% { background: #fff59d; box-shadow: inset 0 0 0 2px #fbc02d; } 100% { background: #fafafa; box-shadow: none; } }
|
||
.assign-sub { font-size: 10px; color: #888; margin-top: 1px; }
|
||
.assign-pick-hd { font-size: 11px; font-weight: 700; color: #455a64; padding: 6px 10px; border-bottom: 1px solid #eef2f7; background: #f7f5fc; }
|
||
.assign-pick-incap { opacity: 0.62; }
|
||
.assign-skill { display: inline-block; color: #fff; border-radius: 6px; padding: 0 5px; font-size: 9px; font-weight: 600; margin-right: 3px; }
|
||
.assign-lvl { display: inline-flex; align-items: center; gap: 1px; border: 1px solid #cfd8dc; border-radius: 6px; padding: 0 5px; font-size: 9px; font-weight: 600; color: #607d8b; margin-right: 3px; cursor: pointer; }
|
||
.assign-lvl.set { border-color: #6366f1; color: #4338ca; background: #eef2ff; }
|
||
.jtr-row { display: flex; align-items: center; gap: 10px; padding: 5px 6px; border-bottom: 1px solid #f0f2f5; }
|
||
.jtr-type { flex: 1; min-width: 0; font-size: 13px; font-weight: 600; color: #37474f; }
|
||
.jtr-stars { display: flex; align-items: center; gap: 1px; }
|
||
/* D — surcouche de revue de la répartition suggérée */
|
||
.suggest-tech { border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 8px; overflow: hidden; }
|
||
.suggest-wknd { border-color: #f59e0b; background: #fffbeb; } /* groupe placeholder « en attente du quart week-end » */
|
||
.suggest-map { height: 440px; border-radius: 8px; overflow: hidden; border: 1px solid #cfd8dc; }
|
||
.suggest-legend { display: flex; flex-wrap: wrap; gap: 4px 12px; padding: 6px 2px 2px; }
|
||
.suggest-leg { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: #37474f; }
|
||
.suggest-leg-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||
.suggest-tech-hd { display: flex; align-items: center; padding: 6px 10px; background: #f7f5fc; border-bottom: 1px solid #eef2f7; }
|
||
.suggest-occ { display: inline-flex; align-items: center; gap: 6px; margin-left: 12px; min-width: 130px; }
|
||
.suggest-occ-bar { flex: 1; height: 6px; border-radius: 3px; background: #e2e8f0; overflow: hidden; min-width: 60px; }
|
||
.suggest-occ-fill { height: 100%; background: #16a34a; border-radius: 3px; transition: width .2s; }
|
||
.suggest-occ-fill.high { background: #f59e0b; }
|
||
.suggest-occ-fill.over { background: #ef4444; }
|
||
.suggest-occ-txt { font-size: 11px; font-weight: 700; color: #475569; white-space: nowrap; }
|
||
.suggest-hd-skills { display: inline-flex; align-items: center; gap: 3px; cursor: pointer; }
|
||
.suggest-hd-skills:hover .assign-skill { filter: brightness(1.12); }
|
||
.suggest-hd-more { font-size: 10px; font-weight: 700; color: #64748b; background: #e2e8f0; border-radius: 8px; padding: 0 5px; line-height: 15px; }
|
||
.suggest-prev-toggle { display: inline-flex; align-items: center; gap: 2px; margin-left: 8px; font-size: 10.5px; font-weight: 600; color: #64748b; background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 8px; padding: 0 6px; cursor: pointer; user-select: none; }
|
||
.suggest-prev-toggle:hover { border-color: #94a3b8; }
|
||
.suggest-prev { opacity: 0.75; background: #f8fafc; border-radius: 6px; }
|
||
.suggest-clk { cursor: pointer; }
|
||
.suggest-clk:hover { color: #4f46e5; text-decoration: underline; }
|
||
.suggest-dur { font-size: 11px; white-space: nowrap; color: #475569; border-bottom: 1px dashed #cbd5e1; cursor: pointer; }
|
||
.suggest-dur:hover { color: #4f46e5; border-bottom-color: #4f46e5; }
|
||
.suggest-leg-time { font-size: 10px; color: #94a3b8; padding: 0 6px 2px 30px; line-height: 1.2; }
|
||
.suggest-leg-return { color: #64748b; font-style: italic; padding-top: 2px; border-top: 1px dashed #e2e8f0; margin-top: 1px; }
|
||
.assign-sla { display: inline-flex; align-items: center; gap: 2px; font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 8px; white-space: nowrap; margin-left: 4px; }
|
||
.assign-sla.sla-breached { background: #fee2e2; color: #b91c1c; }
|
||
.assign-sla.sla-at_risk { background: #ffedd5; color: #c2410c; }
|
||
.rt-chip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border: 1.5px solid #cbd5e1; border-radius: 14px; font-size: 12px; font-weight: 600; color: #334155; cursor: pointer; user-select: none; background: #fff; }
|
||
.rt-chip.off { opacity: 0.55; border-style: dashed; }
|
||
.rt-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||
.rt-n { display: inline-flex; align-items: center; justify-content: center; min-width: 17px; height: 17px; border-radius: 9px; padding: 0 4px; color: #fff; font-size: 10px; font-weight: 800; }
|
||
/* Bande « jobs dus non assignés » (vue Tournées) : chips secteur (filtrent liste+carte) + chips job + Suggérer */
|
||
.rt-unassigned { margin-bottom: 6px; padding: 6px 8px; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; }
|
||
.rt-ua-top { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; }
|
||
/* Carrousel : rangée unique qui défile horizontalement (pas de retour à la ligne). */
|
||
.rt-ua-jobs { display: flex; align-items: center; flex-wrap: nowrap; gap: 5px; margin-top: 5px; overflow-x: auto; overflow-y: hidden; padding-bottom: 4px; scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch; overscroll-behavior-x: contain; }
|
||
.rt-ua-jobs > .rt-ua-chip { flex: 0 0 auto; scroll-snap-align: start; }
|
||
.rt-ua-jobs::-webkit-scrollbar { height: 6px; }
|
||
.rt-ua-jobs::-webkit-scrollbar-thumb { background: #fdba74; border-radius: 3px; }
|
||
.rt-ua-lbl { font-size: 12px; color: #9a3412; display: inline-flex; align-items: center; gap: 3px; }
|
||
.rt-sect { display: inline-flex; align-items: center; gap: 2px; padding: 2px 8px; border: 1px solid #cbd5e1; border-radius: 12px; background: #fff; font-size: 11.5px; font-weight: 600; color: #475569; cursor: pointer; user-select: none; }
|
||
.rt-sect.on { background: #6366f1; border-color: #6366f1; color: #fff; }
|
||
.rt-sect-n { font-weight: 800; margin-left: 2px; opacity: .85; }
|
||
.rt-sect-x { border-style: dashed; color: #94a3b8; }
|
||
.rt-chip-sep { width: 1px; align-self: stretch; min-height: 16px; background: #fed7aa; margin: 0 2px; }
|
||
.rt-ua-chip { display: inline-flex; align-items: center; gap: 3px; max-width: 230px; padding: 2px 8px 2px 6px; border: 1px solid #e2e8f0; border-left: 3px solid #9e9e9e; border-radius: 6px; background: #fff; font-size: 11.5px; color: #334155; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.rt-ua-chip:hover { border-color: #6366f1; }
|
||
.jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; }
|
||
.jt-chip:hover { border-color: #6366f1; }
|
||
.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||
.suggest-day { padding: 2px 8px 6px; }
|
||
.suggest-day-lbl { font-size: 11px; font-weight: 700; color: #475569; padding: 4px 2px 2px; }
|
||
.suggest-entry { display: flex; align-items: center; gap: 2px; padding: 4px 6px; border-radius: 6px; font-size: 12.5px; }
|
||
.suggest-entry:hover { background: #f8fafc; }
|
||
.suggest-entry-t { flex: 1; min-width: 0; }
|
||
.suggest-tech-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 6px; }
|
||
.suggest-tech-chip { display: flex; align-items: center; gap: 6px; padding: 5px 8px; border: 1px solid #e2e8f0; border-radius: 8px; cursor: pointer; font-size: 12.5px; background: #fff; user-select: none; }
|
||
.suggest-tech-chip:hover { background: #f8fafc; }
|
||
.suggest-tech-chip.on { border-color: #6366f1; background: #eef2ff; }
|
||
.suggest-skill-chip { border: 1px solid; border-radius: 12px; padding: 1px 9px; font-size: 11px; cursor: pointer; user-select: none; background: #fff; }
|
||
.suggest-step { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; border-radius: 50%; background: #e0e7ff; color: #4338ca; font-size: 10px; font-weight: 700; margin-right: 2px; flex-shrink: 0; }
|
||
.suggest-mlvl { font-size: 10px; color: #b45309; font-weight: 700; white-space: nowrap; margin-right: 3px; }
|
||
.assign-foot { border-top: 1px solid #e0e0e0; padding: 6px 9px; font-size: 11px; color: #555; line-height: 1.45; background: #fafafa; border-radius: 0 0 8px 8px; }
|
||
.assign-job.dragging { opacity: .4; } /* source estompée pendant le glissé (le fantôme compact suit le curseur) */
|
||
/* Dialogue Timeline d'une ressource */
|
||
.tldlg-day { padding: 7px 0; border-bottom: 1px solid #eee; }
|
||
.tldlg-bar { position: relative; height: 18px; background: #f1f3f5; border-radius: 3px; overflow: hidden; margin-bottom: 4px; }
|
||
.tldlg-tick { position: absolute; top: 1px; font-size: 8px; color: #90a4ae; transform: translateX(-50%); pointer-events: none; }
|
||
.tldlg-job { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 2px 2px 2px 4px; border-radius: 4px; }
|
||
.tldlg-job:hover { background: #f5f5f5; }
|
||
.tldlg-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
|
||
.tldlg-time { font-variant-numeric: tabular-nums; color: #555; flex: 0 0 auto; min-width: 34px; }
|
||
.cell.drop-hover { outline: 2px dashed #5e35b1; outline-offset: -2px; background: #f3e9fb; }
|
||
.tl-proj { position: absolute; top: 0; bottom: 0; left: 0; border-radius: 2px; opacity: .42; outline: 1px dashed rgba(0,0,0,.35); outline-offset: -1px; } /* aperçu occupation projetée (fantôme) */
|
||
.drop-badge { position: absolute; top: -8px; right: 2px; z-index: 6; background: #5e35b1; color: #fff; font-size: 9px; font-weight: 700; padding: 0 4px; border-radius: 5px; white-space: nowrap; box-shadow: 0 1px 4px rgba(0,0,0,.3); pointer-events: none; }
|
||
.drop-badge.over { background: #e53935; } /* projection ≥ 100% = surbooké */
|
||
.grid-wrap { overflow-x: auto; border: 1px solid #e0e0e0; border-radius: 6px; max-width: 100%; }
|
||
.roster-grid { border-collapse: collapse; font-size: 12px; width: 100%; user-select: none; -webkit-user-select: none; }
|
||
.roster-grid th, .roster-grid td { border: 1px solid #c7d0dc; text-align: center; padding: 2px; } /* séparateurs plus contrastés (était #eee, invisible sur le gris des cellules) */
|
||
.roster-grid th + th, .roster-grid td + td { border-left-color: #aab6c6; } /* séparateur de COLONNE (jour) plus marqué que les lignes — en-tête ET rangées */
|
||
.roster-grid thead th { position: sticky; top: 0; background: #fafafa; z-index: 1; }
|
||
.tech-col { position: sticky; left: 0; background: #fff; text-align: left !important; white-space: nowrap; padding: 2px 8px !important; min-width: 240px; max-width: 300px; z-index: 2; }
|
||
.roster-grid thead .tech-col { z-index: 3; }
|
||
.roster-grid tbody tr:hover td { background: #f0f7ff; }
|
||
.roster-grid tbody tr:hover .tech-col { background: #f0f7ff; }
|
||
th.weekend, td.weekend { background: #f5f5f5; }
|
||
th.holiday, td.holiday { background: #fff3e0; }
|
||
th.clk, td.clk { cursor: pointer; }
|
||
.dow { font-size: 10px; color: #999; text-transform: uppercase; }
|
||
.dnum { font-size: 11px; font-weight: 600; }
|
||
.grp { font-size: 9px; color: #999; background: #f0f0f0; border-radius: 3px; padding: 0 4px; margin-left: 2px; }
|
||
.role-ic { color: #546e7a; vertical-align: middle; margin-right: 3px; display: inline-flex; } /* icône de rôle monochrome (Lucide) */
|
||
.tech-row { display: flex; align-items: center; gap: 3px; flex-wrap: nowrap; min-width: 0; }
|
||
.tech-name { white-space: nowrap; flex-shrink: 0; }
|
||
.th { white-space: nowrap; flex-shrink: 0; }
|
||
.tech-row > .q-btn, .tech-row > .q-badge, .tech-row > .role-ic, .tech-row > .grp, .tech-row > .eff { flex-shrink: 0; }
|
||
.tech-skills { display: flex; align-items: center; gap: 2px; overflow: hidden; flex: 1 1 auto; min-width: 0; } /* chips inline, débordement clippé */
|
||
.skill-edit-btn { flex-shrink: 0; }
|
||
.skill-chip { font-size: 10px; line-height: 15px; height: 15px; padding: 0 5px; border-radius: 8px; color: #fff; font-weight: 600; white-space: nowrap; flex-shrink: 0; display: inline-flex; align-items: center; gap: 2px; }
|
||
.skill-rank { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: #e2e8f0; color: #64748b; font-size: 9px; font-weight: 800; margin-right: 5px; flex-shrink: 0; }
|
||
.skill-rank-1 { background: #fef3c7; color: #b45309; box-shadow: 0 0 0 1.5px rgba(250,204,21,0.7); }
|
||
.chip-lvl { display: inline-flex; align-items: center; justify-content: center; min-width: 13px; height: 13px; padding: 0 1px; border-radius: 50%; background: rgba(0,0,0,.32); font-size: 8px; font-weight: 800; box-shadow: 0 0 0 1.5px #fff; margin-left: 1px; } /* niveau 1–5 · contour blanc pour détacher la couleur (vitesse) */
|
||
.add-skill-hint { font-size: 10px; color: #9e9e9e; font-style: italic; } /* invite quand aucune compétence */
|
||
/* Compétence compacte = cercle de la couleur du skill + chiffre du niveau ; survol → nom complet + niveau + vitesse */
|
||
/* Compétence : cercle de couleur avec ICÔNE vectorielle (Material, offline) ; devient un rectangle arrondi (pill) icône+chiffre si un niveau est défini */
|
||
.skill-dot { display: inline-flex; align-items: center; justify-content: center; gap: 1px; min-width: 16px; height: 16px; padding: 0 1px; border-radius: 50%; color: #fff; flex-shrink: 0; cursor: pointer; box-shadow: inset 0 0 0 1px rgba(0,0,0,.16); }
|
||
.skill-dot.has-lvl { border-radius: 8px; padding: 0 4px 0 2px; }
|
||
.skill-dot .q-icon { color: #fff; }
|
||
.sd-lvl { font-size: 9px; font-weight: 800; line-height: 1; color: #fff; text-shadow: 0 1px 1px rgba(0,0,0,.4); }
|
||
.skill-more { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; font-size: 9px; font-weight: 800; color: #5a6678; background: #e8ebf0; box-shadow: inset 0 0 0 1px rgba(0,0,0,.10); cursor: pointer; flex-shrink: 0; } /* « +N » compétences restantes (détail au survol) */
|
||
.skill-more:hover { background: #dde1e8; }
|
||
/* Barre de charge AGRÉGÉE par tech sur les journées visibles (occupé / offrable) */
|
||
.tech-load { display: inline-flex; align-items: center; gap: 4px; margin-left: 4px; flex-shrink: 0; cursor: default; }
|
||
.tech-load-bar { width: 44px; height: 6px; border-radius: 3px; background: #e3e7ea; overflow: hidden; display: inline-block; }
|
||
.tech-load-fill { display: block; height: 100%; border-radius: 3px; }
|
||
.tech-load-pct { font-size: 9px; font-weight: 700; color: #607d8b; }
|
||
.tech-load-pct.over { color: #c62828; }
|
||
.hide-eye { flex-shrink: 0; opacity: .45; } .tech-row:hover .hide-eye { opacity: 1; } /* œil masquer (discret, visible au survol) */
|
||
tr.res-hidden { opacity: .5; background: repeating-linear-gradient(45deg, #fafafa, #fafafa 6px, #f0f0f0 6px, #f0f0f0 12px); } /* ressource masquée affichée en grisé */
|
||
tr.res-hidden .hide-eye { opacity: 1; }
|
||
.tech-name.clk:hover { text-decoration: underline; }
|
||
.hol-toggle { font-size: 9px; color: #ccc; cursor: pointer; border: 1px solid #eee; border-radius: 3px; width: 14px; margin: 1px auto 0; line-height: 12px; }
|
||
.hol-toggle.on { background: #ff9800; color: #fff; border-color: #ff9800; }
|
||
.cell { cursor: pointer; min-height: 24px; position: relative; background: #eef1f4; } /* gris pâle = INDISPONIBLE par défaut ; un quart crée une zone blanche (.tl-shift) */
|
||
.cell.over { background: #fbe2cf; } /* SURCHARGE : la cellule entière se teinte orange pâle (le triangle reste dans le coin) */
|
||
.cell.noshift { background: #fff3e0; box-shadow: inset 0 0 0 1.5px #ffb74d; } /* WARNING : ticket F assigné mais AUCUN quart planifié ce jour → cellule ambre */
|
||
.cell:hover { outline: 2px solid #1976d2; outline-offset: -2px; }
|
||
.cell.sel { outline: 2px solid #00897b; outline-offset: -2px; background: #e0f2f1; }
|
||
.cell.dirty { box-shadow: inset 0 0 0 2px #ff9800; }
|
||
.cell.cov { cursor: default; font-size: 11px; }
|
||
.code-chip { display: inline-block; min-width: 18px; padding: 1px 5px; border-radius: 4px; font-weight: 700; font-size: 11px; line-height: 16px; margin: 1px; }
|
||
.cell-dirty-demo { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 4px; font-weight: 700; font-size: 11px; background: #1976d2; color: #fff; box-shadow: inset 0 0 0 2px #ff9800; }
|
||
.ch-h { opacity: .7; font-weight: 400; font-size: 9px; margin-left: 1px; }
|
||
.free { color: #ccc; }
|
||
.offshift-warn { display: inline-flex; align-items: center; gap: 1px; font-size: 10px; font-weight: 700; color: #ef6c00; cursor: pointer; line-height: 1; } /* job assigné un jour sans quart publié */
|
||
.office-dots { position: absolute; right: 2px; top: 2px; bottom: 2px; z-index: 2; display: flex; flex-direction: column; flex-wrap: wrap; align-content: flex-end; gap: 1.5px; cursor: pointer; } /* tickets SANS déplacement (admin/support/NP) = points ronds empilés (≈3-4 de haut puis nouvelle colonne vers la gauche), colorés par dept ; clic = liste cliquable, survol = aperçu ; n'occupent pas la charge */
|
||
.office-dot { width: 5px; height: 5px; min-width: 5px; border-radius: 50%; opacity: .9; display: inline-block; box-shadow: 0 0 0 .5px rgba(255,255,255,.5); }
|
||
/* tickets SANS déplacement (vue semaine) = 1 SEUL bloc POINTILLÉ ; largeur ∝ somme des estimés (plancher 1h) ; survol/clic = liste des tâches */
|
||
.office-blk { position: absolute; top: auto; bottom: 2px; height: 11px; z-index: 3; min-width: 7px; box-sizing: border-box; border-radius: 4px; cursor: pointer; border: 1px solid rgba(90,99,140,.5); background: repeating-linear-gradient(45deg, rgba(99,102,241,.24) 0 3px, rgba(99,102,241,.06) 3px 6px); display: flex; align-items: center; justify-content: center; } /* admin/sans-déplacement = bloc RÉGULIER (aligné, rayures diagonales pour le distinguer) mais plus MINCE (11px) et FLUSH au bas (2px) — fini le demi-bloc flottant décollé qui désorganisait */
|
||
.office-blk:hover { border-color: rgba(79,70,229,.9); filter: brightness(1.05); }
|
||
.office-blk-ic { color: rgba(79,70,229,.7); }
|
||
.hdr-ruler { position: relative; height: 11px; margin-top: 3px; }
|
||
.hdr-ruler .tick { position: absolute; top: 2px; transform: translateX(-50%); font-size: 8px; color: #aab; line-height: 1; font-weight: 400; }
|
||
.hdr-ruler .tick::before { content: ''; position: absolute; top: -3px; left: 50%; width: 1px; height: 2px; background: #d0d0d8; }
|
||
.tl { position: relative; height: 24px; min-width: 110px; background: transparent; border-radius: 4px; margin: 2px 0; overflow: hidden; cursor: pointer; } /* track TRANSPARENT : toute la cellule est blanche (pas d'élément blanc séparé) */
|
||
/* ── Mode JOUR (style Gaiia) : 1 colonne pleine largeur → timelines hautes + règle d'heures alignée → dispo des jobs facile ── */
|
||
.roster-grid.day-mode .tl { height: 30px; }
|
||
.roster-grid.day-mode .cell { min-height: 34px; }
|
||
.grid-axis { position: relative; width: 100%; height: 13px; margin: 2px 0 1px; border-bottom: 1px dashed #e3e7ee; }
|
||
.grid-axis-tick { position: absolute; top: 0; transform: translateX(-50%); font-size: 9px; line-height: 13px; color: #90a4ae; font-weight: 700; white-space: nowrap; }
|
||
.grid-axis-tick::after { content: 'h'; font-weight: 400; opacity: .65; }
|
||
/* ── Vue BOARD (Kanban horizontal) : pool vertical (recherche/tri) + techs en lanes horizontales à échelle d'heures ── */
|
||
.kbb-wrap { display: flex; gap: 10px; align-items: flex-start; padding: 2px 2px 12px; }
|
||
.kbb-pool { flex: 0 0 268px; width: 268px; background: #eef2f7; border: 1px solid #d7deea; border-radius: 8px; display: flex; flex-direction: column; max-height: calc(100vh - 205px); }
|
||
.kbb-pool.drop-hover { outline: 2px dashed #5e35b1; outline-offset: -2px; background: #f3e9fb; }
|
||
.kbb-pool-hd { display: flex; align-items: center; font-size: 12px; font-weight: 700; color: #2b3445; padding: 7px 9px 4px; }
|
||
.kbb-pool-tools { display: flex; flex-direction: column; gap: 5px; padding: 0 7px 7px; border-bottom: 1px solid #e0e6ef; }
|
||
.kbb-pool-body { flex: 1 1 auto; overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 5px; }
|
||
.kbb-board { flex: 1 1 auto; min-width: 0; }
|
||
.kbb-daybar { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #455; padding: 2px 4px 6px; flex-wrap: wrap; }
|
||
.kbb-scroll { overflow-x: auto; padding-bottom: 8px; }
|
||
.kbb-axis-row, .kbb-row { display: flex; align-items: stretch; width: max-content; min-width: 100%; }
|
||
.kbb-name-sp, .kbb-name { flex: 0 0 150px; width: 150px; box-sizing: border-box; position: sticky; left: 0; z-index: 5; background: #fff; }
|
||
.kbb-name { display: flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 600; color: #2b3445; padding: 0 4px 0 6px; border-right: 1px solid #e3e7ee; }
|
||
.kbb-name .ellipsis { flex: 1 1 auto; min-width: 0; }
|
||
.kbb-axis { position: relative; flex: 0 0 auto; height: 16px; border-bottom: 1px dashed #e3e7ee; }
|
||
.kbb-row { border-bottom: 1px solid #e2e6ed; } /* hairline très mince, centrée entre 2 lanes (marges symétriques) */
|
||
.kbb-lane { position: relative; flex: 0 0 auto; height: 60px; margin: 2px 0; background: #fff; border-radius: 4px; overflow: hidden; }
|
||
.kbb-lane.drop-hover { outline: 2px dashed #5e35b1; outline-offset: -2px; background: #f3e9fb; }
|
||
.kbb-lane-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 10.5px; color: #aab2bd; pointer-events: none; }
|
||
/* trajet d'approche (km/min) avant le job : zone pâle hachurée, plus courte que le bloc */
|
||
.kbb-leg { position: absolute; top: 15px; bottom: 15px; background: repeating-linear-gradient(45deg, rgba(120,144,176,.10) 0 4px, rgba(120,144,176,.18) 4px 8px); border: 1px dashed rgba(120,144,176,.5); border-radius: 3px; display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
||
.kbb-leg-t { font-size: 9px; font-weight: 700; color: #5b6b80; white-space: nowrap; }
|
||
.kbb-blk { position: absolute; top: 7px; bottom: 7px; border-radius: 5px; background: #fff; color: #1c2533; border: 1.5px solid #cfd6e0; border-left-width: 4px; display: flex; flex-direction: column; justify-content: center; gap: 1px; padding: 2px 7px; cursor: grab; box-shadow: 0 1px 3px rgba(0,0,0,.12); overflow: hidden; } /* carte BLANCHE, contour + icône colorés (skill), texte noir — style Gaiia */
|
||
.kbb-blk:hover { box-shadow: 0 2px 8px rgba(0,0,0,.22); z-index: 2; }
|
||
.kbb-blk.assist { border-style: dashed; background: #faf8ff; cursor: default; }
|
||
.kbb-blk-l1 { display: flex; align-items: center; gap: 3px; font-size: 11px; font-weight: 700; line-height: 1.2; }
|
||
.kbb-blk-l2 { font-size: 9.5px; font-weight: 500; color: #6a7686; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.kbb-blk-t { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
/* Volet détails (double-clic) : grand panneau droit */
|
||
.jd-card { width: min(900px, 84vw); max-width: 94vw; height: 100vh; display: flex; flex-direction: column; }
|
||
.jd-body { flex: 1 1 auto; overflow: auto; }
|
||
.jd-meta { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: #455; }
|
||
.jd-meta > div { display: flex; align-items: center; gap: 6px; }
|
||
.jd-detail { margin-top: 10px; padding: 8px 10px; background: #f6f8fb; border-radius: 6px; white-space: pre-wrap; font-size: 12.5px; color: #333; }
|
||
.jd-team { margin-top: 12px; padding: 10px; border: 1px solid #e6e2f2; background: #faf9fe; border-radius: 8px; }
|
||
/* Suivi terrain (géofencing) : étapes horizontales façon suivi de colis. */
|
||
.jd-geo { margin-top: 12px; padding: 10px 8px; border: 1px solid #d6eeeb; background: #f3fbfa; border-radius: 8px; }
|
||
.jd-geo-track { display: flex; align-items: flex-start; }
|
||
.jd-geo-step { flex: 1; text-align: center; position: relative; }
|
||
.jd-geo-step::before { content: ''; position: absolute; top: 13px; left: -50%; width: 100%; height: 2px; background: #cfe3e0; z-index: 0; }
|
||
.jd-geo-step:first-child::before { display: none; }
|
||
.jd-geo-step.done::before { background: #26a69a; }
|
||
.jd-geo-ic { position: relative; z-index: 1; width: 28px; height: 28px; margin: 0 auto; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: #e0e0e0; color: #fff; }
|
||
.jd-geo-step.done .jd-geo-ic { background: #26a69a; }
|
||
.jd-geo-step.current .jd-geo-ic { box-shadow: 0 0 0 3px rgba(38,166,154,.3); }
|
||
.jd-geo-lbl { font-size: 10.5px; color: #607d8b; margin-top: 3px; line-height: 1.25; }
|
||
.jd-geo-step.done .jd-geo-lbl { color: #37474f; font-weight: 600; }
|
||
.jd-geo-at { display: block; font-size: 10px; color: #26a69a; font-weight: 700; }
|
||
.jd-map { width: 100%; height: 240px; border-radius: 8px; overflow: hidden; margin-bottom: 8px; background: #eceff3; }
|
||
.jd-track-row { display: flex; align-items: center; gap: 6px; margin: -2px 0 8px; }
|
||
.dt-toggle { cursor: pointer; margin-left: 8px; padding: 0 7px; border-radius: 10px; border: 1px solid #ef6c00; color: #ef6c00; font-weight: 600; white-space: nowrap; }
|
||
.dt-toggle.on { background: #ef6c00; color: #fff; }
|
||
.kbb-rows { position: relative; width: max-content; min-width: 100%; }
|
||
.kbb-now-full { position: absolute; top: 0; bottom: 0; width: 2px; background: #e53935; z-index: 4; pointer-events: none; box-shadow: 0 0 3px rgba(229,57,53,.5); }
|
||
.kbb-shift { position: absolute; top: 2px; bottom: 2px; border: 1px solid #dfe4ea; border-radius: 4px; background: #fff; pointer-events: none; } /* quart = zone BLANCHE (lumière = dispo) sur le fond gris */
|
||
.kbb-shift.oncall { border-style: dotted; border-color: #f9a825; background: rgba(249,168,37,.06); }
|
||
/* garde = shift SUR APPEL : bande jaune pâle + téléphone rouge */
|
||
.kbb-garde { position: absolute; top: 7px; bottom: 7px; background: rgba(255,213,79,.32); border: 1px solid rgba(245,166,35,.6); border-radius: 5px; display: flex; align-items: center; padding: 0 5px; overflow: hidden; }
|
||
.kbb-now-lbl { position: absolute; top: 0; transform: translateX(-50%); background: #e53935; color: #fff; font-size: 9px; font-weight: 800; line-height: 13px; padding: 0 4px; border-radius: 3px; white-space: nowrap; z-index: 6; pointer-events: none; }
|
||
/* Cartes du pool (réutilisées) */
|
||
.kb-card { background: #fff; border: 1px solid #e6e9ef; border-left: 4px solid #90a4ae; border-radius: 6px; padding: 5px 7px; cursor: grab; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
|
||
.kb-card:hover { box-shadow: 0 2px 6px rgba(0,0,0,.13); }
|
||
.kb-card:active { cursor: grabbing; }
|
||
.kb-card.dragging { opacity: .4; }
|
||
.kb-card.hold { opacity: .62; cursor: not-allowed; background: repeating-linear-gradient(45deg, #fafafa 0 6px, #f0f0f0 6px 12px); }
|
||
.kb-card-t { font-size: 12px; font-weight: 600; color: #222; line-height: 1.25; display: flex; align-items: center; }
|
||
.kb-card-m { font-size: 10.5px; color: #67707e; display: flex; align-items: center; gap: 4px; margin-top: 2px; }
|
||
.kb-dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto; }
|
||
.kb-count { margin-left: auto; background: #d7deea; color: #3a4658; border-radius: 9px; padding: 0 7px; font-size: 11px; }
|
||
.kb-load { font-size: 11px; font-weight: 700; color: #2e7d32; }
|
||
.kb-load.over { color: #e53935; }
|
||
.kb-empty { font-size: 11px; color: #9aa3b0; text-align: center; padding: 10px 4px; border: 1px dashed #d7deea; border-radius: 6px; }
|
||
.tl-click { cursor: pointer; } /* clic sur le progressbar → menu jobs (détail + réordonner) */
|
||
.tl-click:hover { outline: 1px solid #1976d2; outline-offset: 1px; }
|
||
/* Éditeur de journée (clic progressbar) — lignes draggables */
|
||
.de-row { display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-bottom: 1px solid #eee; background: #fff; cursor: default; }
|
||
.de-row.de-drag { opacity: .5; background: #ede7f6; }
|
||
.de-row.de-pinned { box-shadow: inset 3px 0 0 #5c6bc0; } /* position FIGÉE (1er/dernier) → liseré indigo */
|
||
.de-row:hover { background: #f7f5fc; }
|
||
.de-handle { touch-action: none; } /* indispensable pour le glisser TACTILE (vuedraggable/SortableJS) */
|
||
.de-ghost { opacity: .45; background: #ede7f6; } /* placeholder pendant le glisser */
|
||
.de-ghost .de-row { background: #ede7f6; }
|
||
.de-ord { font-size: 12px; font-weight: 700; color: #607d8b; min-width: 16px; text-align: center; }
|
||
.de-dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; }
|
||
/* minimap du jour (territoire des arrêts) */
|
||
.de-map-wrap { margin: 8px 0 4px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; position: relative; }
|
||
.de-map-gl { width: 100%; height: 240px; }
|
||
.de-map-cap { font-size: 10px; color: #777; padding: 3px 6px; background: #fafafa; border-top: 1px solid #eee; }
|
||
.de-prio { font-size: 11px; border: 1px solid #ccc; border-left-width: 4px; border-radius: 4px; padding: 2px 4px; background: #fff; }
|
||
.de-dur { display: flex; align-items: center; gap: 2px; font-size: 10px; color: #888; }
|
||
.de-dur input { width: 46px; font-size: 11px; text-align: right; border: 1px solid #cfc4e8; border-radius: 4px; padding: 2px 3px; }
|
||
.de-travel { font-size: 10px; color: #8a6d3b; padding: 1px 0 1px 40px; opacity: .85; } /* espace entre 2 jobs = transport */
|
||
.de-depart { color: #3949ab; font-weight: 600; opacity: 1; } /* trajet origine (dépôt/domicile) → 1er job */
|
||
.de-detail { font-size: 11px; line-height: 1.4; color: #444; background: #f7f5fc; border-left: 3px solid #b39ddb; border-radius: 4px; margin: 0 4px 6px 40px; padding: 6px 8px; max-height: 280px; overflow: auto; }
|
||
.de-msg { border-top: 1px solid #e6e0f0; padding: 4px 0; }
|
||
.de-msg:first-of-type { border-top: none; }
|
||
.de-msg-hdr { font-size: 11px; font-weight: 700; color: #5e35b1; }
|
||
.de-msg-txt { font-size: 11px; white-space: pre-wrap; color: #37474f; }
|
||
.assign-thread { background: #f5f5f5; border-radius: 5px; padding: 4px 7px; margin-top: 4px; max-height: 220px; overflow: auto; }
|
||
.assign-msg { font-size: 11px; border-top: 1px solid #e0e0e0; padding: 3px 0; }
|
||
.assign-msg:first-child { border-top: none; }
|
||
.assign-msg-txt { white-space: pre-wrap; color: #37474f; }
|
||
.tl-shift { position: absolute; top: 0; bottom: 0; background: #fff; border-radius: 4px; border: 1px solid #dfe4ea; box-sizing: border-box; } /* quart = zone BLANCHE (lumière = dispo) sur le fond gris de la cellule */
|
||
.tl-shift.oncall { background: rgba(255,213,79,.30); border: 1px solid rgba(245,166,35,.55); } /* garde = sur appel (jaune pâle, harmonisé Kanban) */
|
||
.tl-shift.over { background: #fff1e0; border-color: #ffc183; } /* zone de quart en surcharge = teinte orange (au lieu du blanc) */
|
||
.tl-absent { position: absolute; inset: 0; border-radius: 2px; box-sizing: border-box; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg, #cfcfcf 0, #cfcfcf 3px, #f0f0f0 3px, #f0f0f0 6px); } /* absent = hachuré gris */
|
||
.tl-blk { position: absolute; top: 2px; bottom: 2px; border-radius: 4px; border: 1px solid; display: flex; align-items: center; justify-content: center; overflow: hidden; } /* fond PASTEL + contour couleur (via inline) + icône couleur centrée si elle rentre */
|
||
.tl-travel { position: absolute; top: 0; bottom: 0; background-image: repeating-linear-gradient(90deg, #78909c 0 3px, transparent 3px 7px); background-size: 100% 3px; background-repeat: no-repeat; background-position: 0 center; opacity: .85; } /* déplacement = pointillés */
|
||
.tl-shift-click { cursor: pointer; } /* bande de quart/garde → clic ouvre la tournée (voir les jobs) */
|
||
.tl-shift-click:hover { filter: brightness(0.92); outline: 1px solid rgba(55,71,79,.4); outline-offset: -1px; }
|
||
.tl-blk-click { cursor: pointer; }
|
||
.tl-blk-click:hover { outline: 1px solid rgba(25,118,210,.7); outline-offset: -1px; filter: brightness(1.08); }
|
||
.tod-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(210,45%,91%), hsl(270,45%,83%)); }
|
||
.occ-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(122,68%,44%), hsl(32,68%,44%)); }
|
||
.leg-absent { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg,#cfcfcf 0,#cfcfcf 3px,#f0f0f0 3px,#f0f0f0 6px); }
|
||
.leg-garde { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; background: rgba(255,179,0,.14); border: 1px dashed #f9a825; }
|
||
tr.paused .tech-col { color: #aaa; }
|
||
tfoot .sum td { background: #fafafa; font-size: 11px; color: #555; font-weight: 600; }
|
||
tfoot .sum .tech-col { background: #fafafa; }
|
||
.eff { font-size: 9px; border-radius: 3px; padding: 0 4px; margin-left: 3px; font-weight: 600; }
|
||
.eff.fast { color: #1b5e20; background: #c8e6c9; }
|
||
.eff.slow { color: #b71c1c; background: #ffe0b2; }
|
||
.demand-tbl { border-collapse: collapse; }
|
||
.demand-tbl th { font-size: 11px; color: #888; font-weight: 600; padding: 2px 6px; text-align: left; }
|
||
.demand-tbl td { padding: 2px 4px; }
|
||
|
||
/* ── Vue mobile « bande de jours + heat » (téléphone) ── */
|
||
.plan-mobile { padding: 8px; }
|
||
.pm-strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 6px; }
|
||
.pm-strip::-webkit-scrollbar { height: 0; }
|
||
.pm-day { position: relative; flex: 0 0 auto; width: 46px; border: 1px solid var(--ops-border, #e2e8f0); border-radius: 10px; background: #fff; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 6px 2px; cursor: pointer; }
|
||
.pm-day.active { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99, 102, 241, .18); }
|
||
.pm-day.today .pm-num { color: #6366f1; }
|
||
.pm-day.weekend { background: #f8fafc; }
|
||
.pm-day.holiday { background: #fff7ed; border-color: #fdba74; } /* férié QC (auto) ou congé manuel */
|
||
.pm-hol-banner { display: flex; align-items: center; gap: 8px; background: #fff7ed; border: 1px solid #fdba74; border-radius: 8px; padding: 6px 10px; margin-bottom: 8px; font-size: 12.5px; color: #9a3412; min-width: 0; }
|
||
.pm-day-hol { position: absolute; top: 1px; left: 3px; color: #ea580c; font-size: 9px; line-height: 1; }
|
||
.pm-day-warn { position: absolute; top: 2px; right: 3px; color: #ef4444; font-size: 9px; line-height: 1; pointer-events: none; } /* ▲ : la dispo du jour vient de quarts non créés (PAS de bord rouge → le contour de sélection reste visible) */
|
||
.pm-dow { font-size: 10px; font-weight: 700; text-transform: uppercase; color: #94a3b8; }
|
||
.pm-num { font-size: 15px; font-weight: 700; color: #1e293b; line-height: 1; }
|
||
.pm-bar { width: 10px; height: 44px; background: #eef2f7; border-radius: 5px; overflow: hidden; display: flex; align-items: flex-end; margin: 2px 0; }
|
||
.pm-fill { width: 100%; border-radius: 5px; transition: height .3s; }
|
||
.pm-h { font-size: 9.5px; font-weight: 700; color: #64748b; white-space: nowrap; }
|
||
.pm-over { color: #ef4444; margin-left: 1px; }
|
||
.pm-techs { margin-top: 10px; }
|
||
.pm-techs-hd { display: flex; align-items: center; font-size: 12px; font-weight: 600; color: #475569; margin-bottom: 6px; }
|
||
.pm-tech { display: flex; align-items: center; gap: 8px; width: 100%; border: none; background: #fff; border-bottom: 1px solid #f1f5f9; padding: 8px 4px; cursor: pointer; }
|
||
.pm-tech-name { flex: 0 0 34%; font-size: 13px; font-weight: 600; color: #1e293b; text-align: left; }
|
||
.pm-tbar { flex: 1; height: 8px; background: #eef2f7; border-radius: 4px; overflow: hidden; }
|
||
.pm-tfill { display: block; height: 100%; border-radius: 4px; transition: width .3s; }
|
||
.pm-th { flex: 0 0 auto; font-size: 12px; font-weight: 700; color: #1e293b; min-width: 46px; text-align: right; white-space: nowrap; }
|
||
.pm-th.over { color: #ef4444; }
|
||
.pm-cap { color: #94a3b8; font-weight: 600; }
|
||
.pm-cap.est { font-style: italic; } /* dénominateur ESTIMÉ (8h, pas de quart réel) */
|
||
.pm-warn { flex: 0 0 auto; color: #ef4444; font-size: 11px; line-height: 1; cursor: help; } /* ▲ aucun quart planifié ce jour */
|
||
.pm-tech.no-shift { background: #fff7f5; } /* fond ambré léger : tech sans quart (problème à régler) */
|
||
.pm-tech.assignable.no-shift { background: #fdf2f8; outline-color: #fbcfe8; }
|
||
.pm-empty { color: #94a3b8; font-size: 13px; text-align: center; padding: 12px; }
|
||
.pm-tip { font-size: 11px; color: #94a3b8; text-align: center; padding: 8px 0 2px; }
|
||
.pm-more { width: 100%; border: none; background: none; color: #6366f1; font-size: 12px; font-weight: 600; padding: 9px; cursor: pointer; }
|
||
.pm-tech-idle { opacity: .55; }
|
||
.pm-ava { flex: 0 0 auto; width: 26px; height: 26px; border-radius: 50%; background: #e0e7ff; color: #4338ca; font-size: 10px; font-weight: 800; display: flex; align-items: center; justify-content: center; }
|
||
.pm-tech.assignable { background: #f5f3ff; outline: 1px dashed #c4b5fd; }
|
||
.pm-tech.assignable:active { background: #ede9fe; }
|
||
.pm-assign-banner { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; background: #6366f1; color: #fff; font-size: 12px; font-weight: 600; padding: 8px 10px; border-radius: 8px; margin-bottom: 8px; }
|
||
.pm-pool { margin-top: 12px; border-top: 1px solid #e2e8f0; padding-top: 8px; }
|
||
.pm-pool-hd { display: flex; align-items: center; font-size: 12px; font-weight: 700; color: #475569; margin-bottom: 4px; }
|
||
.pm-pool-sub { font-size: 10px; color: #94a3b8; margin-bottom: 6px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||
.pm-prio-leg { display: inline-flex; align-items: center; gap: 4px; }
|
||
.pm-prio-leg i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; margin-left: 4px; }
|
||
.pm-count { margin-left: 6px; background: #e2e8f0; color: #475569; border-radius: 9px; font-size: 11px; padding: 0 7px; font-weight: 700; }
|
||
.pm-job { display: flex; align-items: center; gap: 6px; width: 100%; text-align: left; border: 1px solid #e2e8f0; background: #fff; border-radius: 8px; padding: 8px; margin-bottom: 6px; cursor: pointer; }
|
||
.pm-job.sel { border-color: #6366f1; background: #eef2ff; box-shadow: 0 0 0 2px rgba(99, 102, 241, .18); }
|
||
/* « En attente » : carte OPAQUE (sinon le fond de swipe transparaît derrière sur mobile) — hachuré + titre grisé + cursor bloqué. */
|
||
.pm-job.hold { background: repeating-linear-gradient(45deg, #f8fafc 0 7px, #eef2f7 7px 14px); cursor: not-allowed; }
|
||
.pm-job.hold .pm-job-t { color: #64748b; }
|
||
.pm-job-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||
.pm-job-t { font-size: 13px; font-weight: 600; color: #1e293b; }
|
||
.pm-job-s { font-size: 11px; color: #64748b; }
|
||
.pm-job-d { font-size: 10.5px; margin-top: 1px; }
|
||
.pm-job-note { font-size: 10.5px; margin-top: 1px; color: #c2410c; font-style: italic; max-width: 100%; } /* note importante en aperçu sur la carte */
|
||
.pm-job-icons { flex: 0 0 auto; display: flex; align-items: center; gap: 1px; } /* ★ urgent + 📝 note (icônes directes, façon Gmail) */
|
||
.pm-job-icons .q-icon { padding: 2px; cursor: pointer; }
|
||
.pm-job-h { flex: 0 0 auto; font-size: 12px; font-weight: 700; color: #0d9488; min-width: 30px; text-align: right; }
|
||
/* Swipe MAISON : la carte glisse au-dessus d'un fond révélant l'action (vert=+1 j à gauche · indigo=options à droite). Tap = clic natif. */
|
||
.pm-swipe { position: relative; margin-bottom: 6px; border-radius: 8px; overflow: hidden; }
|
||
.pm-swipe-bg { position: absolute; inset: 0; display: flex; align-items: center; justify-content: space-between; border-radius: 8px; background: linear-gradient(90deg, #bbf7d0 0 50%, #c7d2fe 50% 100%); }
|
||
.pm-swipe-hint { display: flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 800; padding: 0 14px; }
|
||
.pm-swipe-hint.l { color: #166534; }
|
||
.pm-swipe-hint.r { color: #3730a3; }
|
||
.pm-swipe .pm-job { margin-bottom: 0; position: relative; touch-action: pan-y; transition: transform .18s ease; }
|
||
.pm-job.swiping { transition: none; }
|
||
/* Feuille d'options (bottom sheet) */
|
||
.pm-opt-card { width: 100%; max-height: 86vh; overflow-y: auto; border-top-left-radius: 16px; border-top-right-radius: 16px; }
|
||
.pm-opt-actions { position: sticky; bottom: 0; background: #fff; border-top: 1px solid #eef2f7; } /* « Fermer » toujours visible (carte qui défile) */
|
||
.pm-opt-label { font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: .02em; margin: 10px 0 4px; }
|
||
.pm-tk-details { background: #f8fafc; border: 1px solid #eef2f7; border-radius: 8px; padding: 7px 9px; margin-top: 4px; }
|
||
.pm-tk-row { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: #334155; line-height: 1.5; min-width: 0; }
|
||
.pm-tk-row .q-icon { flex: 0 0 auto; }
|
||
.pm-tk-row .ellipsis { min-width: 0; }
|
||
.pm-tk-link { color: #0d9488; text-decoration: none; font-weight: 500; }
|
||
.pm-tk-link:active { opacity: .6; }
|
||
.pm-postbox { background: #f8fafc; border: 1px solid #eef2f7; border-radius: 8px; padding: 7px; margin-bottom: 6px; }
|
||
.pm-postbox-foot { gap: 4px; }
|
||
.pm-postbox-hint { font-size: 10.5px; color: #64748b; min-width: 0; }
|
||
.pm-thread { max-height: 34vh; overflow-y: auto; border: 1px solid #eef2f7; border-radius: 8px; background: #fff; padding: 2px 0; }
|
||
.pm-thread-empty { color: #94a3b8; font-size: 12.5px; padding: 10px; display: flex; align-items: center; }
|
||
.pm-post { padding: 7px 10px; border-bottom: 1px solid #f1f5f9; }
|
||
.pm-post:last-child { border-bottom: 0; }
|
||
.pm-post--client { background: #f6fbfa; } /* message du client (entrant) : léger fond teal, comme entrant↔sortant dans la Boîte */
|
||
.pm-post-hd { display: flex; align-items: center; gap: 7px; font-size: 12px; color: #334155; }
|
||
.pm-post-hd b { min-width: 0; }
|
||
.pm-post-av { font-size: 9px; font-weight: 600; color: #fff; flex: 0 0 auto; }
|
||
.pm-post-tag { font-size: 9.5px; background: #e0f2f1; color: #0d9488; padding: 0 5px; border-radius: 6px; flex: 0 0 auto; }
|
||
.pm-post-at { flex: 0 0 auto; font-size: 10.5px; color: #94a3b8; }
|
||
.pm-post-text { font-size: 12.5px; color: #475569; white-space: pre-wrap; word-break: break-word; margin-top: 3px; line-height: 1.4; }
|
||
/* Carrousel de jours (défilement horizontal continu, sans flèches — 28 jours, dimanche compris) + listes bornées (scroll interne, anti-étirement) + chips de filtre + feuille d'assignation (retour user 2026-06-24) */
|
||
.pm-techs-list { max-height: 32vh; overflow-y: auto; }
|
||
.pm-sort { font-size: 11px; min-height: 0; max-width: 120px; }
|
||
.pm-sort .q-field__control { min-height: 26px; }
|
||
.pm-sort .q-field__native { font-size: 11px; font-weight: 600; color: #475569; padding: 0; min-height: 26px; }
|
||
.pm-sort .q-field__prepend { padding-right: 2px; height: 26px; }
|
||
.pm-sort .q-field__marginal { height: 26px; }
|
||
.pm-chips { display: flex; gap: 5px; overflow-x: auto; padding-bottom: 6px; margin-bottom: 2px; }
|
||
.pm-chips::-webkit-scrollbar { height: 0; }
|
||
.pm-chip { flex: 0 0 auto; border: 1px solid #cbd5e1; background: #fff; color: #475569; border-radius: 999px; font-size: 11px; font-weight: 600; padding: 3px 10px; cursor: pointer; white-space: nowrap; }
|
||
.pm-chip.on { color: #fff; border-color: transparent; }
|
||
.pm-chip-x { border-color: #fca5a5; color: #dc2626; }
|
||
.pm-pool-list { max-height: 46vh; overflow-y: auto; }
|
||
.pm-leg { width: 7px; height: 7px; border-radius: 2px; display: inline-block; margin-left: 2px; }
|
||
.pm-sheet { position: fixed; left: 0; right: 0; bottom: 0; z-index: 4500; background: #fff; border-top: 1px solid #e2e8f0; box-shadow: 0 -8px 28px rgba(0, 0, 0, .18); border-radius: 14px 14px 0 0; max-height: 60vh; display: flex; flex-direction: column; }
|
||
.pm-sheet-hd { display: flex; align-items: center; background: #6366f1; color: #fff; font-size: 12.5px; font-weight: 600; padding: 9px 12px; border-radius: 14px 14px 0 0; }
|
||
.pm-sheet-skills { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 7px 12px; background: #eef2ff; border-bottom: 1px solid #e0e7ff; }
|
||
.pm-ss-lbl { font-size: 11px; font-weight: 700; color: #6366f1; }
|
||
.pm-ss-hint { font-size: 10.5px; color: #94a3b8; margin-left: auto; }
|
||
.pm-sheet-body { overflow-y: auto; padding: 4px 10px 10px; }
|
||
/* Mobile : le panneau flottant « Jobs à assigner » devient une feuille plein écran (sinon il flotte mal au doigt). */
|
||
@media (max-width: 640px) {
|
||
.assign-panel { left: 6px !important; right: 6px !important; top: 8px !important; bottom: 8px !important; width: auto !important; height: auto !important; max-height: calc(100vh - 16px) !important; z-index: 6000 !important; }
|
||
.assign-resize { display: none !important; }
|
||
}
|
||
</style>
|