Suite retour Louis : plus de mode soft/hard (toggle du commit précédent retiré). Un seul concept — un job/ticket interne qui OCCUPE la timeline (formation · réunion · projet · entretien) → dé-priorise le tech au dispatch. La PRIORITÉ est le curseur : Moyenne/Basse = les urgences/réparations peuvent quand même prendre le tech ; Urgent (high) = bloc dur, non traversé. Front : dialogue unique « Bloquer du temps » (Intitulé · journée complète · date · début · durée · Priorité [défaut Moyenne] · ticket optionnel), info adaptée à la priorité. Un seul submit `doBlockTime` → /roster/filler-job. Retiré : bascule de mode, branche réserve, `roster.reserveTech`. Les 2 points d'entrée (bouton volet réglages + menu de cellule) → même dialogue, relabellés « Bloquer du temps… ». Hub (dispatch.js suggestSlots) : la traversée urgence passe du flag `job_type === 'Réservation'` à la PRIORITÉ — `isSoftBlock` = bloc interne (Interne/Réservation) de priorité ≤ moyenne → traversé en mode urgent ; high résiste ; les vrais jobs client ne sont JAMAIS traversés (pas de double-booking). Ajout du champ `priority` au fetch. (/roster/reserve laissé en place, inutilisé — retrait ultérieur.) Vérifié : test unitaire pierce (medium/low traversé · high résiste · job client jamais) + aperçu (dialogue unique, priorité Moyenne, info souple, pas de toggle). Build OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6635 lines
627 KiB
Vue
6635 lines
627 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="showLeave = true"><q-item-section avatar><q-icon name="beach_access" color="teal" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
|
||
<q-separator />
|
||
<q-item tag="label" clickable><q-item-section avatar><q-icon name="sms" /></q-item-section><q-item-section>Notifier par SMS</q-item-section><q-item-section side><q-toggle v-model="notifySms" dense /></q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="openCreateJob"><q-item-section avatar><q-icon name="add_task" color="teal-7" /></q-item-section><q-item-section>Créer un job</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="showSuggestSlots = true"><q-item-section avatar><q-icon name="event_available" color="teal-8" /></q-item-section><q-item-section>Trouver un créneau</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="openSchedGenBulk"><q-item-section avatar><q-icon name="event_note" color="primary" /></q-item-section><q-item-section>Générer les horaires (lot)</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="openLegacyPush"><q-item-section avatar><q-icon name="sync_alt" color="deep-orange" /></q-item-section><q-item-section>Publier au legacy</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="() => guard(loadWeek)"><q-item-section avatar><q-icon name="refresh" /></q-item-section><q-item-section>Rafraîchir</q-item-section></q-item>
|
||
</q-list>
|
||
</q-menu>
|
||
</q-btn>
|
||
<q-btn unelevated no-caps color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" @click="doPublish" />
|
||
</div>
|
||
|
||
<!-- Rangée 1 : actions principales (nav · Outils · Générer · Publier) — DESKTOP/tablette uniquement (gt-sm) -->
|
||
<div class="row items-center q-mb-sm q-gutter-xs gt-sm">
|
||
<!-- Sélecteur de VUE à GAUCHE, le plus évident (le titre « Planification » est déjà au-dessus, inutile de le répéter) -->
|
||
<q-btn-toggle v-model="boardView" dense unelevated no-caps class="view-switch" :options="[{ value: 'grid', icon: 'calendar_view_week', label: 'Semaine' }, { value: 'kanban', icon: 'today', label: 'Jour' }, { value: 'month', icon: 'calendar_month', label: 'Mois' }, { value: 'routes', icon: 'route', label: 'Tournées' }]" toggle-color="primary" color="grey-2" text-color="grey-8"><q-tooltip>Semaine = grille (vue d'ensemble) · Jour = disposition d'une journée · Mois = qui est en quart/absent + couverture par compétence · Tournées = trajets réels (OSRM)</q-tooltip></q-btn-toggle>
|
||
<q-chip v-if="dirty" dense size="sm" color="orange" text-color="white" icon="circle">{{ dirtyCount }} non publié(s)</q-chip>
|
||
<q-chip v-if="offShiftWeekCount" dense size="sm" color="warning" text-color="white" icon="warning">{{ offShiftWeekCount }} hors quart<q-tooltip class="bg-grey-9">{{ offShiftWeekCount }} job(s) assigné(s) cette période un jour où la ressource n'a AUCUN quart publié. Repère le ⚠ dans la grille → publier un quart ou réassigner.</q-tooltip></q-chip>
|
||
<q-space />
|
||
<q-btn-group flat>
|
||
<q-btn dense flat icon="keyboard_double_arrow_left" @click="navWeek(-1)"><q-tooltip>Semaine précédente</q-tooltip></q-btn>
|
||
<q-btn dense flat icon="chevron_left" @click="navDay(-1)"><q-tooltip>Jour précédent</q-tooltip></q-btn>
|
||
<q-btn dense flat label="Auj." @click="navToday" />
|
||
<q-btn dense flat icon="chevron_right" @click="navDay(1)"><q-tooltip>Jour suivant</q-tooltip></q-btn>
|
||
<q-btn dense flat icon="keyboard_double_arrow_right" @click="navWeek(1)"><q-tooltip>Semaine suivante</q-tooltip></q-btn>
|
||
</q-btn-group>
|
||
<q-input dense outlined type="date" v-model="boardDate" style="width:150px"><q-tooltip>{{ boardView === 'kanban' ? 'Jour affiché (mode Jour)' : 'Début de la fenêtre (mode Semaine)' }}</q-tooltip></q-input>
|
||
<!-- #2 — Journal des changements + état auto-save (plus d'alerte « non publié ») -->
|
||
<q-btn dense flat round :icon="autosaving ? 'cloud_sync' : 'history'" :color="autosaving ? 'primary' : ''"><q-tooltip>Journal des changements · {{ autosaving ? 'sauvegarde en cours…' : 'auto-sauvegardé' }}</q-tooltip>
|
||
<q-menu anchor="bottom middle" self="top middle"><q-list dense style="min-width:290px;max-width:380px">
|
||
<q-item-label header class="q-py-xs">Journal — <span :class="autosaving ? 'text-primary' : 'text-positive'">{{ autosaving ? 'sauvegarde…' : '✓ auto-sauvegardé' }}</span></q-item-label>
|
||
<q-item v-if="!changeLog.length"><q-item-section class="text-grey-6 text-caption">Aucun changement récent.</q-item-section></q-item>
|
||
<q-item v-for="c in changeLog.slice(0, 30)" :key="c.id" dense :class="{ 'chg-reverted': c.reverted }">
|
||
<q-item-section><q-item-label class="text-caption">{{ c.text }}</q-item-label></q-item-section>
|
||
<q-item-section side class="row items-center no-wrap q-gutter-xs">
|
||
<span class="text-grey-5" style="font-size:10px">{{ new Date(c.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) }}</span>
|
||
<q-badge v-if="c.reverted" color="grey-4" text-color="grey-7" label="annulé" />
|
||
<q-btn v-else-if="c.revert" flat dense round size="9px" icon="undo" color="primary" @click.stop="revertChange(c)"><q-tooltip>Annuler ce changement</q-tooltip></q-btn>
|
||
</q-item-section>
|
||
</q-item>
|
||
</q-list></q-menu>
|
||
</q-btn>
|
||
<q-separator vertical class="q-mx-xs" />
|
||
<!-- Accès RAPIDE au panneau « Jobs à assigner » (liste · carte · lasso · suggestion auto) — badge = nombre à répartir -->
|
||
<q-btn dense :outline="!assignPanel.open" :unelevated="assignPanel.open" no-caps color="deep-purple" icon="assignment_ind" label="À assigner" @click="assignPanel.open ? (assignPanel.open = false) : openAssignPanel()">
|
||
<q-badge v-if="assignPanel.jobs.length" color="red" floating>{{ assignPanel.jobs.length }}</q-badge>
|
||
<q-tooltip>Afficher / masquer les jobs à répartir (liste · carte · lasso · suggestion auto)</q-tooltip>
|
||
</q-btn>
|
||
<!-- Création UNIFIÉE : Tâche · Intervention (sur site → créneau / à distance → ticket support·vente). Remplace « Créer un job » + « Créneau ». -->
|
||
<q-btn dense unelevated no-caps color="teal-7" icon="add" label="Créer" @click="createChooser = true"><q-tooltip>Créer une tâche ou une intervention (sur site · à distance)</q-tooltip></q-btn>
|
||
<!-- Menu OUTILS : regroupe la config secondaire pour désencombrer -->
|
||
<q-btn-dropdown dense outline color="grey-8" icon="build" label="Outils" no-caps>
|
||
<q-list dense style="min-width:250px">
|
||
<!-- ⚙️ RÉGLAGES : config de planification (dépôt, compétences, cadence, durées, modèles, garde) -->
|
||
<q-item-label header class="q-py-xs text-weight-bold text-grey-8">⚙️ Réglages planification</q-item-label>
|
||
<q-item clickable v-close-popup @click="openDepotPicker">
|
||
<q-item-section avatar><q-icon name="warehouse" color="blue-grey-7" /></q-item-section>
|
||
<q-item-section>Point de départ (dépôt)<q-item-label caption class="ellipsis" style="max-width:210px">{{ depot && depot.address ? depot.address : 'non défini — cliquer pour situer' }}</q-item-label></q-item-section>
|
||
</q-item>
|
||
<q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="groups" color="teal" /></q-item-section><q-item-section>Équipe — compétences · cadence · coût<q-item-label caption>Compétences (score + cadence/skill) · coût · catalogue</q-item-label></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="showWeekTemplates = true">
|
||
<q-item-section avatar><q-icon name="bookmark" color="brown" /></q-item-section>
|
||
<q-item-section>Modèles de semaine<q-item-label caption>{{ weekTemplates.length ? weekTemplates.length + ' modèle(s)' + (defaultTemplate ? ' · défaut : ' + defaultTemplate.name : '') : 'enregistrer / appliquer un patron' }}</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 class="q-my-xs" />
|
||
<!-- 🗓 HORAIRES & PERSONNEL : génération de quarts, absences, synchro -->
|
||
<q-item-label header class="q-py-xs text-weight-bold text-grey-8">🗓 Horaires & personnel</q-item-label>
|
||
<q-item clickable v-close-popup @click="doGenerate"><q-item-section avatar><q-icon name="auto_awesome" color="indigo" /></q-item-section><q-item-section>Générer l'horaire (semaine)<q-item-label caption>solveur de quarts — la semaine affichée</q-item-label></q-item-section><q-item-section side><q-spinner v-if="generating" size="16px" color="indigo" /></q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="openSchedGenBulk"><q-item-section avatar><q-icon name="event_note" color="primary" /></q-item-section><q-item-section>Générer les horaires (lot)<q-item-label caption>modèle → plusieurs techs, N semaines</q-item-label></q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="showLeave = true"><q-item-section avatar><q-icon name="beach_access" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="showTechSync = true"><q-item-section avatar><q-icon name="group_add" color="green-7" /></q-item-section><q-item-section>Synchroniser les techniciens</q-item-section></q-item>
|
||
<q-separator class="q-my-xs" />
|
||
<!-- 👁 AFFICHAGE : bascules de ce qui s'affiche sur le board (les raccourcis redondants avec la barre — « À assigner », vue Mois — ont été retirés) -->
|
||
<q-item-label header class="q-py-xs text-weight-bold text-grey-8">👁 Affichage</q-item-label>
|
||
<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-list>
|
||
</q-btn-dropdown>
|
||
<q-separator vertical class="q-mx-xs" />
|
||
<!-- (l'explication détaillée de « Suggérer » vit DANS le dialogue — bandeau ambre à l'ouverture ; le ★ modèle par défaut vit dans ⋮ + ⌘K) -->
|
||
<q-btn unelevated color="primary" icon="auto_awesome" label="Suggérer" @click="openSuggest"><q-tooltip class="bg-grey-9">Répartition automatique des jobs du <b>jour sélectionné</b> (distance · compétence · priorité · taux d'occupation) — revue avant d'appliquer</q-tooltip></q-btn>
|
||
<!-- PUBLIER unifié : action principale + sous-options (SMS · publier au legacy) dans le même bouton -->
|
||
<q-btn-dropdown split :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" no-caps @click="doPublish">
|
||
<q-list dense style="min-width:250px">
|
||
<q-item tag="label" v-ripple>
|
||
<q-item-section avatar><q-checkbox v-model="notifySms" dense /></q-item-section>
|
||
<q-item-section>Notifier les techs par SMS<q-item-label caption>à la publication</q-item-label></q-item-section>
|
||
</q-item>
|
||
<q-separator />
|
||
<!-- #3 — étape d'approbation FACULTATIVE (maker-checker) : Soumettre → Approuver, sans SMS. « Publier » reste l'étape finale (SMS). -->
|
||
<q-item clickable v-close-popup @click="doWeekStatus('submit')" :disable="!dirty"><q-item-section avatar><q-icon name="outbox" color="indigo" /></q-item-section><q-item-section>Soumettre pour approbation<q-item-label caption>marque « Soumis » (sans SMS)</q-item-label></q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="doWeekStatus('approve')" :disable="!dirty"><q-item-section avatar><q-icon name="verified" color="teal" /></q-item-section><q-item-section>Approuver<q-item-label caption>marque « Approuvé » (sans SMS)</q-item-label></q-item-section></q-item>
|
||
<q-separator />
|
||
<q-item clickable v-close-popup @click="openLegacyPush">
|
||
<q-item-section avatar><q-icon name="sync_alt" color="deep-orange" /></q-item-section>
|
||
<q-item-section>Publier au legacy<q-item-label caption>réassigne les tickets dans osTicket (aperçu avant)</q-item-label></q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
</q-btn-dropdown>
|
||
<!-- Utilitaires secondaires regroupés dans un ⋮ (annuler/rétablir gardent leurs raccourcis Ctrl+Z / Ctrl+Shift+Z ;
|
||
le modèle par défaut ★, retiré de la barre, reste joignable ici ET via ⌘K) -->
|
||
<OverflowMenu :groups="[
|
||
{ items: [
|
||
{ icon: 'undo', label: 'Annuler', caption: 'Ctrl+Z', disabled: !history.length, action: undo },
|
||
{ icon: 'redo', label: 'Rétablir', caption: 'Ctrl+Shift+Z', disabled: !future.length, action: redo },
|
||
{ icon: 'refresh', label: 'Rafraîchir', action: () => guard(loadWeek) },
|
||
] },
|
||
{ label: 'Modèles de semaine', items: [
|
||
{ icon: 'star', label: 'Appliquer le modèle par défaut', caption: defaultTemplate ? defaultTemplate.name : 'aucun — marquer ★ dans Modèles', color: 'amber-8', disabled: !defaultTemplate, action: applyDefault },
|
||
{ icon: 'bookmark', label: 'Gérer les modèles…', action: () => { showWeekTemplates = true } },
|
||
] },
|
||
]" />
|
||
</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>
|
||
<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 — effectif requis par créneau » RETIRÉ (remplacé par la vue Mois : besoins par compétence + couverture). -->
|
||
|
||
<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, 16)" />
|
||
<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-16" style="width:84px" @keyup.enter="bulkQuick" @mousedown.stop><q-tooltip>Saisie rapide : 8-16 · 816 · 830-16</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">
|
||
<JobPool variant="mobile" :pool="mobilePool" :selected-day="mobileSelDay" />
|
||
</div>
|
||
<!-- Feuille d'assignation (overlay bas) : techs candidats — n'écrase PAS la liste (zéro décalage au défilement). -->
|
||
<div v-if="selectedNames.length" class="pm-sheet">
|
||
<div class="pm-sheet-hd"><q-icon name="touch_app" size="18px" class="q-mr-xs" />{{ selectedNames.length }} job(s) → touchez un technicien<q-space /><q-btn flat dense no-caps size="sm" label="Annuler" color="white" @click="clearSel()" /></div>
|
||
<!-- Chips compétence DÉCOCHABLES : auto-cochées (compétences des jobs sélectionnés) ; décocher relâche le filtre ; tout décoché = TOUS les techs. -->
|
||
<div v-if="assignSkills.length" class="pm-sheet-skills">
|
||
<span class="pm-ss-lbl">Compétence :</span>
|
||
<button v-for="s in assignSkills" :key="s" type="button" class="pm-chip" :class="{ on: assignSkillActive.has(s) }" :style="assignSkillActive.has(s) ? { background: getTagColor(s), borderColor: getTagColor(s), color: '#fff' } : {}" @click="toggleAssignSkill(s)">{{ s }}<q-icon :name="assignSkillActive.has(s) ? 'check' : 'add'" size="12px" class="q-ml-xs" /></button>
|
||
<span class="pm-ss-hint">{{ assignSkillActive.size ? 'décochez pour tous les techs' : '✓ tous les techs' }}</span>
|
||
</div>
|
||
<div class="pm-sheet-body">
|
||
<button v-for="t in mobileTechAssign" :key="t.id" type="button" class="pm-tech assignable" :class="{ 'no-shift': t.noShift }" @click="onTechTap(t)">
|
||
<span class="pm-ava">{{ initials(t.name) }}</span>
|
||
<span class="pm-tech-name ellipsis">{{ t.name }}</span>
|
||
<span class="pm-tbar"><span class="pm-tfill" :style="{ width: (t.h > 0 ? Math.max(8, Math.min(100, Math.round(t.ratio * 100))) : 0) + '%', background: heatColor(t.ratio) }"></span></span>
|
||
<span v-if="t.noShift" class="pm-warn" title="Aucun quart ce jour — touchez pour créer un quart">▲</span>
|
||
<span class="pm-th" :class="{ over: t.over }">{{ t.h }}<span class="pm-cap" :class="{ est: t.noShift }">/{{ t.cap }}h</span></span>
|
||
</button>
|
||
<div v-if="!mobileTechAssign.length" class="pm-empty">Aucun technicien capable — ajustez les compétences (chips).</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Feuille d'OPTIONS du job (glisser-gauche ou bouton) : priorité directe, statut, compétence, report, note. -->
|
||
<q-dialog v-model="jobSheet.open" position="bottom" @hide="onJobSheetHide">
|
||
<q-card v-if="jobSheet.job" class="pm-opt-card">
|
||
<q-card-section class="row items-center q-pb-xs">
|
||
<q-icon name="work_outline" size="18px" class="q-mr-sm text-grey-7" />
|
||
<div class="text-subtitle2 ellipsis" style="max-width:74vw">{{ deEnt(jobSheet.job.subject || jobSheet.job.service_type || jobSheet.job.name) }}</div>
|
||
<q-space /><q-btn flat dense round icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-pt-none">
|
||
<!-- Détails du ticket (lecture) -->
|
||
<div class="pm-tk-details">
|
||
<div v-if="jobSheet.job.customer_name" class="pm-tk-row"><q-icon name="person" size="14px" color="grey-6" />{{ deEnt(jobSheet.job.customer_name) }}</div>
|
||
<div v-if="jobSheet.job.address || jobSheet.job.service_location" class="pm-tk-row"><q-icon name="place" size="14px" color="grey-6" /><span class="ellipsis">{{ deEnt(jobSheet.job.address || jobSheet.job.service_location) }}</span></div>
|
||
<!-- Contact client (P1) : tél/courriel du compte F lié au ticket, tappables -->
|
||
<div v-if="sheetThread.contact && (sheetThread.contact.phone || sheetThread.contact.email)" class="pm-tk-row"><q-icon name="contact_phone" size="14px" color="grey-6" /><a v-if="sheetThread.contact.phone" :href="'tel:' + sheetThread.contact.phone" class="pm-tk-link">{{ sheetThread.contact.phone }}</a><span v-if="sheetThread.contact.phone && sheetThread.contact.email" class="text-grey-4"> · </span><a v-if="sheetThread.contact.email" :href="'mailto:' + sheetThread.contact.email" class="pm-tk-link ellipsis">{{ sheetThread.contact.email }}</a></div>
|
||
<div class="pm-tk-row"><q-icon name="sell" size="14px" color="grey-6" />{{ jobSheet.job.required_skill || '—' }}<span v-if="jobSheet.job.est_min || jobSheet.job.duration_h" class="text-grey-6"> · ≈ {{ jobSheet.job.est_min ? fmtMin(jobSheet.job.est_min) : (jobSheet.job.duration_h + 'h') }}</span></div>
|
||
<div v-if="jobSheet.job.scheduled_date || jobSheet.job.creation" class="pm-tk-row"><q-icon name="event" size="14px" color="grey-6" /><span v-if="jobSheet.job.scheduled_date" :class="isOverdue(jobSheet.job.scheduled_date) ? 'text-warning text-weight-medium' : ''">{{ fmtDueLabel(jobSheet.job.scheduled_date) }}</span><span v-if="jobSheet.job.creation" class="text-grey-5"> · créé {{ String(jobSheet.job.creation).slice(0, 10) }}</span></div>
|
||
<div v-if="jobSheet.job.legacy_ticket_id || jobSheet.job.name" class="pm-tk-row text-grey-5"><q-icon name="confirmation_number" size="14px" color="grey-6" />{{ jobSheet.job.legacy_ticket_id || jobSheet.job.name }}</div>
|
||
<!-- Validation « service livré » (à la demande) : croise tech-présent (geofence) + service en ligne (modem) -->
|
||
<div class="pm-tk-row">
|
||
<q-icon name="router" size="14px" color="grey-6" />
|
||
<template v-if="serviceCheck.done">
|
||
<q-chip dense square class="q-my-none q-ml-none" :color="serviceCheck.online === true ? 'teal-1' : (serviceCheck.found ? 'deep-orange-1' : 'grey-3')" :text-color="serviceCheck.online === true ? 'teal-9' : (serviceCheck.found ? 'deep-orange-9' : 'grey-8')" :icon="serviceCheck.online === true ? 'check_circle' : (serviceCheck.found ? 'cancel' : 'help')">{{ serviceCheck.label }}</q-chip>
|
||
<q-btn flat dense round size="sm" icon="refresh" color="grey-6" :loading="serviceCheck.loading" @click="checkJobService(jobSheet.job)"><q-tooltip>Revérifier le service</q-tooltip></q-btn>
|
||
</template>
|
||
<q-btn v-else flat dense no-caps size="sm" color="primary" icon="router" :label="serviceCheck.loading ? 'Vérification…' : 'Vérifier le service'" :loading="serviceCheck.loading" @click="checkJobService(jobSheet.job)" />
|
||
</div>
|
||
</div>
|
||
<!-- Conversation client UNIFIÉE — UN seul champ : Réponse au client (Outbox courriel/SMS) OU 📌 Note importante (épinglée au job). Fil le plus récent en premier. -->
|
||
<div class="pm-opt-label">Conversation client <span v-if="sheetThread.messages.length" class="text-grey-5">({{ sheetThread.messages.length }})</span></div>
|
||
<div class="pm-postbox">
|
||
<q-input dense outlined v-model="composerText" type="textarea" rows="2" :placeholder="noteMode ? 'Note interne — ajoutée au fil du ticket, jamais envoyée au client…' : (replyChannel.ok ? ('Réponse ENVOYÉE au client par ' + replyChannel.label + '…') : 'Réponse au client…')" />
|
||
<div class="row items-center q-mt-xs q-gutter-xs">
|
||
<q-btn-toggle v-model="noteMode" dense unelevated no-caps size="sm" :options="[{ label: 'Note interne', value: true, icon: 'sticky_note_2' }, { label: replyChannel.ok ? ('Répondre au client · ' + replyChannel.label) : 'Répondre au client', value: false, icon: 'reply', disable: !replyChannel.ok }]" :toggle-color="noteMode ? 'blue-grey-7' : 'deep-orange'" color="grey-3" text-color="grey-8" />
|
||
<q-space />
|
||
<q-btn dense unelevated no-caps size="sm" :color="noteMode ? 'blue-grey-7' : 'deep-orange'" :icon="noteMode ? 'sticky_note_2' : 'send'" :label="noteMode ? 'Enregistrer la note' : 'Envoyer au client'" :loading="postBusy" :disable="noteMode ? false : !postDraft.trim()" @mousedown.prevent="submitComposer()" />
|
||
</div>
|
||
<div class="row items-center q-mt-xs pm-postbox-foot">
|
||
<q-icon :name="noteMode ? 'push_pin' : (replyChannel.ok ? 'public' : 'block')" size="12px" :color="noteMode ? 'deep-orange-6' : (replyChannel.ok ? 'teal-7' : 'grey-5')" />
|
||
<span class="pm-postbox-hint">{{ noteMode ? 'Note interne — ajoutée au fil du ticket, jamais envoyée au client' : (replyChannel.ok ? ('⚠ ENVOYÉE au client par ' + replyChannel.label + (jobSheet.job.legacy_ticket_id ? ' · consignée au ticket' : '')) : 'Aucun courriel/téléphone au dossier client') }}</span>
|
||
<q-space />
|
||
<q-btn flat dense no-caps size="sm" color="primary" icon="open_in_new" label="Fil complet" @click="openFullThread()" />
|
||
</div>
|
||
</div>
|
||
<div class="pm-thread" v-if="sheetThread.loading || sheetThread.messages.length">
|
||
<div v-if="sheetThread.loading" class="pm-thread-empty"><q-spinner size="15px" class="q-mr-xs" />Chargement du fil…</div>
|
||
<div v-for="(m, i) in sheetThread.messages" :key="i" class="pm-post" :class="{ 'pm-post--client': m.fromClient }">
|
||
<div class="pm-post-hd">
|
||
<q-avatar size="20px" class="pm-post-av" :style="{ background: pmIdentity(m).color }">{{ pmIdentity(m).initials || '?' }}</q-avatar>
|
||
<b class="ellipsis">{{ pmIdentity(m).name || (m.fromClient ? 'Client' : 'Staff') }}</b>
|
||
<span v-if="m.fromClient" class="pm-post-tag">client</span>
|
||
<q-space />
|
||
<span v-if="m.at" class="pm-post-at">{{ relTime(m.at) }}</span>
|
||
</div>
|
||
<div class="pm-post-text">{{ m.text }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="pm-opt-label">Priorité</div>
|
||
<div class="row q-gutter-xs">
|
||
<q-chip v-for="p in POOL_PRIOS" :key="p.value" clickable dense :selected="jobSheet.job.priority === p.value" :style="{ background: jobSheet.job.priority === p.value ? p.color : '#eef2f7', color: jobSheet.job.priority === p.value ? '#fff' : '#475569' }" @click="setJobPriority(jobSheet.job, p.value)">{{ p.label }}<q-tooltip class="bg-grey-9" style="font-size:11px;max-width:230px">{{ p.desc }}</q-tooltip></q-chip>
|
||
</div>
|
||
<div class="pm-opt-label">Statut</div>
|
||
<div class="row q-gutter-xs">
|
||
<q-chip v-for="s in POOL_STATUSES" :key="s.value" clickable dense :icon="s.icon" :color="jobSheet.job.status === s.value ? s.color : 'grey-3'" :text-color="jobSheet.job.status === s.value ? 'white' : 'grey-8'" @click="setJobStatus(jobSheet.job, s.value)">{{ s.label }}</q-chip>
|
||
</div>
|
||
<div class="pm-opt-label">Compétences requises</div>
|
||
<TagEditor :model-value="(jobSheet.job.required_skills && jobSheet.job.required_skills.length) ? jobSheet.job.required_skills : (jobSheet.job.required_skill ? [jobSheet.job.required_skill] : [])" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" placeholder="Ajouter une compétence requise…" style="width:100%" @update:model-value="list => setPoolSkills(jobSheet.job, list)" @create="onCreateRosterTag" />
|
||
<div class="pm-opt-label">Reporter (date prévue)</div>
|
||
<div class="row q-gutter-sm items-center">
|
||
<q-btn dense unelevated no-caps color="indigo" icon="snooze" label="+1 jour" @click="snoozeJob(jobSheet.job, 1)" />
|
||
<q-input dense outlined type="date" v-model="snoozeDate" style="min-width:152px" @update:model-value="v => v && setJobDate(jobSheet.job, v)"><template #prepend><q-icon name="event" size="18px" color="indigo" /></template><q-tooltip>Choisir une date (pré-réglée à +1 semaine)</q-tooltip></q-input>
|
||
</div>
|
||
</q-card-section>
|
||
<!-- Barre du bas : bouton « Fermer » atteignable au pouce (en plus du X et du clic à l'extérieur). Ferme → @hide enregistre la note. -->
|
||
<q-card-actions align="right" class="pm-opt-actions"><q-btn unelevated no-caps color="primary" icon="check" label="Fermer" v-close-popup /></q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Note importante du répartiteur (champ Dispatch Job.notes) -->
|
||
<q-dialog v-model="noteDialog.open" position="bottom">
|
||
<q-card v-if="noteDialog.job" class="pm-opt-card">
|
||
<q-card-section class="row items-center q-pb-none">
|
||
<q-icon name="sticky_note_2" size="18px" color="warning" class="q-mr-sm" />
|
||
<div class="text-subtitle2">Note importante</div>
|
||
<q-space /><q-btn flat dense round icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section>
|
||
<q-input dense outlined type="textarea" autogrow autofocus v-model="noteDialog.text" placeholder="ex. appeler 30 min avant · client préfère AM" maxlength="2000" />
|
||
</q-card-section>
|
||
<q-card-actions align="right" class="q-pt-none">
|
||
<q-btn v-if="noteDialog.text" flat no-caps color="grey-7" label="Effacer" @click="noteDialog.text = ''; saveNote()" />
|
||
<q-btn unelevated no-caps color="warning" icon="check" label="Enregistrer" @click="saveNote" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
</div>
|
||
|
||
<div class="grid-wrap gt-sm" v-show="boardView === 'grid'">
|
||
<table class="roster-grid" :class="{ 'day-mode': dayMode }">
|
||
<thead>
|
||
<tr>
|
||
<th class="tech-col"><div class="row items-center no-wrap">Ressource<q-space /><q-btn v-if="hiddenCount" flat dense round size="xs" :icon="showHidden ? 'visibility' : 'visibility_off'" :color="showHidden ? 'primary' : 'grey-6'" @click="showHidden = !showHidden"><q-badge floating color="grey-7" style="top:-7px;right:6px">{{ hiddenCount }}</q-badge><q-tooltip>{{ showHidden ? 'Cacher les ressources masquées' : (hiddenCount + ' masquée(s) — afficher en grisé') }}</q-tooltip></q-btn></div></th>
|
||
<th v-for="(d, di) in dayList" :key="d.iso" class="clk" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }" @click="maybeSelectCol(di)">
|
||
<div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div>
|
||
<!-- Alerte « jobs non assignés » du jour → ouvre la liste filtrée sur ce jour (remplace l'ouverture auto du panneau au chargement). -->
|
||
<div v-if="(unassignedByDay[d.iso] || []).length" class="day-unassigned" @click.stop="openDayInPanel(d.iso)">
|
||
<q-icon name="warning" size="12px" /> {{ (unassignedByDay[d.iso] || []).length }} à assigner
|
||
<q-tooltip class="bg-grey-9">{{ (unassignedByDay[d.iso] || []).length }} job(s) non assigné(s) ce jour — cliquer pour ouvrir la liste (recherche · ville · compétence)</q-tooltip>
|
||
</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="event" :color="isPaused(t) ? 'orange-8' : 'primary'" @click.stop="openTechSchedule(t)"><q-tooltip>Horaire de {{ t.name }} — récurrent · pause · congés{{ isPaused(t) ? ' (en 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" :class="{ 'tech-paused-pending': !!pendingPause[t.id] }" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Réglages de {{ t.name }} — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée</q-tooltip>{{ t.name }}</span>
|
||
<q-chip v-if="pendingPause[t.id]" dense size="sm" color="orange-9" text-color="white" icon="pause_circle" class="q-ml-xs">pause à publier<q-tooltip class="bg-grey-9">Pause indéfinie en attente — Publier pour appliquer (retire ses quarts futurs + relève ses jobs)</q-tooltip></q-chip>
|
||
<span v-if="t.group" class="grp">{{ t.group }}</span>
|
||
<span v-if="hoursOf(t.id)" class="th text-grey-6">{{ hoursOf(t.id) }}h<q-tooltip class="bg-grey-9" style="font-size:11px">Heures planifiées sur l'étendue affichée ({{ days }} j) — total, PAS une limite hebdomadaire</q-tooltip></span>
|
||
<q-btn flat dense round size="9px" icon="more_vert" color="grey-6" @click.stop><q-tooltip>Actions — {{ t.name }}</q-tooltip>
|
||
<q-menu auto-close anchor="bottom right" self="top right">
|
||
<q-list dense style="min-width:190px">
|
||
<q-item clickable @click="openTimeline(t)"><q-item-section avatar><q-icon name="timeline" color="green-5" /></q-item-section><q-item-section>Timeline de la semaine</q-item-section></q-item>
|
||
<q-item clickable @click="techAppLinkMenu(t)"><q-item-section avatar><q-icon name="phone_iphone" color="deep-purple-5" /></q-item-section><q-item-section>Lien app terrain</q-item-section></q-item>
|
||
</q-list>
|
||
</q-menu>
|
||
</q-btn>
|
||
<div class="tech-skills clk" @click.stop="openSkillEditor(t, $event)">
|
||
<span v-if="tSkills(t).length" class="skill-dot" :class="{ 'has-lvl': !!(t.skill_levels || {})[tSkills(t)[0]] }" :style="{ background: getTagColor(tSkills(t)[0]) }"><q-icon :name="skillSym(tSkills(t)[0])" size="11px" /><span v-if="(t.skill_levels || {})[tSkills(t)[0]]" class="sd-lvl">{{ (t.skill_levels || {})[tSkills(t)[0]] }}</span><q-tooltip class="bg-grey-9"><b>{{ tSkills(t)[0] }}</b> · niveau {{ (t.skill_levels || {})[tSkills(t)[0]] || '—' }}<template v-if="(t.skill_eff || {})[tSkills(t)[0]]"> · {{ effSuffix(skillEffOf(t, tSkills(t)[0])) }}</template></q-tooltip></span><span v-if="tSkills(t).length > 1" class="skill-more">+{{ tSkills(t).length - 1 }}<q-tooltip class="bg-grey-9" style="font-size:11px"><div class="text-weight-medium q-mb-xs">Autres compétences</div><div v-for="sk in tSkills(t).slice(1)" :key="sk" class="row items-center no-wrap" style="gap:5px;line-height:1.7"><q-icon :name="skillSym(sk)" size="13px" :style="{ color: getTagColor(sk) }" /><span>{{ sk }}</span><span v-if="(t.skill_levels || {})[sk]" class="text-grey-5">· niv {{ (t.skill_levels || {})[sk] }}</span></div></q-tooltip></span>
|
||
<span v-if="!(t.skills || []).length" class="add-skill-hint">+ compétences</span>
|
||
</div>
|
||
<q-btn flat dense round size="9px" class="hide-eye" :icon="isHidden(t.id) ? 'visibility_off' : 'visibility'" :color="isHidden(t.id) ? 'grey-5' : 'grey-6'" @click.stop="toggleHidden(t.id)"><q-tooltip>{{ isHidden(t.id) ? 'Réafficher / considérer cette ressource' : 'Masquer & ignorer (hors front-line)' }}</q-tooltip></q-btn>
|
||
</div>
|
||
</td>
|
||
<td v-for="(d, di) in dayList" :key="d.iso" class="cell" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso), sel: isSelected(t.id, d.iso), dirty: isCellDirty(t.id, d.iso), over: cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).over, noshift: (cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).noShift) || (officeBlockH(t.id, d.iso) && !hasReg(t.id, d.iso)), 'drop-hover': dropCell === t.id + '|' + d.iso }" @mousedown="onDown(ti, di, $event)" @mouseenter="onEnter(ti, di)" @click="onCellClick(t, d, $event, ti, di)" @contextmenu.prevent="onCellContext(t, d, $event, ti, di)" @dragover.prevent="onCellDragOver(t, d)" @dragleave="dropCell === t.id + '|' + d.iso && (dropCell = null, dropPreview.key = null)" @drop.prevent="onCellDrop($event, t, d)">
|
||
<template v-if="isAbsent(t.id, d.iso) || isPaused(t)">
|
||
<div class="tl"><div class="tl-absent"></div><q-tooltip class="bg-grey-9">Absent · {{ absenceLabel(t.id, d.iso) }}</q-tooltip></div>
|
||
</template>
|
||
<template v-else-if="hasReg(t.id, d.iso) || onGarde(t.id, d.iso) || (cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).noShift)">
|
||
<div class="tl">
|
||
<div v-if="cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).hasReg" class="tl-shift tl-shift-click" :class="{ over: cellOcc(t.id, d.iso).over }" :style="pos(cellOcc(t.id, d.iso).shiftS, cellOcc(t.id, d.iso).shiftE)" @click.stop="openDayFromCell(t, d)"></div>
|
||
<div v-for="(b, bi) in cellGardeBands(t.id, d.iso)" :key="'g' + bi" class="tl-shift tl-shift-click oncall" :style="{ left: b.left, width: b.width }" @click.stop="openDayFromCell(t, d)"></div>
|
||
<div v-for="(b, bi) in cellBlocks(t.id, d.iso)" :key="'j' + bi" class="tl-blk tl-blk-click" :class="{ 'tl-blk-legacy': b.legacy, 'tl-blk-done': b.done, 'tl-blk-assist': b.assist }" :style="blockStyle(b, cellPct(t.id, d.iso))" @click.stop="openDayFromCell(t, d)" @mousedown.stop><q-icon v-if="b.done || (b.e - b.s) >= (dayMode ? 0.75 : 2.5)" :name="blkIcon(b)" :style="{ color: blkColor(b) }" size="12px" /><q-tooltip class="bg-grey-9" :delay="350" style="font-size:11px"><template v-if="b.assist">👥 <b>Assistant (renfort)</b> · ≈{{ Math.round((b.e - b.s) * 10) / 10 }}h</template><template v-else><b>{{ b.subject || b.skill || 'Job' }}</b><br>{{ b.legacy ? (b.legacy_dept || b.dept || 'Ticket') : (b.skill || 'Job') }} · ≈{{ Math.round((b.e - b.s) * 10) / 10 }}h<span v-if="blkOpsOnly(b)"><br><span class="text-warning">⚠ Dispatché OPS — pas publié dans F</span></span><br><span class="text-info">clic = détails du jour (carte + trajet) · clic droit = renvoyer au pool</span></template></q-tooltip><q-menu v-if="b.job && !b.legacy && !b.assist" context-menu touch-position auto-close><q-list dense style="min-width:180px"><q-item clickable @click="unassignOne(b)"><q-item-section avatar><q-icon name="inbox" color="grey-7" /></q-item-section><q-item-section>Renvoyer au pool</q-item-section></q-item></q-list></q-menu></div>
|
||
<!-- Aperçu d'occupation projetée pendant le drag : barre fantôme + delta -->
|
||
<div v-if="isDropTarget(t.id, d.iso) && projPct(t.id, d.iso) != null" class="tl-proj" :style="{ width: Math.min(100, projPct(t.id, d.iso)) + '%', background: occColor(projPct(t.id, d.iso)) }"></div>
|
||
<!-- pas de bulle sur le FOND de cellule : clic sur un bloc = son ticket · clic ailleurs dans la cellule = éditeur de jour (liste + timeline + carte) -->
|
||
</div>
|
||
</template>
|
||
<span v-else-if="offShiftJobs(t.id, d.iso).length" class="offshift-warn" @click.stop="openTimeline(t)"><q-icon name="warning" size="13px" color="warning" />{{ offShiftJobs(t.id, d.iso).length }}<q-tooltip class="bg-grey-9">{{ offShiftJobs(t.id, d.iso).length }} job(s) assigné(s) ce jour SANS quart publié — publier un quart ou réassigner. Clic → timeline.</q-tooltip></span>
|
||
<span v-else class="free">·</span>
|
||
<div v-if="isDropTarget(t.id, d.iso)" class="drop-badge" :class="{ over: projPct(t.id, d.iso) >= 100 }">+{{ dropPreview.addH }}h<template v-if="projPct(t.id, d.iso) != null"> → {{ projPct(t.id, d.iso) }}%</template></div>
|
||
<q-icon v-if="cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).over" name="warning" color="warning" size="14px" class="ovl-warn"><q-tooltip class="bg-grey-9" style="font-size:11.5px; max-width:260px"><b>⚠ Surcharge</b><br>{{ overWhy(t.id, d.iso) }}</q-tooltip></q-icon>
|
||
<div v-if="officeBlockH(t.id, d.iso)" class="office-blk" :style="officeBlockStyle(t.id, d.iso)" @click.stop><q-menu anchor="bottom right" self="top right" auto-close><q-list dense style="min-width:250px; max-width:360px"><q-item-label header class="q-py-xs">{{ cellOfficeN(t.id, d.iso) }} ticket(s) sans déplacement · ≈{{ fmtOfficeH(t.id, d.iso) }} · {{ t.name }}</q-item-label><q-item v-for="(j, ji) in officeJobs(t.id, d.iso)" :key="'om' + ji" clickable @click="openLegacyBlock({ lid: j.id, subject: j.subject, dept: j.dept })"><q-item-section avatar style="min-width:18px"><span class="office-dot" :style="{ background: legacyDeptColor(j.dept) || '#9fa8da' }"></span></q-item-section><q-item-section><q-item-label lines="2">{{ j.subject }}</q-item-label><q-item-label caption>{{ j.dept }} · #{{ j.id }}<span v-if="j.est_min || j.duration_h"> · ≈{{ j.est_min ? fmtMin(j.est_min) : (j.duration_h + 'h') }}</span></q-item-label></q-item-section><q-item-section side><q-icon name="open_in_new" size="14px" color="grey-6" /></q-item-section></q-item></q-list></q-menu></div>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="!visibleTechs.length"><td :colspan="dayList.length + 1" class="text-grey-6 q-pa-md text-center">Aucun technicien (filtre ?).</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- ── VUE BOARD (Kanban horizontal, façon Dispatch/Gaiia) : pool « À assigner » VERTICAL (recherche + tri) à gauche ;
|
||
techs en LANES horizontales à ÉCHELLE D'HEURES (réutilise cellBands + pos + axisTicks). Glisser = assigner (hub). ── -->
|
||
<!-- P3 — Onglet « Tournées » : trajets RÉELS (jobs assignés) de la journée sélectionnée, 1 couleur/tech (composant RouteMap partagé, OSRM) -->
|
||
<!-- Vue MOIS : résumé mensuel (en quart / absents) + couverture par compétence (remplace « Demande ») -->
|
||
<!-- anchor = jour sélectionné partagé (même mois que le jour choisi en Semaine/Jour/Tournées) ; refresh-key = recharge quand un brouillon de quart vient d'être auto-sauvegardé -->
|
||
<MonthOverview v-if="boardView === 'month'" :techs="techs" :templates="templates" :anchor="selDay || start" :refresh-key="monthRefresh" @generated="() => guard(loadWeek)" />
|
||
|
||
<div v-if="boardView === 'routes'" class="routes-wrap q-pa-sm">
|
||
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
|
||
<span class="text-caption text-grey-7">Journée :</span>
|
||
<OccupancyStrip :days="routeStripDays" :selected="routesDay" style="flex:1 1 100%;min-width:0" @select="routesDay = $event" />
|
||
<q-space />
|
||
<q-btn dense unelevated no-caps size="sm" color="deep-purple-6" text-color="white" icon="forward_to_inbox" label="Notifier" :disable="!dayRoutes.length" @click="openNotifyDlg"><q-tooltip>Envoyer à chaque tech (SMS/courriel) le lien de sa tournée + les infos clés (adresse · AM/PM · urgent). Aperçu avant envoi.</q-tooltip></q-btn>
|
||
<q-btn dense unelevated no-caps size="sm" :color="showLivePos ? 'teal-6' : 'grey-4'" :text-color="showLivePos ? 'white' : 'grey-8'" icon="my_location" :label="'GPS live' + (dayLivePositions.length ? ' (' + dayLivePositions.length + ')' : '')" @click="showLivePos = !showLivePos"><q-tooltip>Positions GPS des techs (Traccar), rafraîchies ~25 s. Cliquer pour {{ showLivePos ? 'masquer' : 'afficher' }}.</q-tooltip></q-btn>
|
||
<!-- Tracé GPS RÉEL du jour — visible seulement quand UNE tournée est affichée (naturellement seule, ou isolée via les chips techs) -->
|
||
<q-btn v-if="soloRouteTech" dense unelevated no-caps size="sm" :color="showRouteTrack ? 'deep-orange-6' : 'grey-4'" :text-color="showRouteTrack ? 'white' : 'grey-8'" icon="timeline" :label="'Tracé réel' + (showRouteTrack && routeTrackMsg ? ' · ' + routeTrackMsg : '')" @click="showRouteTrack = !showRouteTrack"><q-tooltip>Parcours GPS réel (Traccar) de {{ soloRouteTech.name }} ce jour — trait orange superposé à la tournée prévue. S'affiche quand une seule tournée est isolée.</q-tooltip></q-btn>
|
||
<span class="text-caption text-grey-6">{{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail</span>
|
||
</div>
|
||
<!-- Chips techs : clic = ISOLER la tournée de ce tech (masquer les autres) ; re-clic = tout réafficher. Pastille = nb de jobs, couleur du tech. -->
|
||
<div v-if="allDayRoutes.length" class="row items-center q-mb-xs" style="gap:5px;flex-wrap:wrap">
|
||
<span v-for="r in allDayRoutes" :key="'chip' + r.id" class="rt-chip" :class="{ off: hiddenRouteTechs.has(r.id) }" :style="hiddenRouteTechs.has(r.id) ? {} : { borderColor: r.color, background: r.color + '14' }" @click="toggleRouteTech(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)">
|
||
<span class="rt-dot" :style="{ background: hiddenRouteTechs.has(r.id) ? '#cbd5e1' : r.color }"></span>{{ r.name }}
|
||
<span class="rt-n" :style="{ background: hiddenRouteTechs.has(r.id) ? '#94a3b8' : r.color }">{{ r.stops.length }}</span>
|
||
<q-tooltip>{{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour n\'afficher QUE cette tournée' : 'Cliquer pour isoler cette tournée (masquer les autres)' }}</q-tooltip>
|
||
</span>
|
||
<button v-if="hiddenRouteTechs.size" type="button" class="rt-chip rt-chip-all" @click="showAllRouteTechs"><q-icon name="done_all" size="13px" />Tout afficher</button>
|
||
</div>
|
||
<!-- Jobs DUS ce jour, NON assignés (mis en évidence) — chips secteur filtrent liste+carte · clic = détail · « Suggérer » = dispatch auto -->
|
||
<div v-if="routeDayUnassigned.length" class="rt-unassigned">
|
||
<div class="rt-ua-top">
|
||
<button type="button" class="rt-ua-lbl rt-ua-open" @click="openDayInPanel(routesDay)"><q-icon name="inbox" size="15px" color="deep-orange-7" /> <b>{{ routeDayUnassignedView.length }}</b> à répartir<span v-if="routeSectorSel.size" class="text-grey-6"> / {{ routeDayUnassigned.length }}</span><q-icon name="open_in_full" size="12px" class="q-ml-xs" /><q-tooltip>Voir la liste des jobs de ce jour (recherche · ville · compétence)</q-tooltip></button>
|
||
<template v-if="routeSectors.length > 1">
|
||
<button v-for="s in routeSectors" :key="s.sector" type="button" class="rt-sect" :class="{ on: routeSectorSel.has(s.sector) }" @click="toggleRouteSector(s.sector)"><q-icon name="place" size="11px" />{{ s.sector }}<span class="rt-sect-n">{{ s.n }}</span></button>
|
||
<button v-if="routeSectorSel.size" type="button" class="rt-sect rt-sect-x" @click="routeSectorSel = new Set()">✕ tout</button>
|
||
</template>
|
||
<template v-if="routePrios.length > 1">
|
||
<span class="rt-chip-sep" />
|
||
<button v-for="p in routePrios" :key="p.value" type="button" class="rt-sect rt-prio" :class="{ on: routePrioSel.has(p.value) }"
|
||
:style="routePrioSel.has(p.value) ? { background: p.color, borderColor: p.color, color: '#fff' } : { borderColor: p.color, color: p.color }"
|
||
@click="toggleRoutePrio(p.value)"><q-icon name="flag" size="11px" />{{ p.label }}<span class="rt-sect-n">{{ p.n }}</span></button>
|
||
</template>
|
||
<q-space />
|
||
<q-btn dense unelevated no-caps size="sm" color="primary" icon="auto_awesome" label="Suggérer" @click="openSuggest"><q-tooltip>Répartir automatiquement les jobs {{ routeSectorSel.size ? 'du secteur' : 'de ce jour' }}</q-tooltip></q-btn>
|
||
</div>
|
||
<!-- Carrousel horizontal (1 rangée, défile latéralement) : compact au-dessus de la carte, tactile/tablette, montre TOUS les jobs (pas de plafond à 16). -->
|
||
<div class="rt-ua-jobs">
|
||
<span v-for="j in routeDayUnassignedView" :key="j.name" class="rt-ua-chip" :style="{ borderLeftColor: prioColor(j.priority) }" @click="openJobDetail(j)">
|
||
<q-icon :name="skillSym(j.required_skill || j.service_type)" size="14px" :style="{ color: getTagColor(j.required_skill || j.service_type) }" />{{ j.subject || j.service_type || j.name }}<template v-if="jobCity(j)"> · {{ jobCity(j) }}</template>
|
||
<q-tooltip class="bg-grey-9">{{ j.required_skill || '—' }}<template v-if="j.customer_name"> · {{ j.customer_name }}</template><template v-if="j.address"><br>📍 {{ j.address }}</template></q-tooltip>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<RouteMap ref="routesMapRef" :routes="dayRoutes" :live="showLivePos ? dayLivePositions : []" :pins="routeUnassignedPins" :track="showRouteTrack ? routeTrack : null" 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">
|
||
<JobPool variant="docked" :pool="kanbanPool" />
|
||
</div>
|
||
|
||
<!-- Board : techs en lanes horizontales + échelle d'heures (la liste de techs suit le filtre skill/sous-groupe) -->
|
||
<div class="kbb-board" ref="kbBoardEl">
|
||
<div class="kbb-daybar"><b>{{ kanbanDay ? (kanbanDay.dow + ' ' + kanbanDay.dnum) : '—' }}</b><span class="text-grey-6">· glisser une carte sur une lane = assigner · {{ visibleTechs.length }} tech(s)</span></div>
|
||
<div class="kbb-scroll" ref="kbScrollEl">
|
||
<div class="kbb-axis-row">
|
||
<div class="kbb-name-sp"></div>
|
||
<div class="kbb-axis" :style="{ width: kbInnerW + 'px' }"><span v-for="tk in kbTicks" :key="'a' + tk.h" class="grid-axis-tick" :style="{ left: tk.left }">{{ tk.h }}</span><div v-if="nowLblStyle" class="kbb-now-lbl" :style="nowLblStyle">{{ nowHHMM }}</div></div>
|
||
</div>
|
||
<div class="kbb-rows">
|
||
<!-- Trait « maintenant » CONTINU : 1 seul élément sur toute la hauteur des lanes -->
|
||
<div v-if="nowLineStyle" class="kbb-now-full" :style="nowLineStyle"></div>
|
||
<div v-for="t in visibleTechs" :key="t.id" class="kbb-row">
|
||
<div class="kbb-name"><span class="ellipsis clk" style="cursor:pointer" @click.stop="openSkillEditor(t, $event)">{{ t.name }}<q-tooltip>Réglages de {{ t.name }} — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée</q-tooltip></span><q-icon v-if="kanbanDay && cellOcc(t.id, kanbanDay.iso) && cellOcc(t.id, kanbanDay.iso).over" name="warning" color="warning" size="15px"><q-tooltip class="bg-grey-9" style="font-size:11.5px; max-width:260px"><b>⚠ Surcharge</b><br>{{ overWhy(t.id, kanbanDay.iso) }}</q-tooltip></q-icon><q-icon name="tune" size="14px" color="grey-5" class="cursor-pointer" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Réglages du tech (compétences · GPS · domicile · tournée)</q-tooltip></q-icon></div>
|
||
<div class="kbb-lane" :style="kbLaneStyle" :class="{ 'drop-hover': kanbanDay && dropCell === t.id + '|' + kanbanDay.iso }"
|
||
@dragover.prevent="kanbanDay && onCellDragOver(t, kanbanDay)" @dragleave="dropCell = null" @drop="kanbanDay && onKbDrop($event, t)"
|
||
@click.self="kanbanDay && openDayFromCell(t, kanbanDay)" @contextmenu.self.prevent="onKbLaneMenu(t, $event)">
|
||
<!-- quart prévu = délimitation pointillée fine (ex. 7–18h), garde = pointillé ambre -->
|
||
<div v-for="(b, bi) in kbShiftBands(t.id)" :key="'s' + bi" class="kbb-shift" :class="{ oncall: b.oncall }" :style="kbPos(b.s, b.e)"></div>
|
||
<!-- garde = shift SUR APPEL : bande jaune pâle + téléphone rouge (occupe la timeline, ne stretche pas l'axe) -->
|
||
<div v-for="(g, gi) in kbGardeBands(t.id)" :key="'g' + gi" class="kbb-garde" :style="kbPos(g.s, g.e)"><q-icon name="phone_in_talk" color="negative" size="13px" /><q-tooltip class="bg-grey-9" style="font-size:11px">📞 Sur appel (garde) {{ fmtH(g.s) }}h–{{ fmtH(g.e) }}h — soir / fin de semaine</q-tooltip></div>
|
||
<template v-for="(b, bi) in kanbanLaneBlocks(t.id)" :key="'j' + bi + (b.assist ? 'a' : '')">
|
||
<!-- trajet d'approche (km/min) = zone pâle AVANT le job -->
|
||
<div v-if="b.legMin" class="kbb-leg" :style="kbPos(b.legS, b.legE)"><span class="kbb-leg-t">{{ b.legMin }}′</span><q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px">{{ b.legReal ? '🚗 Trajet routier (Mapbox)' : '≈ Trajet estimé' }} : {{ b.legKm }} km · {{ b.legMin }} min avant ce job</q-tooltip></div>
|
||
<div class="kbb-blk" :class="{ assist: b.assist, 'kbb-blk-legacy': b.legacy, 'kbb-blk-opsonly': blkOpsOnly(b) }" :style="{ ...kbPos(b.s, Math.min(b.e, 24)), ...blkFill(b) }" :draggable="!b.assist && !b.legacy" @dragstart="!b.assist && !b.legacy && onJobDragStart($event, b)" @dragend="onJobDragEnd" @click.stop="b.legacy ? openLegacyBlock(b) : (!b.assist && kbBlockClick(t))" @dblclick.stop="!b.legacy && kbBlockDbl(b, t)">
|
||
<div class="kbb-blk-l1"><q-icon :name="blkIcon(b)" size="12px" :style="{ color: blkColor(b) }" /><span class="kbb-blk-t">{{ b.subject }}</span></div>
|
||
<div class="kbb-blk-l2">{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h<span v-if="b.legacy"> · {{ b.dept }}</span><span v-else-if="b.customer"> · {{ b.customer }}</span><span v-if="b.assist"> · assistant</span></div>
|
||
<q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px"><template v-if="b.legacy">🗂 <b>{{ b.subject }}</b><br>{{ b.dept || 'osTicket' }} · ≈{{ Math.round((b.dur || 0) * 10) / 10 }}h · #{{ b.lid }}<br><span class="text-light-green-4">✓ Assigné dans F (autoritaire)</span><br><span class="text-info">clic = ouvrir le ticket ↗</span></template><template v-else>{{ b.subject }}<template v-if="b.customer"><br>{{ b.customer }}</template><template v-if="b.address"><br>📍 {{ b.address }}</template><template v-if="b.assist"><br>👥 assistant (renfort)</template><br>{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h<template v-if="blkOpsOnly(b)"><br><span class="text-warning">⚠ Dispatché OPS — pas encore publié dans F</span></template><template v-if="!b.assist"><br><span class="text-info">clic droit = renvoyer au pool</span></template></template></q-tooltip>
|
||
<q-menu v-if="!b.assist && !b.legacy" context-menu touch-position auto-close><q-list dense style="min-width:190px"><q-item clickable @click="unassignOne(b)"><q-item-section avatar><q-icon name="inbox" color="grey-7" /></q-item-section><q-item-section>Renvoyer au pool<q-item-label caption>désassigner ce tech</q-item-label></q-item-section></q-item><q-item clickable @click="openJobDetail(b, t)"><q-item-section avatar><q-icon name="open_in_full" color="grey-7" /></q-item-section><q-item-section>Détails / équipe</q-item-section></q-item></q-list></q-menu>
|
||
</div>
|
||
</template>
|
||
<div v-if="kanbanDay && cellOfficeN(t.id, kanbanDay.iso)" class="office-dots kbb-office" @click.stop><span v-for="(j, ji) in officeJobs(t.id, kanbanDay.iso).slice(0, 12)" :key="'kod' + ji" class="office-dot" :style="{ background: legacyDeptColor(j.dept) || '#9fa8da' }"></span><q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px; max-width:320px"><b>{{ cellOfficeN(t.id, kanbanDay.iso) }} ticket(s) sans déplacement</b> (admin / support / NP) — hors charge dispatch.<br><span v-for="(j, ji) in officeJobs(t.id, kanbanDay.iso).slice(0, 12)" :key="'kop' + ji">• {{ j.subject }} <span class="text-grey-5">· {{ j.dept }}</span><br></span><br><span class="text-info">clic = liste · ouvrir un ticket ↗</span></q-tooltip><q-menu anchor="bottom right" self="top right" auto-close><q-list dense style="min-width:250px; max-width:360px"><q-item-label header class="q-py-xs">{{ cellOfficeN(t.id, kanbanDay.iso) }} ticket(s) sans déplacement · {{ t.name }}</q-item-label><q-item v-for="(j, ji) in officeJobs(t.id, kanbanDay.iso)" :key="'kom' + ji" clickable @click="openLegacyBlock({ lid: j.id, subject: j.subject, dept: j.dept })"><q-item-section avatar style="min-width:18px"><span class="office-dot" :style="{ background: legacyDeptColor(j.dept) || '#9fa8da' }"></span></q-item-section><q-item-section><q-item-label lines="2">{{ j.subject }}</q-item-label><q-item-label caption>{{ j.dept }} · #{{ j.id }}</q-item-label></q-item-section><q-item-section side><q-icon name="open_in_new" size="14px" color="grey-6" /></q-item-section></q-item></q-list></q-menu></div>
|
||
</div>
|
||
</div>
|
||
<div v-if="!visibleTechs.length" class="kb-empty" style="margin:10px">Aucun technicien (filtre ?).</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Matrice « Couverture — dispo vs requis » retirée : le besoin = toutes les spécialités chaque jour (règle simple, à automatiser via AI plus tard). -->
|
||
|
||
<!-- Types de shift — extrait dans components/planif/ShiftTypesDialog.vue (décomposition #4) ; @changed → recharge les templates partagés -->
|
||
<ShiftTypesDialog v-model="showShiftEditor" :templates="templates" @changed="refreshTemplates" />
|
||
|
||
<!-- Modèles de semaine (patrons d'horaire) — dialogue dédié ; état + mutations dans la page (LS_TPL) -->
|
||
<WeekTemplatesDialog v-model="showWeekTemplates" :templates="weekTemplates" @save="saveTemplate" @apply="applyTemplate" @set-default="setDefaultTemplate" @delete="deleteTemplate" />
|
||
|
||
<!-- Congés & disponibilités — extrait dans components/planif/LeaveDialog.vue (décomposition #4) -->
|
||
<LeaveDialog v-model="showLeave" :techs="techs" :tech-options="techOptions" />
|
||
|
||
<q-dialog v-model="showTeamEditor">
|
||
<q-card style="min-width:640px;max-width:96vw">
|
||
<q-card-section class="row items-center q-pb-none"><div class="text-subtitle1 text-weight-bold">Équipe — compétences, cadence & coût</div><q-space /><q-btn flat round dense icon="close" v-close-popup /></q-card-section>
|
||
<q-card-section>
|
||
<!-- Gérer les compétences (catalogue global) — fusionné ici : renommer / recolorer / supprimer partout -->
|
||
<q-expansion-item dense icon="sell" label="Gérer les compétences (catalogue)" caption="Renommer · recolorer · supprimer — partout" header-class="text-teal-8" class="q-mb-xs">
|
||
<div class="q-pa-sm">
|
||
<div v-if="!managedTags.length" class="text-grey-6 text-caption">Aucune compétence — ajoute-en via un technicien ci-dessous.</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>
|
||
</q-expansion-item>
|
||
<q-separator class="q-my-sm" />
|
||
|
||
<div class="text-caption text-grey-7 q-mb-xs">Clique un technicien pour régler ses <b>compétences</b> (ordre = priorité), son <b>score</b> ★ et sa <b>cadence par compétence</b> (défaut global 100 %), plus son <b>coût</b>. Enregistrement automatique.</div>
|
||
<div style="max-height:58vh;overflow:auto">
|
||
<q-list separator>
|
||
<q-expansion-item v-for="t in editTechs" :key="t.id" dense group="teamTech">
|
||
<template #header>
|
||
<q-item-section>
|
||
<div class="row items-center no-wrap">
|
||
<span class="text-weight-medium">{{ t.name }}</span><span v-if="t.group" class="grp q-ml-xs">{{ t.group }}</span>
|
||
<q-space />
|
||
<span class="text-caption text-grey-6">{{ (t.skills || []).length }} comp. · cad. {{ Math.round(100 / (Number(t.efficiency) || 1)) }} % · {{ loadedCostRow(t.id) }} $/h</span>
|
||
</div>
|
||
<div class="row items-center q-gutter-xs q-mt-xs">
|
||
<span v-for="sk in (t.skills || []).slice(0, 7)" :key="sk" class="skill-chip" :style="{ background: getTagColor(sk) }">{{ sk }}</span>
|
||
<span v-if="!(t.skills || []).length" class="text-caption text-grey-5">aucune compétence</span>
|
||
</div>
|
||
</q-item-section>
|
||
</template>
|
||
<div class="q-pa-sm">
|
||
<SkillCadenceTable :tech="t" :catalog="tagCatalog" :get-color="getTagColor" :palette="TAG_PALETTE" show-global
|
||
@tags-change="items => onTagsChange(t, items)"
|
||
@set-level="e => setSkillLevel(t, e.sk, e.level)"
|
||
@set-cadence="e => setSkillEffPct(t, e.sk, e.pct)"
|
||
@set-global="pct => setGlobalCadence(t, pct)"
|
||
@create-tag="onCreateRosterTag" @update-tag="onUpdateRosterTag" />
|
||
<q-separator class="q-my-sm" />
|
||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">💵 Coût <span class="text-grey-5 text-weight-regular">chargé/h = salaire × (1 + charges %) + autres</span></div>
|
||
<div class="row items-center q-gutter-sm">
|
||
<q-input dense outlined type="number" step="0.5" v-model.number="teamCost[t.id].salary" style="width:118px" label="Salaire/h" @blur="saveCostRow(t)" />
|
||
<q-input dense outlined type="number" step="1" v-model.number="teamCost[t.id].charges" style="width:118px" label="Charges %" @blur="saveCostRow(t)" />
|
||
<q-input dense outlined type="number" step="0.5" v-model.number="teamCost[t.id].other" style="width:118px" label="Autres/h" @blur="saveCostRow(t)" />
|
||
<div class="text-weight-bold text-center">= {{ loadedCostRow(t.id) }} $/h</div>
|
||
</div>
|
||
</div>
|
||
</q-expansion-item>
|
||
</q-list>
|
||
</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) <HelpHint title="Rotation de garde (sur appel)">La garde, c'est le technicien <b>sur appel</b> hors des heures normales (soir, fin de semaine). Il n'est pas planifié à l'horaire mais reste disponible en cas d'urgence. Cette rotation définit, par département, quel tech est de garde et selon quelle alternance.</HelpHint></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>
|
||
<!-- Éditeur compétences/cadence PARTAGÉ (même gabarit que l'outil « Équipe ») -->
|
||
<SkillCadenceTable :tech="skillDialog" :catalog="tagCatalog" :get-color="getTagColor" :palette="TAG_PALETTE"
|
||
@tags-change="items => onTagsChange(skillDialog, items)"
|
||
@set-level="e => setSkillLevel(skillDialog, e.sk, e.level)"
|
||
@set-cadence="e => setSkillEffPct(skillDialog, e.sk, e.pct)"
|
||
@create-tag="onCreateRosterTag" @update-tag="onUpdateRosterTag" />
|
||
<div v-if="(skillDialog.skills || []).length" class="text-caption text-green-7 q-mt-xs row items-center no-wrap" style="gap:3px"><q-icon name="cloud_done" size="13px" />Enregistré automatiquement — pas de bouton à cliquer.</div>
|
||
<q-separator class="q-my-sm" />
|
||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🗓 Horaire</div>
|
||
<div class="row items-center q-gutter-xs">
|
||
<q-btn dense outline size="sm" no-caps color="indigo-7" icon="event_note" label="Générer l'horaire…" @click="openSchedGen(skillDialog)"><q-tooltip>Ouvre l'assistant de quarts (modèles 5×8h · 4×10h · 3×12h + heures/jour + N semaines) → publie les quarts. Ce n'est PAS un bouton « enregistrer ».</q-tooltip></q-btn>
|
||
<q-btn dense outline size="sm" no-caps color="primary" label="5×8h rapide" @click="applyWeekPreset(skillDialog, [1,2,3,4,5], 8, 16)"><q-tooltip>Applique 5×8h à la semaine affichée (à publier)</q-tooltip></q-btn>
|
||
<q-btn dense outline size="sm" no-caps color="deep-orange-7" icon="event_busy" label="Bloquer du temps…" @click="openReserve(skillDialog)"><q-tooltip>Bloquer du temps (formation · réunion · projet · entretien…) : un job/ticket qui occupe la journée → dé-priorise au dispatch. Priorité Urgent = bloc dur ; sinon les urgences peuvent quand même passer.</q-tooltip></q-btn>
|
||
</div>
|
||
<q-separator class="q-my-sm" />
|
||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🏠 Domicile <span class="text-grey-5 text-weight-regular">(départ de tournée si plus proche que le dépôt)</span></div>
|
||
<div class="row items-center no-wrap q-gutter-xs">
|
||
<q-btn dense outline size="sm" no-caps color="teal" :icon="techHomes[skillDialog.id] ? 'edit_location_alt' : 'add_location_alt'" :label="techHomes[skillDialog.id] ? 'Modifier' : 'Définir'" @click="openHomePicker(skillDialog)" />
|
||
<span class="text-caption text-grey-6 ellipsis col">{{ techHomes[skillDialog.id] && techHomes[skillDialog.id].address ? techHomes[skillDialog.id].address : 'non défini' }}</span>
|
||
</div>
|
||
<q-separator class="q-my-sm" />
|
||
<!-- 📍 Appareil GPS (Traccar) — même contrôle que l'éditeur de jour, ici accessible directement depuis le tech -->
|
||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">📍 Appareil GPS <span class="text-grey-5 text-weight-regular">(Traccar — suivi live + tracé réel)</span></div>
|
||
<div class="row items-center no-wrap q-gutter-xs">
|
||
<template v-if="deviceOf(skillDialog)">
|
||
<q-chip dense :color="deviceOf(skillDialog).missing ? 'orange-1' : 'green-1'" :text-color="deviceOf(skillDialog).missing ? 'orange-9' : 'green-9'" icon="place" class="text-weight-medium">
|
||
<q-icon name="circle" size="8px" :color="deviceOf(skillDialog).online ? 'positive' : 'grey-5'" class="q-mr-xs" />{{ deviceOf(skillDialog).label }}
|
||
<q-tooltip>{{ deviceOf(skillDialog).online ? 'En ligne' : 'Hors ligne' }}{{ deviceOf(skillDialog).uniqueId ? ' · ' + deviceOf(skillDialog).uniqueId : '' }}</q-tooltip>
|
||
</q-chip>
|
||
<q-btn flat dense no-caps size="sm" color="indigo" icon="swap_horiz" label="Changer"><q-tooltip>Relier à un autre véhicule</q-tooltip>
|
||
<q-menu anchor="bottom left" self="top left"><div class="q-pa-xs" style="min-width:250px"><q-input dense outlined v-model="deviceFilter" placeholder="Filtrer les véhicules…" clearable autofocus class="q-mb-xs" /><q-list dense style="max-height:40vh;overflow:auto"><q-item v-for="o in deviceOptions" :key="o.value" clickable v-close-popup @click="onPickDevice(skillDialog, o.value)" :active="deviceOf(skillDialog) && deviceOf(skillDialog).value === o.value" active-class="bg-green-1"><q-item-section avatar style="min-width:22px"><q-icon name="circle" size="9px" :color="o.online ? 'positive' : 'grey-4'" /></q-item-section><q-item-section><q-item-label>{{ o.label }}</q-item-label><q-item-label v-if="o.uniqueId" caption>{{ o.uniqueId }}</q-item-label></q-item-section></q-item><q-item v-if="!deviceOptions.length"><q-item-section class="text-grey-6">{{ devicesLoading ? 'Chargement…' : 'Aucun appareil' }}</q-item-section></q-item></q-list></div></q-menu>
|
||
</q-btn>
|
||
<q-btn flat dense round size="sm" icon="link_off" color="grey-6" @click="onPickDevice(skillDialog, null)"><q-tooltip>Dissocier l'appareil GPS</q-tooltip></q-btn>
|
||
</template>
|
||
<template v-else>
|
||
<span class="text-caption text-grey-6">non associé</span>
|
||
<q-btn v-if="deviceSuggestOf(skillDialog)" dense unelevated size="sm" no-caps color="primary" icon="link" :label="'Associer ' + deviceSuggestOf(skillDialog).label" @click="onPickDevice(skillDialog, deviceSuggestOf(skillDialog).value)"><q-tooltip>Appareil probable (nom similaire au tech)</q-tooltip></q-btn>
|
||
<q-btn flat dense no-caps size="sm" color="indigo" icon="add_location_alt" label="Choisir"><q-tooltip>Relier un véhicule Traccar</q-tooltip>
|
||
<q-menu anchor="bottom left" self="top left"><div class="q-pa-xs" style="min-width:250px"><q-input dense outlined v-model="deviceFilter" placeholder="Filtrer les véhicules…" clearable autofocus class="q-mb-xs" /><q-list dense style="max-height:40vh;overflow:auto"><q-item v-for="o in deviceOptions" :key="o.value" clickable v-close-popup @click="onPickDevice(skillDialog, o.value)"><q-item-section avatar style="min-width:22px"><q-icon name="circle" size="9px" :color="o.online ? 'positive' : 'grey-4'" /></q-item-section><q-item-section><q-item-label>{{ o.label }}</q-item-label><q-item-label v-if="o.uniqueId" caption>{{ o.uniqueId }}</q-item-label></q-item-section></q-item><q-item v-if="!deviceOptions.length"><q-item-section class="text-grey-6">{{ devicesLoading ? 'Chargement…' : 'Aucun appareil' }}</q-item-section></q-item></q-list></div></q-menu>
|
||
</q-btn>
|
||
</template>
|
||
</div>
|
||
<q-separator class="q-my-sm" />
|
||
<q-btn dense unelevated size="sm" no-caps color="deep-purple" icon="route" label="Voir sa tournée" class="full-width" @click="viewTechTournee(skillDialog)"><q-tooltip>Passe en vue Tournées et isole la tournée de ce tech sur la carte</q-tooltip></q-btn>
|
||
</div>
|
||
</q-menu>
|
||
|
||
<!-- Impact d'un retrait de compétence : jobs assignés devenus invalides → redistribuer -->
|
||
<q-dialog v-model="skillImpactOpen">
|
||
<q-card style="min-width:420px;max-width:520px" v-if="skillImpactDialog">
|
||
<q-card-section class="row items-center q-pb-none">
|
||
<q-icon name="warning" color="orange" size="22px" class="q-mr-sm" />
|
||
<div class="text-subtitle1 text-weight-bold col">{{ skillImpactDialog.kind === 'absence' ? 'Absence — impact' : 'Compétence retirée — impact' }}</div>
|
||
<q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section>
|
||
<div v-if="skillImpactDialog.kind === 'absence'" class="text-body2 q-mb-sm"><b>{{ skillImpactDialog.jobs.length }}</b> job(s) assigné(s) à <b>{{ skillImpactDialog.tech.name }}</b> tombent sur son absence — à redistribuer.</div>
|
||
<div v-else class="text-body2 q-mb-sm"><b>{{ skillImpactDialog.jobs.length }}</b> job(s) assigné(s) à <b>{{ skillImpactDialog.tech.name }}</b> exigent « <b>{{ skillImpactDialog.skill }}</b> », qu'on vient de retirer — il/elle ne peut plus les faire.</div>
|
||
<q-list dense bordered class="rounded-borders" style="max-height:320px;overflow:auto">
|
||
<q-item v-for="j in skillImpactDialog.jobs" :key="j.name" class="q-py-sm">
|
||
<q-item-section>
|
||
<q-item-label>{{ j.service_type || 'Job' }}{{ j.customer_name ? ' — ' + j.customer_name : '' }}</q-item-label>
|
||
<q-item-label caption><q-icon name="event" size="12px" /> {{ j.scheduled_date ? j.scheduled_date.slice(5) : '—' }} {{ j.start_time ? j.start_time.slice(0, 5) : '' }} · {{ j.location_label || j.service_location || '—' }}</q-item-label>
|
||
</q-item-section>
|
||
<q-item-section side style="min-width:188px">
|
||
<q-select dense outlined options-dense :model-value="impactPlan[j.name]" :options="candidateOptions(j.name)" emit-value map-options :loading="loadingCandidates" label="Réassigner à" @update:model-value="v => { impactPlan[j.name] = v }" />
|
||
</q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
<div class="text-caption text-grey-6 q-mt-sm">Choix <b>classés</b> = techs qualifiés <b>libres au même créneau</b> (le 1er = suggéré). « À recontacter » → file RDV (créneaux filtrés, SMS client possible).</div>
|
||
</q-card-section>
|
||
<q-card-actions align="right">
|
||
<q-btn flat label="Ignorer" v-close-popup :disable="redistributing" />
|
||
<q-btn outline color="grey-8" no-caps label="Tout à recontacter" :loading="redistributing" @click="doRedistribute('requeue')" />
|
||
<q-btn unelevated color="primary" icon="check" no-caps label="Appliquer" :loading="redistributing" @click="applyImpactPlan" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Gestionnaire global des compétences (tags) : renommer / recolorer / supprimer partout -->
|
||
<q-dialog v-model="showTagManager">
|
||
<q-card style="min-width:420px;max-width:540px">
|
||
<q-card-section class="row items-center q-pb-none">
|
||
<div class="text-subtitle1 text-weight-bold col">🏷 Gérer les compétences</div>
|
||
<q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section style="max-height:62vh;overflow:auto">
|
||
<div v-if="!managedTags.length" class="text-grey-6">Aucune compétence — ajoute-en via un employé.</div>
|
||
<q-list v-else dense separator>
|
||
<q-item v-for="tg in managedTags" :key="tg.label">
|
||
<q-item-section avatar>
|
||
<q-btn flat dense round size="sm" icon="circle" :style="{ color: tg.color }"><q-tooltip>Couleur</q-tooltip>
|
||
<q-menu><div class="q-pa-xs" style="width:208px">
|
||
<div class="row"><q-btn v-for="c in TAG_PALETTE" :key="c" v-close-popup flat dense round size="xs" icon="circle" :style="{ color: c }" @click="onUpdateRosterTag({ name: tg.label, color: c })" /></div>
|
||
<div class="row items-center no-wrap q-mt-xs q-px-xs"><span class="text-caption text-grey-7 q-mr-sm">Perso</span><input type="color" :value="tg.color" @change="e => onUpdateRosterTag({ name: tg.label, color: e.target.value })" style="width:42px;height:26px;border:1px solid #ddd;border-radius:4px;background:none;cursor:pointer;padding:0" /></div>
|
||
</div></q-menu>
|
||
</q-btn>
|
||
</q-item-section>
|
||
<q-item-section>
|
||
<q-item-label class="cursor-pointer">{{ tg.label }}
|
||
<q-popup-edit :model-value="tg.label" auto-save v-slot="scope" @save="v => renameTagGlobal(tg.label, v)">
|
||
<q-input dense autofocus :model-value="scope.value" @update:model-value="scope.value = $event" label="Renommer (partout)" @keyup.enter="scope.set" />
|
||
</q-popup-edit>
|
||
</q-item-label>
|
||
<q-item-label caption>{{ tg.count }} technicien(s) · clic = renommer</q-item-label>
|
||
</q-item-section>
|
||
<q-item-section side>
|
||
<q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="deleteTagGlobal(tg)"><q-tooltip>Supprimer partout</q-tooltip></q-btn>
|
||
</q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
<div class="text-caption text-grey-6 q-mt-sm">Renommer/supprimer s'applique à <b>tous les techniciens</b> qui ont la compétence. Recolorer change la couleur partout.</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Panneau FLOTTANT déplaçable : jobs à assigner (groupes parent-enfant) → glisser sur une case (tech × jour) -->
|
||
<div v-if="assignPanel.open" class="assign-panel" :style="{ left: assignPanel.x + 'px', top: assignPanel.y + 'px', width: assignPanel.w + 'px', height: assignPanel.h + 'px', maxHeight: '92vh' }">
|
||
<div class="assign-hdr" @mousedown="panelHeaderDown">
|
||
<q-icon name="drag_indicator" size="18px" /><span>Jobs à assigner ({{ floatingPool.skillFilter.length ? floatingPool.filteredJobs.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>
|
||
<!-- 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 v-if="assignPanel.loading" class="text-grey-6 q-pa-md text-center">Chargement…</div>
|
||
<JobPool v-else variant="floating" :pool="floatingPool" />
|
||
<div v-if="assignPanel.jobs.length" class="assign-foot">
|
||
<template v-if="selectedNames.length"><b>{{ selectedNames.length }}</b> sélectionné(s) · <b>{{ selectedHours }}h</b>
|
||
<q-btn dense unelevated size="sm" color="primary" icon="person_add" :label="'Assigner (' + selectedNames.length + ')'" class="q-ml-sm">
|
||
<q-tooltip>Assigner TOUTE la sélection à un technicien (chaque job garde sa date) — classés par compétence · dispo · distance · charge</q-tooltip>
|
||
<q-menu anchor="top right" self="bottom right">
|
||
<div class="assign-pick-hd">Assigner {{ selectedNames.length }} job(s) · {{ selectedHours }}h à…</div>
|
||
<q-list dense style="min-width:280px;max-height:46vh;overflow:auto">
|
||
<q-item v-for="tk in techsForSelection()" :key="tk.id" clickable v-close-popup @click="quickAssignSelection(tk)" :class="{ 'assign-pick-incap': !tk.capable }">
|
||
<q-item-section avatar style="min-width:32px"><q-avatar size="26px" :color="tk.capable ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:10px;font-weight:700">{{ initials(tk.name) }}</q-avatar></q-item-section>
|
||
<q-item-section>
|
||
<q-item-label>{{ tk.name }}<q-icon v-if="!tk.capable" name="warning" size="12px" color="orange" class="q-ml-xs"><q-tooltip>Manque une compétence requise par la sélection</q-tooltip></q-icon></q-item-label>
|
||
<q-item-label caption><span v-if="tk.distKm != null">📍 {{ Math.round(tk.distKm) }} km</span><span v-else class="text-grey-5">dist. ?</span> · {{ tk.h }}/{{ tk.cap }}h<span v-if="tk.noShift" class="text-warning"> · ▲ sans quart</span><span v-else-if="tk.over" class="text-negative"> · surchargé</span></q-item-label>
|
||
</q-item-section>
|
||
<q-item-section side><q-icon name="chevron_right" size="16px" color="grey-5" /></q-item-section>
|
||
</q-item>
|
||
<q-item v-if="!techsForSelection().length"><q-item-section class="text-grey-6 text-caption">Aucun technicien visible.</q-item-section></q-item>
|
||
</q-list>
|
||
</q-menu>
|
||
</q-btn>
|
||
<q-btn dense flat size="sm" color="primary" icon="edit_calendar" label="Date" class="q-ml-sm"><q-tooltip>Replanifier toute la sélection (aujourd'hui / demain / date)</q-tooltip><q-menu><q-list dense style="min-width:160px"><q-item clickable v-close-popup @click="setSelDate(todayISO())"><q-item-section avatar style="min-width:28px"><q-icon name="today" size="18px" color="primary" /></q-item-section><q-item-section>Aujourd'hui</q-item-section></q-item><q-item clickable v-close-popup @click="setSelDate(tomorrowISO())"><q-item-section avatar style="min-width:28px"><q-icon name="event" size="18px" color="primary" /></q-item-section><q-item-section>Demain</q-item-section></q-item><q-separator /><q-date :model-value="todayISO()" @update:model-value="v => setSelDate(v)" mask="YYYY-MM-DD" minimal today-btn /></q-list></q-menu></q-btn>
|
||
<q-btn dense unelevated size="sm" color="negative" icon="block" :label="'Fermer (' + selectedNames.length + ')'" class="q-ml-sm" @click="bulkCloseSelected"><q-tooltip>Fermer les tickets sélectionnés dans le legacy (au lieu de réassigner)</q-tooltip></q-btn></template>
|
||
<template v-else>Coche des jobs (groupe lié surligné), puis glisse la sélection sur une case <b>tech × jour</b>. <q-icon name="home_repair_service" size="11px" color="teal" />=terrain · <q-icon name="cloud" size="11px" color="grey-5" />=à distance · 🔒=bloqué.</template>
|
||
</div>
|
||
<div class="assign-resize" @mousedown.stop="panelResizeDown"><q-tooltip>Redimensionner</q-tooltip></div>
|
||
</div>
|
||
|
||
<!-- D : revue de la répartition suggérée (surcouche) — déplacer/retirer avant d'appliquer -->
|
||
<q-dialog v-model="suggestDlg.open" :maximized="$q.screen.lt.sm" @hide="suggestDlg.building = false">
|
||
<q-card :style="$q.screen.lt.sm ? 'width:100%;max-width:100%;height:100%;display:flex;flex-direction:column' : 'width:940px;max-width:96vw;max-height:90vh;display:flex;flex-direction:column'">
|
||
<q-card-section class="row items-center q-py-sm" style="border-bottom:1px solid #e2e8f0">
|
||
<q-icon name="auto_awesome" color="amber-7" size="22px" class="q-mr-sm" />
|
||
<div><div class="text-subtitle1 text-weight-bold">{{ suggestDlg.mode === 'config' ? 'Techniciens disponibles' : 'Répartition suggérée' }}</div><div class="text-caption text-grey-7"><template v-if="suggestDlg.mode === 'config'">Choisis qui participe au dispatch auto{{ suggestDlg.fromSel ? ' de la sélection' : '' }} · {{ suggestSelCount }} sélectionné(s)</template><template v-else>{{ suggestDlg.plan.length }} job(s) · {{ suggestGroups.length }} tech(s){{ suggestDlg.fromSel ? ' · sélection' : '' }} — proximité + charge + compétence. Ajuste puis applique.</template></div></div>
|
||
<q-space />
|
||
<q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="col scroll q-pa-sm" style="overflow:auto">
|
||
<!-- Étape 1 — disponibilité des techniciens + création de quarts 8–16 -->
|
||
<template v-if="suggestDlg.mode === 'config'">
|
||
<div class="row items-start q-mb-sm text-caption text-grey-7" style="gap:6px;background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:7px 10px">
|
||
<q-icon name="auto_awesome" color="amber-8" size="17px" />
|
||
<span>Répartition automatique — compétences · <b>efficacité par compétence</b> · distance (routes réelles) · fenêtres AM/PM · priorités. Les cas simples vont d'abord aux techs les <b>moins polyvalents</b> (réserve les experts). Tu revois et ajustes avant d'appliquer.</span>
|
||
</div>
|
||
<!-- Simulation « ré-optimiser l'existant » : inclut AUSSI les jobs déjà assignés de la fenêtre (le solveur les replace) → comparer/améliorer une tournée déjà dispatchée, avec les nouveaux jobs. Rien n'est écrit avant « Appliquer ». -->
|
||
<div class="row items-center q-mb-sm" style="gap:6px">
|
||
<q-toggle v-model="suggestDlg.includeAssigned" dense size="sm" color="deep-purple" label="Ré-optimiser aussi les jobs déjà assignés (simulation)" />
|
||
<q-icon name="info" size="15px" color="grey-5"><q-tooltip class="bg-grey-9" style="font-size:11px">Inclut les {{ assignedNamesForWindow().length }} job(s) déjà assigné(s) du/des jour(s) → le solveur repropose une répartition de TOUT (existant + nouveaux). Force le solveur VRP. Aperçu seulement — « Appliquer » réassigne.</q-tooltip></q-icon>
|
||
</div>
|
||
<!-- FENÊTRE de répartition = LE jour sélectionné (selDay partagé entre les vues ; modifiable ici). Lasso = seule exception (jobs choisis à la main). -->
|
||
<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 (peut inclure des retards)' : 'chips de compétence du 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) DUS ce jour · retards/sans-date exclus — pour en dispatcher un : le sélectionner à la main dans le pool</span>
|
||
</template>
|
||
</div>
|
||
<div class="row items-center q-mb-xs" style="gap:4px">
|
||
<span class="text-caption text-grey-7 q-mr-xs">Rapide :</span>
|
||
<q-btn dense flat size="sm" no-caps label="Tous" @click="suggestSelectTechs('all')" />
|
||
<q-btn dense flat size="sm" no-caps label="Avec quart" @click="suggestSelectTechs('shift')" />
|
||
<q-btn dense flat size="sm" no-caps label="Aucun" @click="suggestSelectTechs('none')" />
|
||
</div>
|
||
<div class="row items-center q-mb-sm" style="gap:5px;flex-wrap:wrap">
|
||
<span class="text-caption text-grey-7 q-mr-xs">Par compétence :</span>
|
||
<span v-for="s in suggestSkillGroups" :key="s.skill" class="suggest-skill-chip" :class="{ on: suggestSkillOn(s.skill) }" :style="suggestSkillOn(s.skill) ? { background: getTagColor(s.skill), color: '#fff', borderColor: getTagColor(s.skill) } : { borderColor: getTagColor(s.skill), color: getTagColor(s.skill) }" @click="suggestSelectSkill(s.skill)">{{ s.skill }} <b>{{ s.n }}</b></span>
|
||
</div>
|
||
<!-- Groupes de techs enregistrés (sélection manuelle sauvée : Installateurs, Épisseurs…) — utile quand la compétence déduite du job est imprécise. -->
|
||
<div class="row items-center q-mb-sm" style="gap:5px;flex-wrap:wrap">
|
||
<span class="text-caption text-grey-7 q-mr-xs">Groupes :</span>
|
||
<span v-for="g in techGroups" :key="g.name" class="suggest-grp-chip" @click="applyTechGroup(g)"><q-icon name="groups" size="13px" />{{ g.name }} <b>{{ (g.ids || []).length }}</b><q-icon name="close" size="12px" class="suggest-grp-x" @click.stop="deleteTechGroup(g)"><q-tooltip>Supprimer ce groupe</q-tooltip></q-icon><q-tooltip>Sélectionner ce groupe de techniciens</q-tooltip></span>
|
||
<q-btn dense flat size="sm" no-caps icon="bookmark_add" label="Enregistrer" color="primary" @click="saveTechGroup"><q-tooltip>Enregistrer la sélection actuelle comme groupe réutilisable</q-tooltip></q-btn>
|
||
<span v-if="suggestSelCount" class="suggest-grp-chip suggest-grp-clear" @click="suggestSelectTechs('none')"><q-icon name="close" size="13px" />Tout désélectionner</span>
|
||
<span v-if="!techGroups.length" class="text-caption text-grey-5">— enregistre une sélection (ex. Installateurs, Épisseurs)</span>
|
||
</div>
|
||
<div class="suggest-tech-grid">
|
||
<div v-for="t in suggestTechsRanked" :key="t.id" class="suggest-tech-chip" :class="{ on: !!suggestDlg.techSel[t.id] }" @click="suggestDlg.techSel[t.id] = !suggestDlg.techSel[t.id]">
|
||
<q-icon :name="suggestDlg.techSel[t.id] ? 'check_circle' : 'radio_button_unchecked'" size="16px" :color="suggestDlg.techSel[t.id] ? 'primary' : 'grey-5'" />
|
||
<q-avatar size="22px" color="blue-grey-1" text-color="blue-grey-8" style="font-size:9px;font-weight:700">{{ initials(t.name) }}</q-avatar>
|
||
<span class="ellipsis" style="flex:1">{{ t.name }}</span>
|
||
<q-icon v-if="(Number(t.efficiency) || 1) < 0.95" name="bolt" size="14px" color="green-6"><q-tooltip>Rapide (efficacité {{ t.efficiency }})</q-tooltip></q-icon>
|
||
<q-icon v-else-if="(Number(t.efficiency) || 1) > 1.05" name="hourglass_bottom" size="13px" color="orange-6"><q-tooltip>Plus lent (efficacité {{ t.efficiency }})</q-tooltip></q-icon>
|
||
<q-icon v-if="!(dayList || []).some(d => hasShiftDay(t.id, d.iso))" name="event_busy" size="13px" color="amber-7"><q-tooltip>Aucun quart publié cette période</q-tooltip></q-icon>
|
||
<q-icon name="tune" size="14px" color="grey-6" class="cursor-pointer" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Réglages du tech — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée</q-tooltip></q-icon>
|
||
</div>
|
||
</div>
|
||
<q-toggle v-model="suggestDlg.makeShifts" dense size="sm" color="primary" class="q-mt-sm" label="Créer un quart 8–16 pour les techs sans quart (à l'application)" />
|
||
</template>
|
||
<!-- Étape 2 — revue de la répartition proposée -->
|
||
<template v-else>
|
||
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
|
||
<q-btn-toggle v-model="suggestDlg.view" dense no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8" :options="[{ label: 'Liste', value: 'list', icon: 'view_list' }, { label: 'Carte des tournées', value: 'map', icon: 'map' }]" />
|
||
<template v-if="suggestDlg.view === 'map' && suggestWindow.length > 1">
|
||
<q-space />
|
||
<span class="text-caption text-grey-7">Jour :</span>
|
||
<q-btn-toggle v-model="suggestMapDay" dense no-caps unelevated toggle-color="indigo" color="grey-3" text-color="grey-8" :options="suggestWindow.map(iso => ({ label: windowDayLabel(iso), value: iso }))" />
|
||
</template>
|
||
</div>
|
||
<div v-if="suggestDlg.view === 'map'">
|
||
<RouteMap :routes="suggestMapRoutes" height="440px" @metrics="onRouteMetrics" @stop-click="onReviewStopClick" />
|
||
<div class="suggest-legend">
|
||
<span v-for="r in suggestRoutes" :key="r.techId" class="suggest-leg"><span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.techName }} · {{ r.stops.length }} arrêt(s)<span v-if="realRoutes[r.techId]"> · 🚗 {{ realRoutes[r.techId].km }} km / {{ realRoutes[r.techId].mins }} min</span><span v-else-if="r.km"> · 🚗 ≈{{ r.km }} km</span></span>
|
||
<span v-if="!suggestRoutes.length" class="text-grey-6" style="font-size:12px">Aucun job géolocalisé ce jour (utilise « Localiser » sur le pool pour les placer sur la carte).</span>
|
||
</div>
|
||
</div>
|
||
<div v-show="suggestDlg.view !== 'map'">
|
||
<div v-if="!suggestDlg.plan.length" class="text-grey-6 text-center q-pa-lg">Rien à répartir (aucun job éligible).</div>
|
||
<div v-for="g in suggestGroups" :key="g.techId" class="suggest-tech" :class="{ 'suggest-wknd': g.placeholder }">
|
||
<div class="suggest-tech-hd">
|
||
<q-avatar size="24px" :color="g.placeholder ? 'amber-2' : 'blue-grey-1'" :text-color="g.placeholder ? 'amber-9' : 'blue-grey-8'" style="font-size:10px;font-weight:700"><q-icon v-if="g.placeholder" name="hourglass_top" size="15px" /><template v-else>{{ initials(g.techName) }}</template></q-avatar>
|
||
<b class="q-ml-sm">{{ g.techName }}</b>
|
||
<!-- Compétences du tech DANS L'ORDRE de priorité (1re = ★ principale) — clic = éditer/réordonner (même éditeur) -->
|
||
<span v-if="!g.placeholder && techById[g.techId]" class="suggest-hd-skills q-ml-xs" @click.stop="openSkillEditorFor(g.techId, $event)">
|
||
<span v-for="(sk, si) in (techById[g.techId].skills || []).slice(0, 2)" :key="sk" class="assign-skill" :style="{ background: getTagColor(sk), opacity: si === 0 ? 1 : 0.75 }">{{ si === 0 ? '★ ' : '' }}{{ sk }}</span>
|
||
<span v-if="(techById[g.techId].skills || []).length > 2" class="suggest-hd-more">+{{ (techById[g.techId].skills || []).length - 2 }}</span>
|
||
<q-tooltip>Ordre de priorité : {{ (techById[g.techId].skills || []).join(' → ') || 'aucune compétence' }} — clic pour éditer/réordonner</q-tooltip>
|
||
</span>
|
||
<span class="text-grey-6 q-ml-sm" style="font-size:12px">{{ g.jobs }} job(s) · {{ Math.round(g.hours * 10) / 10 }}h<span v-if="g.km"> · 🚗 ≈{{ g.km }} km / {{ g.mins }} min</span><span v-if="!g.placeholder && g.existing > 0" class="text-orange-8" title="Heures déjà assignées à ce tech ces jours-là"> · déjà {{ g.existing }}h</span><span v-if="g.sectors && g.sectors.length" class="text-teal-8" title="Secteur(s) où ce tech a DÉJÀ des jobs assignés"> · 📍 {{ g.sectors.slice(0, 2).join(', ') }}<template v-if="g.sectors.length > 2"> +{{ g.sectors.length - 2 }}</template></span><span v-if="g.placeholder" class="text-amber-9"> · file à assigner</span></span>
|
||
<!-- Occupation projetée = charge déjà assignée + heures suggérées / capacité (quarts réels ou 8h) -->
|
||
<div v-if="!g.placeholder && g.cap" class="suggest-occ" :title="'Occupation projetée ' + g.projected + 'h / ' + g.cap + 'h (déjà ' + g.existing + 'h assigné + ' + (Math.round(g.hours*10)/10) + 'h proposé)'">
|
||
<div class="suggest-occ-bar"><div class="suggest-occ-fill" :class="{ over: g.over, high: !g.over && g.fillPct >= 85 }" :style="{ width: g.fillPct + '%' }"></div></div>
|
||
<span class="suggest-occ-txt" :class="{ 'text-negative': g.over }">{{ g.projected }}/{{ g.cap }}h</span>
|
||
</div>
|
||
<q-icon v-if="g.over" name="error" color="negative" size="16px" class="q-ml-xs"><q-tooltip>Surcharge : {{ g.projected }}h projetées > {{ g.cap }}h de capacité (inclut le déjà-assigné)</q-tooltip></q-icon>
|
||
<q-space />
|
||
<!-- Quart manquant (Q2 §1) : alerte + créer le quart 8-16 (local, enregistré au Publier) -->
|
||
<q-btn v-if="g.noShiftDays && g.noShiftDays.length" flat dense no-caps size="sm" color="amber-8" icon="event_busy" :label="'Créer quart' + (g.noShiftDays.length > 1 ? ' ×' + g.noShiftDays.length : '')" @click="createShiftForGroup(g)"><q-tooltip>{{ g.techName }} n'a pas de quart {{ g.noShiftDays.length > 1 ? 'sur ' + g.noShiftDays.length + ' jour(s)' : 'ce jour' }} — créer un quart 8-16</q-tooltip></q-btn>
|
||
<!-- Point de DÉPART : 🏠 domicile défini ou 🏢 bureau TARGO (défaut) — clic = définir/modifier sur la carte -->
|
||
<q-btn v-if="!g.placeholder" flat dense round size="sm" :icon="techHomes[g.techId] ? 'home' : 'business'" :color="techHomes[g.techId] ? 'teal-7' : 'blue-grey-4'" @click="openHomePicker(techById[g.techId] || { id: g.techId, name: g.techName })"><q-tooltip>Départ : {{ techHomes[g.techId] ? ('domicile — ' + (techHomes[g.techId].address || 'défini')) : 'bureau TARGO (défaut)' }} — cliquer pour définir/modifier le domicile</q-tooltip></q-btn>
|
||
<!-- Éditer / réordonner les compétences du tech directement depuis l'optimiseur (même éditeur : chips ★ + glisser priorité) -->
|
||
<q-btn v-if="!g.placeholder" flat dense round size="sm" icon="edit" color="grey-6" @click="openSkillEditorFor(g.techId, $event)"><q-tooltip>Compétences & niveaux de {{ g.techName }} — glisser pour l'ordre de priorité</q-tooltip></q-btn>
|
||
<!-- Tech malade/absent → le retire ET redistribue ses jobs équitablement sur les autres (ré-optimise) -->
|
||
<q-btn v-if="!g.placeholder" flat dense round size="sm" icon="sick" color="deep-orange-4" @click="markTechSick(g)"><q-tooltip>{{ g.techName }} malade / absent — retirer et redistribuer ses {{ g.jobs }} job(s) sur les autres</q-tooltip></q-btn>
|
||
<!-- Assigner / fusionner TOUTE la file (marche depuis un placeholder → vrai tech, ou pour fusionner 2 files) -->
|
||
<q-btn flat dense round size="sm" :icon="g.placeholder ? 'person_add' : 'drive_file_move'" :color="g.placeholder ? 'amber-9' : 'grey-7'"><q-tooltip>{{ g.placeholder ? 'Assigner cette tournée à un tech' : ('Déplacer / fusionner tous les jobs de ' + g.techName) }}</q-tooltip>
|
||
<q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:250px;max-height:42vh;overflow:auto">
|
||
<q-item-label header class="q-py-xs">{{ g.placeholder ? 'Assigner les' : 'Déplacer les' }} {{ g.jobs }} job(s) à…<span v-if="g.skills && g.skills.length" class="text-grey-6"> (compétence {{ g.skills.join(', ') }})</span></q-item-label>
|
||
<q-item v-for="t in capableTechsFirst(g.skills)" :key="t.id" clickable v-close-popup @click="moveSuggestGroup(g, t.id, t.name)" :disable="t.id === g.techId || (!covers(t, g.skills) && anyCovers(g.skills))">
|
||
<q-item-section avatar style="min-width:30px"><q-avatar size="22px" :color="covers(t, g.skills) ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:9px;font-weight:700">{{ initials(t.name) }}</q-avatar></q-item-section>
|
||
<q-item-section>{{ t.name }}<span v-if="planCountByTech[t.id] && t.id !== g.techId" class="text-teal-7" style="font-size:11px"> · fusionner ({{ planCountByTech[t.id] }})</span></q-item-section>
|
||
<q-item-section side v-if="!covers(t, g.skills)"><q-icon name="block" color="grey-5" size="15px"><q-tooltip>Sans la compétence requise</q-tooltip></q-icon></q-item-section>
|
||
</q-item>
|
||
<q-separator v-if="!showAllMoveTechs" />
|
||
<q-item v-if="!showAllMoveTechs" clickable @click="showAllMoveTechs = true"><q-item-section class="text-primary" style="font-size:12px"><q-icon name="expand_more" size="14px" class="q-mr-xs" />Afficher tous les techniciens…</q-item-section></q-item>
|
||
</q-list></q-menu>
|
||
</q-btn>
|
||
</div>
|
||
<div v-for="d in g.dayList" :key="d.iso" class="suggest-day">
|
||
<div class="suggest-day-lbl">{{ d.label }} <span class="text-grey-6">· {{ Math.round(d.hours * 10) / 10 }}h<span v-if="d.km"> · 🚗 ≈{{ d.km }} km / {{ d.mins }} min</span></span>
|
||
<!-- Jobs DÉJÀ assignés (occupation) : cliquer pour voir — permet de s'ajuster au secteur existant du tech -->
|
||
<span v-if="d.prevJobs && d.prevJobs.length" class="suggest-prev-toggle" @click="prevOpen[g.techId + '|' + d.iso] = !prevOpen[g.techId + '|' + d.iso]">
|
||
🔒 {{ d.prevJobs.length }} déjà assigné(s)<template v-if="d.prevCities.length"> · {{ d.prevCities.slice(0, 2).join(', ') }}</template>
|
||
<q-icon :name="prevOpen[g.techId + '|' + d.iso] ? 'expand_less' : 'expand_more'" size="13px" />
|
||
</span>
|
||
</div>
|
||
<template v-if="prevOpen[g.techId + '|' + d.iso]">
|
||
<div v-for="pj in d.prevJobs" :key="'prev' + pj.name" class="suggest-entry suggest-prev">
|
||
<q-icon name="lock" size="11px" color="grey-5" class="q-mr-xs" />
|
||
<span v-if="pj.start" class="text-grey-6 q-mr-xs" style="font-size:11px">{{ pj.start }}</span>
|
||
<span v-if="pj.skill" class="assign-skill" :style="{ background: getTagColor(pj.skill) }">{{ pj.skill }}</span>
|
||
<span class="suggest-entry-t ellipsis text-grey-7">{{ pj.subject }}<span v-if="pj.customer" class="text-grey-5"> · {{ pj.customer }}</span></span>
|
||
<span class="text-grey-5 q-mx-xs" style="font-size:11px;white-space:nowrap">{{ Math.round((pj.dur || 0) * 10) / 10 }}h</span>
|
||
</div>
|
||
</template>
|
||
<template v-for="(e, ei) in d.entries" :key="e.jobName">
|
||
<div v-if="ei === 0 && legHome(g, d) != null" class="suggest-leg-time">🚗 {{ (legsByKey[g.techId + '|' + d.iso] || {}).kind === 'depot' ? '🏢 bureau TARGO' : '🏠 domicile' }} → 1er arrêt : {{ legHome(g, d) }} min</div>
|
||
<div class="suggest-entry">
|
||
<span class="suggest-step">{{ ei + 1 }}</span>
|
||
<span v-if="e.skill" class="assign-skill" :style="{ background: getTagColor(e.skill) }">{{ e.skill }}</span>
|
||
<span v-if="e.lvl" class="suggest-mlvl" :title="'Maîtrise du tech : ' + e.lvl + (e.reqLevel > 1 ? ' / niveau requis ' + e.reqLevel : '')">★{{ e.lvl }}<span v-if="e.reqLevel > 1" style="opacity:.6">/{{ e.reqLevel }}</span></span>
|
||
<q-icon v-if="e.eff && e.eff < 0.95" name="bolt" size="12px" color="green-6" class="q-mr-xs"><q-tooltip>Tech rapide sur ce type ({{ e.eff }})</q-tooltip></q-icon>
|
||
<span v-if="entrySlaBadge(e)" class="assign-sla q-mr-xs" :class="'sla-' + entrySlaBadge(e).state"><q-icon :name="entrySlaBadge(e).icon" size="11px" />{{ entrySlaBadge(e).short }}<q-tooltip class="bg-grey-9">{{ entrySlaBadge(e).label }}</q-tooltip></span>
|
||
<!-- Coordination d'adresse : 🔗 teal = épinglé sur le tech qui a DÉJÀ un job à cette adresse · 🔗 indigo = N jobs du pool fusionnés en 1 arrêt -->
|
||
<q-icon v-if="e.coordWith" name="link" size="14px" color="teal-7" class="q-mr-xs"><q-tooltip>Coordonné : même adresse que « {{ e.coordWith }} » déjà assigné à {{ e.techName }} — placé sur SON arrêt</q-tooltip></q-icon>
|
||
<q-btn v-else-if="e.sameAddrN > 1" flat dense round size="xs" :icon="e.grouped ? 'inventory_2' : 'link'" :color="e.grouped ? 'indigo-7' : 'indigo-4'" class="q-mr-xs">
|
||
<q-tooltip>{{ e.grouped ? 'Groupé en projet 📦' : e.sameAddrN + ' jobs à la MÊME adresse — 1 seul arrêt · clic pour grouper en projet' }}</q-tooltip>
|
||
<q-menu auto-close><q-list dense style="min-width:300px">
|
||
<q-item clickable @click="groupSameAddr(e, g, d)" :disable="!!e.grouped"><q-item-section avatar style="min-width:32px"><q-icon name="inventory_2" size="18px" color="indigo" /></q-item-section><q-item-section>📦 Grouper en « projet » ({{ e.sameAddrN }} tâches même adresse — parent = la plus longue)</q-item-section></q-item>
|
||
</q-list></q-menu>
|
||
</q-btn>
|
||
<span class="suggest-entry-t ellipsis suggest-clk" @click="openEntryDetail(e, g)" title="Voir le détail du ticket (adresse, description, fil)">{{ e.subject }}<span v-if="e.customer" class="text-grey-6"> · {{ e.customer }}</span></span>
|
||
<span class="suggest-dur q-mx-xs" title="Temps sur place — cliquer pour réviser">{{ Math.round(e.dur * 10) / 10 }}h
|
||
<q-popup-edit :model-value="e.dur" v-slot="scope" buttons label-set="OK" label-cancel="Annuler" @save="v => setEntryDur(e, v)">
|
||
<q-input type="number" v-model.number="scope.value" dense autofocus :step="0.25" :min="0.25" :max="24" suffix="h" label="Temps sur place" @keyup.enter="scope.set" />
|
||
</q-popup-edit>
|
||
</span>
|
||
<q-icon v-if="!e.capable" name="warning" size="14px" color="deep-orange" class="q-mr-xs"><q-tooltip>{{ e.techName }} n'a pas la compétence « {{ e.skill }} » — à réassigner</q-tooltip></q-icon>
|
||
<q-icon v-if="e.noShift" name="event_busy" size="14px" color="amber-8" class="q-mr-xs"><q-tooltip>Aucun quart publié ce jour (assignable, mais ⚠ hors quart)</q-tooltip></q-icon>
|
||
<!-- Contraintes horaires (prises en compte par ⚡ Ré-optimiser) : AM/PM = fenêtre · ⚡ = urgent, servir en début de journée -->
|
||
<!-- PRIORITÉ persistante (MÊME drapeau que les conversations) : contour gris = non classé, coloré = niveau. Liste rapide. -->
|
||
<q-btn flat dense round size="sm" :icon="priorityMeta(jobPriorityOf(e.jobName)).icon" :color="priorityMeta(jobPriorityOf(e.jobName)).color">
|
||
<q-tooltip>Priorité : {{ priorityMeta(jobPriorityOf(e.jobName)).label }} (persistante — SLA/commercial). Ré-optimiser pour appliquer.</q-tooltip>
|
||
<q-menu auto-close anchor="bottom right" self="top right"><q-list dense style="min-width:180px">
|
||
<q-item-label header class="q-py-xs">Priorité</q-item-label>
|
||
<q-item v-for="lvl in PRIORITY_LEVELS" :key="lvl" clickable @click="setEntryPriority(e.jobName,lvl)" :active="jobPriorityOf(e.jobName) === lvl" active-class="bg-green-1">
|
||
<q-item-section avatar><q-icon :name="priorityMeta(lvl).icon" :color="priorityMeta(lvl).color" size="18px" /></q-item-section>
|
||
<q-item-section><q-item-label>{{ priorityMeta(lvl).label }}</q-item-label><q-item-label caption>{{ priorityMeta(lvl).desc }}</q-item-label></q-item-section>
|
||
<q-item-section side v-if="jobPriorityOf(e.jobName) === lvl"><q-icon name="check" color="primary" size="16px" /></q-item-section>
|
||
</q-item>
|
||
</q-list></q-menu>
|
||
</q-btn>
|
||
<q-icon v-if="jobTimeOf(e.jobName).auto" name="auto_awesome" size="11px" color="amber-7" class="q-mr-xs"><q-tooltip>AM/PM détecté automatiquement depuis le titre/note du ticket</q-tooltip></q-icon>
|
||
<span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'am' }" @click="toggleJobAmpm(e.jobName, 'am')"><q-tooltip>Avant-midi (8-12) — Ré-optimiser pour appliquer</q-tooltip>AM</span>
|
||
<span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'pm' }" @click="toggleJobAmpm(e.jobName, 'pm')"><q-tooltip>Après-midi (12-16) — Ré-optimiser pour appliquer</q-tooltip>PM</span>
|
||
<q-space />
|
||
<q-btn flat dense round size="sm" icon="swap_horiz" color="grey-7"><q-tooltip>Déplacer vers un autre tech (classés par proximité + charge)</q-tooltip>
|
||
<q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:250px;max-height:46vh;overflow:auto">
|
||
<q-item-label header class="q-py-xs">Techs par <b>proximité</b> + <b>charge</b><span v-if="e.skill" class="text-grey-6"> · {{ e.skill }}</span></q-item-label>
|
||
<q-item v-for="r in techsForEntry(e)" :key="r.id" clickable v-close-popup @click="moveSuggestEntry(e, r.id, r.name)" :active="r.id === e.techId" :disable="!r.capable && anyCovers([e.skill])">
|
||
<q-item-section avatar style="min-width:30px"><q-avatar size="22px" :color="r.capable ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:9px;font-weight:700">{{ initials(r.name) }}</q-avatar></q-item-section>
|
||
<q-item-section>{{ r.name }}<div class="text-grey-6" style="font-size:10px">{{ r.distKm != null ? '📍 ≈' + (Math.round(r.distKm * 10) / 10) + ' km' : 'sans domicile' }} · {{ r.h }}/{{ r.cap }}h<span v-if="r.noShift"> · sans quart</span></div></q-item-section>
|
||
<q-item-section side v-if="!r.capable"><q-icon name="block" color="grey-5" size="15px"><q-tooltip>Sans la compétence</q-tooltip></q-icon></q-item-section>
|
||
</q-item>
|
||
<q-separator v-if="!showAllMoveTechs" />
|
||
<q-item v-if="!showAllMoveTechs" clickable @click="showAllMoveTechs = true"><q-item-section class="text-primary" style="font-size:12px"><q-icon name="expand_more" size="14px" class="q-mr-xs" />Afficher tous les techniciens…</q-item-section></q-item>
|
||
</q-list></q-menu>
|
||
</q-btn>
|
||
<q-btn flat dense round size="sm" icon="computer" :color="isJobRemote(e.jobName) ? 'indigo-6' : 'grey-4'" @click="toggleJobRemote(e)"><q-tooltip>{{ isJobRemote(e.jobName) ? '🖥 SANS déplacement (à distance) — Ré-optimiser pour l\'exclure des tournées' : 'Marquer « sans déplacement » (configurable à distance, ex. boîtier tel via ACS)' }}</q-tooltip></q-btn>
|
||
<q-btn flat dense round size="sm" icon="inbox" color="grey-6" @click="removeSuggestEntry(e)"><q-tooltip>Retirer (le job reste au pool)</q-tooltip></q-btn>
|
||
</div>
|
||
<div v-if="legInfo(g, d, ei)" class="suggest-leg-time" :title="legInfo(g, d, ei).real ? 'Temps de route réel (OSRM)' : 'Estimation à vol d\'oiseau'">🚗 {{ legInfo(g, d, ei).mins }} min{{ legInfo(g, d, ei).real ? '' : ' ≈' }}</div>
|
||
</template>
|
||
<!-- Retour au point de départ (le tech revient chez lui / au bureau) — inclus dans la distance/temps de la tournée -->
|
||
<div v-if="legReturn(g, d) != null" class="suggest-leg-time suggest-leg-return">🔄 retour {{ (legsByKey[g.techId + '|' + d.iso] || {}).kind === 'depot' ? '🏢 bureau TARGO' : '🏠 domicile' }} : {{ legReturn(g, d) }} min</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</q-card-section>
|
||
<q-card-actions align="right" style="border-top:1px solid #e2e8f0">
|
||
<q-btn v-if="suggestDlg.mode === 'review'" flat no-caps icon="arrow_back" label="Techniciens" @click="suggestDlg.mode = 'config'" />
|
||
<q-space v-if="suggestDlg.mode === 'review'" />
|
||
<q-btn flat no-caps label="Annuler" v-close-popup />
|
||
<q-btn v-if="suggestDlg.mode === 'review'" outline no-caps color="primary" icon="bolt" label="Ré-optimiser" :loading="suggestDlg.building" @click="reoptimize"><q-tooltip>Relance le solveur VRP en tenant compte des contraintes AM/PM/⚡ posées sur les jobs</q-tooltip></q-btn>
|
||
<q-btn v-if="suggestDlg.mode === 'config'" unelevated no-caps color="primary" icon="auto_awesome" :label="'Générer (' + suggestSelCount + ' tech)'" :loading="suggestDlg.building" :disable="!suggestSelCount" @click="runSuggestion" />
|
||
<q-btn v-else unelevated no-caps color="primary" icon="check" :label="'Appliquer ' + suggestDlg.plan.length + ' assignation(s)'" :loading="suggestDlg.applying" :disable="!suggestDlg.plan.length" @click="applySuggestion" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Write-back legacy : aperçu des réassignations osTicket → confirmer avant d'écrire dans le legacy -->
|
||
<q-dialog v-model="legacyPush.open">
|
||
<q-card style="min-width:540px;max-width:660px">
|
||
<q-card-section class="row items-center q-pb-sm">
|
||
<q-icon name="sync_alt" color="deep-orange" size="22px" class="q-mr-sm" />
|
||
<div>
|
||
<div class="text-subtitle1 text-weight-bold">Publier les assignations au legacy</div>
|
||
<div class="text-caption text-grey-7">Réassigne <code>ticket.assign_to</code> au technicien dans osTicket (selon l'assignation Ops).</div>
|
||
</div>
|
||
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-pt-none">
|
||
<div v-if="legacyPush.loading" class="text-grey-6 q-pa-md text-center"><q-spinner size="20px" class="q-mr-sm" />Analyse des assignations…</div>
|
||
<template v-else-if="legacyPush.preview">
|
||
<q-banner v-if="!legacyPush.preview.pushed" dense rounded class="bg-blue-1 text-info q-mb-sm"><template #avatar><q-icon name="info" /></template>Rien à pousser. Assigne d'abord des jobs à des techs (glisser-déposer), puis reviens ici.</q-banner>
|
||
<div v-else class="text-body2 q-mb-sm"><b class="text-warning">{{ legacyPush.preview.pushed }}</b> ticket(s) seront réassignés dans le legacy<span v-if="legacyPush.preview.skipped" class="text-grey-6"> · {{ legacyPush.preview.skipped }} déjà à jour/fermé(s)</span><span v-if="legacyPush.preview.mismatch" class="text-warning"> · {{ legacyPush.preview.mismatch }} ignoré(s) (nom ≠)</span>.</div>
|
||
<q-list dense bordered class="rounded-borders" style="max-height:300px;overflow:auto">
|
||
<q-item v-for="(s, i) in (legacyPush.preview.samples || [])" :key="i" class="q-py-xs">
|
||
<q-item-section avatar style="min-width:28px"><q-icon :name="s.action ? 'warning' : 'arrow_forward'" :color="s.action ? 'deep-orange' : 'green'" size="16px" /></q-item-section>
|
||
<q-item-section>
|
||
<q-item-label v-if="s.action" style="font-size:12px" class="text-warning">Ticket #{{ s.ticket }} — Ops « {{ s.ops_nom }} » ≠ legacy « {{ s.legacy_staff }} » → <b>ignoré</b></q-item-label>
|
||
<q-item-label v-else style="font-size:12px">Ticket #{{ s.ticket }} → <b>{{ s.vers_staff }}</b><span class="text-grey-6"> · {{ s.sujet }}</span></q-item-label>
|
||
</q-item-section>
|
||
<q-item-section side v-if="s.job"><q-btn flat dense round size="sm" icon="close" color="negative" :disable="legacyPush.applying" @click="removeFromPush(s)"><q-tooltip>Désassigner ce job (retour au pool) — ne sera pas poussé</q-tooltip></q-btn></q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
<div class="text-caption text-grey-6 q-mt-xs">Sécurité : seuls les tickets <b>ouverts</b> sont touchés, le nom du staff legacy doit concorder, et c'est idempotent.</div>
|
||
</template>
|
||
</q-card-section>
|
||
<q-card-actions align="right">
|
||
<q-checkbox v-if="legacyPush.preview && legacyPush.preview.pushed" v-model="legacyPush.notify" dense size="sm" color="deep-orange" class="q-mr-auto" label="Notifier les techs par courriel">
|
||
<q-tooltip class="bg-grey-9" max-width="320px">Envoie à chaque tech réassigné le courriel d'assignation (résumé + adresse + lien <b>Répondre</b>), identique au legacy.</q-tooltip>
|
||
</q-checkbox>
|
||
<q-btn flat label="Annuler" v-close-popup />
|
||
<q-btn unelevated color="deep-orange" icon="cloud_upload" label="Écrire dans le legacy" :loading="legacyPush.applying" :disable="!legacyPush.preview || !legacyPush.preview.pushed" @click="applyLegacyPush" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Sélecteur d'emplacement (jobs « hors carte ») : carte bornée au sud de Montréal + recherche d'adresse RQA -->
|
||
<q-dialog v-model="locPicker.open" @show="initLocMap" @hide="destroyLocMap">
|
||
<q-card style="width: 700px; max-width: 96vw">
|
||
<q-card-section class="row items-center q-pb-xs">
|
||
<div class="text-subtitle2">{{ locPicker.mode === 'depot' ? '🏁 Point de départ (dépôt)' : locPicker.mode === 'home' ? ('🏠 ' + locPicker.subject) : '📍 Situer le job' }}<span v-if="locPicker.mode === 'job'" class="text-grey-7"> — {{ locPicker.subject }}</span></div>
|
||
<q-space />
|
||
<q-btn dense flat round icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-pt-none">
|
||
<q-input dense outlined v-model="locPicker.search" placeholder="Chercher une adresse (base RQA Québec)…" class="q-mb-xs" :loading="locPicker.searching" @keyup.enter="locSearch" clearable>
|
||
<template #append><q-btn dense flat icon="search" @click="locSearch" /></template>
|
||
</q-input>
|
||
<div v-if="locPicker.mode === 'home'" class="row items-center q-mb-xs">
|
||
<q-btn dense outline no-caps size="sm" color="indigo" icon="my_location" label="Position GPS actuelle du tech" :loading="liveGpsBusy" @click="pickerUseLiveGps" />
|
||
<span class="text-caption text-grey-6 q-ml-sm">…ou cherche / clique sur la carte</span>
|
||
</div>
|
||
<div v-if="locPicker.mode === 'job' && locPicker.autoMatched" class="text-caption text-positive q-mb-xs row items-center no-wrap">
|
||
<q-icon name="check_circle" size="15px" class="q-mr-xs" /> Adresse reconnue automatiquement (base RQA) — vérifie la carte, puis « Utiliser cet emplacement ».
|
||
</div>
|
||
<div v-else-if="locPicker.mode === 'job' && locPicker.results.length > 1 && locPicker.lat == null" class="text-caption text-warning q-mb-xs row items-center no-wrap">
|
||
<q-icon name="info" size="15px" class="q-mr-xs" /> Plusieurs correspondances — choisis la bonne ci-dessous.
|
||
</div>
|
||
<q-list v-if="locPicker.results.length" bordered dense class="loc-results q-mb-xs">
|
||
<q-item v-for="(rr, i) in locPicker.results" :key="i" clickable @click="pickLocResult(rr)">
|
||
<q-item-section>
|
||
<q-item-label>{{ rr.address_full || ((rr.numero || '') + ' ' + (rr.rue || '')) }}</q-item-label>
|
||
<q-item-label caption>{{ rr.ville }} {{ rr.code_postal }}<span v-if="rr.fiber_available" class="text-positive"> · fibre</span></q-item-label>
|
||
</q-item-section>
|
||
<q-item-section side><q-icon name="place" color="warning" /></q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
<div class="loc-map-wrap">
|
||
<div ref="locMapEl" class="loc-map"></div>
|
||
<q-btn dense round size="sm" class="map-sat-btn" :color="satView ? 'primary' : 'grey-8'" :icon="satView ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satView ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
|
||
<div class="loc-map-hint">Clic = situer · glisser le repère · <b>clic droit = Street View</b></div>
|
||
</div>
|
||
<q-input dense outlined v-model="locPicker.address" label="Adresse (modifiable)" class="q-mt-sm" />
|
||
<div class="text-caption text-grey-6 q-mt-xs" v-if="locPicker.lat != null">Coordonnées : {{ locPicker.lat }}, {{ locPicker.lon }}</div>
|
||
</q-card-section>
|
||
<q-card-actions align="right">
|
||
<q-btn flat label="Annuler" v-close-popup />
|
||
<q-btn unelevated color="primary" icon="place" label="Utiliser cet emplacement" :loading="locPicker.saving" :disable="locPicker.lat == null" @click="saveLocPicker" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Détail d'un job Legacy (clic sur un bloc hachuré) : fil du ticket osTicket, lecture seule -->
|
||
<q-dialog v-model="legacyDetail.open">
|
||
<q-card style="min-width:520px;max-width:680px">
|
||
<q-card-section class="row items-center q-pb-sm">
|
||
<q-icon name="assignment" color="brown" size="20px" class="q-mr-sm" />
|
||
<div class="col" style="min-width:0">
|
||
<div class="text-subtitle1 text-weight-bold ellipsis">{{ legacyDetail.subject }}</div>
|
||
<div class="text-caption text-grey-7">Ticket Legacy #{{ legacyDetail.lid }}<span v-if="legacyDetail.dept"> · {{ legacyDetail.dept }}</span></div>
|
||
</div>
|
||
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-pt-none" style="max-height:60vh;overflow:auto">
|
||
<div v-if="legacyDetail.loading" class="text-center q-pa-md"><q-spinner size="26px" color="brown" /><div class="text-caption text-grey-6 q-mt-sm">Lecture du ticket…</div></div>
|
||
<template v-else-if="legacyDetail.thread && legacyDetail.thread.messages && legacyDetail.thread.messages.length">
|
||
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ legacyDetail.thread.messages.length }} message(s){{ legacyDetail.thread.status ? ' · ' + legacyDetail.thread.status : '' }}</div>
|
||
<div v-for="(m, mi) in legacyDetail.thread.messages" :key="mi" class="de-msg">
|
||
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span></div>
|
||
<div class="de-msg-txt">{{ m.text }}</div>
|
||
</div>
|
||
</template>
|
||
<div v-else class="text-grey-6 q-pa-md text-center">{{ (legacyDetail.thread && legacyDetail.thread.error) ? 'Détail indisponible' : 'Aucun message dans ce ticket.' }}</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Volet DÉTAILS (double-clic sur un job du board) : grand panneau DROIT, pleine hauteur, ~pleine largeur — billet + commentaires -->
|
||
<!-- Notifier les techs : aperçu par tech (lien token + billets + flags) puis envoi SMS/courriel -->
|
||
<q-dialog v-model="notifyDlg.open" :maximized="$q.screen.lt.sm">
|
||
<q-card :style="$q.screen.lt.sm ? 'width:100%;max-width:100%;height:100%' : 'width:560px;max-width:96vw'" class="column no-wrap">
|
||
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid #e2e8f0">
|
||
<q-icon name="forward_to_inbox" color="deep-purple-6" size="22px" class="q-mr-sm" />
|
||
<div class="col" style="min-width:0"><div class="text-subtitle1 text-weight-bold">Notifier les techniciens</div><div class="text-caption text-grey-7">Lien personnel de la tournée + adresse & flags (AM/PM · urgent · appeler avant)</div></div>
|
||
<q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-py-sm text-caption text-grey-7"><q-icon name="info" size="14px" /> Coche le canal <b>par technicien</b> — <b>SMS préféré</b>. Décoche pour exclure quelqu'un (ex. envoi de test).</q-card-section>
|
||
<q-separator />
|
||
<q-card-section class="col scroll q-pa-none" style="overflow:auto">
|
||
<div v-if="notifyDlg.loading" class="text-center q-pa-lg"><q-spinner size="28px" color="primary" /><div class="text-caption text-grey-6 q-mt-sm">Préparation des messages…</div></div>
|
||
<div v-else-if="!notifyDlg.techs.length" class="text-center text-grey-6 q-pa-lg">Aucun technicien avec une tournée à notifier ce jour.</div>
|
||
<q-list v-else separator>
|
||
<q-expansion-item v-for="m in notifyDlg.techs" :key="m.techId" dense>
|
||
<template #header>
|
||
<q-item-section avatar><UserAvatar :email="m.email" :tech-id="m.techId" :name="m.name" :size="30" card /></q-item-section>
|
||
<q-item-section><q-item-label>{{ m.name }}</q-item-label><q-item-label caption>{{ m.jobCount }} intervention(s)<span v-if="!m.phone && !m.email" class="text-negative"> · ⚠ aucune coordonnée</span></q-item-label></q-item-section>
|
||
<q-item-section side @click.stop>
|
||
<div class="row items-center q-gutter-xs no-wrap">
|
||
<q-toggle v-model="m.wantSms" dense size="sm" icon="sms" color="teal-6" :disable="!m.phone"><q-tooltip>{{ m.phone ? 'SMS → ' + m.phone : 'Aucun téléphone (ajoute-le ci-dessous)' }}</q-tooltip></q-toggle>
|
||
<q-toggle v-model="m.wantEmail" dense size="sm" icon="email" color="purple-6" :disable="!m.email"><q-tooltip>{{ m.email ? 'Courriel → ' + m.email : 'Aucun courriel (ajoute-le ci-dessous)' }}</q-tooltip></q-toggle>
|
||
</div>
|
||
</q-item-section>
|
||
</template>
|
||
<q-card flat><q-card-section class="q-pt-none">
|
||
<!-- Édition INLINE si coordonnée manquante — sans quitter le dialogue -->
|
||
<div v-if="!m.phone || !m.email" class="row items-center q-gutter-sm q-mb-sm">
|
||
<q-input v-if="!m.phone" v-model="m.newPhone" dense outlined type="tel" placeholder="Ajouter un téléphone…" style="max-width:190px" @keyup.enter="saveNotifyContact(m)"><template #prepend><q-icon name="sms" size="16px" /></template></q-input>
|
||
<q-input v-if="!m.email" v-model="m.newEmail" dense outlined type="email" placeholder="Ajouter un courriel…" style="max-width:220px" @keyup.enter="saveNotifyContact(m)"><template #prepend><q-icon name="email" size="16px" /></template></q-input>
|
||
<q-btn dense unelevated size="sm" color="primary" icon="save" label="Enregistrer" no-caps :loading="m.saving" :disable="!m.newPhone && !m.newEmail" @click="saveNotifyContact(m)" />
|
||
</div>
|
||
<pre class="notify-preview">{{ m.sms }}</pre>
|
||
</q-card-section></q-card>
|
||
</q-expansion-item>
|
||
</q-list>
|
||
</q-card-section>
|
||
<q-separator />
|
||
<q-card-actions align="right" class="q-pa-md">
|
||
<div class="text-caption text-grey-6 col text-left" style="min-width:0">Aperçu — rien n'est envoyé avant « Envoyer ».</div>
|
||
<q-btn flat no-caps label="Annuler" v-close-popup />
|
||
<q-btn unelevated no-caps color="deep-purple-6" icon="send" :label="'Envoyer (' + notifyRecipientCount + ')'" :loading="notifyDlg.sending" :disable="!notifyRecipientCount" @click="sendNotify" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<q-dialog v-model="jobDetail.open" position="right" full-height>
|
||
<q-card class="jd-card">
|
||
<!-- Barre d'en-tête = titre + FERMER uniquement. Le X ne partage plus la ligne du champ d'assignation (ambiguïté « supprime-t-il le champ ? ») ; il est étiqueté « Fermer ». -->
|
||
<q-card-section class="row items-center no-wrap q-pb-sm">
|
||
<q-icon name="assignment" color="primary" size="22px" class="q-mr-sm" />
|
||
<div class="col" style="min-width:0">
|
||
<div class="text-h6" style="line-height:1.25;word-break:break-word">{{ jobDetail.subject }}</div>
|
||
<div class="text-caption text-grey-7"><router-link v-if="jobDetail.customerId" :to="'/clients/' + encodeURIComponent(jobDetail.customerId)" class="erp-link">{{ jobDetail.customer || jobDetail.customerId }}<q-icon name="open_in_new" size="11px" class="q-ml-xs" /></router-link><template v-else-if="jobDetail.customer">{{ jobDetail.customer }}</template><template v-if="jobDetail.lid"> · Ticket #{{ jobDetail.lid }}</template><template v-if="jobDetail.dept"> · {{ jobDetail.dept }}</template></div>
|
||
<div v-if="jobDetail.name" class="text-caption text-grey-5">{{ jobDetail.name }}</div>
|
||
</div>
|
||
<q-space />
|
||
<q-btn flat round dense icon="close" v-close-popup><q-tooltip>Fermer</q-tooltip></q-btn>
|
||
</q-card-section>
|
||
<q-separator />
|
||
<!-- Assignation MULTIFONCTION — module partagé JobTeamField (À = assigné · CC = assistant · # = follower).
|
||
Les assistants sont gérés par le module (roster) ; le LEAD reste sur la pipeline board (assignNames via jdAssignTech / jdUnassign).
|
||
Le MÊME module est monté dans le détail ticket → une seule source. -->
|
||
<q-card-section class="q-py-sm">
|
||
<JobTeamField class="jd-assign"
|
||
:name="jobDetail.name" :tech-id="jobDetail.techId" :tech-name="jobDetail.techName"
|
||
:team="jobDetail.team" :tech-options="jdTeamOptions" :can-onsite="jobDetail.canTeam" :team-loading="jobDetail.teamLoading"
|
||
@assign-lead="jdAssignTech" @unassign-lead="jdUnassign" @updated="reloadOccupancy()">
|
||
<template #right>
|
||
<TicketStatusControl v-if="jobDetail.canTeam" doctype="Dispatch Job" :docname="jobDetail.name" :status="jobDetail.status" :scheduled-date="jobDetail.iso"
|
||
@changed="p => { if (p && p.status) jobDetail.status = p.status; reloadOccupancy(); reloadPool && reloadPool() }" />
|
||
</template>
|
||
</JobTeamField>
|
||
</q-card-section>
|
||
<q-separator />
|
||
<q-card-section class="jd-body scroll">
|
||
<!-- Détails SIMPLES d'abord. Le volet consomme désormais les MÊMES modules partagés que le détail ticket (IssueDetail). -->
|
||
<div class="jd-meta">
|
||
<div v-if="jobDetail.time"><q-icon name="schedule" size="16px" /> {{ jobDetail.time }}</div>
|
||
<div v-if="jobDetail.address"><q-icon name="place" size="16px" /> {{ jobDetail.address }}</div>
|
||
</div>
|
||
<!-- Durée estimée — module partagé. Il persiste duration_h ; le board conserve son override d'optimisation + le rappel « Ré-optimiser ». -->
|
||
<DurationField :name="jobDetail.name" :dur-h="jobDetail.durH"
|
||
@updated="p => { durOverride[jobDetail.name] = p.duration_h; jobDetail.durH = p.duration_h; $q.notify({ type: 'positive', message: 'Durée révisée : ' + p.duration_h + ' h — Ré-optimiser pour recalculer', timeout: 2500 }) }" />
|
||
<!-- Compétences requises — module partagé (store hub /roster/job-skills). -->
|
||
<RequiredSkillsField :name="jobDetail.name" :skills="jobDetail.skills" :all-tags="tagCatalog" :get-color="getTagColor"
|
||
@updated="arr => { jobDetail.skills = arr; jobDetail.skill = arr[0] || ''; reloadPool && reloadPool() }" @create="onCreateRosterTag" />
|
||
<!-- Attribuer cette compétence à un tech précis → matché au dispatch auto. BOARD-ONLY (nécessite la liste des techs). -->
|
||
<div v-if="jobDetail.skill" class="row items-center q-gutter-xs q-mb-sm no-wrap">
|
||
<span class="text-caption text-grey-6 no-wrap">Donner à :</span>
|
||
<TechSelect v-model="jdSkillTech" :options="jdAllTechOpts" label="un technicien…" style="min-width:180px;flex:1" />
|
||
<q-btn dense unelevated color="teal-7" icon="add" no-caps :disable="!jdSkillTech" :loading="jdSkillBusy" @click="jdGiveSkillToTech" label="Attribuer" />
|
||
</div>
|
||
<!-- Carte + tracé GPS (Traccar) — module partagé. Le board fournit les points de contexte à proximité + le jour affiché. -->
|
||
<JobMapModule :name="jobDetail.name" :lat="jobDetail.lat" :lon="jobDetail.lon"
|
||
:tech-id="jobDetail.techId" :iso="(kanbanDay && kanbanDay.iso) || jobDetail.iso" :can-track="kbCanTrack" :today-iso="nowET.iso" :nearby="jdNearby()" />
|
||
<!-- Suivi terrain (géofencing) — module partagé (objet déjà chargé dans openJobDetail). -->
|
||
<GeofenceTimeline :geofence="jobDetail.geofence" />
|
||
<!-- Fil du billet — module partagé. Spinner board conservé pendant le chargement (évite un double fetch dans le module). -->
|
||
<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>
|
||
<JobThread v-else :thread="jobDetail.thread" :detail="jobDetail.detail" :lid="jobDetail.lid" />
|
||
<!-- Répondre au fil — module partagé. Recharge le fil du volet après envoi. -->
|
||
<JobReplyBox :name="jobDetail.name" :lid="jobDetail.lid" @sent="reloadJdThread" />
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- #1 — Sommaire des changements AVANT de publier (ajouts/retraits par jour + nb SMS) -->
|
||
<q-dialog v-model="pubConfirm">
|
||
<q-card style="min-width:380px;max-width:96vw">
|
||
<q-card-section class="row items-center q-pb-none">
|
||
<q-icon name="cloud_upload" color="positive" size="22px" class="q-mr-sm" />
|
||
<div class="text-subtitle1 text-weight-bold">Publier l'horaire</div>
|
||
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-pt-sm">
|
||
<div class="text-body2 q-mb-sm"><b class="text-positive">{{ pubSummary.added }}</b> quart(s)<span v-if="pubSummary.removed"> · <b class="text-negative">{{ pubSummary.removed }}</b> retiré(s)</span><span v-if="pubSummary.paused"> · <b class="text-orange-9">{{ pubSummary.paused }}</b> pause(s)</span><span v-if="pubSummary.absAdded"> · <b class="text-orange-8">{{ pubSummary.absAdded }}</b> congé(s)</span><span v-if="pubSummary.absRemoved"> · <b class="text-teal-7">{{ pubSummary.absRemoved }}</b> congé(s) levé(s)</span> · <b>{{ notifySms ? pubSummary.added + ' SMS aux techs' : 'sans SMS' }}</b></div>
|
||
<q-list dense bordered style="border-radius:8px;max-height:44vh;overflow:auto">
|
||
<template v-for="g in pubSummary.techs" :key="g.tech">
|
||
<q-item-label header class="q-py-xs text-weight-bold row items-center">
|
||
<UserAvatar :tech-id="g.tech" :name="g.name" :size="18" card class="q-mr-xs" style="flex:0 0 auto" />{{ g.name }}
|
||
</q-item-label>
|
||
<q-item v-if="g.pause" dense><q-item-section avatar style="min-width:24px"><q-icon name="pause_circle" color="orange-9" size="16px" /></q-item-section><q-item-section class="text-orange-9 text-weight-medium">Pause indéfinie<span class="text-grey-6" v-if="g.pause.reason"> · {{ g.pause.reason }}</span><span class="text-grey-6" v-if="g.rem.length"> · {{ g.rem.length }} quart(s) futur(s) retiré(s)</span></q-item-section></q-item>
|
||
<q-item v-for="(a, i) in g.add" :key="'a' + i" dense><q-item-section avatar style="min-width:24px"><q-icon name="add" color="positive" size="16px" /></q-item-section><q-item-section>{{ a.shift }}<span class="text-grey-6"> · {{ fmtDueLabel(a.date) }}</span></q-item-section></q-item>
|
||
<q-item v-for="(r, i) in g.rem" :key="'r' + i" dense><q-item-section avatar style="min-width:24px"><q-icon name="remove" color="negative" size="16px" /></q-item-section><q-item-section><span style="text-decoration:line-through">{{ r.shift }}</span><span class="text-grey-6"> · {{ fmtDueLabel(r.date) }}</span></q-item-section></q-item>
|
||
<q-item v-for="(ab, i) in g.abs" :key="'ab' + i" dense><q-item-section avatar style="min-width:24px"><q-icon :name="ab.op === 'set' ? 'event_busy' : 'event_available'" :color="ab.op === 'set' ? 'orange-8' : 'teal-7'" size="16px" /></q-item-section><q-item-section>{{ ab.op === 'set' ? (ab.type || 'Congé') : 'congé levé' }}<span class="text-grey-6"> · {{ fmtDueLabel(ab.date) }}</span></q-item-section></q-item>
|
||
</template>
|
||
</q-list>
|
||
</q-card-section>
|
||
<q-card-actions align="right" class="q-px-md q-pb-md">
|
||
<q-btn flat no-caps label="Annuler" color="grey-7" v-close-popup />
|
||
<q-btn unelevated no-caps color="positive" icon="cloud_upload" :label="notifySms ? 'Publier + SMS' : 'Publier'" :loading="publishing" @click="doPublishConfirmed" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Bloquer du temps d'un tech = UN simple job/ticket qui OCCUPE la timeline (formation, réunion, projet, entretien…).
|
||
Occupe la journée ⇒ dé-priorise au dispatch. La PRIORITÉ est le curseur : Moyenne/Basse = les urgences/réparations
|
||
peuvent quand même le prendre ; Urgent (high) = bloc dur, même les urgences ne le prennent pas. (Fusionne les
|
||
ex- « Réserver du temps » + « Bloquer / job générique » — un seul concept.) -->
|
||
<q-dialog v-model="fillerDlg.open">
|
||
<q-card style="min-width:360px;max-width:96vw">
|
||
<q-card-section class="row items-center q-pb-none">
|
||
<q-icon name="event_busy" color="deep-orange-6" size="22px" class="q-mr-sm" />
|
||
<div class="text-subtitle1 text-weight-bold">Bloquer du temps</div>
|
||
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-gutter-sm">
|
||
<div class="text-body2"><b>{{ fillerDlg.tech && fillerDlg.tech.name }}</b></div>
|
||
<q-input dense outlined v-model="fillerDlg.subject" label="Intitulé" placeholder="Ex. Formation · Réunion · Projet · Entretien véhicule" autofocus />
|
||
<div class="row items-center q-gutter-sm">
|
||
<q-toggle v-model="fillerDlg.fullDay" dense size="sm" color="deep-orange-6" label="Journée complète" @update:model-value="onFillerFullDay" />
|
||
<q-input class="col" dense outlined type="date" v-model="fillerDlg.date" label="Date" />
|
||
</div>
|
||
<div class="row q-col-gutter-sm">
|
||
<q-input class="col-4" dense outlined type="time" v-model="fillerDlg.start" label="Début" :disable="fillerDlg.fullDay" />
|
||
<q-input class="col-4" dense outlined type="number" v-model.number="fillerDlg.dur" label="Durée (h)" min="0.5" step="0.5" :disable="fillerDlg.fullDay" />
|
||
<q-select class="col-4" dense outlined emit-value map-options v-model="fillerDlg.priority" :options="POOL_PRIOS" option-value="value" option-label="label" label="Priorité" />
|
||
</div>
|
||
<div class="text-caption text-grey-6">
|
||
<q-icon name="info" size="14px" />
|
||
<template v-if="fillerDlg.priority === 'high'"> Bloc <b>prioritaire</b> — même les réparations/urgences ne prendront pas ce tech.</template>
|
||
<template v-else> Occupe la période (dé-priorisé au dispatch). Les <b>réparations & urgences</b> peuvent quand même le prendre — passez la priorité à <b>Urgent</b> pour un bloc dur.</template>
|
||
</div>
|
||
<q-toggle v-model="fillerDlg.createTicket" dense size="sm" color="primary" label="Générer un ticket lié" />
|
||
</q-card-section>
|
||
<q-card-actions align="right" class="q-px-md q-pb-md">
|
||
<q-btn flat no-caps label="Annuler" color="grey-7" v-close-popup />
|
||
<q-btn unelevated no-caps color="deep-orange-6" icon="event_busy" label="Bloquer" :loading="fillerDlg.busy" :disable="!fillerDlg.subject" @click="doBlockTime" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Drop d'une job sur un tech SANS quart : créer un quart (durée) ou marquer absent (durée + retour) -->
|
||
<q-dialog v-model="dropAsk.open">
|
||
<q-card style="min-width:380px;max-width:440px">
|
||
<q-card-section class="q-pb-xs">
|
||
<div class="text-subtitle1 row items-center"><q-icon name="report_problem" color="warning" size="22px" class="q-mr-sm" /><b>{{ dropAsk.tech && dropAsk.tech.name }}</b> n'a aucun quart</div>
|
||
<div class="text-caption text-grey-7">{{ dropAsk.day && (dropAsk.day.dow + ' ' + dropAsk.day.dnum) }} · {{ dropAsk.names.length }} job(s) à placer</div>
|
||
</q-card-section>
|
||
|
||
<!-- Choix initial -->
|
||
<q-card-section v-if="dropAsk.mode === 'choice'" class="q-gutter-sm">
|
||
<q-btn unelevated color="primary" icon="add_box" label="Créer un quart et assigner" class="full-width" no-caps @click="dropAsk.mode = 'shift'" align="left" />
|
||
<q-btn outline color="deep-orange" icon="event_busy" label="Marquer le technicien absent" class="full-width" no-caps @click="dropAsk.mode = 'absence'" align="left" />
|
||
<div class="text-caption text-grey-6 q-px-xs">Tant qu'aucun quart n'existe, la job reste au pool — elle n'est pas assignée en douce.</div>
|
||
</q-card-section>
|
||
|
||
<!-- Créer un quart -->
|
||
<q-card-section v-else-if="dropAsk.mode === 'shift'" class="q-gutter-sm">
|
||
<q-select dense outlined v-model="dropAsk.preset" :options="shiftPresets" emit-value map-options label="Durée du quart" />
|
||
<div v-if="dropAsk.preset === 'custom'" class="row q-gutter-sm">
|
||
<q-input dense outlined type="time" v-model="dropAsk.cStart" label="Début" style="flex:1" />
|
||
<q-input dense outlined type="time" v-model="dropAsk.cEnd" label="Fin" style="flex:1" />
|
||
</div>
|
||
<div class="text-caption text-grey-6">Quart créé (non publié) + {{ dropAsk.names.length }} job(s) assignée(s). Annulable ensuite.</div>
|
||
</q-card-section>
|
||
|
||
<!-- Marquer absent -->
|
||
<q-card-section v-else-if="dropAsk.mode === 'absence'" class="q-gutter-sm">
|
||
<q-option-group v-model="dropAsk.scope" :options="[{ label: 'Ce jour', value: 'day' }, { label: 'Cette semaine', value: 'week' }, { label: 'Plage de dates', value: 'range' }]" type="radio" inline dense color="deep-orange" />
|
||
<div v-if="dropAsk.scope === 'range'" class="row q-gutter-sm">
|
||
<q-input dense outlined type="date" v-model="dropAsk.from" label="Du" style="flex:1" />
|
||
<q-input dense outlined type="date" v-model="dropAsk.to" label="Au" style="flex:1" />
|
||
</div>
|
||
<q-select dense outlined v-model="dropAsk.absType" :options="absTypes" label="Motif" />
|
||
<div class="text-caption text-warning row items-center"><q-icon name="info" size="15px" class="q-mr-xs" />Retour prévu le <b class="q-ml-xs">{{ dropAskReturn }}</b>. Les job(s) iront au pool → proposition de réassignation.</div>
|
||
</q-card-section>
|
||
|
||
<q-card-actions align="right">
|
||
<q-btn v-if="dropAsk.mode !== 'choice'" flat dense label="‹ Retour" no-caps @click="dropAsk.mode = 'choice'" />
|
||
<q-space />
|
||
<q-btn flat label="Annuler" no-caps v-close-popup />
|
||
<q-btn v-if="dropAsk.mode === 'shift'" unelevated color="primary" icon="check" label="Créer & assigner" no-caps @click="dropAskCreateShift" />
|
||
<q-btn v-if="dropAsk.mode === 'absence'" unelevated color="deep-orange" icon="event_busy" label="Marquer absent" no-caps @click="dropAskAbsence" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Absence sur PLAGE de jours (défaut = jour affiché ; « cette semaine » ou plage du..au) -->
|
||
<q-dialog v-model="absDialog.open">
|
||
<q-card style="min-width:344px">
|
||
<q-card-section class="row items-center q-pb-sm">
|
||
<q-icon name="event_busy" color="negative" size="20px" class="q-mr-sm" />
|
||
<div class="text-subtitle1 text-weight-bold">Absence — {{ absDialog.techName }}</div>
|
||
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-pt-none">
|
||
<div class="row q-gutter-xs q-mb-sm">
|
||
<q-btn dense unelevated size="sm" color="grey-7" label="Ce jour" @click="absDialog.to = absDialog.from" />
|
||
<q-btn dense unelevated size="sm" color="grey-7" label="Cette semaine" @click="absWeek" />
|
||
</div>
|
||
<div class="row q-col-gutter-sm">
|
||
<q-input class="col" dense outlined type="date" v-model="absDialog.from" label="Du" />
|
||
<q-input class="col" dense outlined type="date" v-model="absDialog.to" label="Au" />
|
||
</div>
|
||
<div class="text-caption text-grey-6 q-mt-xs">{{ absDays().length }} jour(s) · type « Congé »</div>
|
||
</q-card-section>
|
||
<q-card-actions align="right">
|
||
<q-btn flat dense label="Retirer l'absence" color="grey-7" @click="applyAbs(true)" />
|
||
<q-btn unelevated dense label="Marquer absent" color="negative" @click="applyAbs(false)" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Synchroniser les techniciens — extrait dans components/planif/TechSyncDialog.vue (décomposition #4) -->
|
||
<TechSyncDialog v-model="showTechSync" @applied="loadBase" />
|
||
|
||
<!-- Tableur « durées par caractéristique » (additif) — édition inline -->
|
||
<q-dialog v-model="jobChar.open">
|
||
<q-card style="width: 860px; max-width: 97vw">
|
||
<q-card-section class="row items-center q-pb-xs">
|
||
<div class="text-subtitle2">⏱ Durées par caractéristique <span class="text-grey-6">— additif, hors transport</span></div>
|
||
<q-space /><q-btn dense flat round icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section v-if="jobChar.loading" class="text-center q-pa-md"><q-spinner color="primary" size="26px" /></q-card-section>
|
||
<q-card-section v-else class="q-pt-none">
|
||
<div class="text-caption text-grey-7 q-mb-sm">Durée d'un job = <b>1 base</b> + <b>caractéristiques</b> cochées (×qté), × vitesse du tech. Minutes éditables. Les valeurs <b>apprises</b> (geofence) viendront calibrer ces seeds par régression.</div>
|
||
<table class="jc-table">
|
||
<thead><tr><th>Catégorie</th><th>Libellé</th><th>Min</th><th>×Qté</th><th>Mots-clés (auto-détection)</th><th></th></tr></thead>
|
||
<tbody>
|
||
<tr v-for="(it, i) in jobChar.items" :key="i" :class="'jc-' + it.cat">
|
||
<td><q-select dense options-dense outlined v-model="it.cat" :options="['base', 'addon', 'modifier']" style="min-width:92px" /></td>
|
||
<td><q-input dense outlined v-model="it.label" /></td>
|
||
<td style="white-space:nowrap"><q-input dense outlined type="number" v-model.number="it.minutes" style="width:64px" /><span v-if="it.learned_min != null" class="jc-learn"> appris {{ it.learned_min }}m·{{ it.samples }}</span></td>
|
||
<td class="text-center"><q-toggle dense size="sm" v-model="it.per_qty" /></td>
|
||
<td><q-input dense outlined v-model="it.keywords" placeholder="ex. enfoui,enterré" /></td>
|
||
<td><q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="jobChar.items.splice(i, 1)" /></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</q-card-section>
|
||
<q-card-actions align="right">
|
||
<q-btn flat dense icon="add" label="Ajouter une ligne" color="primary" @click="addJobCharRow" />
|
||
<q-space />
|
||
<q-btn flat label="Fermer" v-close-popup />
|
||
<q-btn unelevated color="primary" icon="save" label="Enregistrer" :loading="jobChar.saving" @click="saveJobChar" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Timeline contextuelle d'une ressource : dispatch des jobs de la semaine visible (heures + priorités) -->
|
||
<q-dialog v-model="timelineDlg.open">
|
||
<q-card style="min-width:560px;max-width:780px">
|
||
<q-card-section class="row items-center q-pb-sm">
|
||
<q-icon name="timeline" color="indigo" size="22px" class="q-mr-sm" />
|
||
<div class="text-subtitle1 text-weight-bold">Timeline — {{ timelineDlg.tech && timelineDlg.tech.name }}</div>
|
||
<q-space />
|
||
<q-btn flat dense no-caps size="sm" icon="open_in_new" label="Dispatch" color="indigo" @click="gotoDispatch(timelineDlg.tech)"><q-tooltip>Ouvrir le tableau Dispatch sur cette ressource</q-tooltip></q-btn>
|
||
<q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-pt-none" style="max-height:70vh;overflow:auto">
|
||
<div v-if="!timelineDays.length" class="text-grey-6 q-pa-md text-center">Aucun job planifié cette semaine pour cette ressource.</div>
|
||
<div v-for="day in timelineDays" :key="day.iso" class="tldlg-day">
|
||
<div class="row items-center q-mb-xs">
|
||
<div class="text-weight-medium" :class="{ 'text-warning': day.weekend }">{{ day.label }}</div>
|
||
<q-badge v-if="day.offShift" color="warning" class="q-ml-sm"><q-icon name="warning" size="11px" class="q-mr-xs" />hors quart publié</q-badge>
|
||
<q-space />
|
||
<q-badge v-if="day.pct != null" text-color="white" :style="{ background: occColor(day.pct) }">{{ day.usedH }}h · {{ day.pct }}%</q-badge>
|
||
<q-badge v-else color="grey-5" class="q-ml-xs">{{ day.usedH }}h</q-badge>
|
||
</div>
|
||
<div v-if="!day.offShift" class="tldlg-bar">
|
||
<div v-for="(b, bi) in day.bands" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
|
||
<div v-for="(b, bi) in day.blocks" :key="'k' + bi" class="tl-blk" :style="blockStyle(b, day.pct)"></div>
|
||
<span v-for="tk in axisTicks" :key="'t' + tk.h" class="tldlg-tick" :style="{ left: tk.left }">{{ tk.h }}</span>
|
||
</div>
|
||
<div v-for="j in day.jobs" :key="j.name" class="tldlg-job">
|
||
<span class="tldlg-dot" :style="{ background: prioColor(j.priority) }"><q-tooltip>{{ j.priority }}</q-tooltip></span>
|
||
<span class="tldlg-time">{{ j.start || '—' }}</span>
|
||
<span class="ellipsis">{{ j.subject }}</span>
|
||
<span v-if="j.customer" class="text-grey-6 ellipsis">· {{ j.customer }}</span>
|
||
<q-space /><span class="text-grey-6" style="flex:0 0 auto">{{ j.dur }}h</span>
|
||
</div>
|
||
</div>
|
||
</q-card-section>
|
||
<q-card-section v-if="timelineDays.length" class="q-pt-none text-caption text-grey-6">Trié par priorité puis heure · 🔴 Urgent 🟠 Moyenne ⚪ Basse. Heures posées en premier-trou-libre.</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<div ref="menuAnchorEl" :style="{ position: 'fixed', width: '1px', height: '1px', left: menu.x + 'px', top: menu.y + 'px', pointerEvents: 'none', zIndex: 0 }"></div>
|
||
<q-menu v-model="menu.show" :target="menu.target" anchor="bottom right" self="top left" max-height="85vh">
|
||
<PlanifCellMenu
|
||
:cell-key="(menu.tech && menu.tech.id) + '|' + (menu.day && menu.day.iso)"
|
||
:title="(menu.tech && menu.tech.name) + ' — ' + (menu.day && menu.day.dnum)"
|
||
:shifts="menuCellShifts" :is-garde="menuIsGarde" :is-absent="menuIsAbsent"
|
||
:clipboard-count="cellClipboard.length" :initial-range="menuRange"
|
||
@window="e => applyWindow(e.min, e.max)" @toggle-garde="toggleGardeMenu" @toggle-absent="openAbsDialog"
|
||
@remove-shift="removeShiftFromMenu" @clear="clearOne" @copy="copyFromMenu" @paste="pasteFromMenu"
|
||
@filler="openFillerFromMenu" />
|
||
</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><template v-if="j.customer"> · <router-link v-if="j.customer_id" :to="'/clients/' + encodeURIComponent(j.customer_id)" class="erp-link" @click.stop>{{ j.customer }}</router-link><template v-else>{{ j.customer }}</template></template></div>
|
||
<div v-if="j.address || !hasLL(j)" class="ellipsis" style="font-size:11px;color:#90a4ae"><template v-if="j.address"><q-icon name="place" size="11px" /> {{ j.address }}</template><span v-if="!hasLL(j) && !j.legacy" class="loc-pick-link text-warning" @click.stop="openLocPicker(j)"> · ⚠ sans coords — 📍 situer</span><span v-else-if="!hasLL(j)" class="text-grey-5"> · sans coords (absent de la carte)</span></div>
|
||
</div>
|
||
<template v-if="!j.legacy">
|
||
<div class="de-dur"><input type="number" min="5" step="5" :value="jobMinutes(j)" @change="setJobMinutes(j, $event.target.value)" @click.stop @mousedown.stop /><span>min</span></div>
|
||
<q-btn flat dense round size="sm" icon="open_in_new" color="green-5" @click.stop="openJobDetail(j, dayEditor.tech)"><q-tooltip>Détail du ticket (OPS) — fil, équipe, client</q-tooltip></q-btn>
|
||
<!-- sélecteur de priorité retiré (défaut « Moyenne » conservé en donnée) → gain de place ; priorité gérée au Dispatch -->
|
||
<q-btn v-if="!j.actual_start" flat dense round size="sm" icon="play_arrow" color="green-6" @click="startJobChrono(j)"><q-tooltip>Démarrer (chrono réel → apprentissage)</q-tooltip></q-btn>
|
||
<q-btn v-else-if="!j.actual_end" flat dense round size="sm" icon="stop_circle" color="deep-orange" class="chrono-run" @click="finishJobChrono(j)"><q-tooltip>Terminer (chrono réel)</q-tooltip></q-btn>
|
||
<span v-else class="de-actual" :style="ratioStyle(j)" :title="'Réel mesuré / estimé (min) — geofencing ou chrono'">⏱{{ j.actual_min != null ? j.actual_min : '?' }}/{{ jobMinutes(j) }}m</span>
|
||
<q-btn flat dense round size="sm" icon="edit_location_alt" :color="hasLL(j) ? 'blue-grey-5' : 'deep-orange'" @click="openLocPicker(j)"><q-tooltip>{{ hasLL(j) ? 'Repositionner sur la carte' : 'Situer sur la carte (sans coords)' }}</q-tooltip></q-btn>
|
||
<q-btn flat dense round size="sm" icon="push_pin" :color="j.pin ? 'indigo' : 'grey-5'">
|
||
<q-tooltip>{{ j.pin === 'first' ? 'Figé en 1re position' : j.pin === 'last' ? 'Figé en dernière position' : 'Figer la position (1er / dernier) — respecté par « Optimiser »' }}</q-tooltip>
|
||
<q-menu auto-close anchor="bottom right" self="top right">
|
||
<q-list dense style="min-width:180px">
|
||
<q-item clickable @click="setPin(j, 'first')"><q-item-section avatar><q-icon name="vertical_align_top" :color="j.pin === 'first' ? 'indigo' : 'grey-7'" /></q-item-section><q-item-section>Figer en 1<sup>re</sup> position</q-item-section></q-item>
|
||
<q-item clickable @click="setPin(j, 'last')"><q-item-section avatar><q-icon name="vertical_align_bottom" :color="j.pin === 'last' ? 'indigo' : 'grey-7'" /></q-item-section><q-item-section>Figer en dernière position</q-item-section></q-item>
|
||
<q-item v-if="j.pin" clickable @click="setPin(j, null)"><q-item-section avatar><q-icon name="link_off" color="grey-7" /></q-item-section><q-item-section>Détacher</q-item-section></q-item>
|
||
</q-list>
|
||
</q-menu>
|
||
</q-btn>
|
||
<q-btn flat dense round size="sm" :icon="j.locked ? 'lock' : 'lock_open'" :color="j.locked ? 'deep-orange' : 'grey-5'" @click="j.locked = !j.locked"><q-tooltip>{{ j.locked ? 'Heure FIXE (RDV) — verrouillée, non replanifiée' : 'Heure flexible — replanifiée par la tournée' }}</q-tooltip></q-btn>
|
||
<q-btn flat dense round size="sm" icon="close" color="negative" @click="removeFromDay(j)"><q-tooltip>Retirer du tech (retour au pool)</q-tooltip></q-btn>
|
||
</template>
|
||
<q-btn v-else flat dense round size="sm" icon="open_in_new" color="green-5" @click.stop="openLegacyBlock({ lid: j.lid || j.legacy_id, subject: j.subject, dept: j.dept })"><q-tooltip>Ouvrir le ticket F (lecture seule)</q-tooltip></q-btn>
|
||
</div>
|
||
<div v-if="j.showDetail" class="de-detail">
|
||
<div v-if="j._thread && j._thread.loading" class="text-grey-6" style="font-size:11px"><q-spinner size="13px" class="q-mr-xs" />Chargement du fil…</div>
|
||
<template v-else-if="j._thread && j._thread.messages && j._thread.messages.length">
|
||
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ j._thread.messages.length }} message(s){{ j._thread.status ? ' · ' + j._thread.status : '' }}</div>
|
||
<div v-for="(m, mi) in j._thread.messages" :key="mi" class="de-msg">
|
||
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span></div>
|
||
<div class="de-msg-txt">{{ m.text }}</div>
|
||
</div>
|
||
</template>
|
||
<div v-else style="white-space:pre-wrap;font-size:11px">{{ j.detail || 'Aucun détail importé pour ce ticket.' }}<span v-if="j._thread && j._thread.error" class="text-warning"> · fil indisponible</span></div>
|
||
</div>
|
||
</template>
|
||
<!-- (jobs legacy F géocodés = fusionnés dans la liste éditable ci-dessus → pins sur la carte + tournée optimisable) -->
|
||
</q-card-section>
|
||
<q-card-section v-if="dayEditor.list.length" class="row items-center q-pt-none">
|
||
<span class="text-caption text-grey-6"><q-icon name="drag_indicator" size="13px" /> glisser (souris) · ▲▼ (mobile) = ordre · <q-icon name="push_pin" size="12px" /> figer 1<sup>er</sup>/dernier · 🔒 RDV fixe · total <b>{{ dayTotalH() }}h</b></span><q-space />
|
||
<q-btn v-if="dayEditor.list.some(j => !j.legacy && !hasLL(j) && j.address)" dense outline no-caps size="sm" color="warning" icon="place" :loading="batchLocating" :label="'Localiser ' + dayEditor.list.filter(j => !j.legacy && !hasLL(j) && j.address).length + ' sans GPS'" class="q-mr-sm" @click="batchLocate"><q-tooltip max-width="260px">Faire correspondre l'adresse de chaque job sans coordonnées à une adresse existante (base RQA) → ajoute le GPS. Adresses ambiguës = à situer à la main.</q-tooltip></q-btn>
|
||
<q-btn dense flat no-caps size="sm" color="indigo" icon="route" label="Optimiser depuis le départ" class="q-mr-sm" @click="optimizeFromOrigin"><q-tooltip max-width="280px">Réordonne par plus proche voisin depuis {{ dayOrigin ? (dayOrigin.kind === 'home' ? 'le domicile du tech' : 'le dépôt') : 'le 1er arrêt' }} → la 1re job devient la plus proche. Puis « Enregistrer » (les tickets F restent indicatifs).</q-tooltip></q-btn>
|
||
<q-btn v-if="dayEditor.list.some(j => !j.legacy)" dense unelevated color="primary" :loading="dayEditor.saving" label="Enregistrer" @click="saveDayOrder" />
|
||
<span v-else class="text-caption text-grey-6">Tickets F — ordre de tournée indicatif (non réécrit dans F)</span>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- #17 — Création de job NATIVE OPS (work-order) : réutilise le modal partagé. @created → rafraîchit le pool. -->
|
||
<UnifiedCreateModal v-model="createJobOpen" :mode="createMode" :context="createJobCtx"
|
||
:technicians="techs" :external-tags="tagCatalog" :external-get-color="getTagColor" @created="onJobCreated" />
|
||
<!-- Chooser « Créer » : Tâche · Intervention (sur site → créneau / à distance → ticket) — un seul point d'entrée -->
|
||
<q-dialog v-model="createChooser">
|
||
<q-card style="min-width:340px;max-width:440px">
|
||
<q-card-section class="row items-center q-pb-sm"><div class="text-h6">Créer…</div><q-space /><q-btn flat round dense icon="close" v-close-popup /></q-card-section>
|
||
<q-card-section class="q-pt-none column q-gutter-sm">
|
||
<q-btn no-caps unelevated align="left" color="grey-2" text-color="grey-9" class="cc-opt" @click="startCreate('task')">
|
||
<q-icon name="task_alt" color="indigo" size="24px" class="q-mr-md" /><div class="text-left"><div class="text-weight-medium">Tâche</div><div class="text-caption text-grey-7">Travail interne — sans client ni déplacement</div></div>
|
||
</q-btn>
|
||
<div class="text-caption text-weight-medium text-grey-7 q-mt-xs">Intervention</div>
|
||
<q-btn no-caps unelevated align="left" color="teal-1" text-color="teal-10" class="cc-opt" @click="startCreneau">
|
||
<q-icon name="construction" color="teal-8" size="24px" class="q-mr-md" /><div class="text-left"><div class="text-weight-medium">Sur site — trouver un créneau</div><div class="text-caption text-grey-7">Meilleur tech + date + heure (selon priorité & compétences)</div></div>
|
||
</q-btn>
|
||
<q-btn no-caps flat dense align="left" color="teal-9" class="cc-sub" @click="startCreate('work-order')"><q-icon name="inbox" size="16px" class="q-mr-sm" />…ou ajouter au pool (planifier plus tard)</q-btn>
|
||
<q-btn no-caps unelevated align="left" color="blue-1" text-color="blue-10" class="cc-opt" @click="startCreate('ticket')">
|
||
<q-icon name="headset_mic" color="blue-8" size="24px" class="q-mr-md" /><div class="text-left"><div class="text-weight-medium">À distance — ticket</div><div class="text-caption text-grey-7">Support ou vente (selon priorité & compétences), sans déplacement</div></div>
|
||
</q-btn>
|
||
<q-separator class="q-my-xs" />
|
||
<q-btn no-caps unelevated align="left" color="amber-1" text-color="amber-10" class="cc-opt" @click="createChooser = false; quoteWizardOpen = true">
|
||
<q-icon name="request_quote" color="amber-8" size="24px" class="q-mr-md" /><div class="text-left"><div class="text-weight-medium">Soumission</div><div class="text-caption text-grey-7">Vérifier l'adresse (fibre) → prospect → services & devis — même pour un non-client</div></div>
|
||
</q-btn>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
<!-- « Trouver un créneau » → chips compétence (durée par défaut) + adresse → choisit tech+date+heure puis pré-remplit la création -->
|
||
<SuggestSlotsDialog v-model="showSuggestSlots" :skills="slotSkills" @select="onSlotSelected" @plan-shifts="onPlanShifts" />
|
||
<!-- Soumission : wizard adresse+fibre+prospect → passe le relais à ProjectWizard (panier + rabais + devis + acceptation) -->
|
||
<QuoteWizard v-model="quoteWizardOpen" @qualified="onQuoteQualified" />
|
||
<ProjectWizard v-model="projectWizardOpen" :customer="quoteCustomer" :initial-tier="quoteTier" @created="onQuoteCreated" />
|
||
<!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) → écrit les Shift Assignment -->
|
||
<WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" />
|
||
<TechScheduleDialog v-model="techSchedOpen" :tech="techSchedTech" :pending-abs="pendingAbs" :pending-paused="!!(techSchedTech && pendingPause[techSchedTech.id])" :garde-map="gardeEffective" :refresh-key="techSchedRefresh"
|
||
@edit-schedule="onTechSchedEdit" @changed="onTechSchedChanged" @stage-abs="onStageAbs" @stage-pause="onStagePause"
|
||
@set-shift="onTechSchedSetShift" @remove-shift="onTechSchedRemoveShift" @clear-shifts="onTechSchedClearShifts" @toggle-garde="onTechSchedToggleGarde" />
|
||
</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, provide } from 'vue'
|
||
import { useRoute } from 'vue-router' // deep-link ?day=&skill= (tableau de bord, slot-finder)
|
||
// Icônes de rôle monochromes outline (Material Symbols, style « une couleur » demandé) : échelle = installation.
|
||
import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined'
|
||
import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
||
import { useQuasar } from 'quasar'
|
||
import * as roster from 'src/api/roster'
|
||
import { usePermissions } from 'src/composables/usePermissions'
|
||
import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement
|
||
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
|
||
import { installMaplibre, basemapStyle, createPlanMap } from 'src/config/basemap' // fond de carte MapLibre/OSM auto-hébergé (plus de Mapbox, plus de logo) + init commune des cartes
|
||
import { legacyDeptColor, heatColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé) + heatColor (source unique, aussi <OccupancyStrip>)
|
||
import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur)
|
||
import { relTime, initials } from 'src/composables/useFormatters' // temps relatif + initiales (source unique, ex-locales dé-dupliquées)
|
||
import { messageIdentity, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' // resolvers partagés + priorité (drapeau) réutilisée des conversations
|
||
import { useSla, SLA_BADGE } from 'src/composables/useSla' // SLA existant (politiques éditables) — réutilisé sur les jobs (échéance = création + résolution)
|
||
import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client
|
||
import TechSelect from 'src/components/shared/TechSelect.vue'
|
||
import AssignmentField from 'src/components/shared/AssignmentField.vue'
|
||
// Modules détail on-site/dispatch PARTAGÉS (mêmes composants que IssueDetail) — le volet job les consomme désormais.
|
||
import JobMapModule from 'src/components/shared/detail-sections/modules/JobMapModule.vue'
|
||
import GeofenceTimeline from 'src/components/shared/detail-sections/modules/GeofenceTimeline.vue'
|
||
import DurationField from 'src/components/shared/detail-sections/modules/DurationField.vue'
|
||
import RequiredSkillsField from 'src/components/shared/detail-sections/modules/RequiredSkillsField.vue'
|
||
import JobTeamField from 'src/components/shared/detail-sections/modules/JobTeamField.vue'
|
||
import JobThread from 'src/components/shared/detail-sections/modules/JobThread.vue'
|
||
import JobReplyBox from 'src/components/shared/detail-sections/modules/JobReplyBox.vue'
|
||
import LeaveDialog from 'src/components/planif/LeaveDialog.vue' // Congés & disponibilités (extrait — décomposition #4)
|
||
import TechSyncDialog from 'src/components/planif/TechSyncDialog.vue' // Synchroniser les techniciens (extrait — décomposition #4)
|
||
import ShiftTypesDialog from 'src/components/planif/ShiftTypesDialog.vue' // Types de shift (extrait — décomposition #4)
|
||
import WeekTemplatesDialog from 'src/components/planif/WeekTemplatesDialog.vue' // Modèles de semaine (ex-sous-menu Outils → dialogue, règle « pas de menu-dans-menu »)
|
||
import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
|
||
import HelpHint from 'src/components/shared/HelpHint.vue'
|
||
import { skillIcon, skillSym, markerIcon } from 'src/composables/useSkillIcons' // SOURCE UNIQUE des icônes de compétences (réutilisée fiche client)
|
||
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags/compétences PARTAGÉ (chips colorées, création + palette, niveaux) — SOURCE UNIQUE : jobs ET techs
|
||
import SkillCadenceTable from 'src/components/planif/SkillCadenceTable.vue' // éditeur compétences+cadence PARTAGÉ (volet tech + outil Équipe)
|
||
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 UserAvatar from 'src/components/shared/UserAvatar.vue' // avatar CANONIQUE identité-aware (photo + nom résolu + carte + renommage) — remplace les monogrammes bruts du board
|
||
import OverflowMenu from 'src/components/shared/OverflowMenu.vue' // « ⋮ » standardisé pour les actions secondaires (désencombrer la barre)
|
||
import JobPool from 'src/components/planif/JobPool.vue' // pool « Jobs à assigner » UNIFIÉ (flottant · kanban · mobile) — remplace les 3 réimplémentations
|
||
import { useJobPool } from 'src/composables/useJobPool' // cerveau filtre/tri/groupe/badges partagé par les 3 surfaces
|
||
import { useCommandPalette } from 'src/composables/useCommandPalette' // registre d'actions contextuelles → joignables via ⌘K même si retirées de la barre
|
||
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) — Dispatch déprécié, tout ici
|
||
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » → pré-remplit la création (tech+date+heure+adresse)
|
||
import QuoteWizard from 'src/components/shared/QuoteWizard.vue' // soumission : adresse+fibre+prospect (lead-first)
|
||
import { useCreateSignal } from 'src/composables/useCreateSignal' // FAB global « Créer » → ouvre le chooser ici
|
||
import ProjectWizard from 'src/components/shared/ProjectWizard.vue' // moteur soumission existant (panier + rabais + devis + acceptation) — réutilisé
|
||
import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) — repris/amélioré depuis Dispatch
|
||
import TechScheduleDialog from 'src/components/planif/TechScheduleDialog.vue' // écran unique horaire/pause/congés par tech (icône « event » de la rangée)
|
||
import PlanifCellMenu from 'src/components/planif/PlanifCellMenu.vue' // menu de cellule PARTAGÉ (grille + calendrier par tech)
|
||
import MonthOverview from 'src/components/planif/MonthOverview.vue' // vue MOIS : qui est en quart / absent + couverture par compétence (remplace « Demande »)
|
||
|
||
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
|
||
if (isAbsent(a.tech, a.date)) continue // congé / pause ce jour (serveur OU en attente) → quart matérialisé mais tech absent : hors capacité
|
||
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 days = ref(14) // 2 semaines par défaut (sélecteur retiré pour éviter les changements d'étendue accidentels)
|
||
const start = ref(thisMonday()) // défaut = semaine COURANTE (cohérent avec « Auj. ») — pas la semaine suivante
|
||
const lastWeek = reactive({ start: start.value, days: days.value })
|
||
const 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 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 skillFilter = ref([]) // (maxHours retiré — champ orphelin jamais consommé)
|
||
// Ressources masquées (hors front-line : compta, etc.) — masquées ET ignorées du calcul ; localStorage.
|
||
const hiddenTechs = ref([]); const showHidden = ref(false)
|
||
function isHidden (id) { return hiddenTechs.value.includes(id) }
|
||
function toggleHidden (id) { const i = hiddenTechs.value.indexOf(id); if (i >= 0) hiddenTechs.value.splice(i, 1); else hiddenTechs.value.push(id); localStorage.setItem('roster-hidden-techs-v1', JSON.stringify(hiddenTechs.value)) }
|
||
const hiddenCount = computed(() => techs.value.filter(t => isHidden(t.id)).length)
|
||
const showShiftEditor = ref(false) // v-model de <ShiftTypesDialog> (editTpls/newTpl/fonctions déplacés dans le composant)
|
||
const showWeekTemplates = ref(false) // v-model de <WeekTemplatesDialog> — l'état weekTemplates + mutations restent ICI (localStorage)
|
||
const showTeamEditor = ref(false); const editTechs = ref([]); const teamCost = reactive({})
|
||
const notifySms = ref(false)
|
||
// ── Notifier les techs de leur tournée (SMS/Email + lien token) : aperçu d'abord (compose sans envoi), puis envoi. ──
|
||
const notifyDlg = reactive({ open: false, loading: false, sending: false, techs: [] })
|
||
const notifyRecipientCount = computed(() => (notifyDlg.techs || []).filter(m => (m.wantSms && m.phone) || (m.wantEmail && m.email)).length)
|
||
function notifyScope () { // jour(s) + techs concernés = vue Tournées : jour choisi + techs ayant une tournée ; sinon semaine + techs visibles
|
||
const dates = boardView.value === 'routes' && routesDay.value ? [routesDay.value] : (dayList.value || []).map(d => d.iso)
|
||
const techIds = boardView.value === 'routes' ? (allDayRoutes.value || []).map(r => r.id) : (visibleTechs.value || []).map(t => t.id)
|
||
return { dates, techIds: [...new Set(techIds)] }
|
||
}
|
||
async function openNotifyDlg () {
|
||
const { dates, techIds } = notifyScope()
|
||
if (!dates.length || !techIds.length) { $q.notify({ type: 'warning', message: 'Aucune tournée à notifier (sélectionne un jour avec des jobs assignés).' }); return }
|
||
Object.assign(notifyDlg, { open: true, loading: true, techs: [] })
|
||
// Par tech : SMS PRÉFÉRÉ (coché si téléphone), courriel seulement si pas de téléphone. Toggle par personne → exclure certains (ex. test).
|
||
try { const r = await roster.notifyDispatch({ dates, tech_ids: techIds, preview: true }); notifyDlg.techs = ((r && r.techs) || []).map(m => ({ ...m, wantSms: !!m.phone, wantEmail: !m.phone && !!m.email, newPhone: '', newEmail: '', saving: false })) }
|
||
catch (e) { err(e) } finally { notifyDlg.loading = false }
|
||
}
|
||
async function sendNotify () {
|
||
const smsIds = notifyDlg.techs.filter(m => m.wantSms && m.phone).map(m => m.techId)
|
||
const emailIds = notifyDlg.techs.filter(m => m.wantEmail && m.email).map(m => m.techId)
|
||
if (!smsIds.length && !emailIds.length) { $q.notify({ type: 'warning', message: 'Aucun destinataire coché (SMS ou courriel sur au moins un tech).' }); return }
|
||
const { dates } = notifyScope()
|
||
notifyDlg.sending = true
|
||
try {
|
||
let sms = 0, email = 0; const errs = []
|
||
if (smsIds.length) { const r = await roster.notifyDispatch({ dates, tech_ids: smsIds, sms: true, email: false }); sms = r.sms || 0; if (r.errors) errs.push(...r.errors) }
|
||
if (emailIds.length) { const r = await roster.notifyDispatch({ dates, tech_ids: emailIds, sms: false, email: true }); email = r.email || 0; if (r.errors) errs.push(...r.errors) }
|
||
$q.notify({ type: errs.length ? 'warning' : 'positive', message: `Notifié : ${sms} SMS · ${email} courriel(s)` + (errs.length ? ` · ${errs.length} échec(s)` : ''), timeout: 4000 })
|
||
if (errs.length) console.warn('notify errors', errs)
|
||
notifyDlg.open = false
|
||
} catch (e) { err(e) } finally { notifyDlg.sending = false }
|
||
}
|
||
// Édition INLINE du contact d'un tech sans coordonnées, sans quitter le dialogue (comme ailleurs dans l'app).
|
||
async function saveNotifyContact (m) {
|
||
const phone = String(m.newPhone || '').trim(); const email = String(m.newEmail || '').trim()
|
||
if (!phone && !email) return
|
||
m.saving = true
|
||
try {
|
||
const r = await roster.setTechContact({ tech_id: m.techId, phone: phone || undefined, email: email || undefined })
|
||
if (r && r.ok) { if (phone) { m.phone = phone; m.wantSms = true } if (email) { m.email = email; if (!phone) m.wantEmail = true } m.newPhone = ''; m.newEmail = ''; $q.notify({ type: 'positive', message: 'Coordonnées enregistrées', timeout: 1800 }) }
|
||
else $q.notify({ type: 'negative', message: (r && r.error) || 'Échec de l’enregistrement' })
|
||
} catch (e) { err(e) } finally { m.saving = false }
|
||
}
|
||
const showLeave = ref(false) // ouverture de <LeaveDialog> (état leaveRows/leaveFilter/newLeave + fonctions déplacés dans le composant)
|
||
// numToTime : heure décimale → HH:MM (partagé — aussi utilisé par le glisser-créer de quart). newTpl/newTplRange déplacés dans ShiftTypesDialog.
|
||
function numToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') }
|
||
|
||
const LS_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', 'month'].includes(planifPrefs.value.view) ? planifPrefs.value.view : 'grid') // 'grid' | 'kanban' | 'month' | 'routes'
|
||
watch(boardView, (v) => { savePlanif({ view: v }); if (v === 'kanban') { kbSelIso.value = selDay.value || nowET.value.iso; if (!dayList.value.find(d => d.iso === kbSelIso.value)) { start.value = mondayISO(kbSelIso.value); loadWeek() } _kbDidScroll = false; nextTick(() => setTimeout(kbScrollToDefault, 160)) } else if (v === 'grid' && days.value < 7) { days.value = 7; loadWeek() } else if (v === 'month' && _draftT) { autosaveDraft() } }) // Jour reprend le jour SÉLECTIONNÉ (selDay), plus « aujourd'hui » ; Mois → flush du brouillon (le calendrier lit le serveur)
|
||
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)
|
||
// ── Jour SÉLECTIONNÉ partagé entre les vues (Semaine · Jour · Tournées · mobile) — VARIABLE DE SESSION :
|
||
// choisi dans une vue → mémorisé (sessionStorage, survit au refresh de l'onglet) → repris par les autres vues
|
||
// au lieu de retomber sur « aujourd'hui ». Source unique aussi pour le jour cible de « Suggérer ». ──
|
||
const SEL_DAY_KEY = 'planif_sel_day'
|
||
const selDay = ref((() => { try { const v = sessionStorage.getItem(SEL_DAY_KEY); return (v && /^\d{4}-\d{2}-\d{2}$/.test(v)) ? v : todayISO() } catch (e) { return todayISO() } })())
|
||
function setSelDay (iso) { if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(String(iso))) return; selDay.value = iso; try { sessionStorage.setItem(SEL_DAY_KEY, iso) } catch (e) {} }
|
||
watch(kbSelIso, v => setSelDay(v))
|
||
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
|
||
setSelDay(v) // le sélecteur de date de la barre choisit AUSSI le jour partagé (repris par Jour/Tournées/Suggérer)
|
||
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() }
|
||
},
|
||
})
|
||
// Pool kanban → instance useJobPool unifiée (créée plus bas avec les autres surfaces).
|
||
// Blocs d'une lane : depuis l'occupation BRUTE (occByTechDay = TOUS les jobs assignés ; robuste vs shift-gating qui
|
||
// faisait « disparaître » la carte). Timés (start_h) à leur heure ; non-timés en flow ; inclut les miroirs « assist ».
|
||
const KB_DAY_START = 8 // 8h AM (style Gaiia) : départ par défaut de la tournée
|
||
const kbMatrix = ref({}); const _kbMatPending = new Set() // cache matrice Mapbox par tournée (tech|jour) → 1 call/tournée
|
||
function kanbanLaneBlocks (techId) {
|
||
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return []
|
||
const o = occByTechDay.value[techId + '|' + iso] || { jobs: [] }
|
||
const dur = (j) => Number(j.dur) || 1
|
||
// Jobs réels (occupation) + jobs TERRAIN legacy (synthétique F, comme la grille semaine) : non draggables, clic = ouvre le ticket.
|
||
const base = (o.jobs || []).slice()
|
||
const lg = showLegacyLoad.value ? legacyLoad.value[techId + '|' + iso] : null
|
||
if (lg && Array.isArray(lg.jobs)) { for (const lj of lg.jobs) { if (!lj.field) continue; const d = Number(lj.est_h) || 0; if (d <= 0) continue; base.push({ name: 'LEG:' + lj.id, legacy: true, lid: lj.id, subject: lj.subject, dept: lj.dept, skill: lj.skill, dur: d, start_h: null, route_order: 9999 }) } }
|
||
if (!base.length) return []
|
||
// Ordre de tournée : route_order puis heure.
|
||
const jobs = base.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
|
||
// RDV CONFIRMÉS + miroirs ASSISTANT = verrouillés à leur heure (le renfort suit l'heure du lead) ; tout le reste COULE depuis 8h.
|
||
const fixed = jobs.filter(j => (j.booking_status === 'Confirmé' || j.assist) && j.start_h != null)
|
||
const out = fixed.map(j => ({ ...j, s: j.start_h, e: j.start_h + dur(j), fixed: true }))
|
||
let cursor = KB_DAY_START
|
||
const mat = (kbMatrix.value[techId + '|' + iso] || {}).map || null // matrice Mapbox RÉELLE (1 call/tournée) si chargée
|
||
const dep = depot.value
|
||
let prev = (dep && dep.lat != null) ? { lat: +dep.lat, lon: +dep.lon } : null
|
||
let prevName = (dep && dep.lat != null) ? 'DEPOT' : null
|
||
for (const j of jobs) {
|
||
if (j.assist) continue // miroir assistant : déjà placé (fixed) ; ne coule pas et ne compte pas dans le trajet de l'assistant
|
||
if (j.booking_status === 'Confirmé' && j.start_h != null) { if (j.lat != null) { prev = { lat: +j.lat, lon: +j.lon }; prevName = j.name } continue }
|
||
const d = dur(j); const blk = { ...j }
|
||
// Trajet d'approche : temps ROUTIER RÉEL (matrice Mapbox) si dispo, sinon repli à vol d'oiseau (~55 km/h).
|
||
let legMin = 0, legKm = 0
|
||
const real = (mat && prevName && j.name) ? mat[prevName + '>' + j.name] : null
|
||
if (real) { legMin = Math.min(120, real.min); legKm = real.km != null ? real.km : 0 }
|
||
else if (prev && j.lat != null && j.lon != null) { const km = haversineKm(prev.lat, prev.lon, +j.lat, +j.lon); if (km != null && km > 0.05) { legKm = km; legMin = Math.min(60, Math.round(km * 1.1)) } }
|
||
if (legMin >= 3) { blk.legS = cursor; blk.legMin = legMin; blk.legKm = Math.round(legKm * 10) / 10; blk.legReal = !!real; cursor += legMin / 60; blk.legE = cursor }
|
||
let guard = 0; while (guard++ < 50) { const c = fixed.find(f => cursor < (f.start_h + dur(f)) && (cursor + d) > f.start_h); if (c) cursor = c.start_h + dur(c); else break }
|
||
blk.s = cursor; blk.e = cursor + d; out.push(blk); cursor += d
|
||
if (j.lat != null && j.lon != null) { prev = { lat: +j.lat, lon: +j.lon }; prevName = j.name }
|
||
}
|
||
return out
|
||
}
|
||
// Matrice OSRM RÉELLE par tournée (tech×jour) : 1 SEUL appel /tournée (≤25 coords) → temps de route réels pour tous les legs. Cache par signature (set de jobs) → pas de refetch inutile.
|
||
async function fetchKbMatrix (techId) {
|
||
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return
|
||
const key = techId + '|' + iso
|
||
const o = occByTechDay.value[key]
|
||
const jobs = (o && o.jobs ? o.jobs : []).filter(j => isFinite(+j.lat) && isFinite(+j.lon) && (+j.lat || +j.lon))
|
||
const sig = jobs.map(j => j.name).join(',')
|
||
if (jobs.length < 1) return
|
||
const cached = kbMatrix.value[key]; if ((cached && cached.sig === sig) || _kbMatPending.has(key)) return
|
||
const dep = depot.value; const pts = []
|
||
if (dep && isFinite(+dep.lat) && isFinite(+dep.lon)) pts.push({ name: 'DEPOT', lat: +dep.lat, lon: +dep.lon })
|
||
for (const j of jobs) pts.push({ name: j.name, lat: +j.lat, lon: +j.lon })
|
||
if (pts.length < 2) return
|
||
const use = pts.slice(0, 25) // Matrix = 25 coords max
|
||
_kbMatPending.add(key)
|
||
try {
|
||
const d = await roster.osrmTable(use.map(p => [p.lon, p.lat])) // OSRM auto-hébergé (via hub) — même forme que Mapbox Matrix
|
||
const dur = d.durations || [], dist = d.distances || []; const map = {}
|
||
for (let i = 0; i < use.length; i++) for (let k = 0; k < use.length; k++) { if (i === k) continue; const sec = dur[i] && dur[i][k]; if (sec == null) continue; const m = dist[i] && dist[i][k]; map[use[i].name + '>' + use[k].name] = { min: Math.max(2, Math.round(sec / 60)), km: m != null ? Math.round(m / 100) / 10 : null } }
|
||
kbMatrix.value = { ...kbMatrix.value, [key]: { sig, map } } // réactif → les legs se recalculent (temps routiers réels)
|
||
} catch (e) { /* repli haversine (géré dans kanbanLaneBlocks) */ } finally { _kbMatPending.delete(key) }
|
||
}
|
||
let _kbMatT = null // le watch déclencheur est enregistré PLUS BAS (après la déclaration de occByTechDay) pour éviter la zone morte temporelle
|
||
async function reloadPool () { try { assignPanel.jobs = capPickups((await roster.unassignedJobs()).jobs) } catch (e) { /* non bloquant */ } }
|
||
// ── Création de job NATIVE OPS (#17) — Dispatch déprécié → tout depuis Planification. Réutilise UnifiedCreateModal (work-order). ──
|
||
// Job OPS-natif = PAS de legacy_ticket_id → coexiste avec les jobs synchronisés de F (la sync F ne touche que les jobs à legacy_ticket_id). « Sync F transitoire » = coexistence, pas de push F pour l'instant.
|
||
const createJobOpen = ref(false)
|
||
const createJobCtx = ref({})
|
||
const showSuggestSlots = ref(false)
|
||
const createChooser = ref(false) // chooser « Créer » : Tâche / Intervention (sur site / à distance)
|
||
const createMode = ref('work-order') // mode passé à UnifiedCreateModal : 'task' | 'work-order' | 'ticket'
|
||
function openCreateJob () { createMode.value = 'work-order'; createJobCtx.value = { scheduled_date: '' }; createJobOpen.value = true } // pool (non planifié) par défaut
|
||
// Point d'entrée unifié « Créer ». startCreate ouvre le formulaire dans le bon mode ; startCreneau ouvre plutôt le solveur de créneau (sur site).
|
||
function startCreate (mode) { createMode.value = mode; createJobCtx.value = { scheduled_date: '' }; createChooser.value = false; createJobOpen.value = true }
|
||
function startCreneau () { createMode.value = 'work-order'; createChooser.value = false; showSuggestSlots.value = true }
|
||
// Soumission : QuoteWizard qualifie (adresse+fibre+prospect) → on ouvre ProjectWizard pré-rempli avec le client.
|
||
const quoteWizardOpen = ref(false)
|
||
const projectWizardOpen = ref(false)
|
||
const quoteCustomer = ref(null)
|
||
const quoteTier = ref(null) // forfait d'intérêt choisi dans la vitrine QuoteWizard → pré-remplit le panier ProjectWizard
|
||
function onQuoteQualified (payload) {
|
||
const cust = (payload && payload.customer) || null
|
||
// Rattache l'adresse qualifiée (+ zone tarifaire) au client passé à ProjectWizard → portée sur le devis (le prix dépend de l'adresse).
|
||
quoteCustomer.value = cust ? { ...cust, qualified_address: (payload && payload.address) || null } : null
|
||
quoteTier.value = (payload && payload.tier) || null
|
||
if (quoteCustomer.value) projectWizardOpen.value = true
|
||
}
|
||
function onQuoteCreated (job, meta) {
|
||
projectWizardOpen.value = false
|
||
// Pas de contrat signé requis (JWT/DocuSeal) → on enchaîne direct sur « Trouver un créneau » d'installation. Sinon : attendre l'approbation.
|
||
if (meta && !meta.needsAcceptance) {
|
||
$q.dialog({ title: 'Soumission créée', message: 'Planifier l\'installation maintenant (trouver un créneau) ?', cancel: 'Plus tard', ok: { label: 'Trouver un créneau', color: 'teal-8', noCaps: true }, persistent: false })
|
||
.onOk(() => { showSuggestSlots.value = true })
|
||
} else {
|
||
$q.notify({ type: 'positive', icon: 'request_quote', message: 'Soumission créée — en attente d\'approbation client avant de planifier', timeout: 3200 })
|
||
}
|
||
}
|
||
function onSlotSelected (slot) { // un créneau choisi → pré-remplit tech+date+heure+adresse+coords+compétence
|
||
createJobCtx.value = { assigned_tech: slot.tech_id, scheduled_date: slot.date, start_time: slot.start_time, address: slot._address || '', duration_h: slot._duration || 1, _latitude: slot._latitude, _longitude: slot._longitude, tags: slot._skill ? [slot._skill] : [] }
|
||
createJobOpen.value = true
|
||
}
|
||
async function onJobCreated () { createJobOpen.value = false; await reloadPool(); await reloadOccupancy(); $q.notify({ type: 'positive', icon: 'add_task', message: 'Job créé — visible dans « À assigner »', timeout: 2600 }) }
|
||
// Drop sur la colonne « À assigner » = renvoyer au pool (désassigner, ERPNext only).
|
||
async function onKanbanUnassign (ev) {
|
||
ev.preventDefault()
|
||
const raw = (ev.dataTransfer && ev.dataTransfer.getData('text/plain')) || draggingJobName.value; draggingJobName.value = null
|
||
const names = (raw || '').split(',').filter(Boolean); onJobDragEnd(); if (!names.length) return
|
||
let ok = 0; for (const n of names) { try { await roster.unassignJobRoster(n); ok++ } catch (e) { err(e) } } // SÉQUENTIEL (frappe_pg)
|
||
if (ok) { $q.notify({ type: 'info', message: ok + ' job(s) renvoyé(s) au pool', timeout: 2200 }); await reloadOccupancy(); await reloadPool() }
|
||
}
|
||
// Clic droit sur une carte job → désassigner directement (retour au pool), sans devoir la glisser sur un autre tech.
|
||
async function unassignOne (b) {
|
||
const jn = b && (b.name || b.job); if (!jn || b.assist || b.legacy) return // Kanban=name, grille=job ; legacy/assist non désassignables
|
||
try { await roster.unassignJobRoster(jn); await reloadOccupancy(); await reloadPool(); $q.notify({ type: 'info', message: (b.subject || b.skill || 'Job') + ' renvoyé au pool', timeout: 2200 }) } catch (e) { err(e) }
|
||
}
|
||
// Ligne-curseur « maintenant » (Eastern Time, America/Toronto), rafraîchie chaque minute — style Gaiia.
|
||
const nowTick = ref(0); let _nowTimer = null
|
||
const nowET = computed(() => {
|
||
nowTick.value // dépendance réactive → recalcule à chaque tick (minute)
|
||
try {
|
||
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Toronto', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(new Date())
|
||
const o = {}; for (const p of parts) o[p.type] = p.value
|
||
const hh = o.hour === '24' ? 0 : Number(o.hour)
|
||
return { iso: `${o.year}-${o.month}-${o.day}`, h: hh + Number(o.minute) / 60 }
|
||
} catch (e) { return { iso: '', h: null } }
|
||
})
|
||
const nowHHMM = computed(() => { const n = nowET.value; if (n.h == null) return ''; const h = Math.floor(n.h); return String(h).padStart(2, '0') + ':' + String(Math.round((n.h - h) * 60)).padStart(2, '0') })
|
||
// ── Échelle d'heures du board en PIXELS (style Gaiia/dispatch) : 7h–18h REMPLIT la largeur dispo (mesurée),
|
||
// scroll horizontal pour les heures hors plage ; lanes hautes + rangées serrées = plus de place pour les détails. ──
|
||
const KB_VIS_FROM = 6; const KB_VIS_TO = 18; const KB_NAME_W = 150 // fenêtre de REMPLISSAGE 6–18h ; 0–6 et 18–24 restent SCROLLABLES (continuité)
|
||
const kbBoardEl = ref(null); const kbBoardW = ref(1100); let _kbRO = null
|
||
const kbScrollEl = ref(null); let _kbDidScroll = false
|
||
function kbScrollToDefault () { if (kbScrollEl.value) { kbScrollEl.value.scrollLeft = Math.max(0, (KB_VIS_FROM - kbAxis.value.min) * kbPxH.value); _kbDidScroll = true } } // cale la vue sur 6h (0–6 reste accessible en scrollant à gauche)
|
||
watch(kbBoardEl, (el) => { if (_kbRO) { _kbRO.disconnect(); _kbRO = null } if (el) { _kbRO = new ResizeObserver(es => { kbBoardW.value = es[0].contentRect.width; if (!_kbDidScroll) nextTick(kbScrollToDefault) }); _kbRO.observe(el); kbBoardW.value = el.clientWidth || 1100 } })
|
||
// Plage RENDUE : couvre au moins 7–18, étendue aux heures réelles des shifts (scroll pour le hors-plage).
|
||
// Vue calée sur 7–18h : on NE rend PAS 00–06h (ni la nuit/garde). Min fixe à 7 ; max étendu aux jobs réels mais plafonné à 21h.
|
||
const kbAxis = computed(() => ({ min: 0, max: 24 })) // rendu PLEINE JOURNÉE → scroll continu (0–6 à gauche, 18–24 à droite) ; 6–18 remplit la largeur
|
||
const kbPxH = computed(() => Math.max(46, (kbBoardW.value - KB_NAME_W - 4) / (KB_VIS_TO - KB_VIS_FROM))) // px/heure : 7–18 = largeur dispo
|
||
const kbInnerW = computed(() => Math.round((kbAxis.value.max - kbAxis.value.min) * kbPxH.value))
|
||
// Lane : fond BLANC + lignes verticales pâles toutes les 0,5 h (alignées sur l'axe : 0 = kbAxis.min).
|
||
const kbLaneStyle = computed(() => { const half = Math.max(8, kbPxH.value / 2); return { width: kbInnerW.value + 'px', backgroundColor: '#eef1f4', backgroundImage: 'repeating-linear-gradient(90deg, rgba(0,0,0,.05) 0 1px, transparent 1px ' + half + 'px)' } }) // gris pâle = indispo ; le quart (.kbb-shift) y crée une zone blanche
|
||
function kbPos (s, e) { const m = kbAxis.value.min, p = kbPxH.value; const L = (Math.max(m, s) - m) * p; const W = (Math.min(e, kbAxis.value.max) - Math.max(m, s)) * p; return { left: Math.round(L) + 'px', width: Math.max(6, Math.round(W)) + 'px' } }
|
||
const kbTicks = computed(() => { const a = kbAxis.value; const out = []; for (let h = a.min; h <= a.max; h++) out.push({ h, left: Math.round((h - a.min) * kbPxH.value) + 'px' }); return out })
|
||
// Bandes de quart (px) : réguliers + garde (brut depuis cellsOf/templates, sans pos %).
|
||
function kbShiftBands (techId) {
|
||
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return []
|
||
let s = Infinity, e = -Infinity
|
||
for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || t.on_call) continue; const ss = hToNum(t.start_time), ee = hToNum(t.end_time); if (ss != null) s = Math.min(s, ss); if (ee != null) e = Math.max(e, ee <= ss ? 24 : ee) }
|
||
// Allonge la zone blanche du quart pour COUVRIR les blocs qui débordent (jobs + trajets) ; garde = bande séparée.
|
||
const blks = kanbanLaneBlocks(techId)
|
||
if (!isFinite(s)) { if (!blks.length) return []; s = Math.min(...blks.map(b => b.s)); e = Math.max(...blks.map(b => b.e)) }
|
||
else for (const b of blks) { if (b.s < s) s = b.s; if (b.e > e) e = b.e }
|
||
return [{ s, e }]
|
||
}
|
||
const kbOnGarde = (techId) => kanbanDay.value ? onGarde(techId, kanbanDay.value.iso) : false
|
||
// Garde = shift SUR APPEL : bande jaune pâle sur la timeline (heures de garde) + téléphone rouge (pas une icône dans le nom).
|
||
function kbGardeBands (techId) {
|
||
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return []
|
||
const g = gardeEffective.value[techId + '|' + iso]; if (!g) return []
|
||
const t = tplByName.value[g]; if (!t) return []
|
||
const s = hToNum(t.start_time), e = hToNum(t.end_time); if (s == null || e == null) return []
|
||
return [{ s, e: e <= s ? 24 : e }]
|
||
}
|
||
// Ligne « maintenant » (px) — seulement si la colonne = aujourd'hui (ET) et dans la plage rendue.
|
||
const nowLeftPx = computed(() => { const n = nowET.value; if (n.h == null || !kanbanDay.value || kanbanDay.value.iso !== n.iso) return null; const a = kbAxis.value; if (n.h < a.min || n.h > a.max) return null; return (n.h - a.min) * kbPxH.value })
|
||
const nowLineStyle = computed(() => { const x = nowLeftPx.value; return x == null ? null : { left: (KB_NAME_W + Math.round(x)) + 'px' } }) // trait continu (inclut la colonne noms)
|
||
const nowLblStyle = computed(() => { const x = nowLeftPx.value; return x == null ? null : { left: Math.round(x) + 'px' } }) // étiquette dans la règle (après les noms)
|
||
// Export GPX du parcours GPS (jour courant) via Traccar — si le tech a un appareil associé.
|
||
const kbIsToday = computed(() => { const n = nowET.value; return !!(kanbanDay.value && n.iso && kanbanDay.value.iso === n.iso) })
|
||
function exportGpx (techId) {
|
||
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return
|
||
const from = new Date(iso + 'T00:00:00').toISOString(); const to = new Date(iso + 'T23:59:59').toISOString()
|
||
window.open(roster.gpxUrl(techId, from, to), '_blank')
|
||
}
|
||
// ── Détails d'un job : double-clic sur un bloc → grand volet DROIT (billet + commentaires) ; simple clic = éditeur de jour. ──
|
||
const jobDetail = reactive({ open: false, name: '', subject: '', customer: '', customerId: '', address: '', skill: '', skills: [], time: '', durH: 1, detail: '', lid: null, iso: '', dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null, assignTech: null, geofence: null, status: '' })
|
||
// Décode les entités HTML (« d'équipement » → « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés).
|
||
const _deEntEl = typeof document !== 'undefined' ? document.createElement('textarea') : null
|
||
function deEnt (s) { if (!s || String(s).indexOf('&') < 0 || !_deEntEl) return s || ''; _deEntEl.innerHTML = String(s); return _deEntEl.value }
|
||
async function openJobDetail (b, t) {
|
||
if (!b) return
|
||
jdShowTrack.value = false; jdShowMap.value = false
|
||
const durH0 = b.dur != null ? +b.dur : (b.est_min ? +b.est_min / 60 : (b.duration_h != null ? +b.duration_h : 1))
|
||
// b peut être un BLOC assigné (b.customer/skill/detail/legacy_id) OU un job BRUT du pool (customer_name/required_skill/
|
||
// legacy_detail/legacy_ticket_id) — ouvert depuis une goutte/carrousel. On mappe les DEUX formes pour toujours montrer
|
||
// client, compétence, description et le fil du billet.
|
||
const lid = b.legacy_id || b.legacy_ticket_id || null
|
||
Object.assign(jobDetail, { open: true, name: b.name || '', subject: deEnt(b.subject || b.name || 'Job'), customer: deEnt(b.customer || b.customer_name || ''), customerId: b.customer_id || '', address: deEnt(b.address || b.service_location || ''), skill: b.skill || b.required_skill || '', skills: (Array.isArray(b.required_skills) && b.required_skills.length ? b.required_skills.filter(Boolean) : (b.skill || b.required_skill ? [b.skill || b.required_skill] : [])), time: (b.start ? (b.start + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : '')) : (b.scheduled_date ? fmtDueLabel(b.scheduled_date) : '')), durH: Math.round((durH0 || 1) * 100) / 100, detail: deEnt(b.detail || b.legacy_detail || ''), lid, iso: b.scheduled_date || b.iso || (boardView.value === 'routes' ? routesDay.value : '') || '', dept: b.dept || b.legacy_dept || '', techId: (t && t.id) || b.assigned_tech || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : (b.latitude != null ? +b.latitude : null), lon: b.lon != null ? +b.lon : (b.longitude != null ? +b.longitude : null), loading: !!lid, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null, assignTech: null, status: b.status || '' })
|
||
// Géofencing (suivi façon colis) : timeline En route → Arrivé → Reparti, non bloquant.
|
||
jobDetail.geofence = null
|
||
if (b.name) roster.jobGeofence(b.name).then(g => { if (jobDetail.name === b.name) jobDetail.geofence = g }).catch(() => {})
|
||
if (lid) { try { jobDetail.thread = await roster.ticketThread(lid) } catch (e) { jobDetail.thread = { error: true, messages: [] } } finally { jobDetail.loading = false } }
|
||
if (jobDetail.canTeam) { jobDetail.teamLoading = true; try { const r = await roster.getJobTeam(b.name); jobDetail.team = r.assistants || [] } catch (e) { jobDetail.team = [] } finally { jobDetail.teamLoading = false } }
|
||
}
|
||
// Timeline géofencing pour l'affichage (façon suivi de colis) : 4 étapes + heure atteinte.
|
||
const GEO_STEPS = [{ k: 'assigned', label: 'Assigné', icon: 'assignment_ind' }, { k: 'en_route', label: 'En route', icon: 'directions_car' }, { k: 'on_site', label: 'Arrivé sur place', icon: 'location_on' }, { k: 'departed', label: 'Reparti', icon: 'check_circle' }]
|
||
const geoTimeline = computed(() => {
|
||
const gf = jobDetail.geofence; const events = (gf && gf.events) || []
|
||
const atOf = (k) => { const e = [...events].reverse().find(x => x.status === k); return e ? new Date(e.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', timeZone: 'America/Toronto' }) : null }
|
||
const order = { assigned: 0, en_route: 1, on_site: 2, departed: 3 }
|
||
const curIdx = order[(gf && gf.state) || 'assigned'] ?? 0
|
||
return GEO_STEPS.map((s, i) => ({ ...s, at: s.k === 'assigned' ? null : atOf(s.k), done: i <= curIdx, current: i === curIdx }))
|
||
})
|
||
// Options du sélecteur d'assistant (TechSelect, recherche par nom OU compétence) : tous les techs sauf le lead + l'équipe déjà là.
|
||
// Label = nom · compétences (searchable) ; CAPABLES d'abord (possèdent la compétence du job) ; value = id (scalaire).
|
||
function techNameById (id) { const t = (techs.value || []).find(x => x.id === id); return t ? t.name : '' } // repli nom d'un assistant depuis l'id
|
||
// Nom d'assistant ROBUSTE : une vieille ligne a pu être persistée avec tech_name = la CHAÎNE « undefined »/« null » → on la traite comme vide et on résout par l'id.
|
||
function jdAssistantName (a) { const bad = v => !v || v === 'undefined' || v === 'null'; if (a && !bad(a.tech_name)) return a.tech_name; const n = techNameById(a && a.tech_id); return n || (a && !bad(a.tech_id) ? a.tech_id : '—') }
|
||
// Éditeur de COMPÉTENCES REQUISES du job (TagEditor partagé : chips colorées + création + palette) + attribution à un tech (dispatch auto).
|
||
const jdSkillTech = ref(null)
|
||
const jdSkillBusy = ref(false)
|
||
// Normalise la sortie d'un éditeur de compétences → libellés propres dédupliqués.
|
||
// TagEditor émet un tableau de libellés (ou de {tag}) ; on tolère aussi une CSV (rétro-compat).
|
||
function normSkillList (list) {
|
||
const raw = Array.isArray(list) ? list : String(list || '').split(',')
|
||
return [...new Set(raw.map(x => (typeof x === 'string' ? x : (x && x.tag) || '')).map(s => String(s).trim()).filter(Boolean))]
|
||
}
|
||
// LISTE de compétences requises du job → store hub /roster/job-skills (PAS un champ ERPNext). required_skill(principale)=1re. Le tech doit les avoir TOUTES.
|
||
async function jdSetJobSkills (list) {
|
||
const arr = normSkillList(list)
|
||
jobDetail.skills = arr; jobDetail.skill = arr[0] || ''
|
||
if (jobDetail.name) { try { await roster.setJobSkills(jobDetail.name, arr); if (typeof reloadPool === 'function') reloadPool() } catch (e) { err(e) } }
|
||
}
|
||
const jdAllTechOpts = computed(() => (techs.value || []).map(t => ({ label: t.name + ((t.skills || []).length ? ' · ' + t.skills.slice(0, 3).join(', ') : ''), value: t.id })))
|
||
async function jdGiveSkillToTech () { // attribue au tech TOUTES les compétences requises du job qu'il n'a pas encore
|
||
const req = (jobDetail.skills && jobDetail.skills.length) ? jobDetail.skills : (jobDetail.skill ? [jobDetail.skill] : [])
|
||
const tid = jdSkillTech.value; if (!req.length || !tid) return
|
||
const t = (techs.value || []).find(x => x.id === tid); if (!t) return
|
||
const missing = req.filter(s => !(t.skills || []).includes(s))
|
||
if (!missing.length) { $q.notify({ type: 'info', message: t.name + ' a déjà ces compétences' }); jdSkillTech.value = null; return }
|
||
jdSkillBusy.value = true
|
||
try { const skills = [...(t.skills || []), ...missing]; await roster.setTechSkills(t.id, skills.join(','), t.skill_levels || {}, t.skill_eff || {}); t.skills = skills; jdSkillTech.value = null; $q.notify({ type: 'positive', icon: 'construction', message: missing.join(', ') + ' → ' + t.name + ' (dispatch)' }) } catch (e) { err(e) } finally { jdSkillBusy.value = false }
|
||
}
|
||
const jdTeamOptions = computed(() => {
|
||
const taken = new Set([jobDetail.techId, ...(jobDetail.team || []).map(a => a.tech_id)])
|
||
const req = jobDetail.required_skill || jobDetail.skill || ''
|
||
return (techs.value || []).filter(t => !taken.has(t.id))
|
||
.map(t => { const sk = (t.skills || []); return { capable: !!(req && sk.includes(req)), label: t.name + (sk.length ? ' · ' + sk.slice(0, 4).join(', ') : ''), value: t.id } })
|
||
.sort((a, b) => (b.capable - a.capable) || a.label.localeCompare(b.label))
|
||
.map(({ label, value }) => ({ label, value }))
|
||
})
|
||
// Fil du billet, PLUS RÉCENT EN HAUT (le dernier commentaire porte souvent l'info la plus importante).
|
||
const jdMessages = computed(() => { const m = (jobDetail.thread && jobDetail.thread.messages) || []; return m.slice().reverse() })
|
||
// Répondre au fil depuis la job (note interne par défaut ; au client si coché). Attribution = agent connecté (prénom+nom).
|
||
const { userName: _opsUserName } = usePermissions()
|
||
const jdReply = ref('')
|
||
const jdReplyPublic = ref(false)
|
||
const jdReplySending = ref(false)
|
||
async function sendJdReply () {
|
||
const text = jdReply.value.trim(); if (!text || jdReplySending.value) return
|
||
jdReplySending.value = true
|
||
try {
|
||
const r = await roster.postJobComment({ job: jobDetail.name, lid: jobDetail.lid, text, isPublic: jdReplyPublic.value, agentName: _opsUserName.value || '' })
|
||
if (r && r.ok) {
|
||
const wasPublic = jdReplyPublic.value
|
||
jdReply.value = ''; jdReplyPublic.value = false
|
||
$q.notify({ type: 'positive', message: wasPublic ? 'Réponse envoyée au client' : 'Note interne ajoutée', timeout: 2200 })
|
||
if (jobDetail.lid) { try { jobDetail.thread = await roster.ticketThread(jobDetail.lid) } catch (e) {} } // recharge le fil
|
||
} else { $q.notify({ type: 'negative', message: (r && r.error) || 'Échec de l\'envoi', timeout: 3000 }) }
|
||
} catch (e) { $q.notify({ type: 'negative', message: 'Échec de l\'envoi', timeout: 3000 }) } finally { jdReplySending.value = false }
|
||
}
|
||
// Recharge le fil du volet après un envoi via le module JobReplyBox (le module poste ; le volet possède jobDetail.thread).
|
||
async function reloadJdThread () { if (jobDetail.lid) { try { jobDetail.thread = await roster.ticketThread(jobDetail.lid) } catch (e) { /* garde l'existant */ } } }
|
||
async function jdAddAssistant () {
|
||
const id = jobDetail.teamAdd; if (!id || !jobDetail.name) return
|
||
const tech = (techs.value || []).find(t => t.id === id)
|
||
// Nom robuste : tech chargé, sinon libellé de l'option choisie (« Nom · compétences »), sinon l'id — jamais « undefined ».
|
||
const opt = (jdTeamOptions.value || []).find(o => o.value === id)
|
||
const name = (tech && tech.name) || (opt && String(opt.label || '').split(' · ')[0].trim()) || String(id)
|
||
try {
|
||
await roster.addAssistant(jobDetail.name, { tech_id: String(id), tech_name: name, duration_h: 0, pinned: 1 })
|
||
jobDetail.teamAdd = null
|
||
const r = await roster.getJobTeam(jobDetail.name); jobDetail.team = r.assistants || []
|
||
await reloadOccupancy()
|
||
$q.notify({ type: 'positive', icon: 'group_add', message: name + ' ajouté en renfort · bloc hachuré réservé dans son horaire', timeout: 2600 })
|
||
} catch (e) { err(e) }
|
||
}
|
||
async function jdRemoveAssistant (techId) {
|
||
if (!jobDetail.name) return
|
||
try { await roster.removeAssistant(jobDetail.name, techId); const r = await roster.getJobTeam(jobDetail.name); jobDetail.team = r.assistants || []; await reloadOccupancy(); $q.notify({ type: 'info', message: 'Assistant retiré', timeout: 1600 }) } catch (e) { err(e) }
|
||
}
|
||
// Champ unique d'assignation façon courriel To/Cc : 1er tech choisi = assigné (À) ; ajouts suivants (job natif) = assistants (Cc / renfort).
|
||
function jdAssignOrAssist (techId) {
|
||
if (!techId) return
|
||
if (jobDetail.techName && jobDetail.canTeam) { jobDetail.teamAdd = techId; jdAddAssistant(); jobDetail.teamAdd = null } // déjà un assigné + job natif → assistant
|
||
else jdAssignTech(techId) // sinon → assigner / réassigner (vaut aussi pour les tickets legacy, sans assistant)
|
||
jobDetail.assignTech = null // vider le champ après le choix
|
||
}
|
||
// ── Adaptateurs pour AssignmentField (champ multifonction partagé) ──
|
||
// Chips lues depuis jobDetail (le parent reste propriétaire de la vérité) ; les followers sont gérés par le composant.
|
||
const jdAssignee = computed(() => (jobDetail.techName || jobDetail.techId) ? { id: jobDetail.techId, name: jobDetail.techName } : null)
|
||
// onsite = pinned (bloc horaire réservé). Assistant simple (pinned 0) = sur l'équipe sans bloc.
|
||
const jdAssistants = computed(() => (jobDetail.team || []).map(a => ({ id: a.tech_id, name: jdAssistantName(a), onsite: !!a.pinned })))
|
||
// Ajouter/mettre à jour un assistant : { value=id, label, onsite }. onsite → pinned=1 (bloc horaire) ; sinon pinned=0.
|
||
// Le hub `add` remplace la ligne du même tech_id → sert aussi à CHANGER le niveau (assistant ↔ sur place). Nom robuste.
|
||
async function jdSetAssistant ({ value, label, onsite } = {}) {
|
||
const id = value; if (!id || !jobDetail.name) return
|
||
const tech = (techs.value || []).find(t => t.id === id)
|
||
const name = (tech && tech.name) || (label && String(label).split(' · ')[0].trim()) || String(id)
|
||
try {
|
||
await roster.addAssistant(jobDetail.name, { tech_id: String(id), tech_name: name, duration_h: 0, pinned: onsite ? 1 : 0 })
|
||
const r = await roster.getJobTeam(jobDetail.name); jobDetail.team = r.assistants || []
|
||
await reloadOccupancy()
|
||
$q.notify({ type: 'positive', icon: onsite ? 'construction' : 'group_add', message: name + (onsite ? ' — sur place (bloc réservé)' : ' — assistant'), timeout: 2400 })
|
||
} catch (e) { err(e) }
|
||
}
|
||
// Retirer le chip « À » (assigné) → renvoyer le job au pool (non assigné).
|
||
async function jdUnassign () {
|
||
if (!jobDetail.name) return
|
||
try { await roster.unassignJobRoster(jobDetail.name); jobDetail.techId = ''; jobDetail.techName = ''; await reloadOccupancy(); if (reloadPool) await reloadPool(); $q.notify({ type: 'info', message: 'Renvoyé au pool (non assigné)', timeout: 1800 }) } catch (e) { err(e) }
|
||
}
|
||
let _kbClickT = null
|
||
function kbBlockClick (t) { clearTimeout(_kbClickT); _kbClickT = setTimeout(() => { if (kanbanDay.value) openDayFromCell(t, kanbanDay.value) }, 230) } // simple clic (différé) = éditeur de jour
|
||
function kbBlockDbl (b, t) { clearTimeout(_kbClickT); openJobDetail(b, t) } // double-clic = volet détails
|
||
// Clic DROIT sur le FOND d'une lane (hors job) → menu de quart (Jour/Soir/Garde/Absent/heures + créer un quart).
|
||
// Clic GAUCHE sur le fond = éditeur de jour (liste complète + carte) — voir le template (kbb-lane @click.self).
|
||
function onKbLaneMenu (t, ev) { if (!kanbanDay.value) return; openShiftMenu(t, kanbanDay.value, ev, -1, -1) }
|
||
// Drop d'une job sur une lane : assigne (onCellDrop → hub) PUIS crée un quart 8–16 auto si le tech n'a aucun quart régulier ce jour.
|
||
async function onKbDrop (ev, t) {
|
||
if (!kanbanDay.value) return
|
||
await onCellDrop(ev, t, kanbanDay.value) // si pas de quart → onCellDrop ouvre le dialogue (créer quart / absence) ; sinon assigne + toast Annuler
|
||
}
|
||
// ── Drop sur une lane SANS quart : demander quoi faire (créer un quart OU marquer absent) au lieu d'assigner en douce. ──
|
||
const dropAsk = reactive({ open: false, tech: null, day: null, names: [], mode: 'choice', preset: '8-16', cStart: '08:00', cEnd: '16:00', scope: 'day', from: '', to: '', absType: 'Congé' })
|
||
const shiftPresets = [{ label: 'Journée · 8 h–16 h', value: '8-16' }, { label: 'Avant-midi · 8 h–12 h', value: '8-12' }, { label: 'Après-midi · 12 h–16 h', value: '12-16' }, { label: 'Soir · 13 h–21 h', value: '13-21' }, { label: 'Personnalisé…', value: 'custom' }]
|
||
const absTypes = ['Congé', 'Maladie', 'Personnel', 'Formation', 'Absent']
|
||
const dropAskReturn = computed(() => { if (!dropAsk.open || dropAsk.mode !== 'absence') return ''; const days = dropAskDays(); return days.length ? addDaysISO(days[days.length - 1], 1) : '' })
|
||
function openDropAsk (names, t, d) { Object.assign(dropAsk, { open: true, tech: t, day: d, names: names.slice(), mode: 'choice', preset: '8-16', cStart: '08:00', cEnd: '16:00', scope: 'day', from: d.iso, to: d.iso, absType: 'Congé' }) }
|
||
function dropAskWindow () { if (dropAsk.preset === 'custom') return [hToNum(dropAsk.cStart), hToNum(dropAsk.cEnd)]; const [a, b] = dropAsk.preset.split('-').map(Number); return [a, b] }
|
||
async function dropAskCreateShift () {
|
||
const t = dropAsk.tech, d = dropAsk.day; const [min, max] = dropAskWindow()
|
||
if (!(max > min)) { $q.notify({ type: 'warning', message: 'La fin doit être après le début.' }); return }
|
||
const tpl = await ensureWindowTpl(min, max)
|
||
if (tpl) {
|
||
pushHistory(); addShift(t.id, t.name, d.iso, tpl)
|
||
// PERSISTE le quart tout de suite (sinon addShift reste local/Proposé → disparaît au rechargement, et le tech « n'a aucun quart »).
|
||
try { await roster.createShift({ tech: t.id, tech_name: t.name, date: d.iso, shift: tpl.name, zone: tpl.zone || '', hours: tpl.hours || 8 }) } catch (e) { err(e) }
|
||
}
|
||
const done = await assignNames(dropAsk.names, t, d)
|
||
dropAsk.open = false
|
||
notifyAssignUndo(done, t, d, tpl ? tpl.name : null) // « Annuler » → désassigne + retire le quart créé
|
||
}
|
||
function dropAskDays () {
|
||
if (dropAsk.scope === 'day') return [dropAsk.day.iso]
|
||
if (dropAsk.scope === 'week') { const m = mondayISO(dropAsk.day.iso); return Array.from({ length: 7 }, (_, i) => addDaysISO(m, i)) }
|
||
const out = []; let dd = dropAsk.from; let g = 0; while (dd && dropAsk.to && dd <= dropAsk.to && g++ < 400) { out.push(dd); dd = addDaysISO(dd, 1) }; return out
|
||
}
|
||
function dropAskAbsence () {
|
||
const t = dropAsk.tech; const days = dropAskDays(); if (!days.length) { $q.notify({ type: 'warning', message: 'Plage de dates invalide.' }); return }
|
||
pushHistory()
|
||
for (const dd of days) { const k = t.id + '|' + dd; const beforeVal = pendingAbs.value[k] ? { ...pendingAbs.value[k] } : null; setAbsPending(k, dropAsk.absType || 'Congé'); logAbsChange('Congé ajouté · ' + dd, k, beforeVal) } // LOCAL → à Publier (IROPS au moment de Publier)
|
||
const retour = addDaysISO(days[days.length - 1], 1)
|
||
const noms = dropAsk.names.slice(); dropAsk.open = false; scheduleDraftSave()
|
||
$q.notify({ type: 'warning', icon: 'event_busy', message: `${t.name} absent ${days.length} j (${dropAsk.absType}) en attente · retour le ${retour}. Publier pour appliquer. ${noms.length} job(s) laissé(s) au pool.`, timeout: 8000, actions: [{ label: 'Assigner à un autre tech', color: 'white', handler: () => openAssignPanel() }] })
|
||
}
|
||
// ── 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 }
|
||
function applyAbs (remove) {
|
||
const days = absDays(); if (!days.length || !absDialog.techId) return
|
||
pushHistory(); let n = 0
|
||
for (const d of days) { const k = absDialog.techId + '|' + d; const beforeVal = pendingAbs.value[k] ? { ...pendingAbs.value[k] } : null; setAbsPending(k, remove ? null : 'Congé'); logAbsChange((remove ? 'Congé retiré · ' : 'Congé ajouté · ') + d, k, beforeVal); n++ } // LOCAL → à Publier
|
||
$q.notify({ type: 'info', message: (remove ? ('Absence retirée (' + n + ' j)') : (n + ' jour(s) marqué(s) absent')) + ' — Publier pour appliquer' })
|
||
absDialog.open = false; scheduleDraftSave()
|
||
}
|
||
|
||
// ── Mini-carte du volet détails : zoome sur le job + layer « tracé GPS réel » (Traccar) en option ──
|
||
const jdMapEl = ref(null); let _jdMap = null
|
||
const jdShowMap = ref(false) // carte masquée par défaut dans le volet détail (pas requise) — dépliable au besoin
|
||
const jdShowTrack = ref(false); const jdTrackMsg = ref('')
|
||
// Ajuste la durée du job depuis le volet détail (comme « Suggérer ») → persiste duration_h + override de session.
|
||
async function jdSetDuration (h) {
|
||
h = Math.round((Number(h) || 0) * 100) / 100
|
||
if (h <= 0 || !jobDetail.name) return
|
||
durOverride[jobDetail.name] = h
|
||
const pj = (assignPanel.jobs || []).find(x => x.name === jobDetail.name)
|
||
await patchJob(pj || { name: jobDetail.name }, { duration_h: h }, 'Durée révisée : ' + h + ' h — Ré-optimiser pour recalculer')
|
||
}
|
||
// Assigner ce job à un tech DEPUIS le volet détail ; si d'autres tâches à la MÊME adresse/client/jour → proposer de
|
||
// les assigner au même tech (moins de clics). Réutilise assignNames + siblingJobs (même logique que la grille).
|
||
async function jdAssignTech (techId) {
|
||
if (!techId || !jobDetail.name) return
|
||
const tech = techById[techId] || (visibleTechs.value || []).find(x => x.id === techId)
|
||
if (!tech) { jobDetail.assignTech = null; return }
|
||
const iso = jobDetail.iso || routesDay.value || todayISO()
|
||
let names = [jobDetail.name]
|
||
const sibs = siblingJobs(names)
|
||
if (sibs.length) {
|
||
const ex = sibs[0]
|
||
const yes = await new Promise(res => $q.dialog({ title: 'Assigner le groupe ?', message: `${sibs.length} autre(s) tâche(s) à « ${String(ex.address || ex.service_location || '').slice(0, 60)} » — les assigner aussi à ${tech.name} ?`, cancel: { label: 'Non, juste celle-ci', flat: true }, ok: { label: 'Oui, tout assigner', color: 'primary', unelevated: true } }).onOk(() => res(true)).onCancel(() => res(false)).onDismiss(() => res(false)))
|
||
if (yes) names = [...new Set([...names, ...sibs.map(s => s.name)])]
|
||
}
|
||
await assignNames(names, tech, { iso })
|
||
jobDetail.techId = tech.id; jobDetail.techName = tech.name; jobDetail.assignTech = null
|
||
$q.notify({ type: 'positive', message: names.length > 1 ? `${names.length} tâches assignées à ${tech.name}` : `Assigné à ${tech.name}`, timeout: 1800 })
|
||
}
|
||
// Toggle Traccar disponible UNIQUEMENT aujourd'hui ou hier (ET) — sinon Traccar trop lourd/lent.
|
||
const kbCanTrack = computed(() => {
|
||
const iso = kanbanDay.value && kanbanDay.value.iso; const n = nowET.value.iso; if (!iso || !n) return false
|
||
const yest = new Date(Date.parse(n + 'T12:00:00Z') - 86400000).toISOString().slice(0, 10)
|
||
return iso === n || iso === yest
|
||
})
|
||
// Autres jobs (pool + occupation) À PROXIMITÉ (~3 km) du job affiché → points PÂLES sur la carte miniature pour le contexte.
|
||
function jdNearby () {
|
||
const la = jobDetail.lat, lo = jobDetail.lon
|
||
if (!isFinite(la) || !isFinite(lo)) return []
|
||
const out = []; const seen = new Set()
|
||
const add = (lat, lon, subject, name) => {
|
||
lat = +lat; lon = +lon
|
||
if (!isFinite(lat) || !isFinite(lon) || Math.abs(lat) < 0.01) return
|
||
if (name) { if (seen.has(name)) return; seen.add(name) }
|
||
if (name && name === jobDetail.name) return
|
||
if (Math.abs(lat - la) < 1e-5 && Math.abs(lon - lo) < 1e-5) return // même point que le job affiché
|
||
if (haversineKm(la, lo, lat, lon) > 3) return
|
||
out.push([lon, lat, subject || ''])
|
||
}
|
||
for (const j of (assignPanel.jobs || [])) add(j.latitude, j.longitude, j.subject, j.name)
|
||
const occ = occByTechDay.value || {}
|
||
for (const k in occ) for (const j of ((occ[k] || {}).jobs || [])) if (!j.cancelled) add(j.lat, j.lon, j.subject, j.name)
|
||
return out.slice(0, 80)
|
||
}
|
||
async function initJdMap () {
|
||
if (!jdMapEl.value) return
|
||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !jdMapEl.value) return
|
||
if (_jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null }
|
||
const lat = jobDetail.lat, lon = jobDetail.lon; const hasLL = isFinite(lat) && isFinite(lon) && (lat || lon)
|
||
_jdMap = createPlanMap(jdMapEl.value, { center: hasLL ? [lon, lat] : [-73.6756, 45.1599], zoom: hasLL ? 14 : 9 })
|
||
_jdMap.on('load', () => {
|
||
_jdMap.resize()
|
||
// Autres jobs à proximité = points PÂLES (contexte), ajoutés AVANT le marqueur du job → celui-ci reste au-dessus.
|
||
if (hasLL) {
|
||
_jdMap.addSource('jd-near', { type: 'geojson', data: { type: 'FeatureCollection', features: jdNearby().map(p => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [p[0], p[1]] }, properties: { t: p[2] } })) } })
|
||
_jdMap.addLayer({ id: 'jd-near', type: 'circle', source: 'jd-near', paint: { 'circle-radius': 5, 'circle-color': '#94a3b8', 'circle-opacity': 0.45, 'circle-stroke-color': '#fff', 'circle-stroke-width': 1 } })
|
||
_jdMap.on('mouseenter', 'jd-near', () => { _jdMap.getCanvas().style.cursor = 'pointer' })
|
||
_jdMap.on('mouseleave', 'jd-near', () => { _jdMap.getCanvas().style.cursor = '' })
|
||
_jdMap.on('click', 'jd-near', (e) => { const p = e.features[0].properties; new mapboxgl.Popup({ offset: 10 }).setLngLat(e.features[0].geometry.coordinates).setHTML('<div style="font-size:11px;color:#475569">' + String(p.t || '').replace(/[<>&]/g, '') + '</div>').addTo(_jdMap) })
|
||
}
|
||
if (hasLL) new mapboxgl.Marker({ color: '#1976d2' }).setLngLat([lon, lat]).addTo(_jdMap) // le point du job (au-dessus des points pâles)
|
||
_jdMap.addSource('jd-track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||
_jdMap.addLayer({ id: 'jd-track-halo', type: 'line', source: 'jd-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ff6f00', 'line-width': 8, 'line-opacity': 0.22 } })
|
||
_jdMap.addLayer({ id: 'jd-track-l', type: 'line', source: 'jd-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ef6c00', 'line-width': 3, 'line-opacity': 0.78 } })
|
||
if (jdShowTrack.value) loadJdTrack()
|
||
})
|
||
}
|
||
async function loadJdTrack () {
|
||
jdTrackMsg.value = '…'
|
||
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso || !jobDetail.techId || !_jdMap) { jdTrackMsg.value = ''; return }
|
||
const from = new Date(iso + 'T00:00:00').toISOString()
|
||
const to = (iso === nowET.value.iso) ? new Date().toISOString() : new Date(iso + 'T23:59:59').toISOString()
|
||
try {
|
||
const r = await roster.traccarTrack(jobDetail.techId, from, to); const coords = (r && r.coords) || []
|
||
const src = _jdMap.getSource('jd-track')
|
||
if (src) src.setData(coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} } : { type: 'FeatureCollection', features: [] })
|
||
jdTrackMsg.value = coords.length >= 2 ? (coords.length + ' points GPS') : 'Aucun tracé ce jour (ou pas d\'appareil).'
|
||
} catch (e) { jdTrackMsg.value = 'Tracé indisponible' }
|
||
}
|
||
watch(jdShowTrack, (on) => { if (!_jdMap) return; if (on) loadJdTrack(); else { const s = _jdMap.getSource('jd-track'); if (s) s.setData({ type: 'FeatureCollection', features: [] }); jdTrackMsg.value = '' } })
|
||
// Carte NON initialisée à l'ouverture (masquée par défaut) ; init seulement quand on déplie « Voir la carte ».
|
||
watch(() => jobDetail.open, (open) => { if (!open) { jdShowMap.value = false; if (_jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null } } })
|
||
watch(jdShowMap, (on) => { if (on && jobDetail.open) nextTick(() => setTimeout(initJdMap, 200)); else if (!on && _jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null } })
|
||
|
||
const techOptions = computed(() => techs.value.map(t => ({ label: t.name, value: t.id })))
|
||
const tplByName = computed(() => Object.fromEntries(templates.value.map(t => [t.name, t])))
|
||
// ── CALQUE de garde LIVE ──────────────────────────────────────────────────
|
||
// La garde n'est PAS matérialisée pour l'affichage : on la recalcule à la volée depuis les règles
|
||
// (rotation déterministe ancrée + saut d'absent, via rotationTech). Éditer la séquence ou marquer
|
||
// une absence se reflète INSTANTANÉMENT, sans régénérer → fini la désync « la suite est bousillée ».
|
||
// Clé techId|iso → nom du shift de garde (semaine vs week-end). Vacances ⇒ substitut auto.
|
||
const gardeOverlay = computed(() => {
|
||
const map = {}; const rules = gardeRules.value; if (!rules.length) return map
|
||
for (const d of dayList.value) {
|
||
const dow = dowOf(d.iso); const weekend = (dow === 0 || dow === 6)
|
||
for (const rule of rules) {
|
||
if (!(rule.weekdays || []).includes(dow)) continue
|
||
const sh = (weekend && rule.shiftWeekend) ? rule.shiftWeekend : rule.shift
|
||
if (!tplByName.value[sh]) continue
|
||
const id = rotationTech(rule, d.iso); if (!id) continue
|
||
map[id + '|' + d.iso] = sh // si 2 règles visent le même tech/jour, la dernière gagne (rare)
|
||
}
|
||
}
|
||
return map
|
||
})
|
||
// Shift de garde à utiliser pour un ajout MANUEL un jour donné : celui des règles (semaine/WE), sinon 1er modèle on_call.
|
||
function gardeShiftForDay (iso) {
|
||
const dow = dowOf(iso); const weekend = (dow === 0 || dow === 6)
|
||
for (const rule of gardeRules.value) { if (!(rule.weekdays || []).includes(dow)) continue; const sh = (weekend && rule.shiftWeekend) ? rule.shiftWeekend : rule.shift; if (tplByName.value[sh]) return sh }
|
||
const oc = templates.value.find(t => t.on_call); return oc ? oc.name : null
|
||
}
|
||
// Garde EFFECTIVE = rotation (gardeOverlay) + overrides MANUELS (touche « G »/menu, localStorage) :
|
||
// manualGarde[key]='on' → ajoute la garde (shift du jour) ; 'off' → la retire (override d'une garde de règle).
|
||
// Permet de DÉPLACER la garde à la main (tech en congé → la placer ailleurs) SANS toucher aux règles.
|
||
const gardeEffective = computed(() => {
|
||
const base = { ...gardeOverlay.value }
|
||
for (const key in manualGarde.value) {
|
||
const v = manualGarde.value[key]
|
||
if (v === 'off') delete base[key]
|
||
else if (v === 'on') { const sh = gardeShiftForDay(key.split('|')[1]); if (sh) base[key] = sh }
|
||
}
|
||
return base
|
||
})
|
||
function onGarde (techId, iso) { return !!gardeEffective.value[techId + '|' + iso] }
|
||
// Nb de techs de garde par jour (garde EFFECTIVE) → ligne de pied cohérente avec la grille
|
||
const gardeCountByDate = computed(() => { const m = {}; for (const k in gardeEffective.value) { const iso = k.split('|')[1]; m[iso] = (m[iso] || 0) + 1 } return m })
|
||
// chip() (pastille couleur du type de shift) déplacé dans ShiftTypesDialog — seul consommateur.
|
||
|
||
// techs visibles (recherche + groupe + tri)
|
||
const groupOptions = computed(() => { const s = new Set(); for (const t of techs.value) if (t.group) s.add(t.group); return [...s].sort().map(g => ({ label: g, value: g })) })
|
||
// Compétences distinctes (chips de filtrage) + score de PRIORITÉ provisoire.
|
||
const allSkills = computed(() => [...new Set(techs.value.flatMap(t => t.skills || []))].sort())
|
||
// Édition des compétences dans la cellule du nom (popover) + suggestions ALIGNÉES sur les catégories de job.
|
||
const jobTypes = ref([]) // service_types distincts (catégories de job) → suggérés comme compétences
|
||
const skillByType = ref({}) // type de job → compétence requise déduite (politique booking) — sert au repli reqSkill
|
||
const skillDialog = ref(null) // tech dont on édite les compétences
|
||
const skillMenuTarget = ref(null) // élément cliqué = ancre du popover (près de la souris, sur la rangée)
|
||
const skillMenuShown = ref(false)
|
||
function openSkillEditor (t, ev) { skillDialog.value = t; skillMenuTarget.value = (ev && ev.currentTarget) || null; skillMenuShown.value = true; loadTraccarDevices() } // charge les appareils GPS pour la section « Appareil GPS » du volet
|
||
// BLOQUER DU TEMPS = un simple job/ticket qui OCCUPE la timeline (formation, réunion, projet, entretien…). Occupe la
|
||
// journée ⇒ dé-priorise au dispatch. La PRIORITÉ est le curseur : Moyenne/Basse = urgences/réparations peuvent quand
|
||
// même le prendre (suggestSlots pierce les blocs priorité ≤ moyenne en mode urgent) ; Urgent (high) = bloc dur.
|
||
// UN seul concept (fusion des ex- « Réserver du temps » + « Bloquer / job générique » — plus de mode soft/hard).
|
||
const fillerDlg = reactive({ open: false, tech: null, date: '', start: '08:00', dur: 8, fullDay: true, shiftH: 8, priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false })
|
||
function shiftHoursOf (techId, iso) { // longueur cumulée des quarts de la case (0 si aucun)
|
||
const occ = cellOcc(techId, iso)
|
||
if (occ && occ.hasReg && occ.shiftE > occ.shiftS) return Math.round((occ.shiftE - occ.shiftS) * 10) / 10
|
||
return 0
|
||
}
|
||
// Ouverture depuis le volet réglages du tech (bouton « Bloquer du temps… ») : jour = sélection courante, 2 h par défaut.
|
||
function openReserve (t) {
|
||
if (!t) return
|
||
skillMenuShown.value = false
|
||
const iso = selDay.value || mobileSelIso.value || kbSelIso.value || start.value || todayISO()
|
||
Object.assign(fillerDlg, { open: true, tech: { id: t.id, name: t.name }, date: iso, start: '08:00', dur: 2, fullDay: false, shiftH: 0, priority: 'medium', job_type: 'Interne', subject: '', createTicket: false, busy: false })
|
||
}
|
||
// Ouverture depuis le menu de cellule (clic case) : journée complète par défaut, pré-rempli tech+date+durée (= longueur du quart, sinon 8 h).
|
||
function openFillerFromMenu () {
|
||
if (!menu.tech || !menu.day) return
|
||
const sh = shiftHoursOf(menu.tech.id, menu.day.iso)
|
||
const occ = cellOcc(menu.tech.id, menu.day.iso)
|
||
const startH = (occ && occ.hasReg && occ.shiftS != null) ? occ.shiftS : 8
|
||
Object.assign(fillerDlg, {
|
||
open: true, tech: { id: menu.tech.id, name: menu.tech.name }, date: menu.day.iso,
|
||
start: numToTime(startH), dur: sh || 8, fullDay: true, shiftH: sh || 8,
|
||
priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false,
|
||
})
|
||
menu.show = false
|
||
}
|
||
function onFillerFullDay (v) { if (v) { fillerDlg.dur = fillerDlg.shiftH || 8 } } // journée complète → durée = quart
|
||
// Soumission UNIQUE : crée le job/ticket interne qui occupe la timeline (priorité = curseur dur/souple).
|
||
async function doBlockTime () {
|
||
if (!fillerDlg.tech || !fillerDlg.date || !fillerDlg.subject || fillerDlg.busy) return
|
||
fillerDlg.busy = true
|
||
const dur = fillerDlg.fullDay ? (fillerDlg.shiftH || 8) : (Number(fillerDlg.dur) || 8)
|
||
try {
|
||
const r = await roster.createFillerJob({
|
||
tech: fillerDlg.tech.id, date: fillerDlg.date, start_time: fillerDlg.start, duration_h: dur,
|
||
priority: fillerDlg.priority, job_type: fillerDlg.job_type || 'Interne', subject: fillerDlg.subject, create_ticket: fillerDlg.createTicket,
|
||
})
|
||
if (r && r.ok) {
|
||
const soft = fillerDlg.priority !== 'high'
|
||
$q.notify({ type: 'positive', icon: 'event_busy', message: fillerDlg.tech.name + ' — ' + dur + 'h bloquées' + (soft ? ' (urgences OK)' : ' (bloc dur)') + (r.issue ? ' · ticket ' + r.issue : '') })
|
||
fillerDlg.open = false; try { await reloadOccupancy() } catch (e) {}
|
||
} else $q.notify({ type: 'negative', message: (r && r.error) || 'Blocage impossible' })
|
||
} catch (e) { $q.notify({ type: 'negative', message: 'Blocage impossible : ' + (e.message || e) }) } finally { fillerDlg.busy = false }
|
||
}
|
||
// « Voir sa tournée » depuis le volet réglages du tech : bascule en vue Tournées + isole ce tech sur la carte.
|
||
function viewTechTournee (tech) {
|
||
if (!tech || !tech.id) return
|
||
skillMenuShown.value = false
|
||
if (!routesDay.value) routesDay.value = todayISO()
|
||
boardView.value = 'routes'
|
||
nextTick(() => { const others = allDayRoutes.value.map(r => r.id).filter(x => x !== tech.id); hiddenRouteTechs.value = new Set(others) })
|
||
}
|
||
function openSkillEditorFor (techId, ev) { const t = (visibleTechs.value || []).find(x => x.id === techId); if (t) openSkillEditor(t, ev) } // depuis la revue de l'optimiseur
|
||
// TagEditor (showLevel) ↔ tech : skills = libellés (CSV) · skill_levels = {compétence: niveau 1–5} (JSON).
|
||
// Par compétence : SCORE (maîtrise 1–5, qualité) + EFFICACITÉ (facteur vitesse ; défaut = efficacité globale).
|
||
function skillLevelOf (t, sk) { return (t.skill_levels && t.skill_levels[sk]) || 0 } // 0 = non défini (étoiles vides) ; pas de défaut trompeur
|
||
// Applique un horaire standard à CE tech sur la semaine affichée (presets type Dispatch).
|
||
async function applyWeekPreset (t, dows, min, max) {
|
||
const tpl = await ensureWindowTpl(min, max); if (!tpl) return
|
||
pushHistory()
|
||
for (const d of dayList.value) { if (dows.includes(dowOf(d.iso))) { clearLocal(t.id, d.iso); addShift(t.id, t.name, d.iso, tpl) } }
|
||
skillMenuShown.value = false
|
||
$q.notify({ type: 'positive', message: t.name + ' : horaire appliqué (semaine affichée) — pense à Publier', timeout: 2500 })
|
||
}
|
||
// ── Écran unique « Horaire » par tech (icône event de la rangée) : pause indéfinie + horaire récurrent + calendrier congés + archivage. ──
|
||
const techSchedOpen = ref(false); const techSchedTech = ref(null)
|
||
const techSchedRefresh = ref(0) // ↑ après une écriture de quart → le calendrier par tech recharge son mois
|
||
const monthRefresh = ref(0) // ↑ quand un brouillon vient d'être auto-sauvegardé → la vue Mois recharge (les quarts créés en Semaine/Jour s'y reflètent)
|
||
// Ouvre le calendrier par tech en FLUSHANT d'abord le brouillon en attente (debounce 800 ms) : le dialogue lit le
|
||
// SERVEUR (occupation + assignations Proposé/Publié) → sans flush, un quart tout juste peint n'apparaîtrait pas.
|
||
async function openTechSchedule (t) { techSchedTech.value = t; if (_draftT) { try { await autosaveDraft() } catch (e) { /* best-effort */ } } techSchedOpen.value = true }
|
||
// (le watcher « autosave terminé → rafraîchir les calendriers » vit à côté de `autosaving` plus bas — TDZ)
|
||
// ── Calendrier par tech : le menu de cellule PARTAGÉ y émet des intentions ; on les exécute avec les mêmes fonctions que la grille. ──
|
||
// Quarts = persistés PAR DATE (createShift 'Publié' / deleteAssignment) car le calendrier est mensuel (hors fenêtre de la grille).
|
||
async function onTechSchedSetShift ({ techId, iso, min, max } = {}) {
|
||
if (!techId || !iso || !(max > min)) return
|
||
const t = (techs.value || []).find(x => x.id === techId); const tpl = await ensureWindowTpl(min, max); if (!tpl) return
|
||
try {
|
||
// Remplace : retire les quarts réguliers existants de ce jour, puis pose le nouveau.
|
||
const existing = (await roster.listAssignments(iso, 1)).assignments || []
|
||
for (const a of existing) { const tp = tplByName.value[a.shift]; if (a.tech === techId && a.date === iso && tp && !tp.on_call) { try { await roster.deleteAssignment(a.name) } catch (e) {} } }
|
||
await roster.createShift({ tech: techId, tech_name: t ? t.name : '', date: iso, shift: tpl.name, zone: tpl.zone || '', hours: tpl.hours || 8 })
|
||
$q.notify({ type: 'positive', message: `Quart ${fmtH(min)}h–${fmtH(max)}h · ${iso}`, timeout: 1800 })
|
||
} catch (e) { err(e) }
|
||
techSchedRefresh.value++; await loadWeek()
|
||
}
|
||
async function onTechSchedRemoveShift ({ name } = {}) { if (!name) return; try { await roster.deleteAssignment(name); $q.notify({ type: 'info', message: 'Quart retiré', timeout: 1500 }) } catch (e) { err(e) } techSchedRefresh.value++; await loadWeek() }
|
||
async function onTechSchedClearShifts ({ names } = {}) { if (!names || !names.length) return; for (const n of names) { try { await roster.deleteAssignment(n) } catch (e) {} } $q.notify({ type: 'info', message: 'Quarts retirés', timeout: 1500 }); techSchedRefresh.value++; await loadWeek() }
|
||
function onTechSchedToggleGarde ({ techId, iso } = {}) { if (techId && iso) toggleGardeCells([techId + '|' + iso]) } // garde = manualGarde (localStorage), clé tech|iso — cross-fenêtre OK
|
||
function onTechSchedEdit (t) { techSchedOpen.value = false; openSchedGen(t) } // « Modifier l'horaire récurrent » → réutilise WeeklyScheduleEditor existant
|
||
// Calendrier par tech : les congés y sont STAGÉS (publish-required), comme dans la grille. { techId, changes:[{iso, type}] } (type '' = retrait).
|
||
function onStageAbs ({ techId, changes } = {}) {
|
||
if (!techId || !Array.isArray(changes) || !changes.length) return
|
||
pushHistory(); let n = 0
|
||
for (const c of changes) {
|
||
const k = techId + '|' + c.iso; const wantType = c.type || null
|
||
if ((effAbsType(techId, c.iso) || null) === wantType) continue // déjà dans l'état voulu
|
||
const beforeVal = pendingAbs.value[k] ? { ...pendingAbs.value[k] } : null
|
||
setAbsPending(k, wantType)
|
||
logAbsChange((wantType ? (wantType + ' · ') : 'Congé retiré · ') + c.iso, k, beforeVal); n++
|
||
}
|
||
if (n) { $q.notify({ type: 'info', message: n + ' changement(s) de congé en attente — Publier pour appliquer', timeout: 2200 }); scheduleDraftSave() }
|
||
}
|
||
// PAUSE INDÉFINIE (émise par TechScheduleDialog) : on STAGE (want=true) → prend effet visuellement, commise au Publier.
|
||
// Dé-stager (want=false) une pause en attente = simple retrait. Réactiver un tech DÉJÀ en pause (serveur) reste immédiat
|
||
// (statut Disponible + re-matérialisation du patron) — hors du flux « brouillon ».
|
||
async function onStagePause ({ techId, want, reason } = {}) {
|
||
if (!techId) return
|
||
const t = (techs.value || []).find(x => x.id === techId); const nm = (t && t.name) || techId
|
||
if (want) {
|
||
if (isServerPaused(t) && !pendingPause.value[techId]) { // déjà en pause côté serveur → simple maj du motif (immédiat)
|
||
try { await roster.pauseTechnician(techId, true, reason || '') } catch (e) { err(e) }
|
||
return
|
||
}
|
||
const already = !!pendingPause.value[techId]
|
||
pendingPause.value = { ...pendingPause.value, [techId]: { reason: reason || '' } }; savePendingPause()
|
||
if (!already) { // 1re mise en attente → journal + notif (les re-émissions « motif » sur blur restent silencieuses)
|
||
logChange('⏸ Pause indéfinie à publier · ' + nm)
|
||
$q.notify({ type: 'info', message: nm + ' — pause en attente. Publier pour appliquer (retire ses quarts + relève ses jobs).', timeout: 3000 })
|
||
}
|
||
} else {
|
||
if (pendingPause.value[techId]) { // annule une pause en attente (jamais écrite)
|
||
const m = { ...pendingPause.value }; delete m[techId]; pendingPause.value = m; savePendingPause()
|
||
logChange('Pause annulée (non publiée) · ' + nm)
|
||
} else if (isServerPaused(t)) { // réactivation immédiate d'un tech en pause
|
||
try { await roster.pauseTechnician(techId, false, ''); if (t) t.status = 'Disponible'; await roster.materializeShifts({ weeks: 6, tech: techId }).catch(() => {}); $q.notify({ type: 'positive', message: nm + ' réactivé — horaire récurrent re-matérialisé' }); await loadWeek() } catch (e) { err(e) }
|
||
}
|
||
}
|
||
}
|
||
async function onTechSchedChanged (ev) {
|
||
// pause : reflète le statut localement ; archivage : recharge la base (le tech disparaît). Congés : recharge la semaine (hachures).
|
||
if (ev && ev.status) { const tt = techs.value.find(x => x.id === ev.id); if (tt) tt.status = ev.status }
|
||
if (ev && ev.archived) { await loadBase() }
|
||
await loadWeek()
|
||
}
|
||
|
||
// ── Génération de quarts HEBDO par tech (modèles + N semaines) — écrit DIRECTEMENT les Shift Assignment (Publié). ──
|
||
const schedGenOpen = ref(false); const schedGenTechs = ref([])
|
||
function openSchedGen (t) { schedGenTechs.value = [{ id: t.id, name: t.name }]; skillMenuShown.value = false; schedGenOpen.value = true } // 1 tech
|
||
function openSchedGenBulk () { schedGenTechs.value = (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name, skills: t.skills || [] })); schedGenOpen.value = true } // LOT : tous les techs visibles (+ compétences pour filtrer)
|
||
const _hmNum = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 }
|
||
async function onScheduleApply ({ schedule, weeks, techIds, mode }) {
|
||
const targets = (schedGenTechs.value || []).filter(t => (techIds || []).includes(t.id)); if (!targets.length) return
|
||
// ── RÉCURRENT : le patron devient la SOURCE (weekly_schedule) → matérialisation auto (fériés/vacances sautés, manuels préservés) ──
|
||
if (mode === 'recurring') {
|
||
const patt = {}; for (const d of schedule) patt[d.dow] = d.on ? { start: d.start, end: d.end } : null
|
||
let okT = 0, failT = 0
|
||
for (const t of targets) { try { const r = await roster.setWeeklySchedule(t.id, patt); if (r && r.ok) okT++; else { failT++; err(new Error('weekly-schedule ' + t.name + ': ' + ((r && r.error) || 'échec'))) } } catch (e) { failT++; err(e) } }
|
||
let mat = null, matErr = ''; try { mat = await roster.materializeShifts({ weeks }) } catch (e) { matErr = (e && e.message) || 'erreur'; err(e) }
|
||
try { await loadWeek() } catch (e) {}
|
||
// #4c : on NE SWALLOW PLUS — les échecs (compte tech introuvable, création de quart refusée) remontent à l'écran.
|
||
const parts = [okT + ' tech']
|
||
if (failT) parts.push('⚠ ' + failT + ' non enregistré(s)')
|
||
if (mat) { parts.push(mat.created + ' quart(s)/' + weeks + ' sem.'); if (mat.create_fail) parts.push('⚠ ' + mat.create_fail + ' échec(s) de création'); if (mat.skipped_holiday) parts.push(mat.skipped_holiday + ' férié(s) sauté(s)') } else if (matErr) parts.push('⚠ matérialisation : ' + matErr)
|
||
$q.notify({ type: (failT || (mat && mat.create_fail) || matErr) ? 'warning' : 'positive', icon: 'event_repeat', message: 'Horaire récurrent · ' + parts.join(' · '), timeout: 5500 })
|
||
schedGenTechs.value = []
|
||
return
|
||
}
|
||
// ── EXCEPTION : override ponctuel (source='manuel', préservé par la matérialisation) sur N semaines dès la semaine affichée ──
|
||
let created = 0, failed = 0; const tplByKey = {}
|
||
const base = start.value // lundi de la semaine affichée
|
||
for (const t of targets) {
|
||
for (let w = 0; w < weeks; w++) {
|
||
for (let i = 0; i < 7; i++) {
|
||
const day = schedule[i]; if (!day || !day.on) continue
|
||
const sh = _hmNum(day.start), eh = _hmNum(day.end); if (!(eh > sh)) { failed++; continue }
|
||
const key = sh + '-' + eh; let tpl = tplByKey[key]
|
||
if (!tpl) { tpl = await ensureWindowTpl(sh, eh); tplByKey[key] = tpl }
|
||
if (!tpl) { failed++; continue }
|
||
const iso = addDaysISO(base, w * 7 + i)
|
||
try { const r = await roster.createShift({ tech: t.id, tech_name: t.name, date: iso, shift: tpl.name, hours: tpl.hours || calcHours(day.start, day.end) }); if (r && (r.ok || r.existed)) created++; else failed++ } catch (e) { failed++ }
|
||
}
|
||
}
|
||
}
|
||
try { await loadWeek() } catch (e) {} // rafraîchit la grille (semaine affichée)
|
||
$q.notify({ type: created ? 'positive' : 'warning', icon: 'event_available', message: created + ' quart(s) publié(s) · ' + targets.length + ' tech' + (weeks > 1 ? ' (' + weeks + ' sem.)' : '') + (failed ? ' · ' + failed + ' ignoré(s)' : ''), timeout: 4000 })
|
||
schedGenTechs.value = []
|
||
}
|
||
function skillEffOf (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? Number(e) : (Number(t.efficiency) || 1) } // facteur (pour le calcul de priorité)
|
||
// Efficacité saisie en % de PERFORMANCE : + = plus VITE (moins de temps) · − = plus LENT. Conversion ↔ facteur.
|
||
// CADENCE en % : 100 = normal · 200 = deux fois plus de jobs (moitié du temps) · <100 = plus lent. Interne = FACTEUR de durée (200 %→0.5).
|
||
function effPctOf (factor) { const f = Number(factor) || 1; return Math.round(100 / (f > 0 ? f : 1)) }
|
||
function factorFromPct (pct) { const p = Number(pct) || 100; const f = 100 / (p > 0 ? p : 100); return Math.max(0.1, Math.min(5, Math.round(f * 100) / 100)) } // cap 20 %–1000 %
|
||
// Efficacité GLOBALE saisie en % (haut = plus rapide, comme le per-skill) — tampon pour ne pas se battre avec la frappe.
|
||
const gEffBuf = reactive({})
|
||
function commitGEff (t) { if (!(t.id in gEffBuf)) return; const v = gEffBuf[t.id]; delete gEffBuf[t.id]; t.efficiency = factorFromPct(v); saveEff(t) }
|
||
function skillEffPct (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? effPctOf(e) : '' } // '' = hérite de la globale
|
||
function setSkillEffPct (t, sk, pct) { const m = { ...(t.skill_eff || {}) }; if (pct === '' || pct == null) delete m[sk]; else m[sk] = factorFromPct(pct); t.skill_eff = m; queueSkillSave(t) } // libre (ex. +80)
|
||
// Saisie efficacité : tampon local pendant l'édition (pas de debounce qui perdait la valeur au blur) → commit sur blur/Entrée.
|
||
const effBuf = reactive({})
|
||
function effBufKey (t, sk) { return (t && t.id) + '::' + sk }
|
||
function commitSkillEff (t, sk) { const k = effBufKey(t, sk); if (!(k in effBuf)) return; const v = effBuf[k]; delete effBuf[k]; setSkillEffPct(t, sk, v) }
|
||
function setSkillLevel (t, sk, v) { if (!t.skill_levels) t.skill_levels = {}; t.skill_levels = { ...t.skill_levels, [sk]: v }; queueSkillSave(t) }
|
||
// Efficacité GLOBALE du tech, éditable/réinitialisable depuis le popover (débouncée via setTechEfficiency).
|
||
// Couleur du cercle (vitesse PAR compétence) : vite → vert · normal → bleu-gris · lent → rouge. Intensité ∝ écart.
|
||
function effColor (factor) { const p = effPctOf(factor); if (p === 100) return '#607d8b'; const a = Math.min(Math.abs(p - 100), 200) / 200; const hue = p > 100 ? 122 : 4; return 'hsl(' + hue + ',' + Math.round(50 + a * 28) + '%,' + (44 - Math.round(a * 8)) + '%)' }
|
||
function skillEffColor (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e == null || e === '') ? '#607d8b' : effColor(Number(e)) } // neutre si pas d'override (pure par-compétence)
|
||
// Icône Material (offline, jeu Quasar par défaut) représentant une compétence/type — par mots-clés, repli 'bolt'.
|
||
// Icône de compétence pour q-icon (accepte les SYMBOLES SVG importés) : échelle=installation, casque=support,
|
||
// outils=réparation ; sinon ligature classique unique (skillIcon). NB: les pins carte (HTML brut) gardent skillIcon.
|
||
// Compétences du tech pour l'affichage (icône principale + « +N ») : on RESPECTE l'ordre de priorité
|
||
// défini par glisser-déposer dans l'éditeur de compétences (t.skills) — la 1re = fonction principale.
|
||
// (ex. une personne au support voit son casque en 1er ; réparation ≠ support, chacun garde son rang.)
|
||
function tSkills (t) { return [...(t.skills || [])] }
|
||
// 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('')
|
||
}
|
||
// (skillIcon / skillSym / markerIcon / BUCKET_TRUCK → composables/useSkillIcons.js — SOURCE UNIQUE réutilisable)
|
||
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
|
||
// ── Pools « Jobs à assigner » UNIFIÉS : une instance useJobPool par surface (détenues ici → la carte du panneau lit floatingPool.filteredJobs).
|
||
// Créées TÔT (avant assignLocations / le watch de la carte) ; les deps sont des fonctions hoistées, les closures (depot, mobileSelDay) sont paresseuses. ──
|
||
const _poolDist = (j) => { const dep = depot.value; return (dep && dep.lat != null && j.latitude != null) ? (haversineKm(dep.lat, dep.lon, +j.latitude, +j.longitude) ?? 9e9) : 9e9 }
|
||
const jpDeps = { jobCity, jobLocKey: _jobLocKey, todayISO, fmtDueLabel }
|
||
const floatingPool = useJobPool(() => assignPanel.jobs, jpDeps, { variant: 'floating' })
|
||
const kanbanPool = useJobPool(() => assignPanel.jobs, jpDeps, { variant: 'docked', distOf: _poolDist })
|
||
const mobilePool = useJobPool(() => assignPanel.jobs, jpDeps, { variant: 'mobile', selectedDay: () => mobileSelDay.value })
|
||
const jpShowMap = computed(() => assignPanel.showMap)
|
||
async function openAssignPanel () { assignPanel.open = true; assignPanel.loading = true; for (const k in selectedJobs) delete selectedJobs[k]; try { assignPanel.jobs = capPickups((await roster.unassignedJobs()).jobs) } catch (e) { err(e) } finally { assignPanel.loading = false } }
|
||
|
||
// Write-back legacy : aperçu (dryRun, 0 écriture) → confirmation → écrit ticket.assign_to dans osTicket.
|
||
const legacyPush = reactive({ open: false, loading: false, applying: false, preview: null, notify: true })
|
||
async function openLegacyPush () { legacyPush.open = true; legacyPush.loading = true; legacyPush.preview = null; try { legacyPush.preview = await roster.pushLegacyPreview() } catch (e) { err(e) } finally { legacyPush.loading = false } }
|
||
async function applyLegacyPush () { legacyPush.applying = true; try { const r = await roster.pushLegacyApply(legacyPush.notify); $q.notify({ type: r.errors ? 'warning' : 'positive', message: `Legacy : ${r.pushed} ticket(s) réassigné(s)` + (legacyPush.notify && r.notified != null ? ` · ${r.notified} avisé(s) par courriel (boîte du tech)` : '') + (r.mismatch ? ` · ${r.mismatch} ignoré(s) (nom ≠)` : '') + (r.errors ? ` · ${r.errors} erreur(s)` : '') }); if (legacyPush.notify && r.notify_errors && r.notify_errors.length) { $q.notify({ type: 'warning', icon: 'mark_email_unread', message: `⚠ ${r.notify_errors.length} avis NON envoyé(s) — ${(r.notify_errors[0] && r.notify_errors[0].error) || ''}`, timeout: 6000 }) } legacyPush.open = false } catch (e) { err(e) } finally { legacyPush.applying = false } }
|
||
// X sur une ligne de l'aperçu → désassigne le job dans Ops (retour au pool) → l'exclut du push, puis rafraîchit l'aperçu + la grille.
|
||
async function removeFromPush (s) {
|
||
if (!s || !s.job) { $q.notify({ type: 'warning', message: 'Job introuvable' }); return }
|
||
legacyPush.applying = true
|
||
try {
|
||
await roster.unassignJobRoster(s.job)
|
||
await reloadOccupancy()
|
||
legacyPush.preview = await roster.pushLegacyPreview()
|
||
$q.notify({ type: 'info', message: 'Job désassigné (retour au pool) — exclu du legacy', timeout: 2200 })
|
||
} catch (e) { err(e) } finally { legacyPush.applying = false }
|
||
}
|
||
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 compétence + date du panneau → fusionnés dans useJobPool : floatingPool.skillChips / .dateChips / .skillFilter / .dateFilter.)
|
||
// 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 = (floatingPool.skillChips.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) }))
|
||
})
|
||
// Depuis l'aperçu « jobs sur la colonne du jour » → ouvre le panneau « À assigner » filtré sur ce jour.
|
||
// Jour PRÉCIS sélectionné → on borne au jour (chip date) ET on groupe par VILLE par défaut (la date étant déjà fixée, le secteur est l'axe utile pour dispatcher une tournée).
|
||
async function openDayInPanel (iso) { await openAssignPanel(); floatingPool.sort.value = 'city'; floatingPool.dateFilter.value = [iso] }
|
||
// (Filtre date+compétence-par-adresse, info d'adresse et badges → fusionnés dans useJobPool : floatingPool.filteredJobs / .jobAddrBadge / .jobAssocOnly.)
|
||
// 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') }
|
||
// (assignGroups / repli des groupes → fusionnés dans useJobPool : floatingPool.groups / floatingPool.collapsed.)
|
||
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 floatingPool.filteredJobs.value) { // carte = MÊME filtre que la liste (date sélectionnée + compétence)
|
||
if (!_hasJobLL(j)) continue
|
||
const k = (Math.round(+j.latitude * 1e5) / 1e5) + ',' + (Math.round(+j.longitude * 1e5) / 1e5)
|
||
if (!by[k]) { by[k] = { lat: +j.latitude, lon: +j.longitude, jobs: [] }; order.push(k) }
|
||
by[k].jobs.push(j)
|
||
}
|
||
const stops = []; const byName = {}
|
||
order.forEach((k, i) => {
|
||
const v = by[k]; const letter = _colLetter(i)
|
||
const skJob = v.jobs.find(j => j.required_skill || j.skill) || {}; const sk = skJob.required_skill || skJob.skill || ''
|
||
const j0 = v.jobs[0]
|
||
stops.push({ lat: v.lat, lon: v.lon, letter, color: sk ? getTagColor(sk) : '#90a4ae', count: v.jobs.length, names: v.jobs.map(j => j.name), title: (j0.address || j0.subject || '').slice(0, 60) })
|
||
for (const j of v.jobs) byName[j.name] = letter
|
||
})
|
||
return { stops, byName }
|
||
})
|
||
function groupLabel (j) { return assignLocations.value.byName[j.name] || '' } // lettre du pin de ce job (repère liste ↔ carte)
|
||
|
||
// ── Carte des jobs à assigner (Mapbox GL) : 1 pin par adresse, lettre = groupe ; clic = sélectionne les jobs co-localisés ──
|
||
const assignMapEl = ref(null); let _assignMap = null, _assignMapRO = null
|
||
|
||
// ── Bascule vue SATELLITE (couche raster ESRI World Imagery — libre, sans jeton — superposée SOUS les pins) ──
|
||
// Préférence partagée par les 2 cartes (panneau + journée), persistée. addSatLayer() est appelé dans chaque on('load')
|
||
// AVANT les couches de pins → le raster reste dessous. toggleSat() bascule la visibilité sur les cartes vivantes.
|
||
const satView = ref((() => { try { return localStorage.getItem('plan_sat') === '1' } catch (e) { return false } })())
|
||
function addSatLayer (map) {
|
||
try {
|
||
if (!map.getSource('sat')) map.addSource('sat', { type: 'raster', tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'], tileSize: 256, attribution: '© Esri, Maxar, Earthstar Geographics' })
|
||
if (!map.getLayer('sat')) map.addLayer({ id: 'sat', type: 'raster', source: 'sat', layout: { visibility: satView.value ? 'visible' : 'none' } })
|
||
} catch (e) { /* style pas prêt — ignoré */ }
|
||
}
|
||
function applySat (map) { try { if (map && map.getLayer && map.getLayer('sat')) map.setLayoutProperty('sat', 'visibility', satView.value ? 'visible' : 'none') } catch (e) {} }
|
||
function toggleSat () { satView.value = !satView.value; try { localStorage.setItem('plan_sat', satView.value ? '1' : '0') } catch (e) {} applySat(_assignMap); applySat(_dayMap); applySat(_locMap) }
|
||
|
||
// Clic DROIT sur une carte → Google Street View à la position cliquée (nouvel onglet). Branché sur les 3 cartes.
|
||
function openStreetView (lng, lat) { window.open(`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${lat},${lng}`, '_blank', 'noopener') }
|
||
// Liens géo externes du volet détail job (évaluer la difficulté avant d'y aller) : Street View (sol) + Google Maps (satellite).
|
||
const jdHasCoords = computed(() => { const la = +jobDetail.lat, lo = +jobDetail.lon; return isFinite(la) && isFinite(lo) && (la !== 0 || lo !== 0) })
|
||
const jdMapsUrl = computed(() => `https://www.google.com/maps/search/?api=1&query=${jobDetail.lat},${jobDetail.lon}`)
|
||
const jdStreetViewUrl = computed(() => `https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${jobDetail.lat},${jobDetail.lon}`)
|
||
|
||
// ── SÉLECTEUR D'EMPLACEMENT (jobs « hors carte ») : carte Mapbox bornée au sud de Montréal (Valleyfield ↔ Lacolle ↔
|
||
// frontière US). Clic / glisser le repère OU recherche d'adresse RQA → coords + adresse, écrits sur le Dispatch Job. ──
|
||
const locMapEl = ref(null); let _locMap = null, _locMarker = null
|
||
const TERR_BOUNDS = [[-74.95, 44.95], [-73.0, 45.55]] // SW (Valleyfield/frontière) → NE
|
||
const locPicker = reactive({ open: false, mode: 'job', job: null, tech: null, techName: '', subject: '', lat: null, lon: null, address: '', search: '', results: [], searching: false, saving: false, autoMatched: false })
|
||
const batchLocating = ref(false)
|
||
function openLocPicker (j) {
|
||
locPicker.mode = 'job'; locPicker.job = j.name; locPicker.tech = null; locPicker.subject = j.subject || j.name
|
||
locPicker.lat = hasLL(j) ? +j.lat : null; locPicker.lon = hasLL(j) ? +j.lon : null
|
||
locPicker.address = j.address || ''; locPicker.search = j.address || ''; locPicker.results = []; locPicker.autoMatched = false
|
||
locPicker.open = true
|
||
// Job avec une adresse mais SANS GPS → faire correspondre tout de suite à une adresse existante (RQA) + coords.
|
||
if (!hasLL(j) && (j.address || '').trim().length >= 3) autoMatchAddress()
|
||
}
|
||
// Définir le DÉPÔT (point de départ par défaut des tournées) via le même sélecteur de carte.
|
||
function openDepotPicker () {
|
||
const d = depot.value || {}; locPicker.mode = 'depot'; locPicker.job = null; locPicker.tech = null; locPicker.subject = 'Point de départ (dépôt)'
|
||
locPicker.lat = hasLL(d) ? +d.lat : null; locPicker.lon = hasLL(d) ? +d.lon : null
|
||
locPicker.address = d.address || ''; locPicker.search = d.address || ''; locPicker.results = []
|
||
locPicker.open = true
|
||
}
|
||
// Définir le DOMICILE d'un tech (origine si plus proche du travail que le dépôt).
|
||
function openHomePicker (t) {
|
||
const h = (techHomes.value[t.id]) || {}; locPicker.mode = 'home'; locPicker.job = null; locPicker.tech = t.id; locPicker.techName = t.name || t.id; locPicker.subject = 'Domicile — ' + (t.name || t.id)
|
||
locPicker.lat = hasLL(h) ? +h.lat : null; locPicker.lon = hasLL(h) ? +h.lon : null
|
||
locPicker.address = h.address || ''; locPicker.search = h.address || ''; locPicker.results = []
|
||
locPicker.open = true
|
||
}
|
||
// Fixer le domicile (= point de départ) d'un tech à sa POSITION GPS LIVE actuelle (Traccar) — INLINE, sans ouvrir la carte.
|
||
const liveGpsBusy = ref(false)
|
||
async function setHomeFromLiveGps (t) {
|
||
if (!t || !t.id) return
|
||
liveGpsBusy.value = true
|
||
try {
|
||
const r = await roster.techLivePosition(t.id)
|
||
if (!r || !r.ok || r.lat == null) { $q.notify({ type: 'warning', message: (r && r.error) || 'Aucune position GPS live (appareil Traccar non associé ?)' }); return }
|
||
const address = await reverseGeocodeOSM(r.lon, r.lat)
|
||
const homes = { ...techHomes.value, [t.id]: { address, lat: +(+r.lat).toFixed(6), lon: +(+r.lon).toFixed(6) } }
|
||
await roster.savePolicy({ tech_homes: homes }); techHomes.value = homes
|
||
if (_dayMap) refreshDayMap()
|
||
$q.notify({ type: 'positive', icon: 'my_location', message: 'Départ fixé à la position GPS actuelle — ' + (t.name || t.id) })
|
||
} catch (e) { err(e) } finally { liveGpsBusy.value = false }
|
||
}
|
||
// Variante DANS le sélecteur de carte (mode domicile) : amène le marqueur sur la position GPS live, ajustable ensuite.
|
||
async function pickerUseLiveGps () {
|
||
if (locPicker.mode !== 'home' || !locPicker.tech) return
|
||
liveGpsBusy.value = true
|
||
try {
|
||
const r = await roster.techLivePosition(locPicker.tech)
|
||
if (!r || !r.ok || r.lat == null) { $q.notify({ type: 'warning', message: (r && r.error) || 'Aucune position GPS live' }); return }
|
||
if (_locMap) { _locMap.easeTo({ center: [r.lon, r.lat], zoom: 15, duration: 400 }); placeLocMarker(r.lon, r.lat, true) } else { locPicker.lon = +r.lon; locPicker.lat = +r.lat; reverseGeocode(r.lon, r.lat) }
|
||
} catch (e) { err(e) } finally { liveGpsBusy.value = false }
|
||
}
|
||
// Appareils Traccar — pour ASSOCIER un tech à son GPS, inline (prérequis du GPS live + tracé). La page Dispatch
|
||
// gérait ça ; on le rapatrie dans Planif. Chargé une fois à l'ouverture de l'éditeur de jour.
|
||
const traccarDevices = ref([]); const devicesLoading = ref(false); let _devicesLoaded = false
|
||
const deviceFilter = ref('')
|
||
async function loadTraccarDevices () {
|
||
if (_devicesLoaded || devicesLoading.value) return
|
||
devicesLoading.value = true
|
||
try { const d = await roster.listTraccarDevices(); traccarDevices.value = Array.isArray(d) ? d : (d && d.devices) || []; _devicesLoaded = true } catch (e) { /* non bloquant */ } finally { devicesLoading.value = false }
|
||
}
|
||
const deviceOptions = computed(() => {
|
||
const q = deviceFilter.value.toLowerCase().trim()
|
||
return traccarDevices.value
|
||
.map(d => ({ label: d.name || d.uniqueId || ('#' + d.id), value: String(d.id), uniqueId: d.uniqueId || '', online: d.status === 'online' }))
|
||
.filter(o => !q || o.label.toLowerCase().includes(q) || (o.uniqueId && o.uniqueId.toLowerCase().includes(q)))
|
||
.sort((a, b) => (b.online ? 1 : 0) - (a.online ? 1 : 0) || a.label.localeCompare(b.label))
|
||
})
|
||
// Appareil GPS relié à N'IMPORTE QUEL tech (résout l'id → nom réel). Réutilisé par l'éditeur de jour ET le volet réglages du tech.
|
||
function deviceOf (tech) {
|
||
const id = tech && tech.traccar_device_id
|
||
if (!id) return null
|
||
const d = traccarDevices.value.find(x => String(x.id) === String(id))
|
||
if (d) return { value: String(d.id), label: d.name || d.uniqueId || ('#' + d.id), online: d.status === 'online', uniqueId: d.uniqueId || '' }
|
||
return { value: String(id), label: devicesLoading.value ? ('#' + id + '…') : ('#' + id + ' — introuvable'), online: false, missing: !devicesLoading.value }
|
||
}
|
||
const jdDevice = computed(() => deviceOf(dayEditor.tech))
|
||
// Suggestion AUTO quand non relié : appareil dont le nom/uniqueId contient un mot du nom du tech (≥3 lettres). Facilite « relier ».
|
||
const _normDev = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '')
|
||
function deviceSuggestOf (tech) {
|
||
if (!tech || deviceOf(tech)) return null
|
||
const parts = _normDev(tech.name).split(/[^a-z]+/).filter(p => p.length >= 3)
|
||
if (!parts.length) return null
|
||
let best = null
|
||
for (const d of traccarDevices.value) {
|
||
const dn = _normDev(d.name) + ' ' + _normDev(d.uniqueId)
|
||
const hit = parts.filter(p => dn.includes(p)).length
|
||
if (hit > (best ? best.hit : 0)) best = { hit, d }
|
||
}
|
||
if (best && best.hit >= 1) { const d = best.d; return { value: String(d.id), label: d.name || ('#' + d.id), online: d.status === 'online' } }
|
||
return null
|
||
}
|
||
const jdDeviceSuggest = computed(() => deviceSuggestOf(dayEditor.tech))
|
||
async function onPickDevice (tech, deviceId) {
|
||
if (!tech || !tech.id) return
|
||
const prev = tech.traccar_device_id || ''; const v = deviceId == null ? '' : String(deviceId)
|
||
if (v === prev) return
|
||
tech.traccar_device_id = v // optimiste
|
||
try { await roster.setTechTraccarDevice(tech.id, v); $q.notify({ type: 'positive', icon: 'gps_fixed', message: v ? 'Appareil GPS associé — ' + (tech.name || tech.id) : 'Appareil GPS dissocié', timeout: 1800 }) }
|
||
catch (e) { tech.traccar_device_id = prev; err(e) }
|
||
}
|
||
async function initLocMap () {
|
||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !locMapEl.value) return
|
||
if (_locMap) { _locMap.resize(); return }
|
||
_locMap = createPlanMap(locMapEl.value, { center: [locPicker.lon || -73.95, locPicker.lat || 45.18], zoom: locPicker.lat != null ? 14 : 8.5, streetView: openStreetView })
|
||
_locMap.on('load', () => {
|
||
_locMap.resize(); addSatLayer(_locMap)
|
||
if (locPicker.lat == null) _locMap.fitBounds(TERR_BOUNDS, { padding: 18, duration: 0 }) // zoom sur la région au sud de Montréal
|
||
else placeLocMarker(locPicker.lon, locPicker.lat, false)
|
||
})
|
||
_locMap.on('click', (e) => placeLocMarker(e.lngLat.lng, e.lngLat.lat, true))
|
||
}
|
||
function placeLocMarker (lon, lat, doReverse) {
|
||
locPicker.lon = +(+lon).toFixed(6); locPicker.lat = +(+lat).toFixed(6)
|
||
if (!_locMarker) {
|
||
_locMarker = new window.mapboxgl.Marker({ draggable: true, color: '#e8590c' }).setLngLat([lon, lat]).addTo(_locMap)
|
||
_locMarker.on('dragend', () => { const ll = _locMarker.getLngLat(); locPicker.lon = +ll.lng.toFixed(6); locPicker.lat = +ll.lat.toFixed(6); reverseGeocode(ll.lng, ll.lat) })
|
||
} else _locMarker.setLngLat([lon, lat])
|
||
if (doReverse) reverseGeocode(lon, lat)
|
||
}
|
||
// Géocodage INVERSE (coord → adresse). 1) NOTRE RQA locale (hub /address/reverse — pas de dépendance externe). 2) repli OSM/Nominatim.
|
||
async function reverseGeocodeOSM (lon, lat) {
|
||
try { const r = await addressApi.reverse(lat, lon); if (r && r.found && r.address) return r.address } catch (e) { /* RQA reverse indispo → repli */ }
|
||
try {
|
||
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&accept-language=fr&zoom=18`)
|
||
const j = await r.json(); const a = j.address || {}
|
||
const line1 = [a.house_number, a.road].filter(Boolean).join(' ')
|
||
const city = a.city || a.town || a.village || a.municipality || a.hamlet || ''
|
||
const parts = [line1, city, a.postcode].filter(Boolean)
|
||
return parts.length ? parts.join(', ') : (j.display_name ? String(j.display_name).replace(/, Canada$/, '') : '')
|
||
} catch (e) { return '' }
|
||
}
|
||
async function reverseGeocode (lon, lat) { const a = await reverseGeocodeOSM(lon, lat); if (a) locPicker.address = a }
|
||
async function locSearch () {
|
||
const q = (locPicker.search || '').trim(); if (q.length < 3) return
|
||
locPicker.searching = true; locPicker.autoMatched = false
|
||
try { const r = await addressApi.conformityCandidates(q); locPicker.results = (r.results || []).filter(x => isFinite(+x.latitude) && isFinite(+x.longitude)).slice(0, 8) } catch (e) { err(e) } finally { locPicker.searching = false }
|
||
}
|
||
// Correspondance AUTO (ouverture d'un job sans GPS) : cherche son adresse dans la base RQA et, si la
|
||
// correspondance est franche (1 seul candidat, ou score ≥ 0.6), l'applique (adresse canonique + coords).
|
||
// Sinon laisse la liste des candidats affichée pour un choix en 1 clic. Jamais bloquant.
|
||
async function autoMatchAddress () {
|
||
const q = (locPicker.search || '').trim(); if (q.length < 3) return
|
||
locPicker.searching = true; locPicker.autoMatched = false
|
||
try {
|
||
const r = await addressApi.conformityCandidates(q)
|
||
const cands = (r.results || []).filter(x => isFinite(+x.latitude) && isFinite(+x.longitude)).slice(0, 8)
|
||
locPicker.results = cands
|
||
const top = cands[0]
|
||
const strong = top && (cands.length === 1 || (top.similarity_score != null && +top.similarity_score >= 0.6))
|
||
if (strong) { pickLocResult(top, true); locPicker.autoMatched = true }
|
||
} catch (e) { /* non bloquant : recherche/situer à la main reste possible */ } finally { locPicker.searching = false }
|
||
}
|
||
function pickLocResult (rr, keepList = false) {
|
||
const lon = +rr.longitude, lat = +rr.latitude; if (!isFinite(lon) || !isFinite(lat)) return
|
||
locPicker.address = rr.address_full || [rr.numero, rr.rue, rr.ville, rr.code_postal].filter(Boolean).join(' ')
|
||
if (!keepList) { locPicker.results = []; locPicker.autoMatched = false }
|
||
if (_locMap) { _locMap.easeTo({ center: [lon, lat], zoom: 16, duration: 500 }); placeLocMarker(lon, lat, false) } else { locPicker.lon = lon; locPicker.lat = lat }
|
||
}
|
||
async function saveLocPicker () {
|
||
if (locPicker.lat == null || locPicker.lon == null) { $q.notify({ type: 'warning', message: 'Choisis un point sur la carte' }); return }
|
||
locPicker.saving = true
|
||
try {
|
||
if (locPicker.mode === 'depot') {
|
||
const obj = { label: 'Dépôt', address: locPicker.address || '', lat: locPicker.lat, lon: locPicker.lon }
|
||
await roster.savePolicy({ depot: obj }); depot.value = obj
|
||
locPicker.open = false; if (_dayMap) refreshDayMap()
|
||
$q.notify({ type: 'positive', icon: 'warehouse', message: 'Dépôt (point de départ) enregistré' })
|
||
} else if (locPicker.mode === 'home') {
|
||
const homes = { ...techHomes.value, [locPicker.tech]: { address: locPicker.address || '', lat: locPicker.lat, lon: locPicker.lon } }
|
||
await roster.savePolicy({ tech_homes: homes }); techHomes.value = homes
|
||
locPicker.open = false; if (_dayMap) refreshDayMap()
|
||
$q.notify({ type: 'positive', icon: 'home', message: 'Domicile enregistré — ' + locPicker.techName })
|
||
} else {
|
||
await roster.setJobLocation(locPicker.job, locPicker.lat, locPicker.lon, locPicker.address)
|
||
const j = dayEditor.list.find(x => x.name === locPicker.job); if (j) { j.lat = locPicker.lat; j.lon = locPicker.lon; if (locPicker.address) j.address = locPicker.address }
|
||
const pj = (assignPanel.jobs || []).find(x => x.name === locPicker.job); if (pj) { pj.latitude = locPicker.lat; pj.longitude = locPicker.lon; if (locPicker.address) pj.address = locPicker.address } // pool « à assigner » : latitude/longitude → rafraîchit le badge sans coords
|
||
locPicker.open = false
|
||
await reloadOccupancy(); if (_dayMap) refreshDayMap()
|
||
$q.notify({ type: 'positive', icon: 'place', message: 'Emplacement enregistré sur le job' })
|
||
}
|
||
} catch (e) { err(e) } finally { locPicker.saving = false }
|
||
}
|
||
// LOT : localiser d'un coup tous les jobs du jour qui ont une adresse mais pas de GPS — chaque adresse est
|
||
// mise en correspondance avec la base RQA (top match franc) puis écrite sur le Dispatch Job. Les adresses
|
||
// ambiguës sont laissées telles quelles (à situer à la main via 📍).
|
||
async function batchLocate () {
|
||
const targets = dayEditor.list.filter(j => !j.legacy && !hasLL(j) && (j.address || '').trim().length >= 3)
|
||
if (!targets.length) { $q.notify({ type: 'info', message: 'Aucun job sans coordonnées à localiser', timeout: 1800 }); return }
|
||
batchLocating.value = true
|
||
let ok = 0, ambiguous = 0
|
||
try {
|
||
for (const j of targets) {
|
||
try {
|
||
const r = await addressApi.conformityCandidates((j.address || '').trim())
|
||
const cands = (r.results || []).filter(x => isFinite(+x.latitude) && isFinite(+x.longitude))
|
||
const top = cands[0]
|
||
const strong = top && (cands.length === 1 || (top.similarity_score != null && +top.similarity_score >= 0.6))
|
||
if (strong) {
|
||
const lat = +top.latitude, lon = +top.longitude
|
||
const addr = top.address_full || [top.numero, top.rue, top.ville, top.code_postal].filter(Boolean).join(' ')
|
||
await roster.setJobLocation(j.name, lat, lon, addr)
|
||
j.lat = lat; j.lon = lon; j.address = addr; ok++
|
||
} else { ambiguous++ }
|
||
} catch (e) { ambiguous++ }
|
||
}
|
||
await reloadOccupancy(); if (_dayMap) refreshDayMap()
|
||
$q.notify({ type: ok ? 'positive' : 'warning', icon: 'place', timeout: 3500,
|
||
message: `${ok} job(s) localisé(s)` + (ambiguous ? ` · ${ambiguous} adresse(s) ambiguë(s) à situer à la main (📍)` : '') })
|
||
} catch (e) { err(e) } finally { batchLocating.value = false }
|
||
}
|
||
function destroyLocMap () { if (_locMarker) { try { _locMarker.remove() } catch (e) {} _locMarker = null } if (_locMap) { try { _locMap.remove() } catch (e) {} _locMap = null } }
|
||
// init/destroy via @show/@hide du q-dialog (le conteneur a sa taille finale à @show → carte fiable)
|
||
|
||
// ── Tableur « durées par caractéristique » (additif, hors transport) — éditable inline, seed + apprentissage ──
|
||
const jobChar = reactive({ open: false, loading: false, saving: false, items: [] })
|
||
async function openJobChar () { jobChar.open = true; jobChar.loading = true; try { const r = await roster.getJobChars(); jobChar.items = r.items || [] } catch (e) { err(e) } finally { jobChar.loading = false } }
|
||
function addJobCharRow () { jobChar.items.push({ id: 'c' + Date.now().toString(36), cat: 'addon', label: '', minutes: 30, per_qty: false, keywords: '', learned_min: null, samples: 0 }) }
|
||
async function saveJobChar () { jobChar.saving = true; try { const r = await roster.saveJobChars(jobChar.items); $q.notify({ type: r.ok ? 'positive' : 'negative', message: r.ok ? `Table enregistrée (${r.count} lignes)` : 'Échec d\'enregistrement' }); if (r.ok) jobChar.open = false } catch (e) { err(e) } finally { jobChar.saving = false } }
|
||
|
||
// Lien app terrain d'un tech : copie le lien (PWA /field?t=) + propose l'envoi SMS au tech (sans build natif requis).
|
||
async function techAppLinkMenu (t) {
|
||
try {
|
||
const r = await roster.techAppLink(t.id); if (!r || !r.ok) { err(new Error('lien app indisponible')); return }
|
||
try { await navigator.clipboard.writeText(r.url) } catch (e) {}
|
||
const actions = [{ label: 'OK', color: 'white' }]
|
||
if (r.phone) actions.unshift({ label: '📲 SMS au tech', color: 'yellow', handler: async () => { try { await sendSmsViaHub(r.phone, 'Ton app terrain Targo (interventions du jour) : ' + r.url, ''); $q.notify({ type: 'positive', message: 'Lien envoyé par SMS à ' + r.name }) } catch (e) { err(e) } } })
|
||
$q.notify({ message: '📱 Lien app copié' + (r.phone ? '' : ' (pas de téléphone au dossier)') + ' — ' + r.url, timeout: 9000, multiLine: true, actions })
|
||
} catch (e) { err(e) }
|
||
}
|
||
|
||
// ── Bloc Legacy sur la timeline : clic → détail du ticket (fil osTicket ingéré in-app, via ticketThread) ──
|
||
const legacyDetail = reactive({ open: false, lid: null, subject: '', dept: '', loading: false, thread: null })
|
||
async function openLegacyBlock (b) {
|
||
if (!b || !b.lid) return
|
||
legacyDetail.lid = b.lid; legacyDetail.subject = b.subject || ('#' + b.lid); legacyDetail.dept = b.dept || ''; legacyDetail.thread = null; legacyDetail.loading = true; legacyDetail.open = true
|
||
try { legacyDetail.thread = await roster.ticketThread(b.lid) } catch (e) { err(e); legacyDetail.thread = { error: true, messages: [] } } finally { legacyDetail.loading = false }
|
||
}
|
||
|
||
// Synchroniser les techniciens → extrait dans components/planif/TechSyncDialog.vue (état + fonctions déplacés ; @applied → loadBase).
|
||
const showTechSync = ref(false)
|
||
const _hasJobLL = (j) => j && j.latitude != null && j.longitude != null && isFinite(+j.latitude) && isFinite(+j.longitude) && (Math.abs(+j.latitude) > 0.01 || Math.abs(+j.longitude) > 0.01)
|
||
const assignNoCoord = computed(() => floatingPool.filteredJobs.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 = floatingPool.filteredJobs.value.filter(j => !_hasJobLL(j))
|
||
if (!coordless.length) { $q.notify({ type: 'positive', message: 'Tous les jobs à assigner ont déjà des coordonnées 🎉', timeout: 2000 }); return }
|
||
const targets = coordless.filter(j => String(j.address || j.location_label || '').trim().length >= 3)
|
||
if (!targets.length) { // sans coords MAIS sans adresse exploitable → pas d'auto-localisation possible : on ouvre le sélecteur manuel sur le 1er
|
||
$q.notify({ type: 'warning', icon: 'wrong_location', timeout: 4000, message: `${coordless.length} job(s) sans adresse exploitable — à situer à la main sur la carte` })
|
||
openLocPicker(coordless[0]); return
|
||
}
|
||
const noAddr = coordless.length - targets.length
|
||
assignPanel.locating = true
|
||
let ok = 0, ambiguous = 0
|
||
try {
|
||
for (const j of targets) {
|
||
const q = String(j.address || j.location_label || '').trim()
|
||
try {
|
||
const r = await addressApi.conformityCandidates(q)
|
||
const cands = (r.results || []).filter(x => isFinite(+x.latitude) && isFinite(+x.longitude))
|
||
const top = cands[0]
|
||
const strong = top && (cands.length === 1 || (top.similarity_score != null && +top.similarity_score >= 0.6))
|
||
if (strong) {
|
||
const lat = +top.latitude, lon = +top.longitude
|
||
const addr = top.address_full || [top.numero, top.rue, top.ville, top.code_postal].filter(Boolean).join(' ')
|
||
await roster.setJobLocation(j.name, lat, lon, addr)
|
||
j.latitude = lat; j.longitude = lon; j.address = addr; ok++
|
||
} else { ambiguous++ }
|
||
} catch (e) { ambiguous++ }
|
||
}
|
||
if (_assignMap) refreshAssignMap()
|
||
$q.notify({ type: ok ? 'positive' : 'warning', icon: 'place', timeout: 4000,
|
||
message: `${ok} job(s) localisé(s)` + (ambiguous ? ` · ${ambiguous} adresse(s) ambiguë(s) — situer à la main (📍)` : '') + (noAddr ? ` · ${noAddr} sans adresse (📍)` : '') })
|
||
} catch (e) { err(e) } finally { assignPanel.locating = false }
|
||
}
|
||
function assignStops () { return assignLocations.value.stops }
|
||
function toggleAssignMap () { assignPanel.showMap = !assignPanel.showMap }
|
||
// ── Lasso / boîte de sélection sur la carte : glisse un rectangle → sélectionne TOUS les jobs dedans (amas compris) ──
|
||
const assignLasso = ref(false)
|
||
function toggleLasso () {
|
||
assignLasso.value = !assignLasso.value
|
||
if (_assignMap) { assignLasso.value ? _assignMap.dragPan.disable() : _assignMap.dragPan.enable(); _assignMap.getCanvas().style.cursor = assignLasso.value ? 'crosshair' : '' }
|
||
}
|
||
function setupBoxSelect (map) { // LASSO FREEFORM : tracé libre (souris ou doigt) → polygone de sélection
|
||
const canvas = map.getCanvasContainer()
|
||
let pts = null, svg = null, poly = null
|
||
const pos = (e) => { const r = canvas.getBoundingClientRect(); const t = (e.touches && e.touches[0]) || e; return { x: t.clientX - r.left, y: t.clientY - r.top } }
|
||
const onMove = (e) => {
|
||
if (!pts) return; if (e.cancelable) e.preventDefault(); pts.push(pos(e))
|
||
if (!svg) {
|
||
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||
svg.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:10'
|
||
poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline')
|
||
poly.setAttribute('fill', 'rgba(99,102,241,.15)'); poly.setAttribute('stroke', '#6366f1'); poly.setAttribute('stroke-width', '2'); poly.setAttribute('stroke-dasharray', '5 3')
|
||
svg.appendChild(poly); canvas.appendChild(svg)
|
||
}
|
||
poly.setAttribute('points', pts.map(q => q.x + ',' + q.y).join(' '))
|
||
}
|
||
const onUp = () => {
|
||
document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp)
|
||
document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onUp)
|
||
if (svg) { svg.remove(); svg = null; poly = null }
|
||
const path = pts; pts = null
|
||
if (path && path.length >= 3) selectInLasso(map, path)
|
||
}
|
||
const onDown = (e) => {
|
||
if (!assignLasso.value || (e.button != null && e.button !== 0)) return
|
||
e.preventDefault(); e.stopPropagation(); pts = [pos(e)]
|
||
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp)
|
||
document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchend', onUp)
|
||
}
|
||
canvas.addEventListener('mousedown', onDown, true)
|
||
canvas.addEventListener('touchstart', onDown, true)
|
||
}
|
||
// Point-in-polygon (ray casting) sur coordonnées écran.
|
||
function _inPoly (pt, poly) {
|
||
let inside = false
|
||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||
const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y
|
||
if (((yi > pt.y) !== (yj > pt.y)) && (pt.x < (xj - xi) * (pt.y - yi) / ((yj - yi) || 1e-9) + xi)) inside = !inside
|
||
}
|
||
return inside
|
||
}
|
||
async function selectInLasso (map, path) { // sélectionne pins + amas dont le CENTRE est dans le tracé
|
||
const names = new Set(); const clusterIds = []
|
||
try {
|
||
const feats = map.queryRenderedFeatures({ layers: ['aj-c', 'aj-cluster'] }) || []
|
||
for (const f of feats) {
|
||
const c = f.geometry && f.geometry.coordinates; if (!c) continue
|
||
const scr = map.project(c)
|
||
if (!_inPoly({ x: scr.x, y: scr.y }, path)) continue
|
||
if (f.properties && f.properties.cluster_id != null) clusterIds.push(f.properties.cluster_id)
|
||
else { try { JSON.parse(f.properties.names || '[]').forEach(n => names.add(n)) } catch (e) {} }
|
||
}
|
||
const src = map.getSource('aj')
|
||
await Promise.all(clusterIds.map(cid => new Promise(res => { try { src.getClusterLeaves(cid, 1000, 0, (err, leaves) => { if (!err && leaves) leaves.forEach(l => { try { JSON.parse(l.properties.names || '[]').forEach(n => names.add(n)) } catch (e) {} }); res() }) } catch (e) { res() } })))
|
||
} catch (e) {}
|
||
names.forEach(n => { selectedJobs[n] = true })
|
||
if (names.size) $q.notify({ type: 'positive', message: names.size + ' job(s) sélectionné(s) au lasso', timeout: 1800 })
|
||
else $q.notify({ type: 'info', message: 'Aucun job dans la zone', timeout: 1500 })
|
||
refreshAssignMap()
|
||
}
|
||
function focusAssignJob (j) { if (_assignMap && _hasJobLL(j)) _assignMap.easeTo({ center: [+j.longitude, +j.latitude], zoom: 13, duration: 400 }) }
|
||
// Carte → liste : déplie les détails du job + scrolle la ligne + flash visuel.
|
||
const flashJob = ref(null)
|
||
function focusAssignJobInList (name) {
|
||
if (!name) return
|
||
const job = assignPanel.jobs.find(j => j.name === name)
|
||
if (job) { job._showThread = true; loadThread(job) }
|
||
flashJob.value = name; setTimeout(() => { if (flashJob.value === name) flashJob.value = null }, 1800)
|
||
nextTick(() => { const el = document.querySelector('.assign-body [data-jobname="' + name + '"]'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center', behavior: 'smooth' }) })
|
||
}
|
||
async function initAssignMap () {
|
||
if (!assignMapEl.value || _assignMap) return
|
||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !assignMapEl.value) return
|
||
_assignMap = createPlanMap(assignMapEl.value, { center: [-73.55, 45.2], zoom: 8, streetView: openStreetView })
|
||
_assignMapRO = new ResizeObserver(() => { if (_assignMap) _assignMap.resize() }); _assignMapRO.observe(assignMapEl.value)
|
||
_assignMap.on('load', () => {
|
||
_assignMap.resize()
|
||
addSatLayer(_assignMap) // couche satellite (sous les pins), masquée par défaut
|
||
// Regroupement (clustering) natif Mapbox : les jobs proches fusionnent en une pastille portant le TOTAL de jobs.
|
||
// Résout le chevauchement des pastilles/texte au dézoom. jobsum = somme des jobs (pas juste le nb d'adresses).
|
||
_assignMap.addSource('aj', { type: 'geojson', cluster: true, clusterRadius: 44, clusterMaxZoom: 13, clusterProperties: { jobsum: ['+', ['get', 'count']], selsum: ['+', ['get', 'seln']] }, data: { type: 'FeatureCollection', features: [] } })
|
||
// Amas : bulle indigo dimensionnée par le nb de jobs ; contour DORÉ si l'amas contient de la sélection
|
||
_assignMap.addLayer({ id: 'aj-cluster', type: 'circle', source: 'aj', filter: ['has', 'point_count'], paint: { 'circle-color': '#6366f1', 'circle-opacity': 0.92, 'circle-radius': ['step', ['get', 'jobsum'], 15, 5, 19, 15, 25], 'circle-stroke-width': ['case', ['>', ['get', 'selsum'], 0], 4, 2.5], 'circle-stroke-color': ['case', ['>', ['get', 'selsum'], 0], '#fbbf24', '#fff'] } })
|
||
_assignMap.addLayer({ id: 'aj-cluster-count', type: 'symbol', source: 'aj', filter: ['has', 'point_count'], layout: { 'text-field': ['get', 'jobsum'], 'text-size': 13, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
|
||
// Pastilles individuelles : UNIQUEMENT hors amas ; contour DORÉ si sélectionnée (surlignage de la sélection)
|
||
_assignMap.addLayer({ id: 'aj-c', type: 'circle', source: 'aj', filter: ['!', ['has', 'point_count']], paint: { 'circle-radius': ['case', ['>', ['get', 'count'], 1], 12, 9], 'circle-color': ['get', 'color'], 'circle-stroke-width': ['case', ['get', 'sel'], 3.5, 2], 'circle-stroke-color': ['case', ['get', 'sel'], '#fbbf24', '#fff'] } })
|
||
_assignMap.addLayer({ id: 'aj-l', type: 'symbol', source: 'aj', filter: ['!', ['has', 'point_count']], layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
|
||
_assignMap.on('mouseenter', 'aj-c', () => { _assignMap.getCanvas().style.cursor = 'pointer' })
|
||
_assignMap.on('mouseleave', 'aj-c', () => { _assignMap.getCanvas().style.cursor = '' })
|
||
// Clic sur un amas → zoom d'expansion qui l'ÉCLATE (le « zoom automatique on clic » demandé).
|
||
_assignMap.on('mouseenter', 'aj-cluster', () => { _assignMap.getCanvas().style.cursor = 'zoom-in' })
|
||
_assignMap.on('mouseleave', 'aj-cluster', () => { _assignMap.getCanvas().style.cursor = '' })
|
||
_assignMap.on('click', 'aj-cluster', (e) => {
|
||
const f = _assignMap.queryRenderedFeatures(e.point, { layers: ['aj-cluster'] })[0]; if (!f) return
|
||
_assignMap.getSource('aj').getClusterExpansionZoom(f.properties.cluster_id, (err, zoom) => {
|
||
if (err) return
|
||
_assignMap.easeTo({ center: f.geometry.coordinates, zoom: Math.min((zoom || 14) + 0.3, 16), duration: 500 })
|
||
})
|
||
})
|
||
_assignMap.on('click', 'aj-c', (e) => {
|
||
const f = e.features[0]; const p = f.properties; const names = JSON.parse(p.names || '[]')
|
||
const allSel = names.length && names.every(n => selectedJobs[n]) // clic = BASCULE (sélectionne, ou désélectionne si déjà tout coché)
|
||
names.forEach(n => { if (allSel) delete selectedJobs[n]; else selectedJobs[n] = true })
|
||
if (!allSel && names[0]) focusAssignJobInList(names[0]) // + scrolle/expand la job dans la liste (à la sélection seulement)
|
||
new window.mapboxgl.Popup({ offset: 13 }).setLngLat(f.geometry.coordinates).setHTML(`<div style="font-size:12px;line-height:1.45"><b>${_esc(p.letter)}</b> · ${_esc(p.title)}<br>${allSel ? '✕ retiré de la sélection' : '✓ ' + p.count + ' job(s) sélectionné(s)'}<br><span style="color:#888">${allSel ? 're-clique pour re-sélectionner' : 'glisse-les sur un tech × jour'}</span></div>`).addTo(_assignMap)
|
||
})
|
||
setupBoxSelect(_assignMap) // lasso : glisser un rectangle → sélectionne les jobs dedans
|
||
refreshAssignMap()
|
||
})
|
||
}
|
||
function refreshAssignMap () {
|
||
if (!_assignMap || !_assignMap.isStyleLoaded()) { setTimeout(refreshAssignMap, 200); return }
|
||
const stops = assignStops(); const src = _assignMap.getSource('aj'); if (!src) return
|
||
src.setData({ type: 'FeatureCollection', features: stops.map(s => { const sel = (s.names || []).some(n => !!selectedJobs[n]); return { type: 'Feature', geometry: { type: 'Point', coordinates: [s.lon, s.lat] }, properties: { label: s.letter + (s.count > 1 ? '·' + s.count : ''), color: s.color, count: s.count, names: JSON.stringify(s.names), letter: s.letter, title: s.title, sel, seln: sel ? 1 : 0 } } }) })
|
||
if (stops.length === 1) _assignMap.easeTo({ center: [stops[0].lon, stops[0].lat], zoom: 11, duration: 400 })
|
||
else if (stops.length > 1) { const b = new window.mapboxgl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat]); stops.forEach(s => b.extend([s.lon, s.lat])); _assignMap.fitBounds(b, { padding: 40, maxZoom: 12, duration: 400 }) }
|
||
}
|
||
function destroyAssignMap () { assignLasso.value = false; if (_assignMapRO) { try { _assignMapRO.disconnect() } catch (e) {} _assignMapRO = null } if (_assignMap) { try { _assignMap.remove() } catch (e) {} _assignMap = null } }
|
||
// ── Grande carte des TOURNÉES proposées (revue Suggérer) : ligne domicile→arrêts par tech, 1 couleur/tech, 1 jour ──
|
||
// Carte de la revue = composant partagé <RouteMap> (P2). Les métriques réelles (OSRM) remontent ici pour la légende.
|
||
function onRouteMetrics (m) {
|
||
// Idempotent : ne muter realRoutes QUE si les valeurs changent (sinon re-rendu inutile → risque de boucle avec RouteMap)
|
||
const keys = Object.keys(m || {})
|
||
let same = keys.length === Object.keys(realRoutes).length
|
||
if (same) for (const k of keys) { const a = realRoutes[k], b = m[k]; if (!a || a.km !== b.km || a.mins !== b.mins) { same = false; break } }
|
||
if (same) return
|
||
for (const k in realRoutes) delete realRoutes[k]
|
||
Object.assign(realRoutes, m)
|
||
}
|
||
// D v2 — routes RÉELLES : Mapbox Directions par tech (domicile→arrêts) → géométrie routière + distance/durée réelles. Cache par signature.
|
||
const _routeCache = new Map()
|
||
const realRoutes = reactive({}) // techId → { km, mins } (la légende préfère ces valeurs réelles quand présentes)
|
||
// Tournée routière RÉELLE d'un tech (OSRM auto-hébergé via hub, plus de Mapbox payant) : tracé + total + temps ENTRE arrêts. Cache par signature.
|
||
async function fetchTechRoute (home, stops) {
|
||
const pts = []; if (home) pts.push([+home.lon, +home.lat]); for (const s of stops) pts.push([+s.lon, +s.lat])
|
||
if (home) pts.push([+home.lon, +home.lat]) // RETOUR au point de départ : la tournée est une boucle domicile→arrêts→domicile
|
||
if (pts.length < 2 || pts.length > 52) return null
|
||
const sig = pts.map(p => p[0].toFixed(5) + ',' + p[1].toFixed(5)).join(';')
|
||
if (_routeCache.has(sig)) return _routeCache.get(sig)
|
||
try {
|
||
const d = await roster.osrmRoute(pts)
|
||
if (!d || !Array.isArray(d.geometry)) return null
|
||
const info = { geometry: { type: 'LineString', coordinates: d.geometry }, km: d.km, mins: d.mins, legs: d.legs || [], hasHome: !!home }
|
||
_routeCache.set(sig, info); return info
|
||
} catch (e) { return null }
|
||
}
|
||
watch(() => assignPanel.showMap, (on) => { if (on) { if (assignPanel.h < 600) assignPanel.h = 640; nextTick(initAssignMap) } else destroyAssignMap() })
|
||
watch(floatingPool.filteredJobs, () => { if (assignPanel.showMap) nextTick(() => { _assignMap ? refreshAssignMap() : initAssignMap() }) }) // carte suit le filtre (date/compétence) + les jobs
|
||
watch(selectedJobs, () => { if (assignPanel.showMap && _assignMap) refreshAssignMap() }) // surlignage doré des pins/amas suit la sélection
|
||
watch(() => assignPanel.open, (o) => { if (!o) destroyAssignMap() })
|
||
|
||
// ── Timeline contextuelle d'une RESSOURCE (dispatch des jobs de la semaine visible) ──
|
||
// Réutilise les helpers de cellule (cellBands/cellBlocks/cellJobs/cellPct) → 0 nouvel appel réseau.
|
||
const timelineDlg = reactive({ open: false, tech: null })
|
||
function openTimeline (t) { timelineDlg.tech = t; timelineDlg.open = true }
|
||
// (Clic sur le progressbar → gotoDispatch : on ouvre le timeline ÉDITABLE du tableau Dispatch, drag-drop + suppression,
|
||
// plutôt qu'un popup maison — réutilisation max + cohérence. Le réordonnancement/priorité se fait là-bas.)
|
||
// Deep-link vers le tableau Dispatch focalisé sur la ressource + le jour cliqué (sinon 1er jour de la semaine).
|
||
function gotoDispatch (t, dateIso) {
|
||
const q = {}
|
||
if (t) q.tech = t.id
|
||
q.date = dateIso || (timelineDays.value[0] && timelineDays.value[0].iso) || start.value
|
||
router.push({ path: '/planification', query: q }) // page Dispatch retirée → reste sur Planification
|
||
}
|
||
// ── Éditeur de JOURNÉE (fenêtre contextuelle ciblée — clic sur le progressbar) ──
|
||
// Garde le contexte de la grille derrière. Timeline + réordonnancement DRAG-DROP + retrait d'un job.
|
||
const dayEditor = reactive({ open: false, tech: null, day: null, list: [], saving: false, dragIdx: null, travelMap: {}, routeReady: false })
|
||
const dayShift = ref({ min: 8, max: 16 }) // quart de l'éditeur de jour (q-range, révélé par « Modifier »)
|
||
const dayShiftEdit = ref(false) // false = affichage statique ; true = curseur d'édition révélé
|
||
// Jobs LEGACY (osTicket F) datés ce jour, PAS encore importés en Dispatch Job → comptés dans l'occupation mais absents
|
||
// de cellJobs (roster). On les liste en LECTURE SEULE dans l'éditeur de jour (sinon « Aucun job » alors que 88% occupé).
|
||
const dayLegacyJobs = computed(() => {
|
||
if (!showLegacyLoad.value || !dayEditor.tech || !dayEditor.day) return []
|
||
const lg = legacyLoad.value[dayEditor.tech.id + '|' + dayEditor.day.iso]
|
||
return lg && Array.isArray(lg.jobs) ? lg.jobs.filter(j => j.field) : []
|
||
})
|
||
function openDayEditor (t, d) {
|
||
dayEditor.tech = t; dayEditor.day = d
|
||
const _w = winOf(t.id, d.iso, false); dayShift.value = _w ? { min: _w.s, max: _w.e } : { min: 8, max: 16 } // init la bande de quart depuis le shift régulier (sinon 8-16 par défaut)
|
||
dayShiftEdit.value = false // toujours rouvrir en mode STATIQUE
|
||
// RDV confirmé (ou heure légacy précise) = heure FIXE → verrouillé ; sinon flexible (replanifiable par la tournée).
|
||
// Roster (Dispatch Jobs, ÉDITABLES) + jobs LEGACY (osTicket F, géocodés, LECTURE SEULE) dans la MÊME liste → pins sur la
|
||
// carte + tournée optimisable + legs de transport, via la logique existante. Le legacy ne se sauvegarde pas (F autoritaire).
|
||
const rosterItems = cellJobs(t.id, d.iso).map(j => ({ ...j, locked: j.booking_status === 'Confirmé', showDetail: false }))
|
||
const legacyItems = dayLegacyJobs.value.map(j => ({ name: 'LEG:' + j.id, legacy: true, readonly: true, legacy_id: j.id, lid: j.id, subject: j.subject, dept: j.dept, skill: j.skill, dur: Number(j.est_h) || 1, lat: j.lat, lon: j.lon, address: j.address || '', customer: j.customer || '', locked: false, showDetail: false }))
|
||
dayEditor.list = [...rosterItems, ...legacyItems]
|
||
dayOriginChoice.value = null // départ = auto (politique) à chaque ouverture
|
||
dayEditor.dragIdx = null; dayEditor.travelMap = {}; dayEditor.routeReady = false; dayEditor.open = true; dayShowTrack.value = false
|
||
loadDayRoute() // charge la matrice de temps routiers RÉELS (Mapbox) → packedDay les utilise dès l'arrivée (réactif)
|
||
loadTraccarDevices() // liste des appareils GPS (pour associer le tech, inline) — chargée une fois
|
||
}
|
||
// Quart MODIFIABLE depuis l'éditeur de jour : applique un gabarit d'heures (réutilise ensureWindowTpl + setCellReplace,
|
||
// comme la saisie rapide « 8-17 ») sur le tech/jour courant. Édition LOCALE → « Publier » pour enregistrer.
|
||
async function saveDayShift () {
|
||
const min = dayShift.value.min; const max = dayShift.value.max
|
||
if (!dayEditor.tech || !dayEditor.day || !(max > min)) return
|
||
const tpl = await ensureWindowTpl(min, max)
|
||
if (tpl) { pushHistory(); setCellReplace(dayEditor.tech.id, dayEditor.tech.name, dayEditor.day.iso, tpl); $q.notify({ type: 'positive', message: `Quart ${fmtH(min)}h–${fmtH(max)}h — publier pour enregistrer`, timeout: 2500 }) }
|
||
}
|
||
function dayShiftLabel () { // affichage statique du quart (lecture seule) dans l'éditeur de jour
|
||
const reg = dayEditor.tech && dayEditor.day && hasReg(dayEditor.tech.id, dayEditor.day.iso)
|
||
if (!reg) return 'Aucun quart planifié'
|
||
return `${fmtH(dayShift.value.min)}h–${fmtH(dayShift.value.max)}h · ${Math.round((dayShift.value.max - dayShift.value.min) * 10) / 10} h`
|
||
}
|
||
// ── Tracé GPS réel (Traccar) SUPERPOSÉ sur la carte de l'éditeur de jour (jobs + itinéraire planifié déjà présents) ──
|
||
// → permet de voir si le tech est réellement passé par ses jobs. Toggle dispo AUJOURD'HUI/HIER seulement (Traccar lourd).
|
||
const dayShowTrack = ref(false); const dayTrackMsg = ref('')
|
||
const dayCanTrack = computed(() => {
|
||
const iso = dayEditor.day && dayEditor.day.iso; const n = nowET.value.iso; if (!iso || !n) return false
|
||
const yest = new Date(Date.parse(n + 'T12:00:00Z') - 86400000).toISOString().slice(0, 10)
|
||
return iso === n || iso === yest
|
||
})
|
||
async function loadDayTrack () {
|
||
dayTrackMsg.value = '…'
|
||
const iso = dayEditor.day && dayEditor.day.iso; const tech = dayEditor.tech && dayEditor.tech.id
|
||
const src = _dayMap && _dayMap.getSource('day-track')
|
||
if (!iso || !tech || !src) { dayTrackMsg.value = ''; return }
|
||
const from = new Date(iso + 'T00:00:00').toISOString()
|
||
const to = (iso === nowET.value.iso) ? new Date().toISOString() : new Date(iso + 'T23:59:59').toISOString()
|
||
try {
|
||
const r = await roster.traccarTrack(tech, from, to); const coords = (r && r.coords) || []
|
||
src.setData(coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} } : { type: 'FeatureCollection', features: [] })
|
||
if (coords.length < 2) { dayTrackMsg.value = 'aucun tracé / appareil'; return }
|
||
// Comparaison RÉEL (GPS) vs PRÉVU (route Mapbox) — km + temps de conduite. Contextuel : seulement quand le tracé est affiché → pas de surcharge.
|
||
let pkm = 0, pmin = 0, hasPlan = false
|
||
for (let i = 0; i < dayEditor.list.length; i++) { const l = dayLeg(i); if (l) { if (l.km != null) { pkm += l.km; hasPlan = true } pmin += (l.min || 0) } }
|
||
const rkm = (r && r.km != null) ? r.km : null; const rmin = (r && r.moving_min != null) ? r.moving_min : null
|
||
const span = (r && r.span_min != null) ? r.span_min : null
|
||
if (rkm == null) { dayTrackMsg.value = coords.length + ' pts GPS'; return }
|
||
const onsite = (span != null && rmin != null) ? Math.max(0, span - rmin) : null // sur place (arrêté) = durée GPS totale − conduite
|
||
let msg = 'réel ' + rkm + ' km' + (rmin != null ? ' · route ' + fmtMin(rmin) : '') + (onsite != null ? ' · sur place ' + fmtMin(onsite) : '')
|
||
if (hasPlan) {
|
||
const dkm = Math.round((rkm - pkm) * 10) / 10
|
||
const flag = (pkm > 0 && rkm > pkm * 1.25) ? '⚠ ' : '✓ ' // ⚠ = beaucoup plus de km que prévu (détour/trafic/route à revoir)
|
||
msg = flag + msg + ' · prévu ' + (Math.round(pkm * 10) / 10) + ' km / ' + Math.round(pmin) + ' min (' + (dkm >= 0 ? '+' : '') + dkm + ' km)'
|
||
}
|
||
dayTrackMsg.value = msg
|
||
} catch (e) { dayTrackMsg.value = 'indispo' }
|
||
}
|
||
watch(dayShowTrack, (on) => { if (!_dayMap) return; if (on) loadDayTrack(); else { const s = _dayMap.getSource('day-track'); if (s) s.setData({ type: 'FeatureCollection', features: [] }); dayTrackMsg.value = '' } })
|
||
// Matrice des temps de trajet ROUTIERS RÉELS entre tous les jobs du jour (Mapbox Matrix, 1 requête).
|
||
// Indépendante de l'ordre → le réordonnancement réutilise la matrice SANS nouvelle requête (recalcul instantané).
|
||
// Repli silencieux sur l'haversine si Mapbox indispo ou coords manquantes.
|
||
async function loadDayRoute () {
|
||
const key = (dayEditor.tech && dayEditor.tech.id) + '|' + (dayEditor.day && dayEditor.day.iso)
|
||
const jobsLL = dayEditor.list.filter(j => j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon))
|
||
const origin = dayOrigin.value // l'origine (dépôt/domicile) entre dans la matrice → trajet origine→1er job RÉEL
|
||
const pts = ((origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? [origin, ...jobsLL] : jobsLL).slice(0, 25) // Matrix = 25 coords max
|
||
if (pts.length < 2) { dayEditor.travelMap = {}; dayEditor.routeReady = false; return }
|
||
try {
|
||
const d = await roster.osrmTable(pts.map(j => [+j.lon, +j.lat])) // OSRM auto-hébergé (via hub) — même forme que Mapbox Matrix
|
||
const dur = d.durations || [], dist = d.distances || []
|
||
if (key !== ((dayEditor.tech && dayEditor.tech.id) + '|' + (dayEditor.day && dayEditor.day.iso))) return // l'éditeur a changé de cible entre-temps
|
||
const map = {}
|
||
for (let i = 0; i < pts.length; i++) for (let k = 0; k < pts.length; k++) {
|
||
if (i === k) continue
|
||
const sec = dur[i] && dur[i][k]; const m = dist[i] && dist[i][k]
|
||
if (sec == null) continue
|
||
map[pts[i].name + '>' + pts[k].name] = { min: Math.max(2, Math.round(sec / 60)), km: m != null ? Math.round(m / 100) / 10 : null, real: true }
|
||
}
|
||
dayEditor.travelMap = map; dayEditor.routeReady = true
|
||
} catch (e) { dayEditor.travelMap = {}; dayEditor.routeReady = false } // repli haversine
|
||
}
|
||
const dayOcc = () => (dayEditor.tech && dayEditor.day) ? cellOcc(dayEditor.tech.id, dayEditor.day.iso) : null
|
||
const dayBands = () => (dayEditor.tech && dayEditor.day) ? cellBands(dayEditor.tech.id, dayEditor.day.iso) : []
|
||
// Blocs RECALCULÉS depuis la SÉQUENCE éditée (packedDay) → l'ordre + les durées + le transport se reflètent + plus d'overlap.
|
||
const dayBlocks = () => packedDay.value.map(p => ({ s: p.startMin, e: p.endMin, skill: p.skill, legacy: p.legacy })) // packedDay inclut déjà les jobs legacy fusionnés
|
||
// Réordonnancement : flèches ↑↓ (fiable) + drag-drop basé sur le DROP (robuste, pas de splice live jittery)
|
||
function moveDayJob (i, dir) { const j = i + dir; const l = dayEditor.list; if (j < 0 || j >= l.length) return; const [x] = l.splice(i, 1); l.splice(j, 0, x) }
|
||
// Figer une job en 1re / dernière position (ou détacher). Réordonne pour honorer les positions figées.
|
||
function setPin (j, pin) { j.pin = (j.pin === pin ? null : pin); reorderByPins() }
|
||
function reorderByPins () {
|
||
const l = dayEditor.list
|
||
const first = l.filter(x => x.pin === 'first')
|
||
const last = l.filter(x => x.pin === 'last')
|
||
const mid = l.filter(x => x.pin !== 'first' && x.pin !== 'last')
|
||
dayEditor.list = [...first, ...mid, ...last]
|
||
}
|
||
function dayDragStart (i, ev) { dayEditor.dragIdx = i; try { ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.setData('text/plain', String(i)) } catch (e) {} }
|
||
function dayDropOn (i) { const from = dayEditor.dragIdx; if (from == null || from === i) { dayEditor.dragIdx = null; return } const l = dayEditor.list; const [x] = l.splice(from, 1); l.splice(i, 0, x); dayEditor.dragIdx = null }
|
||
function dayDragEnd () { dayEditor.dragIdx = null }
|
||
// Durée éditable en MINUTES (pas de 5) — best practice de précision
|
||
function jobMinutes (j) { return Math.round((Number(j.dur) || 0) * 60) }
|
||
// Couleur du « réel/estimé » sur la timeline : vert si dans l'estimé, rouge si dépassé > 10 %, neutre entre.
|
||
function ratioStyle (j) {
|
||
const est = jobMinutes(j); const act = j.actual_min
|
||
if (act == null || !est) return {}
|
||
if (act > est * 1.1) return { color: '#c62828', fontWeight: 700 }
|
||
if (act <= est) return { color: '#2e7d32', fontWeight: 700 }
|
||
return {}
|
||
}
|
||
function setJobMinutes (j, min) { const m = Math.max(5, Math.round((Number(min) || 0) / 5) * 5); j.dur = Math.round(m / 60 * 100) / 100 }
|
||
// Temps de transport estimé entre 2 jobs (haversine via coords Service Location) — provisoire, en attendant la géoloc live (Capacitor)
|
||
function haversineKm (la1, lo1, la2, lo2) { if ([la1, lo1, la2, lo2].some(v => v == null)) return null; const R = 6371; const r = x => x * Math.PI / 180; const dLa = r(la2 - la1); const dLo = r(lo2 - lo1); const s = Math.sin(dLa / 2) ** 2 + Math.cos(r(la1)) * Math.cos(r(la2)) * Math.sin(dLo / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(s)) }
|
||
function travelBetween (a, b) {
|
||
if (!a || !b) return null
|
||
const hit = dayEditor.travelMap && dayEditor.travelMap[a.name + '>' + b.name]
|
||
if (hit) return hit // temps routier RÉEL (Mapbox Matrix)
|
||
const km = haversineKm(a.lat, a.lon, b.lat, b.lon); if (km == null) return null
|
||
return { km: Math.round(km * 10) / 10, min: Math.max(5, Math.round(km / 40 * 60) + 5), real: false } // repli : 40 km/h + 5 min tampon (vol d'oiseau)
|
||
}
|
||
function dayLeg (i) { if (i === 0) { const o = dayOrigin.value; return (o && dayEditor.list[0]) ? travelBetween(o, dayEditor.list[0]) : null } return travelBetween(dayEditor.list[i - 1], dayEditor.list[i]) } // i=0 = trajet origine (dépôt/domicile) → 1er job ; sinon job précédent → courant
|
||
const fmtHM = (h) => { if (h == null) return '—'; const m = Math.round(h * 60); const hh = Math.floor(m / 60), mm = m % 60; return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') } // heure décimale → HH:MM (padded, pour start_time)
|
||
function dayShiftStartH () { const t = dayEditor.tech, d = dayEditor.day; if (!t || !d) return 8; const w = winOf(t.id, d.iso, false); return w ? w.s : 8 }
|
||
// PLANIFICATEUR DE TOURNÉE : recalcule les heures depuis l'ordre de la liste + durées + transport.
|
||
// Job verrouillé (RDV fixe) → garde son heure ; flexible → enchaîné après le précédent (+ transport). Plus d'overlap.
|
||
const packedDay = computed(() => {
|
||
const list = dayEditor.list; const out = []; let cursor = dayShiftStartH()
|
||
const origin = dayOrigin.value
|
||
if (origin && list.length) { const l0 = (travelBetween(origin, list[0]) || {}).min || 0; cursor += l0 / 60 } // départ origine → 1er job (le tech quitte l'origine à l'heure du shift)
|
||
for (let i = 0; i < list.length; i++) {
|
||
const j = list[i]; const dur = Number(j.dur) || 1
|
||
const start = (j.locked && j.start_h != null) ? j.start_h : cursor
|
||
const end = start + dur
|
||
out.push({ ...j, startMin: start, endMin: end })
|
||
const trH = (i < list.length - 1 ? (travelBetween(j, list[i + 1]) || {}).min || 0 : 0) / 60
|
||
cursor = Math.max(cursor, end) + trH
|
||
}
|
||
return out
|
||
})
|
||
// Coords valides : non nulles, finies, ET pas ~0,0 (placeholder « pas de coords » — golfe de Guinée).
|
||
const hasLL = (j) => j && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon) && (Math.abs(+j.lat) > 0.01 || Math.abs(+j.lon) > 0.01)
|
||
const dayNoCoord = computed(() => dayEditor.list.filter(j => !hasLL(j)).length)
|
||
|
||
// ── Origine de tournée : point de départ = DÉPÔT par défaut, OU DOMICILE du tech s'il est plus proche du travail ──
|
||
// (politique dispatch, lue côté client). La 1re job devient la plus proche de cette origine via « Optimiser depuis le départ ».
|
||
const depot = ref(null) // { label, address, lat, lon }
|
||
const techHomes = ref({}) // { techId: { address, lat, lon } }
|
||
// Point de DÉPART d'un tech : son DOMICILE s'il est défini, SINON le bureau TARGO (dépôt) — politique par défaut.
|
||
function techOrigin (techId) {
|
||
const h = techHomes.value[techId]
|
||
if (h && isFinite(+h.lat) && isFinite(+h.lon) && (Math.abs(+h.lat) > 0.01)) return { lat: +h.lat, lon: +h.lon, kind: 'home', address: h.address || '' }
|
||
const d = depot.value
|
||
if (d && isFinite(+d.lat) && isFinite(+d.lon)) return { lat: +d.lat, lon: +d.lon, kind: 'depot', address: d.address || '' }
|
||
return null
|
||
}
|
||
async function loadDispatchPolicy () {
|
||
try { const d = await roster.getPolicy(); depot.value = (d.policy && d.policy.depot) || null; techHomes.value = (d.policy && d.policy.tech_homes) || {} } catch (e) { /* non bloquant */ }
|
||
}
|
||
// Point de départ de la tournée : choix MANUEL dans le dialogue (null = auto/politique). Permet de basculer Bureau↔Domicile
|
||
// pour un tech « dispatch maison » même sans job géolocalisé (sinon la carte n'aurait aucun pin de départ à afficher).
|
||
const dayOriginChoice = ref(null)
|
||
const dayHasDepot = computed(() => { const dp = depot.value; return !!(dp && hasLL(dp)) })
|
||
const dayHasHome = computed(() => { const t = dayEditor.tech; const hm = t && techHomes.value[t.id]; return !!(hm && hasLL(hm)) })
|
||
const dayOrigin = computed(() => {
|
||
const t = dayEditor.tech; if (!t) return null
|
||
const dp = depot.value; const dep = (dp && hasLL(dp)) ? { name: '__origin__', lat: +dp.lat, lon: +dp.lon, label: dp.label || 'Dépôt', address: dp.address || '', kind: 'depot' } : null
|
||
const hm = techHomes.value[t.id]; const home = (hm && hasLL(hm)) ? { name: '__origin__', lat: +hm.lat, lon: +hm.lon, label: 'Domicile', address: hm.address || '', kind: 'home' } : null
|
||
if (dayOriginChoice.value === 'depot' && dep) return dep // départ forcé manuellement (toggle du dialogue) — prioritaire sur l'auto
|
||
if (dayOriginChoice.value === 'home' && home) return home
|
||
const jobs = dayEditor.list.filter(hasLL)
|
||
if (!jobs.length) return dep || home || null
|
||
if (dep && home) { const nd = (o) => Math.min(...jobs.map(j => haversineKm(o.lat, o.lon, +j.lat, +j.lon) ?? 9e9)); return nd(home) < nd(dep) ? home : dep } // domicile gagne s'il est plus proche
|
||
return dep || home || null
|
||
})
|
||
// Affiche la carte même sans job géolocalisé → au moins le point de départ (dépôt/domicile) reste visible.
|
||
const dayShowMap = computed(() => dayEditor.list.length > 0 || !!dayOrigin.value)
|
||
// Bascule manuelle du départ (dépôt ↔ domicile) depuis le dialogue → recalcule pins + itinéraire.
|
||
function setDayOrigin (kind) { dayOriginChoice.value = kind; if (_dayMap) refreshDayMap(); loadDayRoute() }
|
||
|
||
// ── Carte INTERACTIVE de la journée (Mapbox GL) : itinéraire ROUTIER réel (API Directions) + pins
|
||
// numérotés dans l'ordre de tournée. Navigable : zoom molette + boutons (NavigationControl), déplacement. ──
|
||
const dayMapEl = ref(null)
|
||
let _dayMap = null; let _dayMapRO = null; let _dirTimer = null; let _dayStopMarkers = []
|
||
function ensureMapbox () { return Promise.resolve(installMaplibre()) } // MapLibre bundlé + protocole pmtiles:// (alias window.mapboxgl pour compat)
|
||
const _esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
||
function dayStops () { // arrêts géolocalisés, dans l'ordre de tournée (packedDay) + infos pour le popup
|
||
return packedDay.value.filter(hasLL).map((j, i) => ({
|
||
lon: +j.lon, lat: +j.lat, label: String(i + 1), color: j.skill ? getTagColor(j.skill) : '#1976d2', skill: j.skill || '',
|
||
subject: j.subject || '', customer: j.customer || '', address: j.address || '', time: fmtHM(j.startMin) + '–' + fmtHM(j.endMin),
|
||
}))
|
||
}
|
||
// Segments de DÉPLACEMENT (pointillés) = l'espace entre 2 jobs dans la barre timeline.
|
||
const dayTravelSegs = () => { const p = packedDay.value; const out = []; const o = dayOrigin.value; if (o && p.length) { const s = dayShiftStartH(), e = p[0].startMin; if (e - s > 0.02) out.push({ s, e }) } for (let i = 0; i < p.length - 1; i++) { const s = p[i].endMin, e = p[i + 1].startMin; if (e - s > 0.02) out.push({ s, e }) } return out }
|
||
// Centre la carte sur un job (clic sur la ligne de la liste).
|
||
function focusDayJob (j) { if (_dayMap && hasLL(j)) _dayMap.easeTo({ center: [+j.lon, +j.lat], zoom: 14, duration: 500 }) }
|
||
// Fil legacy (messages + réponses du ticket osTicket) chargé à la demande, mis en cache sur le job (j._thread).
|
||
async function loadThread (j) {
|
||
const id = j && (j.legacy_id || j.legacy_ticket_id); if (!id || j._thread) return
|
||
j._thread = { loading: true, messages: [] }
|
||
try { const r = await roster.ticketThread(id); j._thread = { loading: false, messages: r.messages || [], count: r.count || 0, status: r.status || '' } }
|
||
catch (e) { j._thread = { loading: false, error: true, messages: [] } }
|
||
}
|
||
function toggleJobDetail (j) { j.showDetail = !j.showDetail; if (j.showDetail) loadThread(j); focusDayJob(j) } // tournée : clic = déplie détails + fil + centre carte
|
||
function toggleAssignThread (j) { j._showThread = !j._showThread; if (j._showThread) loadThread(j) } // panneau d'assignation : icône fil
|
||
// Fermer un ticket inutile dans le legacy (depuis le panneau) → sort du dispatch (Completed). Acteur = utilisateur Authentik.
|
||
function closeLegacyTicket (j) {
|
||
if (!j || !j.legacy_ticket_id) return
|
||
$q.dialog({ title: 'Fermer le ticket', message: 'Fermer le ticket legacy <b>#' + j.legacy_ticket_id + '</b> ?<br><span class="text-grey-7">' + (j.subject || '').replace(/[<>]/g, '') + '</span><br>→ il sortira du dispatch (Completed).', html: true, cancel: 'Annuler', ok: { label: 'Fermer', color: 'negative', unelevated: true }, persistent: true }).onOk(async () => {
|
||
try {
|
||
const r = await roster.closeLegacyTicket(j.legacy_ticket_id)
|
||
if (r && r.ok) { assignPanel.jobs = assignPanel.jobs.filter(x => x.name !== j.name); $q.notify({ type: 'positive', message: 'Ticket #' + j.legacy_ticket_id + ' fermé dans le legacy' }) }
|
||
else $q.notify({ type: 'warning', message: 'Échec fermeture : ' + ((r && r.error) || '?') })
|
||
} catch (e) { err(e) }
|
||
})
|
||
}
|
||
// Fermeture EN LOT des jobs cochés (1 seul appel) — option « fermer » au lieu de « réassigner ».
|
||
function bulkCloseSelected () {
|
||
const sel = assignPanel.jobs.filter(j => selectedJobs[j.name] && j.legacy_ticket_id)
|
||
if (!sel.length) { $q.notify({ type: 'warning', message: 'Aucun ticket legacy coché' }); return }
|
||
$q.dialog({ title: 'Fermer en lot', message: 'Fermer <b>' + sel.length + '</b> ticket(s) dans le legacy ?<br>→ ils sortent du dispatch (Completed).', html: true, cancel: 'Annuler', ok: { label: 'Fermer ' + sel.length, color: 'negative', unelevated: true }, persistent: true }).onOk(async () => {
|
||
try {
|
||
const r = await roster.batchCloseLegacy(sel.map(j => j.legacy_ticket_id))
|
||
if (r && r.ok) {
|
||
const done = new Set((r.results || []).filter(x => x.ok).map(x => String(x.t)))
|
||
assignPanel.jobs = assignPanel.jobs.filter(j => !done.has(String(j.legacy_ticket_id)))
|
||
for (const k in selectedJobs) delete selectedJobs[k]
|
||
$q.notify({ type: 'positive', message: r.closed + ' ticket(s) fermé(s)' + (r.locked ? ' · ' + r.locked + ' verrouillé(s)' : '') + (r.skipped ? ' · ' + r.skipped + ' déjà fermé(s)' : '') })
|
||
} else $q.notify({ type: 'warning', message: 'Échec : ' + ((r && r.error) || '?') })
|
||
} catch (e) { err(e) }
|
||
})
|
||
}
|
||
function fmtDT (iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString('fr-CA', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' } }
|
||
async function initDayMap () {
|
||
if (!dayMapEl.value || _dayMap) return
|
||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !dayMapEl.value) return
|
||
_dayMap = createPlanMap(dayMapEl.value, { center: [-73.6756, 45.1599], zoom: 9, streetView: openStreetView })
|
||
_dayMapRO = new ResizeObserver(() => { if (_dayMap) _dayMap.resize() }); _dayMapRO.observe(dayMapEl.value)
|
||
_dayMap.on('load', () => {
|
||
_dayMap.resize()
|
||
addSatLayer(_dayMap) // couche satellite (sous l'itinéraire et les pins), masquée par défaut
|
||
_dayMap.addSource('day-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||
_dayMap.addLayer({ id: 'day-route-halo', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 9, 'line-opacity': 0.2 } })
|
||
_dayMap.addLayer({ id: 'day-route', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 4, 'line-opacity': 0.85 } })
|
||
// Tracé GPS RÉEL (Traccar) — orange semi-transparent, par-dessus l'itinéraire planifié (bleu) ; sous les pins.
|
||
_dayMap.addSource('day-track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||
_dayMap.addLayer({ id: 'day-track-halo', type: 'line', source: 'day-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ff6f00', 'line-width': 9, 'line-opacity': 0.2 } })
|
||
_dayMap.addLayer({ id: 'day-track-l', type: 'line', source: 'day-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ef6c00', 'line-width': 3.5, 'line-opacity': 0.8 } })
|
||
_dayMap.addSource('day-stops', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||
_dayMap.addLayer({ id: 'day-stops-c', type: 'circle', source: 'day-stops', paint: { 'circle-radius': ['case', ['>', ['get', 'count'], 1], 12, 9], 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' } }) // groupe même adresse = un peu plus gros ; rayon réduit pour moins de chevauchement
|
||
_dayMap.addLayer({ id: 'day-stops-l', type: 'symbol', source: 'day-stops', layout: { 'text-field': ['get', 'label'], 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-size': 12, 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
|
||
// Clic sur un pin → popup avec les détails du job ; curseur main au survol.
|
||
_dayMap.on('mouseenter', 'day-stops-c', () => { _dayMap.getCanvas().style.cursor = 'pointer' })
|
||
_dayMap.on('mouseleave', 'day-stops-c', () => { _dayMap.getCanvas().style.cursor = '' })
|
||
_dayMap.on('click', 'day-stops-c', (e) => {
|
||
const f = e.features[0]; const p = f.properties
|
||
new window.mapboxgl.Popup({ offset: 14 }).setLngLat(f.geometry.coordinates)
|
||
.setHTML(`<div style="font-size:12px;line-height:1.5">${p.popup || ('<b>' + _esc(p.label) + '</b>')}${p.address ? '<br><span style="color:#888">📍 ' + _esc(p.address) + '</span>' : ''}</div>`)
|
||
.addTo(_dayMap)
|
||
})
|
||
refreshDayMap()
|
||
})
|
||
}
|
||
function refreshDayMap () {
|
||
if (!_dayMap || !_dayMap.isStyleLoaded()) { setTimeout(refreshDayMap, 200); return }
|
||
const stops = dayStops()
|
||
const origin = dayOrigin.value
|
||
// Regroupe les arrêts à la MÊME adresse (coords arrondies) → 1 pin « style Gaiia » : icône vectorielle du TYPE de la job
|
||
// PRINCIPALE (1re en ordre de tournée) + son n°, et — si plusieurs jobs à l'adresse — une mini barre segmentée au-dessus
|
||
// du n° (1 segment coloré par job) qui distingue la principale (l'icône) des secondaires. Le popup liste tous les jobs.
|
||
const grp = {}; const gord = []
|
||
for (const s of stops) { const k = s.lon.toFixed(5) + ',' + s.lat.toFixed(5); if (!grp[k]) { grp[k] = { lon: s.lon, lat: s.lat, items: [] }; gord.push(k) } grp[k].items.push(s) }
|
||
// Marqueurs DOM custom (styles inline car ils sortent du scope CSS du composant) ; les couches circle/symbol restent vides.
|
||
_dayStopMarkers.forEach(m => { try { m.remove() } catch (e) {} }); _dayStopMarkers = []
|
||
const sSrc = _dayMap.getSource('day-stops'); if (sSrc) sSrc.setData({ type: 'FeatureCollection', features: [] })
|
||
const seg = (items) => items.length > 1 ? `<div style="display:flex;gap:1.5px;margin-bottom:2px;padding:1.5px;background:#fff;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,.3)">${items.map(i => `<span style="display:block;width:7px;height:3px;border-radius:1px;background:${i.color}"></span>`).join('')}</div>` : ''
|
||
// L'icône peut être une ligature Material OU un chemin SVG custom (format q-icon « d@@style&&…|viewBox », ex. BUCKET_TRUCK) :
|
||
// sur un pin (HTML brut), on rend alors un <svg> inline (blanc) au lieu de la ligature.
|
||
const iconMarkup = (icon) => /^[Mm][\s\d.-]/.test(String(icon))
|
||
? (() => { const [def, vb = '0 0 24 24'] = String(icon).split('|'); const paths = def.split('&&').map(p => { const [d, style = ''] = p.split('@@'); return `<path d="${d}" style="${style.replace(/currentColor/g, '#fff')}"/>` }).join(''); return `<svg viewBox="${vb}" width="14" height="14" style="display:block;fill:#fff">${paths}</svg>` })()
|
||
: `<i class="material-icons" style="font-size:14px;line-height:1">${icon}</i>`
|
||
const pinHtml = (color, icon, num, items, round) => `${seg(items || [])}<div style="display:flex;align-items:center;gap:1px;color:#fff;background:${color};border:2px solid #fff;border-radius:${round ? '50%' : '11px'};padding:${round ? '3px' : '1px 5px 1px 3px'};box-shadow:0 1px 4px rgba(0,0,0,.45)">${iconMarkup(icon)}${num ? `<span style="font-size:10px;font-weight:800;line-height:1;padding:0 1px">${num}</span>` : ''}</div><div style="width:0;height:0;margin:-1px auto 0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid ${color}"></div>`
|
||
const addPin = (lon, lat, html, popup, addr) => {
|
||
const el = document.createElement('div'); el.style.cssText = 'display:flex;flex-direction:column;align-items:center;cursor:pointer;line-height:0'; el.innerHTML = html
|
||
el.addEventListener('click', (ev) => { ev.stopPropagation(); new window.mapboxgl.Popup({ offset: 16, maxWidth: '260px' }).setLngLat([lon, lat]).setHTML(`<div style="font-size:12px;line-height:1.5;max-width:236px;overflow-wrap:anywhere;max-height:44vh;overflow-y:auto">${popup}${addr ? '<br><span style="color:#888">📍 ' + _esc(addr) + '</span>' : ''}</div>`).addTo(_dayMap) })
|
||
_dayStopMarkers.push(new window.mapboxgl.Marker({ element: el, anchor: 'bottom' }).setLngLat([lon, lat]).addTo(_dayMap))
|
||
}
|
||
for (const k of gord) {
|
||
const g = grp[k]; const f = g.items[0]
|
||
const popup = g.items.map(i => `<b>${_esc(i.label)}.</b> ${_esc(i.subject)}${i.time ? ' · ' + _esc(i.time) : ''}${i.customer ? ' · ' + _esc(i.customer) : ''}`).join('<br>')
|
||
addPin(g.lon, g.lat, pinHtml(f.color, skillIcon(f.skill), f.label, g.items, false), popup, f.address) // n° = job principale ; barre = N jobs à l'adresse
|
||
}
|
||
if (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) { // pin de départ (dépôt/domicile) : icône maison/entrepôt
|
||
const oc = origin.kind === 'home' ? '#00897b' : '#37474f'
|
||
addPin(+origin.lon, +origin.lat, pinHtml(oc, origin.kind === 'home' ? 'home' : 'warehouse', '', null, true), '<b>Départ</b> — ' + _esc(origin.label || ''), origin.address || '')
|
||
}
|
||
const all = (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? [...stops, { lon: +origin.lon, lat: +origin.lat }] : stops
|
||
if (all.length === 1) _dayMap.easeTo({ center: [all[0].lon, all[0].lat], zoom: 13, duration: 400 })
|
||
else if (all.length > 1) {
|
||
const b = new window.mapboxgl.LngLatBounds([all[0].lon, all[0].lat], [all[0].lon, all[0].lat])
|
||
all.forEach(s => b.extend([s.lon, s.lat]))
|
||
_dayMap.fitBounds(b, { padding: 45, maxZoom: 14, duration: 400 })
|
||
}
|
||
fetchDayRouteGeom(stops, origin)
|
||
}
|
||
async function fetchDayRouteGeom (stops, origin) { // itinéraire ROUTIER réel (Directions) → tracé sur la carte, DEPUIS l'origine
|
||
const rSrc = _dayMap && _dayMap.getSource('day-route'); if (!rSrc) return
|
||
const ordered = (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? [{ lon: +origin.lon, lat: +origin.lat }, ...(stops || [])] : (stops || [])
|
||
if (ordered.length < 2) { rSrc.setData({ type: 'FeatureCollection', features: [] }); return }
|
||
try {
|
||
const d = await roster.osrmRoute(ordered.slice(0, 50).map(s => [+s.lon, +s.lat])) // OSRM auto-hébergé (via hub) — l'U-turn aux waypoints est permis par défaut
|
||
const src = _dayMap && _dayMap.getSource('day-route')
|
||
if (d && Array.isArray(d.geometry) && d.geometry.length && src) src.setData({ type: 'Feature', geometry: { type: 'LineString', coordinates: d.geometry }, properties: {} })
|
||
} catch (e) { /* repli : pas de tracé routier (les pins restent visibles) */ }
|
||
}
|
||
function destroyDayMap () {
|
||
if (_dirTimer) { clearTimeout(_dirTimer); _dirTimer = null }
|
||
if (_dayMapRO) { _dayMapRO.disconnect(); _dayMapRO = null }
|
||
_dayStopMarkers.forEach(m => { try { m.remove() } catch (e) {} }); _dayStopMarkers = []
|
||
if (_dayMap) { try { _dayMap.remove() } catch (e) {} _dayMap = null }
|
||
}
|
||
// (ré)init à l'ouverture du dialogue (après l'anim) ; refresh débouncé au réordonnancement ; destruction à la fermeture.
|
||
watch(() => dayEditor.open, (open) => { if (open) nextTick(() => setTimeout(initDayMap, 250)); else destroyDayMap() })
|
||
watch(() => dayEditor.list.map(j => j.name).join(','), () => { if (_dayMap) { clearTimeout(_dirTimer); _dirTimer = setTimeout(refreshDayMap, 500) } })
|
||
const dayTotalH = () => Math.round(dayEditor.list.reduce((s, j) => s + (Number(j.dur) || 0), 0) * 10) / 10
|
||
// Chrono (boucle de capture) : début/fin réels → durée mesurée (alimente l'apprentissage des durées par type×tech)
|
||
async function startJobChrono (j) { try { const r = await roster.startJob(j.name); j.actual_start = r.actual_start || '1'; j.actual_end = ''; $q.notify({ type: 'positive', icon: 'play_arrow', message: 'Job démarré (chrono)', timeout: 1500 }) } catch (e) { err(e) } }
|
||
async function finishJobChrono (j) { try { const r = await roster.finishJob(j.name); j.actual_end = '1'; j.actual_min = r.actual_minutes; $q.notify({ type: 'positive', icon: 'check', message: `Terminé · ${r.actual_minutes} min réels`, timeout: 2500 }); reloadOccupancy() } catch (e) { err(e) } }
|
||
async function removeFromDay (j) {
|
||
// TAMPON : on désassigne SEULEMENT dans OPS (ERPNext), comme le clic-droit « Renvoyer au pool ». AUCUNE écriture dans F ici.
|
||
// Le retour au pool dans F (legacy) ne part qu'à « Publier au legacy » (action explicite). Évite tout write-back surprise.
|
||
try {
|
||
await roster.unassignJobRoster(j.name)
|
||
dayEditor.list = dayEditor.list.filter(x => x.name !== j.name); await loadWeek()
|
||
$q.notify({ type: 'info', message: 'Retiré du tech (retour au pool OPS). À publier dans F via « Publier au legacy ».', timeout: 2800 })
|
||
} catch (e) { err(e) }
|
||
}
|
||
// « Optimiser depuis le départ » : ordonne par PLUS-PROCHE-VOISIN à partir de l'origine (dépôt/domicile)
|
||
// → la 1re job devient la plus proche de l'origine. Non destructif : l'utilisateur Enregistre ensuite.
|
||
function optimizeFromOrigin () {
|
||
const origin = dayOrigin.value
|
||
// Positions FIGÉES respectées : les « 1er » restent en tête (dans leur ordre), les « dernier » en queue ; on optimise le MILIEU.
|
||
const first = dayEditor.list.filter(j => j.pin === 'first')
|
||
const last = dayEditor.list.filter(j => j.pin === 'last')
|
||
const middle = dayEditor.list.filter(j => j.pin !== 'first' && j.pin !== 'last')
|
||
const withLL = middle.filter(hasLL)
|
||
const noLL = middle.filter(j => !hasLL(j))
|
||
if (withLL.length < 2 && !first.length && !last.length) { $q.notify({ type: 'info', message: 'Pas assez d’arrêts géolocalisés à optimiser' }); return }
|
||
const remaining = withLL.slice(); const ordered = []
|
||
// Point de départ de l'optim : dernière job figée-en-1er (si géoloc), sinon l'origine (domicile/dépôt), sinon le 1er arrêt du milieu.
|
||
let cur = (first.length && hasLL(first[first.length - 1])) ? first[first.length - 1]
|
||
: (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? origin
|
||
: remaining.shift()
|
||
if (cur && !first.includes(cur) && cur !== origin) ordered.push(cur)
|
||
while (remaining.length) {
|
||
let bi = 0, bd = Infinity
|
||
for (let i = 0; i < remaining.length; i++) { const lg = travelBetween(cur, remaining[i]); const d = lg ? lg.min : 9e9; if (d < bd) { bd = d; bi = i } }
|
||
cur = remaining[bi]; ordered.push(remaining.splice(bi, 1)[0])
|
||
}
|
||
dayEditor.list = [...first, ...ordered, ...noLL, ...last]
|
||
const pinNote = (first.length || last.length) ? ' (positions figées respectées)' : ''
|
||
$q.notify({ type: 'positive', icon: 'route', message: 'Tournée optimisée' + pinNote + (origin ? ' depuis ' + (origin.kind === 'home' ? 'le domicile' : 'le dépôt') : ''), timeout: 2000 })
|
||
}
|
||
async function saveDayOrder () {
|
||
dayEditor.saving = true
|
||
const packed = packedDay.value // heures recalculées par la tournée → on les persiste (start_time)
|
||
// Les jobs LEGACY (F autoritaire) ne sont PAS écrits — on ne persiste que les Dispatch Jobs roster (route_order séquentiel).
|
||
const updates = dayEditor.list.map((j, i) => ({ j, i })).filter(x => !x.j.legacy).map(({ j, i }, k) => ({ job: j.name, route_order: k + 1, priority: j.priority, duration_h: Number(j.dur) || 1, start_time: fmtHM(packed[i].startMin) }))
|
||
try { const r = await roster.reorderJobs(updates); dayEditor.open = false; await loadWeek(); $q.notify({ type: 'positive', message: 'Tournée enregistrée — ordre · heures · durées (' + (r.updated || 0) + ')', timeout: 2400 }) } catch (e) { err(e) } finally { dayEditor.saving = false }
|
||
}
|
||
const timelineDays = computed(() => {
|
||
const t = timelineDlg.tech; if (!t) return []
|
||
const out = []
|
||
for (const d of dayList.value) {
|
||
const shift = hasReg(t.id, d.iso) || onGarde(t.id, d.iso)
|
||
const jobs = shift ? cellJobs(t.id, d.iso) : rawCellJobs(t.id, d.iso) // hors quart : jobs bruts
|
||
if (!jobs.length && !shift) continue // on saute les jours vides
|
||
const o = cellOcc(t.id, d.iso)
|
||
const usedH = shift ? (o ? o.usedH : 0) : Math.round(jobs.reduce((s, j) => s + (j.dur || 0), 0) * 10) / 10
|
||
out.push({ iso: d.iso, label: d.dow + ' ' + d.dnum, weekend: d.weekend, bands: cellBands(t.id, d.iso), blocks: cellBlocks(t.id, d.iso), jobs, pct: shift ? cellPct(t.id, d.iso) : null, usedH, offShift: !shift && jobs.length > 0 })
|
||
}
|
||
return out
|
||
})
|
||
// Sauvegarde DEBOUNCÉE + silencieuse (succès) : coalesce les clics rapides → 1 seul appel (évite « load fail » concurrents).
|
||
let _skillSaveTimer = null
|
||
function queueSkillSave (t) { if (_skillSaveTimer) clearTimeout(_skillSaveTimer); _skillSaveTimer = setTimeout(() => { _skillSaveTimer = null; doSaveSkillData(t) }, 500) }
|
||
async function doSaveSkillData (t) {
|
||
const sset = new Set(t.skills || [])
|
||
const lv = {}; for (const k in (t.skill_levels || {})) if (sset.has(k)) lv[k] = t.skill_levels[k]
|
||
const ef = {}; for (const k in (t.skill_eff || {})) if (sset.has(k)) ef[k] = t.skill_eff[k]
|
||
t.skill_levels = lv; t.skill_eff = ef
|
||
try { await roster.setTechSkills(t.id, (t.skills || []).join(','), lv, ef) } catch (e) { $q.notify({ type: 'negative', message: 'Échec sauvegarde compétences — réessaie', timeout: 1800 }) }
|
||
}
|
||
// ── Catalogue de compétences (réutilise TagEditor) : couleurs persistées + création/suppression ──
|
||
// Palette élargie (incl. roses/magentas) + sélecteur HTML natif pour toute couleur.
|
||
const TAG_PALETTE = [
|
||
'#6366f1', '#3b82f6', '#0ea5e9', '#06b6d4', '#14b8a6', '#10b981', '#22c55e', '#84cc16',
|
||
'#eab308', '#f59e0b', '#f97316', '#ef4444', '#f43f5e', '#fb7185', '#ec4899', '#f472b6',
|
||
'#db2777', '#d946ef', '#a855f7', '#8b5cf6', '#78716c', '#64748b', '#94a3b8', '#111827',
|
||
]
|
||
function hashColor (label) { let h = 0; for (const c of String(label)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return TAG_PALETTE[h % TAG_PALETTE.length] }
|
||
const customTags = ref([]) // [{label,color}] créés à la volée (localStorage)
|
||
function saveCustomTags () { localStorage.setItem('roster-skill-tags-v1', JSON.stringify(customTags.value)) }
|
||
function getTagColor (label) { const ct = customTags.value.find(x => x.label === label); return (ct && ct.color) || hashColor(label) }
|
||
// Teinte PASTEL (≈80 % blanc + 20 % couleur) → fond doux des blocs (moins chargé), avec contour/icône en couleur pleine.
|
||
function pastelColor (hex) {
|
||
const h = String(hex || '').replace('#', ''); if (h.length < 6) return '#eef1f4'
|
||
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16)
|
||
const mix = (c) => Math.round(255 * 0.80 + (isNaN(c) ? 200 : c) * 0.20)
|
||
return `rgb(${mix(r)},${mix(g)},${mix(b)})`
|
||
}
|
||
function hexA (hex, a) { const h = String(hex || '').replace('#', ''); if (h.length < 6) return `rgba(120,130,145,${a})`; return `rgba(${parseInt(h.slice(0, 2), 16)},${parseInt(h.slice(2, 4), 16)},${parseInt(h.slice(4, 6), 16)},${a})` }
|
||
// Fond HACHURÉ (renfort/assistant) : rayures diagonales de la couleur de compétence sur blanc → « réservé sur une autre job ».
|
||
function hatchBg (hex) { const c = hexA(hex, 0.22); return `repeating-linear-gradient(45deg, ${c} 0, ${c} 5px, #ffffff 5px, #ffffff 11px)` }
|
||
// Couleur d'une carte job = COULEUR DE SA COMPÉTENCE (éditable via le gestionnaire de tags → cohérent + simple).
|
||
// required_skill est renseigné côté hub (skill explicite, sinon déduit du type legacy). Repli : couleur du type.
|
||
function panelJobColor (j) { return j.required_skill ? getTagColor(j.required_skill) : (legacyDeptColor(j.legacy_dept) || '#90a4ae') }
|
||
const tagCatalog = computed(() => {
|
||
const m = new Map()
|
||
for (const ct of customTags.value) m.set(ct.label, { name: ct.label, label: ct.label, color: ct.color || hashColor(ct.label), category: 'Custom' })
|
||
for (const s of [...allSkills.value, ...jobTypes.value, 'installation', 'réparation', 'support', 'fibre', 'aérien', 'épissure']) if (s && !m.has(s)) m.set(s, { name: s, label: s, color: getTagColor(s), category: jobTypes.value.includes(s) ? 'Type de job' : 'Compétence' })
|
||
return [...m.values()]
|
||
})
|
||
function onCreateRosterTag ({ label, color }) { if (label && !customTags.value.some(x => x.label === label)) { customTags.value.push({ label, color: color || hashColor(label) }); saveCustomTags() } }
|
||
function onUpdateRosterTag ({ name, color }) { const ct = customTags.value.find(x => x.label === name); if (ct) ct.color = color; else customTags.value.push({ label: name, color }); saveCustomTags() }
|
||
// (onDeleteRosterTag retiré : la suppression de tag passe par deleteTagGlobal du gestionnaire ci-dessous)
|
||
// ── Gestionnaire global de compétences (tags) : renommer / supprimer PARTOUT, recolorer, voir l'usage ──
|
||
const showTagManager = ref(false)
|
||
const managedTags = computed(() => {
|
||
const labels = new Set([...techs.value.flatMap(t => t.skills || []), ...customTags.value.map(c => c.label)])
|
||
return [...labels].filter(Boolean).sort((a, b) => a.localeCompare(b)).map(l => ({ label: l, color: getTagColor(l), count: techs.value.filter(t => (t.skills || []).includes(l)).length }))
|
||
})
|
||
async function renameTagGlobal (oldL, newL) { // remplace le label sur TOUS les techs (skills + niveaux + eff) + catalogue
|
||
newL = String(newL || '').trim(); if (!newL || newL === oldL) return
|
||
for (const t of techs.value) { // SÉQUENTIEL (pas de Promise.all sur erp)
|
||
if (!(t.skills || []).includes(oldL)) continue
|
||
t.skills = t.skills.map(s => s === oldL ? newL : s)
|
||
if (t.skill_levels && t.skill_levels[oldL] != null) { const m = { ...t.skill_levels, [newL]: t.skill_levels[oldL] }; delete m[oldL]; t.skill_levels = m }
|
||
if (t.skill_eff && t.skill_eff[oldL] != null) { const m = { ...t.skill_eff, [newL]: t.skill_eff[oldL] }; delete m[oldL]; t.skill_eff = m }
|
||
try { await roster.setTechSkills(t.id, t.skills.join(','), t.skill_levels || {}, t.skill_eff || {}) } catch (e) { err(e) }
|
||
}
|
||
const ct = customTags.value.find(c => c.label === oldL); if (ct) ct.label = newL; else customTags.value.push({ label: newL, color: getTagColor(oldL) })
|
||
saveCustomTags(); await syncSkillByType({ [oldL]: newL }, null) // cohérence avec la table type de job → compétence (booking)
|
||
$q.notify({ type: 'positive', message: '« ' + oldL +' » renommée « ' + newL + ' » partout', timeout: 2500 })
|
||
}
|
||
// Propage rename/delete d'un tag vers la table booking skill_by_type (Copilote #56) → cohérence globale du filtre.
|
||
async function syncSkillByType (renames, deletes) {
|
||
try {
|
||
const d = await roster.getPolicy(); const map = { ...((d.policy && d.policy.booking && d.policy.booking.skill_by_type) || {}) }; let changed = false
|
||
for (const k in map) { if (deletes && deletes.includes(map[k])) { delete map[k]; changed = true } else if (renames && renames[map[k]]) { map[k] = renames[map[k]]; changed = true } }
|
||
if (changed) await roster.savePolicy({ booking: { skill_by_type: map } })
|
||
} catch (e) { /* non bloquant */ }
|
||
}
|
||
async function deleteTagGlobal (tg) {
|
||
if (tg.count && !window.confirm('Supprimer « ' + tg.label + ' » de ' + tg.count + ' technicien(s) ?')) return
|
||
for (const t of techs.value) {
|
||
if (!(t.skills || []).includes(tg.label)) continue
|
||
t.skills = t.skills.filter(s => s !== tg.label)
|
||
if (t.skill_levels) { const m = { ...t.skill_levels }; delete m[tg.label]; t.skill_levels = m }
|
||
if (t.skill_eff) { const m = { ...t.skill_eff }; delete m[tg.label]; t.skill_eff = m }
|
||
try { await roster.setTechSkills(t.id, t.skills.join(','), t.skill_levels || {}, t.skill_eff || {}) } catch (e) { err(e) }
|
||
}
|
||
customTags.value = customTags.value.filter(c => c.label !== tg.label); saveCustomTags()
|
||
await syncSkillByType(null, [tg.label]) // retire aussi le tag de la table type de job → compétence (booking)
|
||
$q.notify({ type: 'info', message: '« ' + tg.label + ' » supprimée partout', timeout: 2000 })
|
||
}
|
||
function toggleSkill (sk) { const i = skillFilter.value.indexOf(sk); if (i >= 0) skillFilter.value.splice(i, 1); else skillFilter.value.push(sk) }
|
||
function techHasSkill (t) { const sk = (t.skills || []); return !skillFilter.value.length || skillFilter.value.every(f => sk.includes(f)) } // ET : toutes les compétences cochées requises
|
||
// Score de priorité (0 = meilleur) sur les techs qualifiés. DEUX dimensions DISTINCTES + coût :
|
||
// • COMPÉTENCE = niveau 1–5 dans la/les compétence(s) filtrée(s) (qualité ; haut = mieux)
|
||
// • VITESSE = efficiency (<1 = plus rapide = mieux) — un tech peut être très compétent MAIS lent
|
||
// • COÛT = coût chargé $/h. Pondéré compétence 0,4 · vitesse 0,3 · coût 0,3 (sans filtre : vitesse/coût 50/50).
|
||
function techCompetence (t) { const f = skillFilter.value; if (!f.length) return null; const ls = f.map(s => (t.skill_levels && t.skill_levels[s]) || 1); return ls.reduce((a, b) => a + b, 0) / ls.length }
|
||
// Vitesse retenue = efficacité PAR compétence filtrée (skillEffOf, fallback global) ; sinon efficacité globale.
|
||
function techSpeed (t) { const f = skillFilter.value; if (!f.length) return Number(t.efficiency) || 1; const es = f.map(s => skillEffOf(t, s)); return es.reduce((a, b) => a + b, 0) / es.length }
|
||
// PROXIMITÉ (à venir) : on gère le secteur MANUELLEMENT pour l'instant (zones). Ce hook renverra
|
||
// plus tard une distance normalisée 0..1 (0 = sur place) job↔tech (lat/lng de la Service Location vs
|
||
// secteur/base du tech). Tant qu'il retourne null, le score reste inchangé (poids redistribué).
|
||
// eslint-disable-next-line no-unused-vars
|
||
function techProximity (t /*, job */) { return null } // TODO proximité : brancher une fois lat/lng dispo
|
||
const priorityScores = computed(() => {
|
||
const cands = techs.value.filter(t => !isHidden(t.id) && techHasSkill(t)); const m = {}
|
||
if (!cands.length) return m
|
||
const effs = cands.map(techSpeed); const costs = cands.map(t => Number(t.cost_h) || 0)
|
||
const eMin = Math.min(...effs); const eMax = Math.max(...effs); const cMin = Math.min(...costs); const cMax = Math.max(...costs)
|
||
const norm = (v, lo, hi) => (hi > lo ? (v - lo) / (hi - lo) : 0)
|
||
for (const t of cands) {
|
||
const eff = norm(techSpeed(t), eMin, eMax); const cost = norm(Number(t.cost_h) || 0, cMin, cMax)
|
||
const comp = techCompetence(t)
|
||
const prox = techProximity(t) // null pour l'instant → ignoré (secteur manuel)
|
||
let s
|
||
if (comp == null) s = 0.5 * eff + 0.5 * cost
|
||
else { const compN = (Math.min(5, Math.max(1, comp)) - 1) / 4; s = 0.4 * (1 - compN) + 0.3 * eff + 0.3 * cost } // compétence ⊕ vitesse(par-skill) ⊕ coût
|
||
if (prox != null) s = 0.8 * s + 0.2 * prox // quand la proximité arrivera : 20% du score (réservé)
|
||
m[t.id] = s
|
||
}
|
||
return m
|
||
})
|
||
function techScore (t) { const v = priorityScores.value[t.id]; return v == null ? 0 : v }
|
||
function techRank (t) { if (!skillFilter.value.length) return null; const i = visibleTechs.value.findIndex(x => x.id === t.id); return i >= 0 ? i + 1 : null }
|
||
const visibleTechs = computed(() => {
|
||
const q = (search.value || '').trim().toLowerCase() // clearable (X) met search=null → null-safe (sinon le computed plante et le champ « ne se vide pas »)
|
||
const list = techs.value.filter(t => (showHidden.value || !isHidden(t.id)) && (!groupFilter.value || t.group === groupFilter.value) && techHasSkill(t) && (!q || (t.name || '').toLowerCase().includes(q) || (t.group || '').toLowerCase().includes(q)))
|
||
// Filtre par compétence actif → on TRIE par priorité (meilleur score d'abord) ; sinon ordre équipe/nom.
|
||
if (skillFilter.value.length) return list.slice().sort((a, b) => techScore(a) - techScore(b) || (a.name || '').localeCompare(b.name || ''))
|
||
return list.slice().sort((a, b) => (a.group || '~').localeCompare(b.group || '~') || (a.name || '').localeCompare(b.name || ''))
|
||
})
|
||
|
||
const cellsByTechDay = computed(() => { const m = {}; for (const a of assignments.value) { const t = (m[a.tech] || (m[a.tech] = {})); (t[a.date] || (t[a.date] = [])).push(a) } return m })
|
||
function cellsOf (techId, iso) { return (cellsByTechDay.value[techId] && cellsByTechDay.value[techId][iso]) || [] }
|
||
function isPaused (t) { return t.status === 'En pause' }
|
||
function hoursOf (techId) { let h = 0; for (const a of assignments.value) { if (a.tech !== techId) continue; const t = tplByName.value[a.shift]; if (t && t.on_call) continue; h += Number(a.hours) || 0 } return h } // garde exclue (mise en dispo, pas travaillée)
|
||
|
||
const serverSet = ref(new Set())
|
||
const currentSet = computed(() => new Set(assignments.value.map(a => a.tech + '|' + a.date + '|' + a.shift)))
|
||
const diffKeys = computed(() => { const cur = currentSet.value; const srv = serverSet.value; const d = []; for (const k of cur) if (!srv.has(k)) d.push(k); for (const k of srv) if (!cur.has(k)) d.push(k); return d })
|
||
// #2/#3 — « dirty » = quarts NON PUBLIÉS (statut ≠ Publié). Tout est AUTO-SAUVÉ en brouillon → plus d'alerte « modifications non publiées » à la navigation.
|
||
const unpublished = computed(() => assignments.value.filter(a => a.status && a.status !== 'Publié'))
|
||
// « dirty » = quarts NON PUBLIÉS + congés en attente (publish-required) → tout doit passer par « Publier ».
|
||
const dirty = computed(() => unpublished.value.length > 0 || pendingAbsCount.value > 0 || pendingPauseCount.value > 0)
|
||
const dirtyCount = computed(() => unpublished.value.length + pendingAbsCount.value + pendingPauseCount.value)
|
||
const dirtyCells = computed(() => { const s = new Set(unpublished.value.map(a => a.tech + '|' + a.date)); for (const k of Object.keys(pendingAbs.value)) s.add(k); return s })
|
||
function isCellDirty (techId, iso) { return dirtyCells.value.has(techId + '|' + iso) }
|
||
// Statut agrégé de la semaine (pour l'affichage #3) : le « plus bas » statut non publié présent.
|
||
const weekStatus = computed(() => { const st = new Set(unpublished.value.map(a => a.status)); if (st.has('Proposé')) return 'Proposé'; if (st.has('Soumis')) return 'Soumis'; if (st.has('Approuvé')) return 'Approuvé'; return 'Publié' })
|
||
// #1 — Sommaire AVANT publication : liste les quarts NON PUBLIÉS (à publier) par jour + nb SMS.
|
||
const pubConfirm = ref(false)
|
||
// RÉSUMÉ DE PUBLICATION — regroupé PAR TECHNICIEN. Doit refléter TOUT ce que « Publier » va changer (« au cas où on
|
||
// se trompe ») : quarts à publier (ajouts/modifs), quarts RETIRÉS (le hub supprime tout ce qui n'est plus dans la
|
||
// charge — cf. roster.js publish-week), et congés/absences en attente. Le retrait est calculé contre l'instantané
|
||
// SERVEUR pris au chargement (serverSet) → capte aussi une suppression accidentelle.
|
||
const pubSummary = computed(() => {
|
||
const nameOf = id => { const t = (techs.value || []).find(x => x.id === id); return (t && t.name) || id }
|
||
const shiftLabel = s => { const t = tplByName.value[s]; return (t && t.template_name) || s }
|
||
const byTech = {}
|
||
const bucket = tid => (byTech[tid] = byTech[tid] || { tech: tid, name: nameOf(tid), add: [], rem: [], abs: [], pause: null })
|
||
// Quarts à publier = état courant non Publié (Proposé/Soumis/Approuvé → deviendront Publié)
|
||
for (const a of unpublished.value) bucket(a.tech).add.push({ date: a.date, shift: a.shift_name || shiftLabel(a.shift) })
|
||
// Quarts RETIRÉS = présents au chargement mais plus dans l'état courant → seront supprimés au Publier
|
||
let removed = 0
|
||
const cur = currentSet.value
|
||
for (const k of serverSet.value) {
|
||
if (cur.has(k)) continue
|
||
const i1 = k.indexOf('|'); const i2 = k.indexOf('|', i1 + 1); if (i1 < 0 || i2 < 0) continue
|
||
bucket(k.slice(0, i1)).rem.push({ date: k.slice(i1 + 1, i2), shift: shiftLabel(k.slice(i2 + 1)) }); removed++
|
||
}
|
||
// Congés / absences en attente (pendingAbs) — bien envoyés au Publier (flushPendingAbsences) → doivent figurer (ex. Josée-Anne).
|
||
let absAdded = 0; let absRemoved = 0
|
||
for (const [key, v] of Object.entries(pendingAbs.value || {})) {
|
||
const i1 = key.indexOf('|'); if (i1 < 0) continue
|
||
const set = v && v.op === 'set'
|
||
bucket(key.slice(0, i1)).abs.push({ date: key.slice(i1 + 1), type: set ? (v.type || 'Congé') : null, op: set ? 'set' : 'remove' })
|
||
if (set) absAdded++; else absRemoved++
|
||
}
|
||
// PAUSES INDÉFINIES en attente : marque le tech + liste ses quarts FUTURS (aujourd'hui→) qui seront RETIRÉS au Publier.
|
||
// (Ces quarts sont encore dans assignments — donc pas déjà comptés en « rem » ci-dessus ; on les ajoute ici.)
|
||
const todayIso = todayISO(); let paused = 0
|
||
for (const [tid, v] of Object.entries(pendingPause.value || {})) {
|
||
const g = bucket(tid); g.pause = { reason: (v && v.reason) || '' }; paused++
|
||
for (const a of assignments.value) { if (a.tech === tid && a.date >= todayIso) g.rem.push({ date: a.date, shift: a.shift_name || shiftLabel(a.shift), pause: true }) }
|
||
}
|
||
const list = Object.values(byTech)
|
||
const byDate = (x, y) => String(x.date).localeCompare(String(y.date))
|
||
for (const g of list) { g.add.sort(byDate); g.rem.sort(byDate); g.abs.sort(byDate) }
|
||
list.sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
||
return { added: unpublished.value.length, removed, absAdded, absRemoved, paused, techs: list }
|
||
})
|
||
|
||
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 SERVEUR (En pause / Congé / Maladie…) → hachuré
|
||
// #congé PUBLISH-REQUIRED : couche LOCALE d'absences en attente (delta vs serveur), écrite au serveur SEULEMENT à « Publier ».
|
||
// clé 'tech|iso' → { op:'set', type } (marquer absent) | { op:'remove' } (retirer une absence serveur). Persistée localStorage (survie au rechargement).
|
||
const LS_PENDING_ABS = 'roster-pending-abs-v1'
|
||
const pendingAbs = ref({})
|
||
function savePendingAbs () { try { localStorage.setItem(LS_PENDING_ABS, JSON.stringify(pendingAbs.value)) } catch (e) {} }
|
||
const pendingAbsCount = computed(() => Object.keys(pendingAbs.value).length)
|
||
// PAUSE INDÉFINIE en attente (publish-required, comme les congés) : { [techId]: { reason } }. Prend effet VISUELLEMENT
|
||
// (badge « pause à publier » + quarts futurs listés « à retirer » dans le résumé), mais n'est COMMISE qu'au Publier
|
||
// (status En pause + suppression des quarts futurs + relève des jobs). Persistée (survit au refresh, fait partie du brouillon).
|
||
const LS_PENDING_PAUSE = 'roster-pending-pause-v1'
|
||
const pendingPause = ref({})
|
||
function savePendingPause () { try { localStorage.setItem(LS_PENDING_PAUSE, JSON.stringify(pendingPause.value)) } catch (e) {} }
|
||
const pendingPauseCount = computed(() => Object.keys(pendingPause.value).length)
|
||
const isServerPaused = (t) => !!(t && t.status === 'En pause')
|
||
const isEffectivelyPaused = (id) => !!pendingPause.value[id] || isServerPaused((techs.value || []).find(t => t.id === id))
|
||
// Type d'absence EFFECTIF = état serveur écrasé par le delta local en attente. null = pas absent.
|
||
function effAbsType (techId, iso) { const k = techId + '|' + iso; const p = pendingAbs.value[k]; if (p) return p.op === 'set' ? (p.type || 'Congé') : null; return absByTechDay.value[k] || null }
|
||
function isAbsent (techId, iso) { return !!effAbsType(techId, iso) }
|
||
function absenceLabel (techId, iso) { return effAbsType(techId, iso) || 'Absent' }
|
||
async function reloadAbsences () { try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) {} }
|
||
// Pose le delta d'absence d'une cellule (wantType=null → non absent). N'écrit PAS au serveur (publish-required).
|
||
// Si l'état voulu == état serveur → on efface le delta (pas de changement en attente inutile).
|
||
function setAbsPending (key, wantType) {
|
||
const serverType = absByTechDay.value[key] || null
|
||
const m = { ...pendingAbs.value }
|
||
if ((wantType || null) === serverType) delete m[key]
|
||
else m[key] = wantType ? { op: 'set', type: wantType } : { op: 'remove' }
|
||
pendingAbs.value = m; savePendingAbs()
|
||
}
|
||
// Écrit une entrée d'absence dans le journal (avec revert du delta précédent) → annulable comme un quart.
|
||
function logAbsChange (text, key, beforeVal) { logChange(text, { absKey: key, beforeVal }) }
|
||
// Le tech travaille-t-il ce jour selon son patron hebdo (weekly_schedule) ? null = pas de patron → inconnu (on garde l'estimation 8h).
|
||
function patternWorksDay (techId, iso) {
|
||
const t = (techs.value || []).find(x => x.id === techId); const sched = t && t.weekly_schedule
|
||
if (!sched || typeof sched !== 'object') return null
|
||
const day = sched[['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'][new Date(iso + 'T12:00:00Z').getUTCDay()]]
|
||
return !!(day && day.start && day.end)
|
||
}
|
||
// Bascule absence d'1 jour sur des cases (clic + « A » ou menu). Toutes absentes → retire ; sinon marque. LOCAL → à Publier.
|
||
function toggleAbsentCells (targets) {
|
||
if (!targets || !targets.length) return
|
||
const allAbsent = targets.every(k => { const [tid, iso] = k.split('|'); return isAbsent(tid, iso) })
|
||
const want = !allAbsent
|
||
pushHistory(); let n = 0
|
||
for (const k of targets) {
|
||
const [tid, iso] = k.split('|')
|
||
if (!!effAbsType(tid, iso) === want) continue // déjà dans l'état voulu (toggle idempotent)
|
||
const beforeVal = pendingAbs.value[k] ? { ...pendingAbs.value[k] } : null
|
||
setAbsPending(k, want ? 'Congé' : null)
|
||
logAbsChange((want ? 'Congé ajouté · ' : 'Congé retiré · ') + iso, k, beforeVal); n++
|
||
}
|
||
if (n) { $q.notify({ type: 'info', message: (want ? (n + ' congé(s) en attente') : (n + ' congé(s) retiré(s)')) + ' — Publier pour appliquer' }); scheduleDraftSave() }
|
||
}
|
||
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)
|
||
watch(mobileSelIso, v => setSelDay(v)) // le jour touché sur mobile devient le jour partagé (repris par les autres vues)
|
||
// 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é.
|
||
// #4 — capacité par tech-jour. Le tech ABSENT (congé/vacances) = 0h dispo (bug « 48h malgré des vacances »).
|
||
// Sans quart : estimation 8h SEULEMENT un jour où il travaille selon son patron ; jour off du patron = 0h (bug « 48h alors qu'il fait 5×8h »).
|
||
function techLoad (o, hasShift, techId, iso) {
|
||
const h = +(o && o.usedH) || 0
|
||
const book = +(o && o.bookableH) || 0
|
||
const absent = (techId && iso) ? isAbsent(techId, iso) : false
|
||
const works = (techId && iso) ? patternWorksDay(techId, iso) : null // null = patron inconnu → on garde l'estimation
|
||
const cap = absent ? 0 : ((hasShift && book > 0) ? book : (works === false ? 0 : 8))
|
||
return { h: Math.round(h * 10) / 10, cap: Math.round(cap * 10) / 10, ratio: cap > 0 ? h / cap : 0, over: cap > 0 && h > cap + 0.01, noShift: !hasShift && !absent && works !== false, absent }
|
||
}
|
||
// 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 absent = isAbsent(t.id, d.iso); const works = patternWorksDay(t.id, d.iso)
|
||
const cap = absent ? 0 : ((hasSh && bk > 0) ? bk : (works === false ? 0 : 8)) // absent = 0h ; jour off du patron = 0h ; sinon quart réel ou estimation 8h
|
||
assignedH += u; capH += cap; if (cap > 0 && u > cap + 0.01) over++
|
||
if (!absent && !hasSh && works !== false && u > 0.01) noShift++ // tech avec du travail mais aucun quart planifié (et pas en congé / jour off)
|
||
}
|
||
// 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), 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), t.id, iso) })).sort((a, b) => (a.noShift ? 1 : 0) - (b.noShift ? 1 : 0) || a.h - b.h)
|
||
})
|
||
|
||
// ── TAP-TO-ASSIGNER (desktop) : clic sur une job du pool → techs CLASSÉS par pertinence pour CETTE job, 1 clic assigne ──
|
||
// Jour cible = date prévue de la job (sinon 1er jour visible). Classement : capable (compétence) → a un quart → plus proche → moins chargé.
|
||
function jobTargetDay (job) {
|
||
const d = job && job.scheduled_date
|
||
const t = todayISO(); const isos = (dayList.value || []).map(x => x.iso)
|
||
// date valide ET pas dans le passé → respectée. En retard/sans date → aujourd'hui (jamais le passé).
|
||
if (d && d !== 'Sans date' && /^\d{4}-\d{2}-\d{2}$/.test(d) && d >= t) return d
|
||
if (isos.includes(t)) return t
|
||
return isos.find(iso => iso >= t) || isos[0] || t
|
||
}
|
||
function tomorrowISO () { const d = new Date(todayISO() + 'T12:00:00'); d.setDate(d.getDate() + 1); return d.toISOString().slice(0, 10) }
|
||
// Replanifie EN LOT la sélection à une date (reprogrammation réelle optimiste + updateJob). setJobDate/applyJobDate (unitaires + undo) plus bas.
|
||
function setSelDate (iso) {
|
||
const names = selectedNames.value.slice(); let n = 0
|
||
for (const nm of names) { const j = (assignPanel.jobs || []).find(x => x.name === nm); if (j && iso !== j.scheduled_date) { j.scheduled_date = iso; roster.updateJob(j.name, { scheduled_date: iso }).catch(e => err(e)); n++ } }
|
||
if (n) $q.notify({ type: 'info', icon: 'event', message: n + ' job(s) reportés au ' + fmtDueLabel(iso), timeout: 3000 })
|
||
}
|
||
// Niveau requis PERSISTANT d'un job (store hub → survit aux rechargements/sessions) — piloté par la pastille « niv ».
|
||
function setJobReqLevel (job, n) { const lv = Math.max(1, Math.min(5, Number(n) || 1)); job.required_level = lv; roster.setJobLevel(job.name, lv).catch(e => err(e)) }
|
||
function techsForJob (job) {
|
||
const iso = jobTargetDay(job)
|
||
const reqSkill = job && job.required_skill
|
||
const reqSkillList = (job && Array.isArray(job.required_skills) && job.required_skills.length) ? job.required_skills : (reqSkill ? [reqSkill] : [])
|
||
const jlat = +(job && job.lat); const jlon = +(job && job.lon)
|
||
const hasJC = isFinite(jlat) && isFinite(jlon) && (jlat || jlon)
|
||
return (visibleTechs.value || []).map(t => {
|
||
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso), t.id, iso)
|
||
const capable = !reqSkillList.length || reqSkillList.every(s => (t.skills || []).includes(s))
|
||
const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
|
||
const distKm = (hasJC && home) ? haversineKm(home.lat, home.lon, jlat, jlon) : null
|
||
return { id: t.id, name: t.name, _t: t, capable, distKm, ...load }
|
||
}).sort((a, b) =>
|
||
(Number(b.capable) - Number(a.capable)) || // capables d'abord
|
||
((a.noShift ? 1 : 0) - (b.noShift ? 1 : 0)) || // avec quart d'abord
|
||
((a.distKm == null ? 9e9 : a.distKm) - (b.distKm == null ? 9e9 : b.distKm)) || // plus proche
|
||
(a.h - b.h) // moins chargé
|
||
)
|
||
}
|
||
async function quickAssign (job, tech) {
|
||
const iso = jobTargetDay(job)
|
||
const dObj = (dayList.value || []).find(d => d.iso === iso) || { iso }
|
||
draggingJobName.value = job.name
|
||
await onCellDrop({}, tech._t || tech, dObj) // réutilise le chemin d'assignation (garde On Hold + dialogue sans-quart + toast Annuler)
|
||
}
|
||
// ── B : assignation EN LOT de la sélection (bouton « Assigner à un tech ») ──
|
||
// Techs classés pour la SÉLECTION : capable de TOUTES les compétences requises → avec quart → proche du centroïde → moins chargé.
|
||
function techsForSelection () {
|
||
const jobs = (assignPanel.jobs || []).filter(j => selectedJobs[j.name])
|
||
if (!jobs.length) return []
|
||
const reqSkills = [...new Set(jobs.map(j => j.required_skill).filter(Boolean))]
|
||
const ll = jobs.filter(j => isFinite(+j.lat) && isFinite(+j.lon) && (+j.lat || +j.lon))
|
||
const cLat = ll.length ? ll.reduce((s, j) => s + (+j.lat), 0) / ll.length : null
|
||
const cLon = ll.length ? ll.reduce((s, j) => s + (+j.lon), 0) / ll.length : null
|
||
const iso = jobTargetDay(jobs[0]) // jour de référence pour la charge (approx.)
|
||
return (visibleTechs.value || []).map(t => {
|
||
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso), 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 = mobilePool.filteredJobs.value.filter(j => (jobCity(j) || 'Sans secteur') === city && j.status !== 'On Hold')
|
||
const allSel = jobs.length && jobs.every(j => selectedJobs[j.name])
|
||
jobs.forEach(j => { if (allSel) delete selectedJobs[j.name]; else selectedJobs[j.name] = true })
|
||
if (assignPanel.showMap) refreshAssignMap()
|
||
}
|
||
// ── D : SUGGESTION de répartition (glouton proximité + charge + compétence), REVUE en surcouche avant d'appliquer ──
|
||
const suggestDlg = reactive({ open: false, mode: 'config', plan: [], building: false, applying: false, fromSel: false, techSel: {}, makeShifts: true, strategy: 'optimize', view: 'list', jobTime: {}, includeAssigned: false }) // défaut = solveur VRP · strategy: optimize|smart|best|balance|enough · includeAssigned = simulation re-optimisant AUSSI les jobs déjà assignés
|
||
// Noms des jobs DÉJÀ assignés sur la fenêtre (pour la simulation « ré-optimiser l'existant ») — depuis l'occupation.
|
||
// Jobs DÉJÀ assignés dans la fenêtre (objets complets : subject/detail → détection AM/PM). occ.jobs = objets, pas des noms.
|
||
function assignedJobsForWindow () {
|
||
const win = new Set(suggestWindow.value); const out = []; const seen = new Set()
|
||
for (const key in occByTechDay.value) {
|
||
if (!win.has(key.split('|')[1])) continue
|
||
for (const j of (occByTechDay.value[key].jobs || [])) { const n = j && j.name; if (n && !seen.has(n)) { seen.add(n); out.push(j) } }
|
||
}
|
||
return out
|
||
}
|
||
function assignedNamesForWindow () { return assignedJobsForWindow().map(j => j.name).filter(Boolean) }
|
||
// AUTO AM/PM sur les jobs déjà assignés (ré-optimiser l'existant) : sinon la « Note AM » d'un job dispatché serait ignorée → placé en fin de tournée.
|
||
function seedAssignedAmpm () { for (const j of assignedJobsForWindow()) { if (!suggestDlg.jobTime[j.name]) { const ap = detectAmpm(j); if (ap) suggestDlg.jobTime[j.name] = { ampm: ap, auto: true } } } }
|
||
const prevOpen = reactive({}) // revue : sections « déjà assigné(s) » dépliées (clé techId|iso)
|
||
// ── Revue : détail du ticket au clic + temps révisable + temps de route ENTRE arrêts (OSRM) ──
|
||
const durOverride = reactive({}) // jobName → heures révisées par le dispatcher (survit aux Ré-optimiser de la session)
|
||
const legsByKey = reactive({}) // 'techId|iso' → { homeTo: min|null, after: { jobName: min } } — temps de route réels entre arrêts
|
||
function openEntryDetail (e, g) { // clic sur un job de la revue → même fiche détail que la grille (adresse, détail, fil du ticket F)
|
||
const j = (assignPanel.jobs || []).find(x => x.name === e.jobName) || {}
|
||
openJobDetail({
|
||
name: e.jobName, subject: e.subject || j.subject, customer: e.customer || j.customer_name || '',
|
||
address: j.address || j.location_label || '', skill: e.skill || j.required_skill || '', dur: e.dur,
|
||
detail: j.legacy_detail || '', legacy_id: j.legacy_ticket_id || null, dept: j.legacy_dept || '',
|
||
lat: e.lat, lon: e.lon,
|
||
}, (g && !g.placeholder) ? { id: g.techId, name: g.techName } : null)
|
||
}
|
||
// Clic sur une pastille de la carte → fiche détail COMPLÈTE du ticket (même dialog que la grille). rid = techId de la tournée.
|
||
function onReviewStopClick (stop, rid) { // carte de la REVUE : l'arrêt porte l'entrée du plan
|
||
if (!stop) return
|
||
const e = stop.entry || { jobName: stop.name, subject: stop.subject, skill: '', lat: stop.lat, lon: stop.lon, dur: 0 }
|
||
const g = suggestGroups.value.find(x => x.techId === rid) || { techId: rid, techName: (techById.value[rid] || {}).name || '', placeholder: false }
|
||
openEntryDetail(e, g)
|
||
}
|
||
function onRoutesStopClick (stop, rid) { // onglet TOURNÉES : l'arrêt porte le job d'occupation
|
||
if (!stop) return
|
||
const j = stop.job || {}
|
||
openJobDetail({
|
||
name: stop.name || j.name, subject: stop.subject || j.subject, customer: j.customer || j.customer_name || '',
|
||
customer_id: j.customer_id || '', // ← lien vers la fiche client depuis un job ouvert sur la carte (était perdu : reconstruit sans customer_id)
|
||
address: j.address || j.location_label || '', skill: j.skill || '', dur: j.dur,
|
||
detail: j.legacy_detail || '', legacy_id: j.legacy_id || j.legacy_ticket_id || null, dept: j.legacy_dept || j.dept || '',
|
||
lat: stop.lat, lon: stop.lon,
|
||
}, { id: rid, name: (techById.value[rid] || {}).name || '' })
|
||
}
|
||
function setEntryDur (e, v) { // temps révisé : plan (réactif) + mémoire de session + persistance ERPNext duration_h
|
||
const h = Math.round(Math.max(0.25, Math.min(24, Number(v) || 0)) * 4) / 4
|
||
if (!h) return
|
||
e.dur = h; durOverride[e.jobName] = h
|
||
const j = (assignPanel.jobs || []).find(x => x.name === e.jobName)
|
||
if (j) patchJob(j, { duration_h: h }, 'Temps révisé : ' + h + ' h — Ré-optimiser pour recalculer les tournées')
|
||
}
|
||
// Temps de route réels entre les arrêts de chaque tournée (1 appel OSRM par tech×jour, cache par signature dans fetchTechRoute).
|
||
async function loadLegTimes () {
|
||
for (const g of suggestGroups.value) {
|
||
if (g.placeholder) continue
|
||
const h = techOrigin(g.techId) // domicile, sinon bureau TARGO
|
||
for (const d of g.dayList) {
|
||
const geo = d.entries.filter(e => e.lat != null && e.lon != null)
|
||
if (geo.length < (h ? 1 : 2)) continue
|
||
const info = await fetchTechRoute(h, geo.map(e => ({ lat: +e.lat, lon: +e.lon })))
|
||
if (!info || !info.legs || !info.legs.length) continue
|
||
const legs = info.legs; const out = { homeTo: null, after: {}, returnTo: null, kind: h ? h.kind : null }
|
||
let li = 0
|
||
if (h) { out.homeTo = legs[0] ? legs[0].mins : null; li = 1 }
|
||
for (let k = 0; k < geo.length - 1; k++) { const L = legs[li + k]; if (L) out.after[geo[k].jobName] = L.mins }
|
||
if (h) { const rl = legs[li + geo.length - 1]; if (rl) out.returnTo = rl.mins } // dernier segment = dernier arrêt → point de départ
|
||
legsByKey[g.techId + '|' + d.iso] = out
|
||
}
|
||
}
|
||
}
|
||
// Temps de route affiché ENTRE deux lignes : réel (OSRM) si connu, sinon estimation haversine, sinon rien.
|
||
function legInfo (g, d, ei) {
|
||
const e = d.entries[ei]; const nx = d.entries[ei + 1]
|
||
if (!e || !nx) return null
|
||
const real = (legsByKey[g.techId + '|' + d.iso] || {}).after
|
||
if (real && real[e.jobName] != null) return { mins: real[e.jobName], real: true }
|
||
if (e.lat != null && e.lon != null && nx.lat != null && nx.lon != null) return { mins: estTravelMin(haversineKm(+e.lat, +e.lon, +nx.lat, +nx.lon)), real: false }
|
||
return null
|
||
}
|
||
function legHome (g, d) { const v = (legsByKey[g.techId + '|' + d.iso] || {}).homeTo; return v != null ? v : null }
|
||
function legReturn (g, d) { const v = (legsByKey[g.techId + '|' + d.iso] || {}).returnTo; return v != null ? v : null } // temps de retour au départ
|
||
// Contraintes horaires par job (⚡ Optimiser) : AM/PM (fenêtre dure) + « tôt » (urgence → début de journée). Session (clé = jobName, survit aux ré-optimisations).
|
||
const AMPM_WIN = { am: [480, 720], pm: [720, 960] } // 8-12 / 12-16 (heure d'horloge)
|
||
// Détecte une contrainte AM/PM dans le titre/note du ticket (legacy « Note AM » / « Note PM » importé tel quel).
|
||
// Renvoie 'am' | 'pm' | null (null si absent ou ambigu = les deux présents).
|
||
function detectAmpm (job) {
|
||
const t = (String(job && job.subject || '') + ' ' + String(job && (job.legacy_detail || job.notes) || '')).toLowerCase()
|
||
const am = /\bam\b|avant.?midi|\bmatin\b/.test(t)
|
||
const pm = /\bpm\b|apr[eè]s.?midi/.test(t)
|
||
if (am && !pm) return 'am'
|
||
if (pm && !am) return 'pm'
|
||
return null
|
||
}
|
||
function jobTimeOf (name) { return suggestDlg.jobTime[name] || {} }
|
||
function toggleJobAmpm (name, v) { const c = suggestDlg.jobTime[name] || {}; suggestDlg.jobTime[name] = { ...c, ampm: c.ampm === v ? null : v } }
|
||
// 🖥 « SANS DÉPLACEMENT » (à distance, ex. config boîtier tel via ACS) : persistant (store hub), exclu des tournées par l'optimiseur.
|
||
const poolRemote = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = !!j.remote; return m })
|
||
function isJobRemote (name) { return !!poolRemote.value[name] }
|
||
async function toggleJobRemote (e) {
|
||
const j = (assignPanel.jobs || []).find(x => x.name === e.jobName); const next = !(j ? j.remote : e.remote)
|
||
if (j) j.remote = next; e.remote = next // optimiste
|
||
try { await roster.setJobRemote(e.jobName, next) } catch (er) { if (j) j.remote = !next; e.remote = !next; err(er); return }
|
||
$q.notify({ type: 'info', icon: 'computer', message: next ? '🖥 Sans déplacement — Ré-optimiser pour l\'exclure des tournées' : 'Déplacement requis rétabli — Ré-optimiser', timeout: 2600 })
|
||
}
|
||
// 📦 Grouper les tâches MÊME ADRESSE en « projet » (parent_job ERPNext) — parent = la plus longue, les autres deviennent sous-tâches.
|
||
async function groupSameAddr (e, g, d) {
|
||
const key = x => (x.lat != null && x.lon != null) ? (+x.lat).toFixed(4) + ',' + (+x.lon).toFixed(4) : null
|
||
const k = key(e); if (!k) return
|
||
const grp = d.entries.filter(x => key(x) === k)
|
||
if (grp.length < 2) { $q.notify({ type: 'info', message: 'Une seule tâche à cette adresse dans ce plan.' }); return }
|
||
const parent = grp.slice().sort((a, b) => (b.dur || 0) - (a.dur || 0))[0]
|
||
const children = grp.filter(x => x !== parent).map(x => x.jobName)
|
||
try {
|
||
const r = await roster.groupJobs(parent.jobName, children)
|
||
if (!r || !r.ok) throw new Error((r && r.error) || 'échec du groupement')
|
||
grp.forEach(x => { x.grouped = true })
|
||
$q.notify({ type: 'positive', icon: 'inventory_2', message: `📦 Projet créé : « ${(parent.subject || '').slice(0, 42)} » + ${children.length} sous-tâche(s)`, timeout: 3500 })
|
||
} catch (er) { err(er) }
|
||
}
|
||
// PRIORITÉ du job — MÊME drapeau que les conversations (priorityMeta) + champ ERPNext STANDARD (low/medium/high) via patchJob, comme la fiche du pool. Une seule source.
|
||
const poolPriority = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = String(j.priority || '').toLowerCase(); return m })
|
||
function jobPriorityOf (name) { return poolPriority.value[name] || '' } // medium → priorityMeta = contour gris (non classé)
|
||
// ── SLA sur les jobs (RÉUTILISE useSla des tickets) : échéance de RÉSOLUTION = création + politique[priorité]. Compute-on-read, aucune migration ERPNext. ──
|
||
const poolByName = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = j; return m })
|
||
const { slaFor: _slaForTicket } = useSla()
|
||
const PRIO_CAP = { high: 'High', 'élevée': 'High', elevee: 'High', urgent: 'Urgent', medium: 'Medium', moyenne: 'Medium', normal: 'Medium', low: 'Low', basse: 'Low' }
|
||
function jobSlaBadge (job) { // { state, color, text, icon, label } pour at_risk/breached SEULEMENT (états actionnables) ; null sinon
|
||
if (!job) return null
|
||
// ⚠️ Échéance = DATE DE VISITE PRÉVUE (scheduled_date), PAS la création : un job planifié dans le futur n'est PAS en retard
|
||
// même si l'incident est ancien (le client peut avoir demandé une visite plus tard). La création ne sert que faute de date.
|
||
const sched = job.scheduled_date
|
||
if (sched && sched !== 'Sans date' && /^\d{4}-\d{2}-\d{2}$/.test(sched)) {
|
||
// Badge « en retard » UNIQUEMENT si la date de visite est PASSÉE (aujourd'hui/futur = à l'heure, pas de badge).
|
||
if (sched < todayISO()) { const b = SLA_BADGE.breached; return { state: 'breached', color: b.color, text: b.text, icon: b.icon, label: 'Visite prévue en retard — ' + fmtDueLabel(sched), short: 'En retard' } }
|
||
return null
|
||
}
|
||
// Aucune date de visite planifiée : SLA classique (vieillissement du backlog depuis la création).
|
||
if (!job.creation) return null
|
||
const prio = PRIO_CAP[String(job.priority || jobPriorityOf(job.name) || 'medium').toLowerCase()] || 'Medium'
|
||
// job → ticket synthétique : first_responded_on renseigné → on SAUTE la bande « réponse » et on prend la bande RÉSOLUTION depuis la création.
|
||
const s = _slaForTicket({ issue_type: job.job_type || undefined, priority: prio, creation: job.creation, first_responded_on: job.creation, status: (String(job.status || '').toLowerCase() === 'completed' ? 'Closed' : job.status) })
|
||
if (!s || (s.state !== 'at_risk' && s.state !== 'breached')) return null
|
||
const b = SLA_BADGE[s.state]
|
||
return { state: s.state, color: b.color, text: b.text, icon: b.icon, label: s.label, short: s.state === 'breached' ? 'SLA dépassé' : 'SLA proche' }
|
||
}
|
||
function entrySlaBadge (e) { return jobSlaBadge(poolByName.value[e.jobName]) } // revue : retrouve le job du pool pour l'échéance
|
||
function setEntryPriority (name, lvl) { // écrit la priorité ERPNext du job (low/medium/high) — réutilise patchJob (optimiste + updateJob)
|
||
const j = (assignPanel.jobs || []).find(x => x.name === name); if (!j) return
|
||
patchJob(j, { priority: lvl || 'medium' }, 'Priorité : ' + priorityMeta(lvl).label)
|
||
}
|
||
// Réglages de l'optimiseur VRP (curseur distance↔spécialité, vitesse repli, budget de calcul) — persistés localement, ajustables sans redéploiement.
|
||
const solverOpts = reactive({ rankWeight: 2, overtimeCoef: 20, speedKmh: 45, maxSeconds: 8 }) // un COÛT par critère (pas de slider)
|
||
try { const s = JSON.parse(localStorage.getItem('ops.solverOpts') || 'null'); if (s && typeof s === 'object') Object.assign(solverOpts, s) } catch (e) { /* défauts */ }
|
||
watch(solverOpts, v => { try { localStorage.setItem('ops.solverOpts', JSON.stringify(v)) } catch (e) {} }, { deep: true })
|
||
// « Ramassage d'équipement » (récupération chez le client) = simple collecte → job court (~5 min).
|
||
// On ne surallocue pas le temps même si la classification/est_min du ticket dit plus long.
|
||
const PICKUP_RE = /ramm?ass|r[ée]cup[ée]ration/i
|
||
const PICKUP_MIN = 5
|
||
function isPickup (j) { return !!j && PICKUP_RE.test(String(j.subject || j.service_type || j.job_type || '')) }
|
||
function jobDur (j) { if (isPickup(j)) return PICKUP_MIN / 60; return (j && (j.est_min ? j.est_min / 60 : Number(j.duration_h || 1))) || 1 }
|
||
// Normalise les jobs du pool à l'entrée (SOURCE UNIQUE, appelé aux 3 points de chargement) :
|
||
// (1) décode les entités HTML des champs texte (adresses legacy « l'église » → « l'église ») via deEnt,
|
||
// (2) cap la durée AFFICHÉE des ramassages à 5 min (le temps suit jobDur partout : pool, carte, occupation).
|
||
function capPickups (jobs) {
|
||
return (jobs || []).map(j => {
|
||
if (!j) return j
|
||
const d = { ...j }
|
||
if (d.subject) d.subject = deEnt(d.subject)
|
||
if (d.customer_name) d.customer_name = deEnt(d.customer_name)
|
||
if (d.address) d.address = deEnt(d.address)
|
||
if (d.service_location) d.service_location = deEnt(d.service_location)
|
||
if (d.legacy_detail) d.legacy_detail = deEnt(d.legacy_detail)
|
||
return isPickup(d) ? { ...d, est_min: PICKUP_MIN } : d
|
||
})
|
||
}
|
||
// Construit un plan job→tech×jour SANS rien écrire (proposition). Chaque job garde sa date si elle est dans la fenêtre.
|
||
function buildSuggestion () {
|
||
const useSel = selectedNames.value.length > 0
|
||
suggestDlg.fromSel = useSel
|
||
const jobs = suggestJobs() // lasso si actif, sinon pool filtré par les chips de date (jours cochés)
|
||
const days = (dayList.value || []); const dayIsos = days.map(d => d.iso)
|
||
const techs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id]) // seulement les techs choisis (disponibles ce jour)
|
||
if (!jobs.length || !dayIsos.length || !techs.length) { suggestDlg.plan = []; return }
|
||
const pr = j => ({ high: 0, urgent: 0, élevée: 0, medium: 1, moyenne: 1, normal: 1, low: 2, basse: 2 }[String(j.priority || '').toLowerCase()] ?? 1)
|
||
const today = todayISO()
|
||
const win = suggestWindow.value; const winSet = new Set(win); const winMax = win.length ? win[win.length - 1] : today // fenêtre : dates sélectionnées ou auj.+demain
|
||
const isOver = j => (j.scheduled_date && j.scheduled_date !== 'Sans date' && j.scheduled_date < today) ? 0 : 1
|
||
const sorted = [...jobs].sort((a, b) => pr(a) - pr(b) || isOver(a) - isOver(b) || String(a.scheduled_date || '9').localeCompare(String(b.scheduled_date || '9')) || jobDur(b) - jobDur(a))
|
||
const proj = {} // techId|iso → { h, cap, pts:[[lat,lon]] } — charge PROJETÉE, initialisée depuis l'occupation actuelle
|
||
const cell = (tid, iso) => { const k = tid + '|' + iso; if (!proj[k]) { const load = techLoad(techDayOcc(tid, iso), hasShiftDay(tid, iso), tid, iso); proj[k] = { h: load.h || 0, cap: load.cap || 8, pts: [], cities: new Set() } } return proj[k] }
|
||
// Poids selon la STRATÉGIE : smart · best (meilleurs d'abord, cascade) · balance (round-robin) · enough (juste ce qu'il faut → réserve les experts)
|
||
const strat = suggestDlg.strategy || 'smart'
|
||
// rank = ORDRE de la compétence chez le tech (0 = compétence PRINCIPALE) → un job va d'abord aux spécialistes de CETTE compétence, épargnant les polyvalents.
|
||
const W = strat === 'best' ? { dist: 0.4, skill: 15, eff: 65, cost: 20, load: -0.25, overqual: 0, rank: 10 }
|
||
: strat === 'balance' ? { dist: 0.6, skill: 6, eff: 30, cost: 8, load: 24, overqual: 0, rank: 8 }
|
||
: strat === 'enough' ? { dist: 1.4, skill: 0, eff: 25, cost: 10, load: 2, overqual: 22, rank: 16 } // « juste ce qu'il faut » : capable mais le moins sur-qualifié possible
|
||
: { dist: 1.6, skill: 6, eff: 30, cost: 7, load: 0.6, overqual: 0, rank: 14 }
|
||
const costs = techs.map(t => Number(t.cost_h) || 0); const cMin = Math.min(...costs); const cRange = (Math.max(...costs) - cMin) || 1 // normalisation coût 0..1
|
||
const plan = []
|
||
const holdByDay = {} // jobs SANS tech de quart → regroupés par jour puis découpés en tournées PLACEHOLDER (pass 2)
|
||
for (const j of sorted) {
|
||
const reqSkill = j.required_skill || skillByType.value[j.service_type] || '' // compétence PRINCIPALE (déduite du TYPE si absente)
|
||
const reqSkillList = (Array.isArray(j.required_skills) && j.required_skills.length) ? j.required_skills : (reqSkill ? [reqSkill] : []) // LISTE : le tech doit toutes les avoir
|
||
const reqLevel = Number(j.required_level) || 1 // niveau imposé PAR LE JOB (persistant) — plus de défaut global par compétence
|
||
const jlat = +(j.lat != null ? j.lat : j.latitude), jlon = +(j.lon != null ? j.lon : j.longitude); const hasLL = isFinite(jlat) && isFinite(jlon) && (jlat || jlon) // pool hub = latitude/longitude (bruts ERP) ; grille = lat/lon
|
||
const jcity = jobCity(j) || '' // municipalité — sert de repli de proximité quand le job n'a pas de coordonnées
|
||
const dur = Number(durOverride[j.name]) > 0 ? Number(durOverride[j.name]) : jobDur(j) // temps révisé par le dispatcher prioritaire
|
||
const sched = (j.scheduled_date && j.scheduled_date !== 'Sans date' && /^\d{4}-\d{2}-\d{2}$/.test(j.scheduled_date)) ? j.scheduled_date : null
|
||
let cand
|
||
if (j._reDate && winSet.has(j._reDate)) cand = [j._reDate] // date re-choisie (dans la fenêtre)
|
||
else if (sched && winSet.has(sched)) cand = [sched] // daté DANS la fenêtre → ce jour
|
||
else if (sched && sched > winMax) continue // daté APRÈS la fenêtre → PAS dispatché maintenant (on ne planifie pas loin)
|
||
else {
|
||
// En retard (date < fenêtre) ou sans date : JAMAIS auto-dispatché. UNE seule exception : la sélection
|
||
// manuelle explicite (lasso/cases) — les chips du pool n'ouvrent plus cette porte (ticket ex. 254403).
|
||
if (!useSel) continue
|
||
cand = win
|
||
}
|
||
if (!cand.length) continue
|
||
let best = null
|
||
for (const t of techs) {
|
||
// SKILL = filtre DUR : un tech sans la compétence requise n'est JAMAIS candidat (ex. Josée-Anne ne fait ni réparation ni installation).
|
||
if (reqSkillList.length && !reqSkillList.every(s => (t.skills || []).includes(s))) continue
|
||
const capable = true
|
||
const skillRank = reqSkill ? Math.max(0, (t.skills || []).indexOf(reqSkill)) : 0 // ORDRE de la compétence chez le tech : 0 = principale (spécialiste) ; 1,2… = secondaire (polyvalent)
|
||
const lvl = reqSkill ? (skillLevelOf(t, reqSkill) || 0) : 3 // maîtrise 0-5 (3 = neutre sans compétence requise)
|
||
const eff = reqSkill ? skillEffOf(t, reqSkill) : (Number(t.efficiency) || 1) // vitesse : <1 = plus rapide pour cette compétence
|
||
const effDur = dur * (eff > 0 ? eff : 1) // durée EFFECTIVE = un tech rapide prend moins de capacité pour le même job
|
||
const costN = ((Number(t.cost_h) || 0) - cMin) / cRange
|
||
const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut) — départ de tournée
|
||
for (const iso of cand) {
|
||
const c = cell(t.id, iso)
|
||
const noShift = !hasShiftDay(t.id, iso)
|
||
const over = Math.max(0, (c.h + effDur) - c.cap)
|
||
// PROXIMITÉ : vraie distance km si géolocalisé ; SINON repli par VILLE (regroupe la même municipalité, pénalise le mélange lointain type Ste-Barbe + Napierville)
|
||
let prox
|
||
if (hasLL && c.pts.length) prox = Math.min(...c.pts.map(p => haversineKm(p[0], p[1], jlat, jlon))) // proche des jobs déjà planifiés (route serrée)
|
||
else if (hasLL && home && isFinite(+home.lat) && isFinite(+home.lon)) prox = haversineKm(+home.lat, +home.lon, jlat, jlon) // 1er job → depuis le domicile
|
||
else if (jcity && c.cities.has(jcity)) prox = 0 // même ville qu'un job déjà planifié ce jour = même tournée
|
||
else if (c.cities.size) prox = 40 // le tech a déjà une AUTRE ville ce jour → forte pénalité (évite de mélanger des villes éloignées)
|
||
else prox = 15 // 1er job du tech ce jour, sans coords → neutre
|
||
const under = Math.max(0, reqLevel - lvl) // sous le niveau requis pour ce job (maîtrise insuffisante — compétence déjà garantie par le filtre dur)
|
||
let score = over * 120 + (noShift ? 60 : 0) + under * 200 // surcharge / hors-quart / SOUS-qualifié
|
||
score += prox * W.dist // proximité (km réels OU regroupement par ville)
|
||
score += c.h * W.load // load>0 = étale (round-robin) · load<0 = remplit les 1ers (cascade)
|
||
score += (5 - lvl) * W.skill // (best/smart) maîtrise faible pénalisée → avantage aux experts
|
||
score += skillRank * (W.rank || 0) // ORDRE de la compétence : le spécialiste (rang 0) passe avant le polyvalent → épargne les moins spécialisés
|
||
score += Math.max(0, lvl - reqLevel) * (W.overqual || 0) // (« juste ce qu'il faut ») sur-qualification pénalisée → garde les experts pour les jobs exigeants
|
||
score += (eff - 1) * W.eff // rapide (eff<1) récompensé · lent (eff>1) pénalisé
|
||
score += costN * W.cost // moins cher préféré
|
||
if (!best || score < best.score) best = { t, iso, score, noShift, capable, over, lvl, eff, effDur, skillRank }
|
||
}
|
||
}
|
||
// Commit sur un VRAI tech seulement s'il a un quart ce jour-là. Sinon → réservé pour une file PLACEHOLDER (pass 2), à assigner ensuite à un vrai tech.
|
||
if (best && !best.noShift) {
|
||
const c = cell(best.t.id, best.iso); c.h += best.effDur; if (hasLL) c.pts.push([jlat, jlon]); if (jcity) c.cities.add(jcity) // capacité + ville pour la proximité des suivants
|
||
plan.push({ jobName: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || j.location_label || '', techId: best.t.id, techName: best.t.name, iso: best.iso, dur, skill: reqSkill || '', capable: best.capable, noShift: false, over: best.over > 0, lat: hasLL ? jlat : null, lon: hasLL ? jlon : null, lvl: best.lvl, eff: best.eff, reqLevel })
|
||
} else {
|
||
const holdIso = best ? best.iso : (sched && winSet.has(sched) ? sched : (cand.find(d => !isWeekendIso(d)) || cand[0]))
|
||
;(holdByDay[holdIso] = holdByDay[holdIso] || []).push({ j, dur, reqSkill, reqLevel, jlat, jlon, hasLL, jcity, capable: !best || best.capable })
|
||
}
|
||
}
|
||
// PASS 2 — jobs sans tech de quart → files PLACEHOLDER par jour, groupées PAR COMPÉTENCE puis découpées en tournées ~8 h par PROXIMITÉ
|
||
// (km réels, sinon même ville ; >60 km = nouvelle tournée). Une tournée = 1 compétence + 1 secteur → assignable à un tech qui a CETTE compétence.
|
||
const HOLD_CAP = 8
|
||
for (const iso of Object.keys(holdByDay)) {
|
||
const bySkill = {}; for (const it of holdByDay[iso]) { const k = it.reqSkill || '__any__'; (bySkill[k] = bySkill[k] || []).push(it) } // 1 groupe par compétence
|
||
const dLbl = (FR_DOW[dowOf(iso)] || '') + ' ' + iso.slice(8) // ex. « ven 03 »
|
||
let ti = 0
|
||
for (const sk of Object.keys(bySkill)) {
|
||
const items = bySkill[sk].slice().sort((a, b) => (a.jcity || '').localeCompare(b.jcity || '')) // départ groupé par ville → tournées cohérentes
|
||
const queues = []
|
||
for (const it of items) {
|
||
let bq = null, bd = Infinity
|
||
for (const q of queues) {
|
||
if (q.h + it.dur > HOLD_CAP + 0.01) continue // tournée pleine (~8 h)
|
||
let d
|
||
if (it.hasLL && q.pts.length) d = Math.min(...q.pts.map(p => haversineKm(p[0], p[1], it.jlat, it.jlon)))
|
||
else if (it.jcity && q.cities.has(it.jcity)) d = 0
|
||
else if (q.cities.size || q.pts.length) d = 40
|
||
else d = 15
|
||
if (d < bd) { bd = d; bq = q }
|
||
}
|
||
if (!bq || bd > 60) { bq = { h: 0, pts: [], cities: new Set(), jobs: [] }; queues.push(bq) } // trop loin ou tout plein → nouvelle tournée
|
||
bq.h += it.dur; if (it.hasLL) bq.pts.push([it.jlat, it.jlon]); if (it.jcity) bq.cities.add(it.jcity); bq.jobs.push(it)
|
||
}
|
||
const skLbl = sk === '__any__' ? '' : ' ' + sk
|
||
queues.forEach((q) => {
|
||
ti++
|
||
const hid = `__hold__|${iso}|${ti}`
|
||
const label = `⏳ ${dLbl}${skLbl} — tournée ${ti} (à assigner)`
|
||
for (const it of q.jobs) plan.push({ jobName: it.j.name, subject: it.j.subject || it.j.service_type || it.j.name, customer: it.j.customer_name || it.j.location_label || '', techId: hid, techName: label, placeholder: true, iso, dur: it.dur, skill: it.reqSkill || '', capable: it.capable, noShift: false, over: false, lat: it.hasLL ? it.jlat : null, lon: it.hasLL ? it.jlon : null, lvl: 0, eff: 1, reqLevel: it.reqLevel })
|
||
})
|
||
}
|
||
}
|
||
suggestDlg.plan = plan
|
||
}
|
||
function openSuggest () {
|
||
// Jour de base = le jour SÉLECTIONNÉ PARTAGÉ (selDay — synchronisé depuis la bande Tournées, le Jour kanban,
|
||
// le sélecteur de date et la bande mobile) → « Suggérer se base toujours sur la journée sélectionnée ».
|
||
suggestDay.value = (boardView.value === 'routes' && routesDay.value) || (boardView.value === 'kanban' && kanbanDay.value && kanbanDay.value.iso) || selDay.value || todayISO()
|
||
suggestDlg.fromSel = selectedNames.value.length > 0
|
||
// Étape « disponibilité » d'abord : défaut = techs AVEC un quart sur un des jours de la fenêtre (= disponibles) ; sinon tous les visibles.
|
||
const win = suggestWindow.value // fenêtre = chips de date cochés, sinon le jour du sélecteur
|
||
const techs = visibleTechs.value || []
|
||
const withShift = techs.filter(t => win.some(iso => hasShiftDay(t.id, iso)))
|
||
const availPool = withShift.length ? withShift : techs
|
||
// Pré-sélection par COMPÉTENCE : parmi les dispos, ceux dont les compétences couvrent au moins un skill requis
|
||
// par les jobs du périmètre (jour + filtres). Toujours modifiable (le dialogue liste tous les techs, cochables) ;
|
||
// repli sur tous les dispos si aucun skill requis connu ou aucun match.
|
||
const scopeJobs = suggestJobs() || []
|
||
// AUTO AM/PM : pré-applique la contrainte horaire détectée dans le titre/note (« Note AM/PM » legacy) si non déjà posée.
|
||
for (const j of scopeJobs) { if (!suggestDlg.jobTime[j.name]) { const ap = detectAmpm(j); if (ap) suggestDlg.jobTime[j.name] = { ampm: ap, auto: true } } }
|
||
const needed = new Set(scopeJobs.map(j => j.required_skill).filter(Boolean))
|
||
const skilled = needed.size ? availPool.filter(t => (t.skills || []).some(s => needed.has(s))) : []
|
||
const base = skilled.length ? skilled : availPool
|
||
const sel = {}; base.forEach(t => { sel[t.id] = true }); suggestDlg.techSel = sel
|
||
suggestDlg.plan = []; suggestDlg.mode = 'config'; suggestDlg.open = true
|
||
}
|
||
const suggestSelCount = computed(() => Object.values(suggestDlg.techSel).filter(Boolean).length)
|
||
// Dispatch auto : la FENÊTRE = LE jour sélectionné (selDay partagé entre vues ; modifiable dans le dialogue).
|
||
// Changer le jour dans le dialogue met aussi à jour le jour partagé (mémoire de session). Non destructif (revue avant d'appliquer).
|
||
const suggestDay = ref('')
|
||
watch(suggestDay, v => setSelDay(v))
|
||
const suggestWindow = computed(() => {
|
||
// RÈGLE DÉFINITIVE (retour terrain, ticket ex. 254403 en retard auto-dispatché) : la fenêtre = EXACTEMENT
|
||
// LE JOUR SÉLECTIONNÉ (bande Tournées / Jour kanban / sélecteur du dialogue — tous synchronisés via selDay).
|
||
// Les chips de date du pool FILTRENT l'affichage mais n'élargissent JAMAIS le dispatch auto ; les jobs en
|
||
// retard ou sans date ne sont dispatchés QUE par sélection manuelle explicite (lasso/cases cochées).
|
||
if (boardView.value === 'routes' && routesDay.value) return [routesDay.value]
|
||
return [suggestDay.value || selDay.value || todayISO()]
|
||
})
|
||
// 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 = seule façon de dispatcher un retard/sans-date
|
||
// 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')
|
||
// RÈGLE : seuls les jobs dont la DATE DUE = le jour sélectionné sont auto-dispatchés (jamais les retards,
|
||
// jamais les sans-date, jamais les autres jours — ticket ex. 254403). Appliqué à la SOURCE : couvre le
|
||
// solveur VRP (job_names) ET l'heuristique locale. Les chips de COMPÉTENCE du pool restreignent encore
|
||
// (ex. décocher « téléphonie ») ; les chips de DATE n'influencent plus le dispatch (affichage seulement).
|
||
const win = new Set(suggestWindow.value)
|
||
let jobs = (assignPanel.jobs || []).filter(j => j.status !== 'On Hold' && j.scheduled_date && j.scheduled_date !== 'Sans date' && win.has(j.scheduled_date))
|
||
const sk = floatingPool.skillFilter.value || []
|
||
if (sk.length) jobs = jobs.filter(j => sk.includes(j.required_skill))
|
||
return jobs
|
||
}
|
||
const suggestJobCount = computed(() => suggestJobs().length)
|
||
// Y a-t-il un filtre restreignant le dispatch ? (lasso ou chips de TYPE — les chips de date ne comptent plus : le jour vient de selDay)
|
||
const suggestFiltered = computed(() => !!(selectedNames.value.length || floatingPool.skillFilter.value.length))
|
||
// Libellé du périmètre : « sélectionnés » (lasso) · « des types cochés » (chips de compétence).
|
||
const suggestScope = computed(() => {
|
||
if (selectedNames.value.length) return 'sélectionnés'
|
||
return floatingPool.skillFilter.value.length ? 'des types cochés' : 'du jour'
|
||
})
|
||
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 ((floatingPool.dateFilter.value || []).length) return w.map(windowDayLabel).join(', ')
|
||
return w.length === 1 ? windowDayLabel(w[0]) : (windowDayLabel(w[0]) + ' + ' + windowDayLabel(w[w.length - 1]))
|
||
})
|
||
function suggestSelectTechs (which) { // 'all' | 'shift' | 'none'
|
||
const techs = visibleTechs.value || []; const sel = {}
|
||
if (which === 'all') techs.forEach(t => { sel[t.id] = true })
|
||
else if (which === 'shift') techs.forEach(t => { if ((dayList.value || []).some(d => hasShiftDay(t.id, d.iso))) sel[t.id] = true })
|
||
suggestDlg.techSel = sel
|
||
}
|
||
// « Groupe de techs par compétence » : sélectionner d'un coup tous les techs d'une compétence (ex : tous les monteurs).
|
||
const suggestSkillGroups = computed(() => {
|
||
const m = {}
|
||
for (const t of (visibleTechs.value || [])) for (const s of (t.skills || [])) m[s] = (m[s] || 0) + 1
|
||
return Object.entries(m).map(([skill, n]) => ({ skill, n })).sort((a, b) => b.n - a.n)
|
||
})
|
||
function suggestSkillOn (skill) { const techs = (visibleTechs.value || []).filter(t => (t.skills || []).includes(skill)); return !!techs.length && techs.every(t => suggestDlg.techSel[t.id]) }
|
||
function suggestSelectSkill (skill) { // (dé)sélectionne TOUT le groupe de techs ayant cette compétence
|
||
const sel = { ...suggestDlg.techSel }
|
||
const techs = (visibleTechs.value || []).filter(t => (t.skills || []).includes(skill))
|
||
const allOn = techs.length && techs.every(t => sel[t.id])
|
||
techs.forEach(t => { if (allOn) delete sel[t.id]; else sel[t.id] = true })
|
||
suggestDlg.techSel = sel
|
||
}
|
||
// ── Groupes de techs ENREGISTRÉS (ex. « Installateurs », « Épisseurs ») — sélection manuelle sauvée par utilisateur,
|
||
// utile quand la compétence déduite du job est imprécise. Persisté serveur (useUserPrefs, suit l'usager). ──
|
||
const { prefs: techGroupPrefs, save: saveTechGroupPrefs } = useUserPrefs('techGroups', { groups: [] })
|
||
const techGroups = computed(() => techGroupPrefs.value.groups || [])
|
||
function applyTechGroup (g) { // remplace la sélection par les techs du groupe (présents/visibles seulement)
|
||
const ids = new Set(g.ids || []); const sel = {}
|
||
for (const t of (visibleTechs.value || [])) if (ids.has(t.id)) sel[t.id] = true
|
||
suggestDlg.techSel = sel
|
||
}
|
||
function saveTechGroup () {
|
||
const ids = Object.keys(suggestDlg.techSel).filter(id => suggestDlg.techSel[id])
|
||
if (!ids.length) { $q.notify({ type: 'warning', message: 'Sélectionne au moins un technicien avant d’enregistrer un groupe' }); return }
|
||
$q.dialog({ title: 'Enregistrer le groupe', message: 'Nom du groupe (ex. Installateurs, Épisseurs)', prompt: { model: '', type: 'text' }, cancel: true }).onOk(name => {
|
||
name = String(name || '').trim(); if (!name) return
|
||
const groups = [...(techGroupPrefs.value.groups || [])].filter(g => g.name.toLowerCase() !== name.toLowerCase())
|
||
groups.push({ name, ids }); groups.sort((a, b) => a.name.localeCompare(b.name))
|
||
saveTechGroupPrefs({ groups })
|
||
$q.notify({ type: 'positive', message: `Groupe « ${name} » enregistré (${ids.length} tech)` })
|
||
})
|
||
}
|
||
function deleteTechGroup (g) {
|
||
saveTechGroupPrefs({ groups: (techGroupPrefs.value.groups || []).filter(x => x.name !== g.name) })
|
||
}
|
||
// Tri des techs par QUALITÉ (maîtrise ↑ · vitesse ↑ · coût ↓) → « les meilleurs en premier » dans la liste de dispo.
|
||
function techQuality (t) {
|
||
const lvls = Object.values(t.skill_levels || {}); const avgLvl = lvls.length ? lvls.reduce((a, b) => a + b, 0) / lvls.length : 0
|
||
const eff = Number(t.efficiency) || 1
|
||
return (5 - avgLvl) * 2 + (eff - 1) * 5 + ((Number(t.cost_h) || 0) / 100) // plus BAS = meilleur
|
||
}
|
||
const suggestTechsRanked = computed(() => [...(visibleTechs.value || [])].sort((a, b) => techQuality(a) - techQuality(b)))
|
||
async function runSuggestion () {
|
||
if (!suggestSelCount.value) { $q.notify({ type: 'warning', message: 'Choisis au moins un technicien disponible.', timeout: 2500 }); return }
|
||
suggestDlg.strategy = 'optimize' // UNE seule méthode : solveur VRP (routes réelles + compétences + efficacité + AM/PM + priorités)
|
||
showAllMoveTechs.value = false // picker « déplacer vers » : repart filtré aux techs sélectionnés+compétents
|
||
// « Ré-optimiser l'existant » : le solveur récupère les données des jobs assignés par leur nom (l'heuristique locale
|
||
// n'a que le pool en mémoire) → on force le solveur VRP.
|
||
if (suggestDlg.includeAssigned) suggestDlg.strategy = 'optimize'
|
||
suggestDlg.building = true
|
||
try {
|
||
if (suggestDlg.strategy === 'optimize') await optimizeSuggestion() // solveur VRP OR-Tools (routes + compétences + temps sur place)
|
||
else buildSuggestion() // heuristique gloutonne (rapide, local)
|
||
} catch (e) { err(e); buildSuggestion() } finally { suggestDlg.building = false }
|
||
suggestDlg.view = 'list'; suggestMapDay.value = suggestWindow.value[0] || '' // carte des tournées : 1er jour de la fenêtre par défaut
|
||
suggestDlg.mode = 'review'
|
||
loadLegTimes().catch(() => {}) // temps de route réels entre arrêts (OSRM, non bloquant)
|
||
}
|
||
// Ré-optimiser depuis la revue : relance le solveur VRP en tenant compte des contraintes AM/PM/⚡ posées sur les jobs.
|
||
// Tech malade/absent : le désélectionne puis RÉ-OPTIMISE → ses jobs sont répartis équitablement (le solveur équilibre la charge) sur les techs restants.
|
||
async function markTechSick (g) {
|
||
if (!g || isHoldId(g.techId)) return
|
||
suggestDlg.techSel[g.techId] = false
|
||
$q.notify({ type: 'info', icon: 'sick', message: g.techName + ' retiré (malade/absent) — redistribution de ses ' + g.jobs + ' job(s)…', timeout: 2200 })
|
||
await reoptimize()
|
||
}
|
||
async function reoptimize () {
|
||
suggestDlg.strategy = 'optimize'; suggestDlg.building = true
|
||
try { await optimizeSuggestion() } catch (e) { err(e) } finally { suggestDlg.building = false }
|
||
suggestDlg.view = 'list'; suggestMapDay.value = suggestWindow.value[0] || ''
|
||
loadLegTimes().catch(() => {}) // recalcule les temps entre arrêts après ré-optimisation
|
||
}
|
||
// Fenêtre de quart (min depuis minuit) d'un tech un jour donné, depuis ses cellules + le modèle. null si aucun quart régulier.
|
||
function shiftWindowMin (techId, iso) {
|
||
let s = Infinity, e = -Infinity
|
||
for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || t.on_call) continue; const ss = hToNum(t.start_time), ee = hToNum(t.end_time); if (ss != null) s = Math.min(s, ss); if (ee != null) e = Math.max(e, ee <= ss ? 24 : ee) }
|
||
if (!isFinite(s) || !isFinite(e) || e <= s) return null
|
||
return { start: Math.round(s * 60), end: Math.round(e * 60) }
|
||
}
|
||
// « Optimiser » = P4 : l'ORCHESTRATION COMPLÈTE vit au hub (/roster/optimize-plan — pool, quarts, occupation, mêmes règles pour le cron
|
||
// auto-plan) avec règles d'adresse : même adresse = 1 arrêt ; job à l'adresse d'un job DÉJÀ assigné → épinglé sur CE tech (coordination).
|
||
// Repli : l'ancienne orchestration locale si le hub est indisponible.
|
||
async function optimizeSuggestion () {
|
||
suggestDlg.makeShifts = true // quarts 8-16 assumés (hub assumed_shift) → créés au Publier
|
||
const selTechs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id])
|
||
if (suggestDlg.includeAssigned) seedAssignedAmpm() // ré-optimiser l'existant : détecte AM/PM des jobs déjà dispatchés (fenêtre dure 8-12 / 12-16)
|
||
const job_windows = {}
|
||
for (const n in suggestDlg.jobTime) { const w = AMPM_WIN[(suggestDlg.jobTime[n] || {}).ampm]; if (w) job_windows[n] = { tw_start_min: w[0], tw_end_min: w[1] } }
|
||
try {
|
||
const r = await roster.optimizePlanHub({
|
||
dates: suggestWindow.value, tech_ids: selTechs.map(t => t.id),
|
||
// job_names = pool filtré (+ jobs DÉJÀ assignés de la fenêtre si « ré-optimiser l'existant ») → le solveur les replace.
|
||
job_names: [...new Set([...suggestJobs().map(j => j.name), ...(suggestDlg.includeAssigned ? assignedNamesForWindow() : [])])],
|
||
opts: { rank_weight: 20, versatility_weight: 3, speed_kmh: solverOpts.speedKmh, max_seconds: solverOpts.maxSeconds, overtime_coef: solverOpts.overtimeCoef }, // rang (compétence principale) DOMINE ; polyvalence = léger départage
|
||
dur_overrides: { ...durOverride }, job_windows,
|
||
})
|
||
if (!r || !r.ok || !Array.isArray(r.plan)) throw new Error((r && r.error) || 'réponse invalide')
|
||
const out = []
|
||
for (const e of r.plan) {
|
||
const t = techById.value[e.techId] || {}
|
||
out.push({
|
||
...e, techName: e.techName || t.name || e.techId, placeholder: false, over: false,
|
||
capable: e.capable !== false, noShift: !!(r.assumed_shift && r.assumed_shift[e.techId + '|' + e.iso]),
|
||
lvl: e.skill ? (skillLevelOf(t, e.skill) || 0) : 0, eff: e.skill ? skillEffOf(t, e.skill) : (Number(t.efficiency) || 1),
|
||
})
|
||
}
|
||
for (const e of (r.unassigned || [])) out.push({ ...e, techId: '__unassigned__', techName: '⚠️ Non assignés — ajoute des techs ou assigne à la main', placeholder: true, noShift: false, capable: true, lvl: 0, eff: 1 })
|
||
for (const e of (r.remote || [])) out.push({ ...e, techId: '__remote__', techName: '🖥 Sans déplacement — à faire à distance (assigner à un agent)', placeholder: true, noShift: false, capable: true, lvl: 0, eff: 1 })
|
||
suggestDlg.plan = out
|
||
return
|
||
} catch (e) {
|
||
$q.notify({ type: 'warning', message: 'Optimisation serveur indisponible — repli local', timeout: 2500 })
|
||
}
|
||
await optimizeSuggestionLocal()
|
||
}
|
||
// Ancienne orchestration CLIENT (repli d'urgence si le hub est injoignable) — même solveur, sans les règles d'adresse.
|
||
async function optimizeSuggestionLocal () {
|
||
buildSuggestion() // pose les jours + skill/dur/coords/reqLevel sur chaque entrée + les placeholders
|
||
suggestDlg.makeShifts = true // on SUPPOSE un quart 8-16 pour chaque tech sélectionné → à créer au moment du Publier
|
||
const plan = suggestDlg.plan
|
||
const byDay = {}; const unassigned = [] // PAS de faux « tech placeholder » : on n'utilise QUE les techs sélectionnés ; le reste = 1 seule liste « non assignés »
|
||
for (const e of plan) {
|
||
if (e.placeholder && isWeekendIso(e.iso)) { unassigned.push(e); continue } // week-end : pas de quart auto → non assigné
|
||
;(byDay[e.iso] = byDay[e.iso] || []).push(e)
|
||
}
|
||
const selTechs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id])
|
||
// Priorité du job → « priority_boost » (pénalité de drop) : URGENT jamais laissé de côté ; BASSE lâchée en 1er si la capacité manque.
|
||
const poolPr = Object.fromEntries((assignPanel.jobs || []).map(j => [j.name, String(j.priority || '').toLowerCase()]))
|
||
const prBoost = p => ({ high: 400000, 'élevée': 400000, urgent: 400000, medium: 0, moyenne: 0, normal: 0, low: -60000, basse: -60000 }[p] ?? 0) // HAUTE jamais lâchée ; basse lâchée en 1er
|
||
const prEarly = p => ({ high: 10, 'élevée': 10, urgent: 10 }[p] || 0) // HAUTE = servie tôt (début de journée)
|
||
const out = []
|
||
for (const iso of Object.keys(byDay)) {
|
||
const entries = byDay[iso]
|
||
const byName = Object.fromEntries(entries.map(e => [e.jobName, e]))
|
||
const jobs = entries.map(e => {
|
||
const jt = suggestDlg.jobTime[e.jobName] || {}
|
||
const win = AMPM_WIN[jt.ampm] // AM/PM → fenêtre horaire dure
|
||
const p = poolPr[e.jobName] // priorité (surcharge persistante incluse via buildUnassigned) : urgent/high jamais lâché + servi tôt
|
||
return { id: e.jobName, lat: e.lat, lon: e.lon, service_min: Math.max(5, Math.round((e.dur || 1) * 60)), skill: e.skill || '', priority_boost: prBoost(p), urgent_weight: prEarly(p), ...(win ? { tw_start_min: win[0], tw_end_min: win[1] } : {}) }
|
||
})
|
||
const vehicles = []
|
||
for (const t of selTechs) {
|
||
// Fenêtre = quart réel ; SINON quart 8-16 supposé (créé au Publier). Jamais de quart auto le week-end → pas de véhicule.
|
||
const w = shiftWindowMin(t.id, iso) || (isWeekendIso(iso) ? null : { start: 480, end: 960 })
|
||
if (!w) continue
|
||
// RÉDUIT la capacité par la charge DÉJÀ assignée ce jour (sinon surcharge, ex. « déjà 8.8h » + 12h → 20/16h).
|
||
const occ = techDayOcc(t.id, iso); const usedMin = occ ? Math.round((occ.usedH || 0) * 60) : 0
|
||
const startMin = Math.min(w.end - 15, w.start + usedMin)
|
||
const overtimeMin = Math.round(0.2 * (w.end - w.start)) // plafond ~120 % du quart nominal ; au-delà de 100 % = pénalisé (équilibrage)
|
||
const o = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
|
||
vehicles.push({ id: t.id, name: t.name, skills: t.skills || [], home_lat: o ? o.lat : null, home_lon: o ? o.lon : null, shift_start_min: startMin, shift_end_min: w.end, overtime_min: overtimeMin })
|
||
}
|
||
if (!vehicles.length) { unassigned.push(...entries); continue } // aucun tech éligible ce jour → non assignés
|
||
let res
|
||
try { res = await roster.optimizeRoutes({ jobs, vehicles, max_seconds: solverOpts.maxSeconds, rank_weight: 20, versatility_weight: 3, speed_kmh: solverOpts.speedKmh, overtime_coef: solverOpts.overtimeCoef }) } catch (e) { unassigned.push(...entries); continue }
|
||
if (!res || res.status !== 'OK') { unassigned.push(...entries); continue } // solveur absent/erreur → non assignés (ne jamais perdre de jobs)
|
||
const placed = new Set()
|
||
for (const rt of (res.routes || [])) {
|
||
const t = selTechs.find(x => x.id === rt.vehicle) || { id: rt.vehicle, name: rt.vehicle_name }
|
||
for (const st of (rt.stops || [])) { const e0 = byName[st.job_id]; if (!e0) continue; placed.add(st.job_id); out.push({ ...e0, techId: t.id, techName: t.name, placeholder: false, noShift: !hasShiftDay(t.id, iso), capable: true }) } // noShift → pastille « Créer quart »
|
||
}
|
||
for (const jid of (res.unassigned || [])) { const e0 = byName[jid]; if (e0 && !placed.has(jid)) unassigned.push(e0) }
|
||
}
|
||
// UN SEUL groupe « Non assignés » (aucun faux tech) : capacité/compétence insuffisante avec les techs sélectionnés → en ajouter, ou assigner à la main via le menu ⇄ (classé proximité + charge).
|
||
for (const e of unassigned) out.push({ ...e, techId: '__unassigned__', techName: '⚠️ Non assignés — ajoute des techs ou assigne à la main', placeholder: true, noShift: false })
|
||
suggestDlg.plan = out
|
||
}
|
||
// D v2 — ordonne une tournée par PLUS-PROCHE-VOISIN depuis le domicile du tech + distance totale (haversine, sans appel réseau).
|
||
function nnOrder (entries, home) {
|
||
// ORDRE = fenêtre horaire D'ABORD (AM 8-12 → sans-fenêtre → PM 12-16), plus-proche-voisin DANS chaque bloc,
|
||
// en chaînant la position d'un bloc au suivant. Respecte « faire en AM / PM » (toggle explicite OU détecté du texte
|
||
// ex. « **Passer en premier en AM** », « PM Fin de journée »). AVANT : NN pur → une job PM pouvait finir en 1er.
|
||
const ampmKey = e => { const a = jobTimeOf(e.jobName).ampm || detectAmpm(e); return a === 'am' ? 'am' : a === 'pm' ? 'pm' : 'none' }
|
||
const blocks = { am: [], none: [], pm: [] }
|
||
for (const e of entries) blocks[ampmKey(e)].push(e)
|
||
const out = []; let km = 0
|
||
let cur = (home && isFinite(+home.lat) && isFinite(+home.lon)) ? { lat: +home.lat, lon: +home.lon } : null
|
||
for (const key of ['am', 'none', 'pm']) {
|
||
const rem = blocks[key].filter(e => e.lat != null && e.lon != null)
|
||
const noLL = blocks[key].filter(e => e.lat == null || e.lon == null)
|
||
while (rem.length) {
|
||
let bi = 0, bd = Infinity
|
||
if (cur) for (let i = 0; i < rem.length; i++) { const d = haversineKm(cur.lat, cur.lon, rem[i].lat, rem[i].lon); if (d < bd) { bd = d; bi = i } }
|
||
if (cur && isFinite(bd)) km += bd
|
||
cur = rem[bi]; out.push(rem.splice(bi, 1)[0])
|
||
}
|
||
out.push(...noLL)
|
||
}
|
||
return { ordered: out, km: Math.round(km * 10) / 10 }
|
||
}
|
||
const estTravelMin = km => Math.round(km / 45 * 60) // ~45 km/h moyen (route + arrêts) → estimation « ≈ »
|
||
// Regroupé par tech puis par jour, chaque jour ordonné en tournée (D v2) + km/temps de route estimés.
|
||
const suggestGroups = computed(() => {
|
||
const byTech = {}
|
||
for (const e of suggestDlg.plan) {
|
||
const g = byTech[e.techId] || (byTech[e.techId] = { techId: e.techId, techName: e.techName, days: {}, hours: 0, jobs: 0 })
|
||
const d = g.days[e.iso] || (g.days[e.iso] = { iso: e.iso, entries: [], hours: 0 })
|
||
d.entries.push(e); d.hours += e.dur; g.hours += e.dur; g.jobs++
|
||
}
|
||
const dnumOf = iso => { const d = (dayList.value || []).find(x => x.iso === iso); return d ? (d.dow + ' ' + d.dnum) : iso.slice(5) }
|
||
// Ville d'un job d'occupation/legacy : sujets hétérogènes (« Ville | Client » OU « Type | Ville | Client ») → on prend le 1er segment
|
||
// qui RESSEMBLE à une ville (≤4 mots, sans chiffres, sans mots-métier), sinon la fin d'adresse, sinon le suffixe « … - Ville ».
|
||
const OCC_STOP = /bris|fibre|install|r[ée]par|modem|internet|panne|coupure|remplac|ramass|retrait|d[ée]mant|note|t[ée]l[ée]|\btv\b|wifi|borne|activation|transfert|[ée]quipement|technicien|est\.|time|urgent|\bsca\b|drop|bo[iî]tier|d[ée]roulage|registre|camping/i
|
||
const cityLike = s => { s = String(s || '').trim(); if (!s || /\d/.test(s)) return ''; if (s.split(/\s+/).length > 4) return ''; if (OCC_STOP.test(s)) return ''; return s }
|
||
const cityOfOcc = j => {
|
||
const parts = String(j.address || '').split(',').map(x => x.trim()).filter(Boolean)
|
||
if (parts.length >= 2) { const c = cityLike(parts[parts.length - 1]); if (c) return c }
|
||
for (const seg of String(j.subject || '').split('|')) {
|
||
const c = cityLike(seg); if (c) return c
|
||
if (seg.includes(' - ')) { const c2 = cityLike(seg.split(' - ').pop()); if (c2) return c2 }
|
||
}
|
||
return ''
|
||
}
|
||
return Object.values(byTech).map(g => {
|
||
const home = isHoldId(g.techId) ? null : techOrigin(g.techId); const isPh = isHoldId(g.techId); let tkm = 0 // départ = domicile, sinon bureau TARGO
|
||
const days = Object.values(g.days).sort((a, b) => a.iso.localeCompare(b.iso)).map(d => {
|
||
const r = nnOrder(d.entries, home); tkm += r.km
|
||
// Jobs DÉJÀ assignés à ce tech ce jour (occupation OPS + tickets legacy F terrain) → visibles + secteur où il est déjà
|
||
let prevJobs = []
|
||
if (!isPh) {
|
||
prevJobs = (((techDayOcc(g.techId, d.iso) || {}).jobs) || []).filter(j => !j.cancelled)
|
||
const lg = legacyLoad.value[g.techId + '|' + d.iso]
|
||
for (const j of ((lg && lg.jobs) || [])) if (j.field) prevJobs.push({ name: 'LEG:' + j.id, subject: j.subject, skill: j.skill || '', dur: Number(j.est_h) || 0, legacy: true })
|
||
}
|
||
const prevCities = [...new Set(prevJobs.map(cityOfOcc).filter(c => c && c !== 'Sans ville'))]
|
||
return { ...d, entries: r.ordered, km: r.km, mins: estTravelMin(r.km), label: dnumOf(d.iso), prevJobs, prevCities }
|
||
})
|
||
const sectors = [...new Set(days.flatMap(d => d.prevCities))] // secteur(s) où le tech a DÉJÀ des jobs → s'ajuster en conséquence
|
||
// Occupation : capacité (quarts réels ou 8h) + charge DÉJÀ présente sur ces jours, + les heures proposées → barre projetée.
|
||
// Occupation PAR JOUR (le vrai plafond = 8h/jour, PAS la somme des 2 jours) : on affiche le JOUR le plus chargé (contrainte qui lie).
|
||
const placeholder = isPh
|
||
let worstPct = 0, worstProj = 0, worstCap = 8, existing = 0, over = false
|
||
if (!placeholder) for (const d of days) {
|
||
const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(g.techId, d.iso), g.techId, d.iso)
|
||
const dcap = l.cap || 8; const dproj = (l.h || 0) + (d.hours || 0); existing += (l.h || 0)
|
||
const pct = dcap ? dproj / dcap * 100 : 0
|
||
if (pct > worstPct) { worstPct = pct; worstProj = dproj; worstCap = dcap }
|
||
if (dproj > dcap * 1.001) over = true
|
||
}
|
||
const noShiftDays = placeholder ? [] : days.filter(d => !isWeekendIso(d.iso) && !hasShiftDay(g.techId, d.iso)).map(d => d.iso) // jours (hors WE) sans quart → alerte + créer quart
|
||
const skills = [...new Set(days.flatMap(d => d.entries).map(e => e.skill).filter(Boolean))] // compétences requises par cette file → cible d'assignation doit les couvrir
|
||
return {
|
||
...g, dayList: days, km: Math.round(tkm * 10) / 10, mins: estTravelMin(tkm),
|
||
cap: Math.round(worstCap * 10) / 10, existing: Math.round(existing * 10) / 10, projected: Math.round(worstProj * 10) / 10,
|
||
fillPct: Math.min(100, Math.round(worstPct)), over: !placeholder && over, placeholder, noShiftDays, skills, sectors
|
||
}
|
||
}).sort((a, b) => b.jobs - a.jobs)
|
||
})
|
||
// Carte des tournées (grande carte dans la revue) : 1 couleur par tech, domicile + arrêts ordonnés, POUR UN SEUL JOUR.
|
||
// Couleur de tournée par index : ANGLE D'OR (137,5°) sur la roue HSL → couleurs consécutives toujours très éloignées,
|
||
// nombre illimité de techs sans collision visuelle (remplace la palette fixe de 12 qui se ressemblait).
|
||
const routeColor = (i) => `hsl(${Math.round((i * 137.508) % 360)}, 70%, 40%)`
|
||
const suggestMapDay = ref('')
|
||
const suggestRoutes = computed(() => {
|
||
const day = suggestMapDay.value || (suggestWindow.value[0] || '')
|
||
const out = []; let ci = 0
|
||
for (const g of suggestGroups.value) {
|
||
if (g.placeholder) continue
|
||
const d = (g.dayList || []).find(x => x.iso === day); if (!d || !d.entries.length) continue
|
||
// MÊME ADRESSE = 1 ARRÊT sur la carte : les entrées aux mêmes coordonnées (~10 m) partagent un seul pin numéroté.
|
||
const stops = []; const seen = new Map()
|
||
for (const e of d.entries) {
|
||
if (e.lat == null || e.lon == null || !isFinite(+e.lat) || !isFinite(+e.lon) || Math.abs(+e.lat) < 0.01) continue // 0,0 / invalides exclus (sinon la carte dézoome jusqu'au golfe de Guinée)
|
||
const k = (+e.lat).toFixed(4) + ',' + (+e.lon).toFixed(4)
|
||
const ex = seen.get(k)
|
||
if (ex) { ex.n++; ex.subject = ex.n + ' jobs — ' + ex.first } else { const s = { lat: +e.lat, lon: +e.lon, seq: stops.length + 1, subject: e.subject, first: e.subject, n: 1, name: e.jobName, entry: e, icon: markerIcon(e.skill) }; seen.set(k, s); stops.push(s) }
|
||
}
|
||
if (!stops.length) continue
|
||
const home = techOrigin(g.techId) // domicile, sinon bureau TARGO
|
||
out.push({ techId: g.techId, techName: g.techName, color: routeColor(ci), km: d.km, mins: d.mins, home: home ? { lat: home.lat, lon: home.lon } : null, stops })
|
||
ci++
|
||
}
|
||
return out
|
||
})
|
||
// RÉFÉRENCE STABLE pour <RouteMap :routes> — computed CACHÉ (ne change QUE si suggestRoutes change).
|
||
// AVANT : `:routes="suggestRoutes.map(...)"` inline créait un NOUVEAU tableau à CHAQUE rendu → le watch deep de RouteMap
|
||
// redessinait → emit('metrics') → onRouteMetrics muta realRoutes → re-rendu → nouveau tableau → BOUCLE INFINIE (figeait la carte).
|
||
const suggestMapRoutes = computed(() => suggestRoutes.value.map(r => ({ id: r.techId, name: r.techName, color: r.color, home: r.home, stops: r.stops })))
|
||
// Carte de la revue = <RouteMap> (réactif sur suggestRoutes) — il ne reste qu'à défauter le jour affiché à l'ouverture de la vue carte.
|
||
watch(() => suggestDlg.view, (v) => { if (v === 'map' && !suggestMapDay.value) suggestMapDay.value = suggestWindow.value[0] || '' })
|
||
// ── P3 — Onglet « Tournées » : tournées RÉELLES (jobs assignés, occupation) de la journée choisie, 1 couleur/tech, tracés OSRM (RouteMap). ──
|
||
const routesDay = ref(null)
|
||
// Sélecteur de jour (Tournées) = bande d'occupation sur 2 SEMAINES (14 j depuis le lundi affiché) par défaut.
|
||
// Données via /roster/capacity (14 j) — DÉCOUPLÉ de la fenêtre de charge du grid (7 j) → barres réelles même en semaine 2.
|
||
const routeCapacity = ref({})
|
||
// Dénominateur FILTRÉ par la/les compétence(s) choisie(s) (mêmes chips que la timeline) → cohérent avec l'affichage.
|
||
async function loadRouteCapacity () { try { const r = await roster.getCapacity(start.value, 14, skillFilter.value); routeCapacity.value = r.capacity || {} } catch (e) { routeCapacity.value = {} } }
|
||
watch([boardView, start, skillFilter], () => { if (boardView.value === 'routes') loadRouteCapacity() }, { immediate: true, deep: true })
|
||
const routeStripDays = computed(() => {
|
||
const out = []
|
||
for (let i = 0; i < 14; i++) {
|
||
const iso = addDaysISO(start.value, i); const c = routeCapacity.value[iso] || {}
|
||
const h = +c.due_h || 0; const cap = +c.cap_h || 0; const dw = dowOf(iso)
|
||
out.push({ iso, dow: FR_DOW[dw], dnum: iso.slice(8) + '/' + iso.slice(5, 7), weekend: dw === 0 || dw === 6, holiday: isHoliday(iso), h, cap, ratio: cap > 0 ? h / cap : (h > 0 ? 1 : 0), over: !!c.overbooked, today: iso === todayISO(), unassigned: (unassignedByDay.value[iso] || []).length })
|
||
}
|
||
return out
|
||
})
|
||
// Jobs DUS le jour sélectionné (Tournées) mais NON assignés — mis en évidence + « Suggérer ». Respecte le filtre de compétence (unassignedByDay).
|
||
const routeDayUnassigned = computed(() => unassignedByDay.value[routesDay.value] || [])
|
||
// Chips SECTEUR (par ville) des non-assignés du jour → filtre la liste ET la carte. Reset au changement de jour.
|
||
const routeSectorSel = ref(new Set())
|
||
const routePrioSel = ref(new Set())
|
||
watch(routesDay, () => { routeSectorSel.value = new Set(); routePrioSel.value = new Set() })
|
||
const routeSectors = computed(() => { const m = {}; for (const j of routeDayUnassigned.value) { const c = jobCity(j) || 'Sans secteur'; m[c] = (m[c] || 0) + 1 } return Object.entries(m).map(([sector, n]) => ({ sector, n })).sort((a, b) => b.n - a.n) })
|
||
function toggleRouteSector (s) { const set = new Set(routeSectorSel.value); if (set.has(s)) set.delete(s); else set.add(s); routeSectorSel.value = set }
|
||
// Chips PRIORITÉ (normalisée high/medium/low) — filtrent la liste + la carte comme les secteurs, et (avec eux) le périmètre de « Suggérer ».
|
||
const NORM_PRIO = { high: 'high', urgent: 'high', 'élevée': 'high', elevee: 'high', medium: 'medium', moyenne: 'medium', normal: 'medium', low: 'low', basse: 'low' }
|
||
const normPrio = j => NORM_PRIO[String(j.priority || 'medium').toLowerCase()] || 'medium'
|
||
const routePrios = computed(() => { const m = {}; for (const j of routeDayUnassigned.value) { const p = normPrio(j); m[p] = (m[p] || 0) + 1 } return POOL_PRIOS.filter(pp => m[pp.value]).map(pp => ({ ...pp, n: m[pp.value] })) })
|
||
function toggleRoutePrio (p) { const set = new Set(routePrioSel.value); if (set.has(p)) set.delete(p); else set.add(p); routePrioSel.value = set }
|
||
const routeDayUnassignedView = computed(() => {
|
||
const sel = routeSectorSel.value; const ps = routePrioSel.value
|
||
if (!sel.size && !ps.size) return routeDayUnassigned.value
|
||
return routeDayUnassigned.value.filter(j => (!sel.size || sel.has(jobCity(j) || 'Sans secteur')) && (!ps.size || ps.has(normPrio(j))))
|
||
})
|
||
const _jlat = j => (j.latitude != null ? +j.latitude : (j.lat != null ? +j.lat : null))
|
||
const _jlon = j => (j.longitude != null ? +j.longitude : (j.lon != null ? +j.lon : null))
|
||
// Gouttes carte = non-assignés du jour (filtrés secteur) qui ont des coordonnées ; couleur = priorité
|
||
// Gouttes carte des non-assignés — GROUPÉES PAR ADRESSE : une pastille par adresse portant les icônes des types de
|
||
// jobs + le temps TOTAL (évite de cliquer chaque ticket ; détail au clic via popup). Couleur = priorité la plus haute.
|
||
const routeUnassignedPins = computed(() => {
|
||
const valid = routeDayUnassignedView.value.filter(j => { const la = _jlat(j); const lo = _jlon(j); return la != null && lo != null && isFinite(la) && isFinite(lo) && Math.abs(la) > 0.01 })
|
||
const prioRank = p => ({ urgent: 0, high: 1, 'élevée': 1, moyenne: 2, medium: 2, normal: 2, low: 3, basse: 3 }[String(p || '').toLowerCase()] ?? 2)
|
||
const groups = new Map()
|
||
for (const j of valid) {
|
||
const la = _jlat(j), lo = _jlon(j)
|
||
const key = String(j.address || '').trim().toLowerCase() || (la.toFixed(5) + ',' + lo.toFixed(5))
|
||
let g = groups.get(key); if (!g) { g = { lat: la, lon: lo, jobs: [], city: jobCity(j), address: j.address || '' }; groups.set(key, g) }
|
||
g.jobs.push(j)
|
||
}
|
||
return [...groups.values()].map(g => {
|
||
const icons = [...new Set(g.jobs.map(j => markerIcon(j.required_skill || j.service_type)))].slice(0, 3)
|
||
const totalMin = g.jobs.reduce((s, j) => s + Math.round(jobDur(j) * 60), 0)
|
||
const topPrio = g.jobs.map(j => j.priority).sort((a, b) => prioRank(a) - prioRank(b))[0]
|
||
return {
|
||
lat: g.lat, lon: g.lon, icons, count: g.jobs.length, totalLabel: fmtMin(totalMin),
|
||
color: prioColor(topPrio), city: g.city, address: g.address,
|
||
jobs: g.jobs.map(j => ({ subject: j.subject || j.service_type || j.name, est: fmtMin(Math.round(jobDur(j) * 60)), job: j })),
|
||
job: g.jobs[0], // clic simple (1 job) → détail direct
|
||
}
|
||
})
|
||
})
|
||
|
||
// ── Deep-link (?day=YYYY-MM-DD & ?skill=…) + action « planifier des quarts » du slot-finder ──
|
||
const route = useRoute()
|
||
const createSignal = useCreateSignal()
|
||
// FAB global « Créer » → signal partagé (useCreateSignal) → ouvre le chooser unifié. Ref module-level = survit à la nav
|
||
// (fresh mount ET même page). nextTick → posé après le rendu (fiable, contrairement au router.replace-au-setup).
|
||
watch(() => createSignal.pending.value, (v) => { if (v) { createSignal.pending.value = false; nextTick(() => { createChooser.value = true }) } }, { immediate: true })
|
||
function matchSkill (skill) { return skill ? (allSkills.value || []).find(s => String(s).toLowerCase() === String(skill).toLowerCase()) : null }
|
||
function focusDay (iso, skill) { // cadre la semaine sur le jour, le sélectionne (grille/mobile/tournées), filtre compétence si connue
|
||
if (iso && /^\d{4}-\d{2}-\d{2}$/.test(iso)) { start.value = mondayISO(iso); mobileSelIso.value = iso; routesDay.value = iso }
|
||
const m = matchSkill(skill); if (m) skillFilter.value = [m]
|
||
}
|
||
// « Aucun créneau » → « Planifier des quarts » : ouvre la timeline des techs (filtrée compétence) au bon jour pour créer des quarts.
|
||
function onPlanShifts ({ skill, date } = {}) {
|
||
showSuggestSlots.value = false
|
||
boardView.value = 'grid' // vue timeline = voir/créer les quarts
|
||
focusDay(date, skill)
|
||
const m = matchSkill(skill)
|
||
$q.notify({ type: 'info', icon: 'event_busy', timeout: 4500, message: m ? `Techs « ${m} » — crée un quart ce jour pour ouvrir des créneaux` : (skill ? `Aucun tech n'a « ${skill} » comme compétence — ajoute-la à un tech ou crée un quart` : 'Crée un quart pour ouvrir des créneaux') })
|
||
}
|
||
const routesMapRef = ref(null)
|
||
const dayRouteMetrics = reactive({})
|
||
const hiddenRouteTechs = ref(new Set()) // chips : techs RETIRÉS de la carte (couleur/badge conservés, réactivables d'un clic)
|
||
function toggleRouteTech (id) {
|
||
// Clic sur un tech = ISOLER sa tournée sur la carte (masquer toutes les autres) → voir une tournée précise.
|
||
// Re-clic sur le tech déjà seul = tout réafficher.
|
||
const others = allDayRoutes.value.map(r => r.id).filter(x => x !== id)
|
||
const soloed = others.length > 0 && !hiddenRouteTechs.value.has(id) && others.every(x => hiddenRouteTechs.value.has(x))
|
||
hiddenRouteTechs.value = soloed ? new Set() : new Set(others)
|
||
}
|
||
function showAllRouteTechs () { hiddenRouteTechs.value = new Set() }
|
||
const allDayRoutes = computed(() => { // TOUTES les tournées du jour (couleurs STABLES par index, même quand on masque)
|
||
const iso = routesDay.value; if (!iso) return []
|
||
const out = []; let ci = 0
|
||
for (const t of (visibleTechs.value || [])) {
|
||
const occ = techDayOcc(t.id, iso)
|
||
const jobs = ((occ && occ.jobs) || []).filter(j => !j.cancelled && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon) && Math.abs(+j.lat) > 0.01)
|
||
if (!jobs.length) continue
|
||
jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // ordre de tournée réel
|
||
const h = techOrigin(t.id) // domicile, sinon bureau TARGO
|
||
out.push({ id: t.id, name: t.name, color: routeColor(ci++), home: h ? { lat: h.lat, lon: h.lon } : null, stops: jobs.map((j, i) => ({ lat: +j.lat, lon: +j.lon, seq: i + 1, subject: j.subject, name: j.name, job: j, icon: markerIcon(j.skill) })) })
|
||
}
|
||
return out
|
||
})
|
||
const dayRoutes = computed(() => allDayRoutes.value.filter(r => !hiddenRouteTechs.value.has(r.id))) // ce que la carte affiche
|
||
|
||
// ── Positions GPS LIVE des techs (onglet Tournées) : polling doux (25 s) tant que l'onglet est ouvert ; couleur = celle de la tournée du tech ──
|
||
const livePosRaw = ref([]) // [{ techId, techName, lat, lon, time, speed }]
|
||
const showLivePos = ref(true) // afficher/masquer les positions GPS live sur la carte des tournées
|
||
let _livePosTimer = null
|
||
async function refreshLivePositions () { try { const r = await roster.techPositions(); livePosRaw.value = (r && r.positions) || [] } catch (e) { /* non bloquant */ } }
|
||
function startLivePositions () { if (_livePosTimer) return; refreshLivePositions(); _livePosTimer = setInterval(refreshLivePositions, 25000) }
|
||
function stopLivePositions () { if (_livePosTimer) { clearInterval(_livePosTimer); _livePosTimer = null } }
|
||
// Positions pour la carte : masque les techs cachés (chips), colore selon leur tournée du jour (sinon gris neutre).
|
||
const dayLivePositions = computed(() => {
|
||
const colorByTech = {}; for (const r of (allDayRoutes.value || [])) colorByTech[r.id] = r.color
|
||
const visIds = new Set((visibleTechs.value || []).map(t => t.id)) // filtre de compétence : seules les positions des techs capables (+ non masqués) sur la carte
|
||
return (livePosRaw.value || [])
|
||
.filter(p => visIds.has(p.techId) && !hiddenRouteTechs.value.has(p.techId))
|
||
.map(p => ({ techId: p.techId, name: p.techName, lat: p.lat, lon: p.lon, time: p.time, speed: p.speed, color: colorByTech[p.techId] || '#455a64' }))
|
||
})
|
||
watch(boardView, (v) => { if (v === 'routes') startLivePositions(); else stopLivePositions() }, { immediate: true })
|
||
watch(routesDay, () => { for (const k in dayRouteMetrics) delete dayRouteMetrics[k]; hiddenRouteTechs.value = new Set() })
|
||
// ── Tracé GPS RÉEL du jour : quand UNE seule tournée est affichée (une seule visible, ou isolée via les chips techs),
|
||
// on superpose le parcours GPS réel du tech (Traccar) en orange, au 1er plan — pour comparer réel vs prévu. ──
|
||
const soloRouteTech = computed(() => dayRoutes.value.length === 1 ? dayRoutes.value[0] : null)
|
||
const showRouteTrack = ref(true)
|
||
const routeTrack = ref(null); const routeTrackMsg = ref('')
|
||
async function loadRouteTrack () {
|
||
const t = soloRouteTech.value; const iso = routesDay.value
|
||
if (boardView.value !== 'routes' || !t || !iso || !showRouteTrack.value) { routeTrack.value = null; routeTrackMsg.value = ''; return }
|
||
routeTrackMsg.value = '…'
|
||
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(t.id, from, to); const coords = (r && r.coords) || []
|
||
if (coords.length < 2) { routeTrack.value = null; routeTrackMsg.value = 'aucun tracé / appareil'; return }
|
||
routeTrack.value = { coords }
|
||
routeTrackMsg.value = (r.km != null ? 'réel ' + r.km + ' km' : coords.length + ' pts') + (r.moving_min != null ? ' · ' + fmtMin(r.moving_min) : '')
|
||
} catch (e) { routeTrack.value = null; routeTrackMsg.value = 'indispo' }
|
||
}
|
||
watch([soloRouteTech, routesDay, showRouteTrack, boardView], loadRouteTrack)
|
||
watch(boardView, (v) => {
|
||
if (v !== 'routes') return
|
||
// Reprend le jour SÉLECTIONNÉ partagé (selDay) — même jour que les vues Semaine/Jour. Si le jour est hors de la
|
||
// fenêtre chargée, on déplace la fenêtre (comme le fait le mode Jour) plutôt que de retomber sur aujourd'hui.
|
||
const sel = selDay.value
|
||
if (sel && (dayList.value || []).some(d => d.iso === sel)) { routesDay.value = sel; return }
|
||
if (sel && /^\d{4}-\d{2}-\d{2}$/.test(sel)) { routesDay.value = sel; start.value = mondayISO(sel); loadWeek(); return }
|
||
if (!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 })
|
||
watch(routesDay, v => setSelDay(v)) // le jour cliqué dans la bande Tournées devient le jour partagé
|
||
// Un tech « couvre » une file s'il possède TOUTES les compétences requises par ses jobs (compétences vides ignorées).
|
||
const covers = (t, skills) => (skills || []).filter(Boolean).every(s => (t.skills || []).includes(s))
|
||
// Existe-t-il AU MOINS un tech capable ? Sinon (données de compétences incomplètes) on ne bloque personne (évite de piéger une file inassignable).
|
||
const anyCovers = (skills) => !((skills || []).filter(Boolean).length) || (visibleTechs.value || []).some(t => covers(t, skills))
|
||
// Liste de techs triée CAPABLES d'abord (couvrent la/les compétence(s)), puis alphabétique — pour les menus d'assignation.
|
||
// Par défaut, le picker « déplacer vers » ne montre QUE les techs SÉLECTIONNÉS pour cette simulation ET compétents ;
|
||
// « Afficher tous » (showAllMoveTechs) lève le filtre. Repli sur la liste complète si aucun sélectionné-compétent (jamais vide).
|
||
const showAllMoveTechs = ref(false)
|
||
function moveTechFilter (list, skills) {
|
||
if (showAllMoveTechs.value) return list
|
||
const cap = (t) => (t.capable != null) ? !!t.capable : covers(t._t || t, skills) // entrée = projection (.capable) ; groupe = tech complet (.skills)
|
||
const sel = list.filter(t => suggestDlg.techSel[t.id] && cap(t))
|
||
return sel.length ? sel : list // jamais vide : repli sur tous si aucun sélectionné-compétent
|
||
}
|
||
function capableTechsFirst (skills) { return moveTechFilter([...(visibleTechs.value || [])].sort((a, b) => (covers(b, skills) - covers(a, skills)) || String(a.name || '').localeCompare(String(b.name || ''))), skills) }
|
||
// Une entrée de la revue → objet job-like pour réutiliser techsForJob (classement proximité + occupation).
|
||
function techsForEntry (e) { return moveTechFilter(techsForJob({ required_skill: e.skill, lat: e.lat, lon: e.lon, scheduled_date: e.iso }), e.skill ? [e.skill] : []) }
|
||
const techById = computed(() => { const m = {}; for (const t of (visibleTechs.value || [])) m[t.id] = t; return m })
|
||
// Réassigne UNE entrée : quand la cible est un VRAI tech, on sort du placeholder + on (re)calcule quart manquant ET compétence.
|
||
function reassignEntry (e, techId, techName) {
|
||
e.techId = techId; e.techName = techName
|
||
const hold = isHoldId(techId)
|
||
e.placeholder = hold
|
||
e.noShift = hold ? false : !hasShiftDay(techId, e.iso)
|
||
e.capable = hold ? true : covers(techById.value[techId] || {}, [e.skill]) // hors compétence → marqué (icône ⚠)
|
||
}
|
||
function moveSuggestEntry (entry, techId, techName) { reassignEntry(entry, techId, techName) } // déplacer 1 job vers un autre tech (ou une file placeholder)
|
||
function moveSuggestGroup (g, techId, techName) { // déplace/fusionne TOUTE la file d'un tech (ou d'un placeholder) vers un autre tech
|
||
if (!techId || techId === g.techId) return
|
||
const targetHad = suggestDlg.plan.some(e => e.techId === techId) // la cible avait déjà une file → c'est une FUSION
|
||
let n = 0; for (const e of suggestDlg.plan) if (e.techId === g.techId) { reassignEntry(e, techId, techName); n++ }
|
||
$q.notify({ type: 'info', icon: targetHad ? 'merge' : 'drive_file_move', message: `${n} job(s) ${targetHad ? 'fusionnés dans' : '→'} ${techName}`, timeout: 2800 })
|
||
}
|
||
// Files déjà présentes dans le plan (pour signaler « fusionner » dans le menu de déplacement).
|
||
const planCountByTech = computed(() => { const m = {}; for (const e of suggestDlg.plan) m[e.techId] = (m[e.techId] || 0) + 1; return m })
|
||
// Crée LOCALEMENT le quart 8-16 pour les jours sans quart de cette file (persisté via Publier, comme l'option makeShifts). PAS d'écriture immédiate.
|
||
async function createShiftForGroup (g) {
|
||
const days = g.noShiftDays || []; if (!days.length) return
|
||
const tpl = await ensureWindowTpl(8, 16); if (!tpl) { $q.notify({ type: 'negative', message: 'Modèle de quart 8-16 introuvable' }); return }
|
||
pushHistory()
|
||
for (const iso of days) setCellReplace(g.techId, g.techName, iso, tpl) // local → recalcule hasShiftDay/occupation ; Publier pour enregistrer
|
||
$q.notify({ type: 'positive', icon: 'event_available', message: `${days.length} quart(s) 8-16 créé(s) pour ${g.techName} — Publier pour enregistrer`, timeout: 3500 })
|
||
}
|
||
function removeSuggestEntry (entry) { suggestDlg.plan = suggestDlg.plan.filter(e => e !== entry) } // retiré du plan → reste au pool
|
||
// Applique : regroupe par tech×jour → réutilise assignNames (assignation serveur + occupation optimiste + retrait du pool).
|
||
async function applySuggestion () {
|
||
suggestDlg.applying = true
|
||
const groups = {}
|
||
for (const e of suggestDlg.plan) { const k = e.techId + '|' + e.iso; (groups[k] = groups[k] || { techId: e.techId, iso: e.iso, names: [] }).names.push(e.jobName) }
|
||
// Quart 8–16 pour les tech×jour SANS quart (si demandé) → capacité + fini le « hors quart ». Local → Publier pour enregistrer.
|
||
let shiftsMade = 0
|
||
if (suggestDlg.makeShifts) {
|
||
const tpl = await ensureWindowTpl(8, 16)
|
||
if (tpl) { let first = true; for (const k of Object.keys(groups)) { const g = groups[k]; if (isHoldId(g.techId) || isWeekendIso(g.iso)) continue; if (!hasShiftDay(g.techId, g.iso)) { const t = (visibleTechs.value || []).find(x => x.id === g.techId); if (t) { if (first) { pushHistory(); first = false } setCellReplace(g.techId, t.name, g.iso, tpl); shiftsMade++ } } } } // JAMAIS de quart auto le week-end
|
||
}
|
||
let total = 0
|
||
for (const k of Object.keys(groups)) {
|
||
const g = groups[k]; const t = (visibleTechs.value || []).find(x => x.id === g.techId); if (!t) continue
|
||
const d = (dayList.value || []).find(x => x.iso === g.iso) || { iso: g.iso, dnum: g.iso.slice(8) }
|
||
try { const done = await assignNames(g.names, t, d); total += (done || []).length } catch (e) { err(e) }
|
||
}
|
||
suggestDlg.applying = false; suggestDlg.open = false
|
||
const pending = suggestDlg.plan.filter(e => e.placeholder).length // jobs en file PLACEHOLDER (pas assignés — à confier à un vrai tech avant d'appliquer)
|
||
if (total || pending) $q.notify({ type: pending ? 'warning' : 'positive', message: (total ? `${total} job(s) répartis` : '') + (shiftsMade ? ` · ${shiftsMade} quart(s) 8–16 créé(s)` : '') + (pending ? ` · ⏳ ${pending} job(s) en file à assigner — assigne les tournées placeholder à un tech (bouton 👤+) puis applique` : (total ? ' — Publier pour enregistrer' : '')), timeout: 7000 })
|
||
await reloadPool()
|
||
}
|
||
// Mobile : assignation AU TOUCHER (le glisser HTML5 ne marche pas au doigt) + MULTI-sélection.
|
||
// Tap un (ou plusieurs) job(s) du pool → tap un tech = assigne TOUTE la sélection. Réutilise selectedJobs/selectedNames.
|
||
// Filtre/tri/groupe du pool mobile → instance useJobPool `mobilePool` (créée avec les autres surfaces).
|
||
// initials → composables/useFormatters (source unique ; 2 premiers mots · '?')
|
||
function toggleSel (j) {
|
||
if (j.status === 'On Hold') { $q.notify({ type: 'warning', message: 'En attente d\'une tâche précédente — non assignable', timeout: 2500 }); return }
|
||
if (selectedJobs[j.name]) delete selectedJobs[j.name]; else selectedJobs[j.name] = true
|
||
}
|
||
// ── Actions rapides du pool (mobile, façon Gmail : glisser + icônes directes ★/📝) ──
|
||
// ⚠ Le champ Dispatch Job.priority = Select { low | medium | high } UNIQUEMENT (pas de « urgent » → 417). L'étoile = « high ».
|
||
// POOL_PRIOS = priorités Dispatch Job — SOURCE UNIQUE (config/dispatch-priority).
|
||
import { DISPATCH_PRIORITIES as POOL_PRIOS } from 'src/config/dispatch-priority'
|
||
const POOL_STATUSES = [{ value: 'open', label: 'Ouvert', icon: 'inbox', color: 'grey-7' }, { value: 'On Hold', label: 'En attente', icon: 'pause_circle', color: 'orange-7' }, { value: 'Cancelled', label: 'Annulé', icon: 'cancel', color: 'red-6' }]
|
||
const jobSheet = reactive({ open: false, job: null })
|
||
const noteDialog = reactive({ open: false, job: null, text: '' })
|
||
const snoozeDate = ref('') // date du sélecteur « Reporter », pré-réglée à +1 semaine à l'ouverture de la feuille
|
||
const sheetNoteText = ref('') // brouillon de la note importante, éditée INLINE dans la feuille (input + bouton Enregistrer)
|
||
// Patch optimiste : applique localement, persiste côté hub, réconcilie le pool sur échec.
|
||
async function patchJob (j, patch, okMsg) {
|
||
if (!j) return
|
||
Object.assign(j, patch)
|
||
try { await roster.updateJob(j.name, patch); if (okMsg) $q.notify({ type: 'positive', message: okMsg, timeout: 1600 }) }
|
||
catch (e) { err(e); await reloadPool() }
|
||
}
|
||
function toggleUrgent (j) { const on = j.priority === 'high'; patchJob(j, { priority: on ? 'medium' : 'high' }, on ? 'Priorité moyenne' : '⚡ Urgent') }
|
||
function setJobPriority (j, p) { patchJob(j, { priority: p }, 'Priorité : ' + ((POOL_PRIOS.find(x => x.value === p) || {}).label || p)) }
|
||
function setJobStatus (j, s) { patchJob(j, { status: s }, 'Statut : ' + ((POOL_STATUSES.find(x => x.value === s) || {}).label || s)) }
|
||
// Compétences requises (LISTE) du job depuis la feuille du pool → store hub /roster/job-skills (PAS un champ ERPNext). Optimiste + réconcilie sur échec.
|
||
async function setPoolSkills (j, list) {
|
||
if (!j) return
|
||
const arr = normSkillList(list)
|
||
j.required_skills = arr; j.required_skill = arr[0] || '' // affichage/couleur/icône immédiats
|
||
if (!j.name) return
|
||
try { await roster.setJobSkills(j.name, arr); $q.notify({ type: 'positive', message: 'Compétences : ' + (arr.join(', ') || '—'), timeout: 1600 }) }
|
||
catch (e) { err(e); await reloadPool() }
|
||
}
|
||
// Reporter : +N jours (base = date actuelle sinon aujourd'hui).
|
||
function snoozeJob (j, days) {
|
||
if (!j) return
|
||
const base = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : todayISO()
|
||
applyJobDate(j, addDaysISO(base, days), j.scheduled_date || '')
|
||
}
|
||
// Reporter à une date PRÉCISE (depuis le sélecteur).
|
||
function setJobDate (j, iso) { if (!j || !iso || iso === j.scheduled_date) return; applyJobDate(j, iso, j.scheduled_date || '') }
|
||
// Pose la date prévue (optimiste) + toast Annuler (rétablit l'ancienne).
|
||
function applyJobDate (j, next, prev) {
|
||
j.scheduled_date = next
|
||
roster.updateJob(j.name, { scheduled_date: next })
|
||
.then(() => $q.notify({ type: 'info', icon: 'event', message: 'Reporté au ' + fmtDueLabel(next), timeout: 4500, actions: [{ label: 'Annuler', color: 'white', handler: () => patchJob(j, { scheduled_date: prev || null }) }] }))
|
||
.catch(e => { err(e); reloadPool() })
|
||
}
|
||
// Ouvre la feuille d'options ; pré-règle le sélecteur de date à AUJOURD'HUI + 1 semaine (pas date due + 1 sem.).
|
||
// Fil complet du ticket affiché dans les détails (le plus récent EN PREMIER). Chargé à l'ouverture via legacy_ticket_id.
|
||
const sheetThread = reactive({ loading: false, ticket: null, messages: [], contact: null })
|
||
// Validation « service livré » (à la demande) : statut service+modem du client/adresse du job → croise tech-présent (geofence) + service en ligne.
|
||
const serviceCheck = reactive({ loading: false, done: false, online: null, found: null, label: '', rxPower: null })
|
||
async function checkJobService (j) {
|
||
if (!j || !j.name) return
|
||
serviceCheck.loading = true; serviceCheck.done = false
|
||
try {
|
||
const r = await roster.jobServiceStatus(j.name)
|
||
if (!r || r.ok === false) { serviceCheck.found = null; serviceCheck.online = null; serviceCheck.label = (r && r.error) || 'Indisponible' }
|
||
else if (r.found === false) { serviceCheck.found = false; serviceCheck.online = null; serviceCheck.label = r.ambiguous ? 'Client ambigu — préciser' : 'Aucun service trouvé' }
|
||
else {
|
||
const s = r.summary || {}
|
||
serviceCheck.found = true
|
||
serviceCheck.online = s.online === true ? true : (s.online === false ? false : null)
|
||
serviceCheck.rxPower = s.rxPower != null ? s.rxPower : null
|
||
serviceCheck.label = (s.label || (serviceCheck.online ? 'En ligne' : 'Statut inconnu')) + (serviceCheck.rxPower != null ? ' · ' + serviceCheck.rxPower + ' dBm' : '')
|
||
}
|
||
} catch (e) { serviceCheck.found = null; serviceCheck.online = null; serviceCheck.label = 'Erreur' }
|
||
finally { serviceCheck.loading = false; serviceCheck.done = true }
|
||
}
|
||
// Mappe un message de ticket osTicket {author, fromClient} vers le resolver comms partagé (nom/initiales/couleur cohérents Boîte↔feuille).
|
||
function pmIdentity (m) {
|
||
const generic = !m || !m.author || m.author === 'Système / client'
|
||
if (m && m.fromClient) return messageIdentity({ from: 'customer', fromName: generic ? '' : m.author }, { customerName: sheetThread.contact && sheetThread.contact.name })
|
||
return messageIdentity({ fromName: (m && m.author) || '' }, {})
|
||
}
|
||
const postDraft = ref('') // brouillon de RÉPONSE au client
|
||
const noteMode = ref(false) // bascule 📌 « Note importante » (true = note épinglée au job j.notes ; false = réponse client via Outbox)
|
||
const postBusy = ref(false)
|
||
// CHAMP UNIQUE du composeur : édite la note épinglée (sheetNoteText → j.notes) en mode note, sinon la réponse (postDraft).
|
||
const composerText = computed({
|
||
get: () => noteMode.value ? sheetNoteText.value : postDraft.value,
|
||
set: (v) => { if (noteMode.value) sheetNoteText.value = v; else postDraft.value = v },
|
||
})
|
||
// Canal de réponse client déduit du contact (courriel prioritaire, sinon SMS) — gouverne l'option « Réponse ».
|
||
const replyChannel = computed(() => {
|
||
const c = sheetThread.contact
|
||
if (c && c.email) return { label: 'courriel', ok: true }
|
||
if (c && c.phone) return { label: 'SMS', ok: true }
|
||
return { label: '', ok: false }
|
||
})
|
||
async function submitComposer () {
|
||
const j = jobSheet.job; if (!j) return
|
||
if (noteMode.value) { // 📌 Note importante → épinglée au job (j.notes) ; pour un ticket legacy, miroir en note interne osTicket
|
||
const txt = (sheetNoteText.value || '')
|
||
patchJob(j, { notes: txt }, 'Note importante enregistrée')
|
||
if (j.legacy_ticket_id && txt.trim()) { try { await roster.postTicket(j.legacy_ticket_id, txt.trim(), false) } catch (e) { /* miroir best-effort */ } }
|
||
return
|
||
}
|
||
const txt = (postDraft.value || '').trim(); if (!txt) return
|
||
postBusy.value = true
|
||
try {
|
||
// Réponse AU CLIENT : résout la conversation du JOB (osTicket OU ERPNext-natif) puis envoie par le MÊME chemin que la Boîte → Outbox + miroir.
|
||
const c = await roster.jobConversation(j.name)
|
||
if (!c || !c.ok || !c.convToken) { $q.notify({ type: 'negative', message: (c && c.error) || 'Contact client introuvable' }); postBusy.value = false; return }
|
||
const email = (authStore.user && authStore.user !== 'authenticated') ? authStore.user : ''
|
||
await roster.sendConvMessage(c.convToken, txt, email)
|
||
const via = c.channel === 'email' ? 'courriel' : (c.channel === 'sms' ? 'SMS' : 'message')
|
||
postDraft.value = ''
|
||
$q.notify({ type: 'positive', icon: 'send', message: 'Réponse envoyée au client par ' + via, timeout: 2400 })
|
||
await loadSheetThread(j)
|
||
} catch (e) { err(e) }
|
||
postBusy.value = false
|
||
}
|
||
// « Fil complet » : ouvre la conversation comms (composeur riche : canned ::, signatures, Unlayer) en pleine page — réutilise ConversationPanel.
|
||
async function openFullThread () {
|
||
const j = jobSheet.job; if (!j || !j.name) return
|
||
try {
|
||
const c = await roster.jobConversation(j.name)
|
||
if (c && c.ok && c.convToken) { jobSheet.open = false; router.push('/communications/c/' + c.convToken) }
|
||
else $q.notify({ type: 'negative', message: (c && c.error) || 'Conversation introuvable' })
|
||
} catch (e) { err(e) }
|
||
}
|
||
async function loadSheetThread (j) {
|
||
const tok = j && j.name
|
||
sheetThread.messages = []; sheetThread.contact = null; sheetThread.ticket = (j && j.legacy_ticket_id) || null; sheetThread.loading = !!(j && j.name)
|
||
if (!j || !j.name) return
|
||
try {
|
||
const r = await roster.jobThread(j.name) // unifié : contact+fil depuis osTicket si legacy_ticket_id, sinon depuis la fiche client ERPNext
|
||
if (jobSheet.job && jobSheet.job.name === tok) {
|
||
sheetThread.messages = (r && r.ok && Array.isArray(r.messages)) ? [...r.messages].reverse() : [] // reverse = plus récent en haut
|
||
sheetThread.contact = (r && r.ok && r.contact) || null
|
||
if (!replyChannel.value.ok && !noteMode.value) noteMode.value = true // aucun canal pour répondre → bascule sur Note importante
|
||
}
|
||
} catch (e) { /* fil indisponible */ }
|
||
if (jobSheet.job && jobSheet.job.name === tok) sheetThread.loading = false
|
||
}
|
||
function openJobSheet (j, opts) { jobSheet.job = j; snoozeDate.value = addDaysISO(todayISO(), 7); sheetNoteText.value = j.notes || ''; postDraft.value = ''; noteMode.value = !(opts && opts.reply); /* défaut SÛR : commentaire INTERNE (jamais un courriel accidentel) — « Répondre au client » = bascule explicite */ serviceCheck.done = false; serviceCheck.loading = false; jobSheet.open = true; loadSheetThread(j) }
|
||
// Auto-save de la note à la FERMETURE de la feuille (X · clic extérieur · bouton Fermer · Échap) — uniquement si modifiée. Plus de bouton Enregistrer.
|
||
function onJobSheetHide () { const j = jobSheet.job; if (j && (sheetNoteText.value || '') !== (j.notes || '')) patchJob(j, { notes: sheetNoteText.value || '' }, 'Note enregistrée') }
|
||
function openNote (j) { noteDialog.job = j; noteDialog.text = j.notes || ''; noteDialog.open = true }
|
||
function saveNote () { const j = noteDialog.job; if (j) patchJob(j, { notes: noteDialog.text || '' }, 'Note enregistrée'); noteDialog.open = false }
|
||
// (Le swipe mobile — tap = feuille / glissé = sélectionner — vit désormais DANS JobPool.vue, variant mobile.)
|
||
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' }
|
||
|
||
// Contexte partagé fourni UNE fois → JobPool.vue l'injecte (selectedJobs/draggingSet gardent la même identité).
|
||
provide('jobPoolCtx', {
|
||
selectedJobs, draggingSet, selectedNames, flashJob, showMap: jpShowMap,
|
||
onJobDragStart, onJobDragEnd,
|
||
techsForJob, quickAssign, initials, jobTargetDay,
|
||
setJobDate, setJobReqLevel, toggleUrgent, toggleAssignThread, openJobSheet, toggleSel, closeLegacyTicket, clearSel,
|
||
toggleGroupSel, toggleGroupSelAll, groupSelected, groupLabel,
|
||
selectAllSector, focusAssignJob,
|
||
getTagColor, prioColor, fmtMin, jobCity, fmtDueLabel, isOverdue, jobIsOnsite, jobSlaBadge, legacyDeptColor, fmtDT, panelJobColor,
|
||
todayISO, tomorrowISO,
|
||
})
|
||
// 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 — capture les quarts ET les congés en attente (les 2 sont annulables via Ctrl+Z).
|
||
function snap () { return { a: JSON.parse(JSON.stringify(assignments.value)), p: JSON.parse(JSON.stringify(pendingAbs.value)) } }
|
||
function restoreSnap (s) { if (Array.isArray(s)) { assignments.value = s; return } assignments.value = s.a || []; pendingAbs.value = s.p || {}; savePendingAbs() }
|
||
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()); restoreSnap(history.value.pop()); logChange('↶ Annulé'); scheduleDraftSave() }
|
||
function redo () { if (!future.value.length) return; history.value.push(snap()); restoreSnap(future.value.pop()); logChange('↷ Rétabli'); scheduleDraftSave() }
|
||
|
||
// garde anti-perte
|
||
// #2 — plus de blocage : tout est auto-sauvé en brouillon. On ATTEND le flush (semaine courante) AVANT de naviguer (évite d'écrire sur la nouvelle semaine).
|
||
async function guard (fn) { await autosaveDraft(); fn() }
|
||
function onWeekChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { start.value = lastWeek.start; return } loadWeek() }
|
||
function onDaysChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { days.value = lastWeek.days; return } loadWeek() }
|
||
function navWeek (dir) { guard(() => { start.value = addDaysISO(start.value, dir * days.value); loadWeek() }) }
|
||
function navDay (dir) { guard(() => {
|
||
if (boardView.value === 'kanban') { // mode Jour : on déplace le JOUR sélectionné (pas la fenêtre)
|
||
const cur = (kanbanDay.value && kanbanDay.value.iso) || nowET.value.iso
|
||
const next = addDaysISO(cur, dir); kbSelIso.value = next
|
||
if (!dayList.value.find(d => d.iso === next)) { start.value = next; loadWeek() } // hors fenêtre → recentre
|
||
} else { start.value = addDaysISO(start.value, dir); loadWeek() }
|
||
}) }
|
||
function navToday () { guard(() => { kbSelIso.value = nowET.value.iso; start.value = dayMode.value ? todayISO() : thisMonday(); loadWeek() }) } // mode Jour → aujourd'hui ; sinon lundi courant
|
||
|
||
// chargement
|
||
async function loadBase () { const tr = await roster.listTechnicians(); techs.value = tr.technicians || []; const tp = await roster.listTemplates(); templates.value = tp.templates || [] }
|
||
async function refreshTemplates () { const tp = await roster.listTemplates(); templates.value = tp.templates || [] }
|
||
|
||
// cadence / efficacité par tech
|
||
// Libellé orienté PERFORMANCE/vitesse (≠ libellé "facteur temps") : facteur 0,9 = plus rapide, 1,2 = plus lent.
|
||
function effSuffix (e) { const p = Math.round(100 / (Number(e) || 1)); if (p === 100) return 'cadence normale'; return 'cadence ' + p + '% (' + (p > 100 ? 'plus de jobs' : 'moins de jobs') + ')' }
|
||
// RÔLE dérivé des compétences (tags) : support→casque · installation→échelle · réparation/terrain→handyman.
|
||
const ROLE_ICON = { support: symOutlinedHeadsetMic, install: symOutlinedToolsLadder, repair: symOutlinedHandyman }
|
||
const ROLE_LABEL = { support: 'Support / service client', install: 'Installation', repair: 'Réparation / terrain' }
|
||
function techRole (t) {
|
||
const s = (t.skills || []).map(x => String(x).toLowerCase())
|
||
if (s.some(x => x.includes('support') || x.includes('service'))) return 'support'
|
||
if (s.some(x => x.includes('install'))) return 'install'
|
||
if (s.some(x => x.includes('répar') || x.includes('repar') || x.includes('fusion') || x.includes('terrain'))) return 'repair'
|
||
return null
|
||
}
|
||
function roleIcon (t) { const r = techRole(t); return r ? ROLE_ICON[r] : null }
|
||
function roleLabel (t) { const r = techRole(t); return r ? ROLE_LABEL[r] : '' }
|
||
// Opère sur les objets tech LIVE (techs.value) → SkillCadenceTable + les handlers de compétence/cadence persistent
|
||
// directement (skill_levels/skill_eff/skills/efficiency). Le coût passe par un tampon (teamCost) sauvé au blur.
|
||
function openTeamEditor () {
|
||
editTechs.value = techs.value
|
||
for (const t of techs.value) teamCost[t.id] = { salary: t.cost_salary_h || 0, charges: t.cost_charges_pct || 0, other: t.cost_other_h || 0 }
|
||
showTeamEditor.value = true
|
||
}
|
||
function loadedCostRow (id) { const c = teamCost[id] || {}; return Math.round(((Number(c.salary) || 0) * (1 + (Number(c.charges) || 0) / 100) + (Number(c.other) || 0)) * 100) / 100 }
|
||
async function saveCostRow (t) {
|
||
const c = teamCost[t.id] || {}
|
||
try { await roster.setTechCost(t.id, { salary: c.salary, charges: c.charges, other: c.other }); t.cost_salary_h = Number(c.salary) || 0; t.cost_charges_pct = Number(c.charges) || 0; t.cost_other_h = Number(c.other) || 0; t.cost_h = loadedCostRow(t.id); $q.notify({ type: 'positive', message: t.name + ' : ' + loadedCostRow(t.id) + ' $/h chargé' }) } catch (e) { err(e) }
|
||
}
|
||
// Cadence GLOBALE du tech (émise par SkillCadenceTable, en %) → facteur + persiste (défaut 100 % ↔ facteur 1).
|
||
function setGlobalCadence (t, pct) { t.efficiency = factorFromPct(pct); saveEff(t) }
|
||
// Éditeur d'équipe (table) : TagEditor émet un tableau → t.skills en tableau, puis persiste (l'API attend une CSV).
|
||
function onTeamSkills (t, items) { t.skills = normSkillList(items); saveSkills(t) }
|
||
async function saveSkills (t) { const csv = normSkillList(t.skills).join(','); try { await roster.setTechSkills(t.id, csv); const tt = techs.value.find(x => x.id === t.id); if (tt) tt.skills = csv ? csv.split(',') : []; $q.notify({ type: 'positive', message: t.name + ' : compétences enregistrées' }) } catch (e) { err(e) } }
|
||
|
||
// congés / disponibilités → extrait dans components/planif/LeaveDialog.vue (état + fonctions déplacés).
|
||
|
||
async function saveEff (t) { const eff = Number(t.efficiency) || 1; try { await roster.setTechEfficiency(t.id, eff); const tt = techs.value.find(x => x.id === t.id); if (tt) tt.efficiency = eff; $q.notify({ type: 'positive', message: t.name + ' : cadence ' + eff }) } catch (e) { err(e) } }
|
||
function loadedCost (t) { return Math.round(((Number(t.salary) || 0) * (1 + (Number(t.charges) || 0) / 100) + (Number(t.other) || 0)) * 100) / 100 }
|
||
async function saveCost (t) { try { await roster.setTechCost(t.id, { salary: t.salary, charges: t.charges, other: t.other }); const tt = techs.value.find(x => x.id === t.id); if (tt) { tt.cost_salary_h = Number(t.salary) || 0; tt.cost_charges_pct = Number(t.charges) || 0; tt.cost_other_h = Number(t.other) || 0; tt.cost_h = loadedCost(t) } $q.notify({ type: 'positive', message: t.name + ' : ' + loadedCost(t) + ' $/h chargé' }) } catch (e) { err(e) } }
|
||
|
||
// éditeur de types de shift (intervalle d'heures)
|
||
function calcHours (st, et) { if (!st || !et) return 0; const [h1, m1] = st.split(':').map(Number); const [h2, m2] = et.split(':').map(Number); let mins = (h2 * 60 + m2) - (h1 * 60 + m1); if (mins < 0) mins += 1440; return Math.round(mins / 60 * 100) / 100 }
|
||
// Types de shift → extrait dans components/planif/ShiftTypesDialog.vue (état + fonctions déplacés ; @changed → refreshTemplates).
|
||
function snapshotServer (list) { serverSet.value = new Set(list.map(a => a.tech + '|' + a.date + '|' + a.shift)) }
|
||
async function loadWeek () {
|
||
loading.value = true
|
||
try {
|
||
const a = await roster.listAssignments(start.value, days.value); assignments.value = a.assignments || []
|
||
snapshotServer(assignments.value); history.value = []; future.value = []; solverStats.value = null
|
||
lastWeek.start = start.value; lastWeek.days = days.value
|
||
await loadStats()
|
||
} catch (e) { err(e) } finally { loading.value = false }
|
||
}
|
||
async function loadStats () {
|
||
try { const s = await roster.getStats(start.value, days.value); dailyStats.value = s.stats || [] } catch (e) { /* non bloquant */ }
|
||
try { const o = await roster.getOccupancy(start.value, $q.screen.lt.md ? MOBILE_STRIP_DAYS : days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ }
|
||
try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) { /* non bloquant */ }
|
||
loadLegacyWindow() // charge legacy datée de la fenêtre (durées estimées appliquées aux timelines) — non bloquant
|
||
}
|
||
// Refresh CIBLÉ + rapide de l'occupation (recharge les blocs/jobs des cellules → timeline à jour) après une assignation,
|
||
// sans le loadWeek complet (assignations + couverture + stats), bien plus lent.
|
||
async function reloadOccupancy () { try { const o = await roster.getOccupancy(start.value, $q.screen.lt.md ? MOBILE_STRIP_DAYS : days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ } }
|
||
|
||
async function doGenerate () {
|
||
generating.value = true
|
||
try {
|
||
const res = await roster.generate(start.value, days.value)
|
||
if (res.status !== 'OPTIMAL' && res.status !== 'FEASIBLE') { err(new Error(res.error || res.message || ('solveur: ' + res.status))); return }
|
||
pushHistory(); assignments.value = res.assignments || []; coverageData.value = res.coverage_report || []
|
||
solverStats.value = { assignments: (res.assignments || []).length, shortfall: res.total_shortfall || 0, spread: res.spread_hours || 0, ms: res.solve_ms || 0 }
|
||
$q.notify({ type: 'positive', message: 'Horaire généré : ' + solverStats.value.assignments + ' assignations (non publié)' })
|
||
} catch (e) { err(e) } finally { generating.value = false }
|
||
}
|
||
// #1 — « Publier » ouvre d'abord le sommaire des changements ; la publication réelle se fait sur confirmation.
|
||
function doPublish () { if (!dirty.value) return; pubConfirm.value = true }
|
||
// #congé publish-required : commit les deltas d'absence en attente (create/remove Tech Availability), SÉQUENTIEL (frappe_pg).
|
||
// Retourne le nb écrit + les clés nouvellement mises en congé (pour l'impact IROPS, une fois l'absence réellement en vigueur).
|
||
async function flushPendingAbsences () {
|
||
const entries = Object.entries(pendingAbs.value); const newlyAbsent = []; let n = 0
|
||
for (const [k, v] of entries) {
|
||
const [tid, iso] = k.split('|')
|
||
try {
|
||
if (v.op === 'set') { await roster.setAbsence(tid, iso, v.type || 'Congé', false); newlyAbsent.push(k); n++ }
|
||
else { await roster.setAbsence(tid, iso, '', true); n++ }
|
||
} catch (e) { err(e) }
|
||
}
|
||
pendingAbs.value = {}; savePendingAbs()
|
||
return { n, newlyAbsent }
|
||
}
|
||
async function doPublishConfirmed () {
|
||
publishing.value = true
|
||
try {
|
||
// PAUSES en attente : la charge publiée EXCLUT les quarts futurs (aujourd'hui→) des techs mis en pause → publish-week
|
||
// les SUPPRIME côté serveur (diff). Les quarts passés restent (historique). Rien retiré si aucune pause.
|
||
const pausedIds = Object.keys(pendingPause.value || {})
|
||
const todayIso = todayISO()
|
||
const payload = pausedIds.length
|
||
? assignments.value.filter(a => !(pausedIds.includes(a.tech) && a.date >= todayIso))
|
||
: assignments.value
|
||
// Mode 'publish' : promeut les brouillons (Proposé/Soumis/Approuvé) → Publié + SMS. Les éditions étaient déjà auto-sauvées.
|
||
const r = await roster.publishWeek(start.value, days.value, payload, notifySms.value, 'publish')
|
||
const done = (r.created || 0) + (r.promoted || 0)
|
||
const abs = await flushPendingAbsences() // congés en attente → serveur
|
||
// PAUSES : applique le statut « En pause » (retire du dispatch/solveur + stoppe la matérialisation du patron).
|
||
const pauseKeys = [] // tech|date futurs des techs mis en pause → relève des jobs (IROPS)
|
||
let pausedDone = 0
|
||
for (const tid of pausedIds) {
|
||
try { const pr = await roster.pauseTechnician(tid, true, (pendingPause.value[tid] || {}).reason || ''); if (!(pr && pr.ok === false)) pausedDone++ } catch (e) { err(e) }
|
||
for (const a of assignments.value) if (a.tech === tid && a.date >= todayIso) pauseKeys.push(tid + '|' + a.date)
|
||
}
|
||
if (pausedIds.length) { pendingPause.value = {}; savePendingPause() }
|
||
$q.notify({ type: r.errors ? 'warning' : 'positive', message: `Publié : ${done} quart(s)` + (abs.n ? ` · ${abs.n} congé(s)` : '') + (pausedDone ? ` · ${pausedDone} pause(s)` : '') + (r.deleted ? ` (${r.deleted} retirés)` : '') + (r.errors ? ` · ${r.errors} erreurs` : '') + (r.notified ? ` · ${r.notified} SMS` : '') })
|
||
pubConfirm.value = false
|
||
await loadWeek()
|
||
// IROPS : jobs des jours désormais indisponibles (congés + pauses) → dialogue de redistribution.
|
||
const impact = [...new Set([...(abs.newlyAbsent || []), ...pauseKeys])]
|
||
if (impact.length) await checkAbsenceImpact(impact)
|
||
} catch (e) { err(e) } finally { publishing.value = false }
|
||
}
|
||
// #3 — étape d'approbation FACULTATIVE : Soumettre (→ Soumis) / Approuver (→ Approuvé), sans SMS. « Publier » reste l'étape finale (SMS).
|
||
async function doWeekStatus (mode) {
|
||
publishing.value = true
|
||
try {
|
||
const r = await roster.publishWeek(start.value, days.value, assignments.value, false, mode)
|
||
const n = (r.created || 0) + (r.promoted || 0)
|
||
$q.notify({ type: r.errors ? 'warning' : 'positive', message: (mode === 'submit' ? 'Soumis pour approbation' : 'Approuvé') + ' · ' + n + ' quart(s)' + (r.errors ? ' · ' + r.errors + ' err.' : '') })
|
||
await loadWeek()
|
||
} catch (e) { err(e) } finally { publishing.value = false }
|
||
}
|
||
|
||
// demande
|
||
function loadLS () { try { 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 = [] } try { pendingAbs.value = JSON.parse(localStorage.getItem(LS_PENDING_ABS) || '{}') } catch { pendingAbs.value = {} } try { pendingPause.value = JSON.parse(localStorage.getItem(LS_PENDING_PAUSE) || '{}') } catch { pendingPause.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) }
|
||
}
|
||
// « Demande » (besoins par template) RETIRÉ → remplacé par la vue Mois (MonthOverview) : besoins EN HEURES par compétence,
|
||
// couverture + alertes, et génération des Shift Requirements pour le solveur. Voir components/planif/MonthOverview.vue.
|
||
|
||
// modèles de semaine
|
||
function saveTemplate () {
|
||
$q.dialog({ title: 'Nouveau modèle', message: "Nom du modèle d'horaire", prompt: { model: '', type: 'text' }, cancel: true }).onOk(name => {
|
||
if (!name) return; const byDow = {}
|
||
for (const a of assignments.value) { const dow = dowOf(a.date); (byDow[dow] || (byDow[dow] = {}))[a.tech] = a.shift }
|
||
weekTemplates.value = [...weekTemplates.value, { name, byDow }]; localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value))
|
||
$q.notify({ type: 'positive', message: 'Modèle « ' + name + ' » enregistré' })
|
||
})
|
||
}
|
||
function deleteTemplate (i) { weekTemplates.value = weekTemplates.value.filter((_, j) => j !== i); localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value)) }
|
||
// Modèle par défaut (★) — un seul à la fois, appliqué en 1 clic
|
||
const defaultTemplate = computed(() => weekTemplates.value.find(t => t.default) || null)
|
||
function setDefaultTemplate (i) { weekTemplates.value = weekTemplates.value.map((t, j) => ({ ...t, default: j === i ? !t.default : false })); localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value)) }
|
||
function applyDefault () { const d = defaultTemplate.value; if (!d) { $q.notify({ type: 'info', message: 'Aucun modèle par défaut — marque-en un avec ★ dans Modèles' }); return } applyTemplate(d) }
|
||
function countPatternDays (tm, techId) { let n = 0; for (const d of dayList.value) { const map = tm.byDow[dowOf(d.iso)]; if (map && map[techId]) n++ } return n }
|
||
// Application « consciente des absences » : on n'assigne pas un tech absent ce jour-là.
|
||
// Absent toute la semaine (≈ congé permanent: maternité/blessure) → flag « à remplacer ».
|
||
// Absent quelques jours (≈ vacances) → on saute juste ces jours, le reste du patron tient.
|
||
function applyTemplate (tm) {
|
||
pushHistory()
|
||
const skipped = {}; let applied = 0
|
||
for (const d of dayList.value) {
|
||
const map = tm.byDow[dowOf(d.iso)]; if (!map) continue
|
||
for (const techId in map) {
|
||
if (isAbsent(techId, d.iso)) { skipped[techId] = (skipped[techId] || 0) + 1; continue }
|
||
const tpl = tplByName.value[map[techId]]; if (!tpl) continue
|
||
const t = techs.value.find(x => x.id === techId); setCellReplace(techId, t ? t.name : techId, d.iso, tpl); applied++
|
||
}
|
||
}
|
||
const fullOut = []; const partial = []
|
||
for (const techId in skipped) {
|
||
const t = techs.value.find(x => x.id === techId); const name = (t ? t.name : techId)
|
||
const type = absByTechDay.value[techId + '|' + (dayList.value.find(d => isAbsent(techId, d.iso)) || {}).iso] || ''
|
||
const lbl = name + (type ? ' (' + type + ')' : '')
|
||
if (/longue durée/i.test(type) || skipped[techId] >= countPatternDays(tm, techId)) fullOut.push(lbl); else partial.push(lbl)
|
||
}
|
||
let msg = 'Modèle « ' + tm.name + ' » appliqué (' + applied + ' assignations)'
|
||
if (partial.length) msg += ' · absence partielle ignorée : ' + partial.join(', ')
|
||
if (fullOut.length) msg += ' · ABSENT toute la semaine — à remplacer : ' + fullOut.join(', ')
|
||
$q.notify({ type: fullOut.length ? 'warning' : 'positive', message: msg, timeout: fullOut.length ? 9000 : 4500, multiLine: true })
|
||
}
|
||
|
||
// édition + sélection
|
||
const menu = reactive({ show: false, target: null, tech: null, day: null, x: 0, y: 0 })
|
||
const menuAnchorEl = ref(null) // ancre 1px positionnée au CURSEUR → le menu s'ouvre là où on clique
|
||
const menuRange = ref({ min: 8, max: 16 }); const quickEntry = ref('')
|
||
function rect (sti, sdi, eti, edi) {
|
||
const t0 = Math.min(sti, eti), t1 = Math.max(sti, eti), d0 = Math.min(sdi, edi), d1 = Math.max(sdi, edi)
|
||
const out = []
|
||
for (let i = t0; i <= t1; i++) for (let j = d0; j <= d1; j++) out.push(visibleTechs.value[i].id + '|' + dayList.value[j].iso)
|
||
return out
|
||
}
|
||
function onDown (ti, di, ev) { if (ev.button !== 0 || ev.shiftKey || ev.ctrlKey || ev.metaKey) return; drag.on = true; drag.ti = ti; drag.di = di; drag.moved = false; drag.base = [] }
|
||
function onEnter (ti, di) { if (!drag.on) return; drag.moved = true; selection.value = [...new Set([...drag.base, ...rect(drag.ti, drag.di, ti, di)])] }
|
||
function onUp () { if (drag.on) { drag.on = false; if (drag.moved) justDragged.value = true } }
|
||
// #2 — AUTO-SAVE : chaque édition de la grille est persistée en BROUILLON (statut 'Proposé') après un court debounce. Plus rien n'est « non sauvegardé ».
|
||
const autosaving = ref(false)
|
||
// Auto-save TERMINÉ → rafraîchit les calendriers qui lisent le SERVEUR (dialogue horaire par tech · vue Mois) :
|
||
// les quarts créés en Semaine/Jour s'y répliquent sans fermer/rouvrir. (Watcher ICI, après la déclaration — TDZ.)
|
||
watch(autosaving, (v, ov) => { if (ov && !v) { if (techSchedOpen.value) techSchedRefresh.value++; if (boardView.value === 'month') monthRefresh.value++ } })
|
||
const changeLog = ref([]) // journal de session : { id, at, text, revert?:{tech,date,before}, reverted? }
|
||
let _draftT = null
|
||
let _chgSeq = 0
|
||
// revert (optionnel) = état AVANT d'une cellule (tech|date) → permet d'annuler CE changement précis depuis le journal (icône history), à la façon de la liste « Publier ».
|
||
function logChange (text, revert) { changeLog.value.unshift({ id: ++_chgSeq, at: Date.now(), text, revert: revert || null, reverted: false }); if (changeLog.value.length > 60) changeLog.value.pop() }
|
||
// Annule UN changement du journal : restaure la cellule (tech, date) à son état d'avant. L'annulation est elle-même dans l'historique global (undo).
|
||
function revertChange (c) {
|
||
if (!c || !c.revert || c.reverted) return
|
||
pushHistory()
|
||
if (c.revert.absKey) { // congé : restaure le delta d'absence précédent de la cellule
|
||
const m = { ...pendingAbs.value }
|
||
if (c.revert.beforeVal) m[c.revert.absKey] = c.revert.beforeVal; else delete m[c.revert.absKey]
|
||
pendingAbs.value = m; savePendingAbs()
|
||
} else {
|
||
const { tech, date, before } = c.revert
|
||
const others = assignments.value.filter(a => !(a.tech === tech && a.date === date))
|
||
assignments.value = [...others, ...JSON.parse(JSON.stringify(before || []))]
|
||
}
|
||
c.reverted = true
|
||
logChange('↶ Annulé · ' + c.text)
|
||
$q.notify({ type: 'info', message: 'Changement annulé', timeout: 1600 })
|
||
scheduleDraftSave()
|
||
}
|
||
function scheduleDraftSave () { if (_draftT) clearTimeout(_draftT); _draftT = setTimeout(autosaveDraft, 800) }
|
||
async function autosaveDraft () {
|
||
if (_draftT) { clearTimeout(_draftT); _draftT = null }
|
||
if (autosaving.value) { scheduleDraftSave(); return } // une sauvegarde en cours → replanifie
|
||
autosaving.value = true
|
||
try { await roster.publishWeek(start.value, days.value, assignments.value, false, 'draft') } catch (e) { /* non bloquant : réessaie au prochain edit */ } finally { autosaving.value = false }
|
||
}
|
||
function cellSnap (techId, iso) { return JSON.parse(JSON.stringify(cellsOf(techId, iso))) } // état AVANT d'une cellule → payload de revert du journal
|
||
function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a => a.shift === tpl.name)) return; const before = cellSnap(techId, iso); assignments.value = [...assignments.value, { tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }]; logChange('Quart ajouté · ' + techName + ' · ' + iso, { tech: techId, date: iso, before }); scheduleDraftSave() }
|
||
function setCellReplace (techId, techName, iso, tpl) { const before = cellSnap(techId, iso); const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept; logChange('Quart défini · ' + techName + ' · ' + iso, { tech: techId, date: iso, before }); scheduleDraftSave() }
|
||
function removeShift (techId, iso, shift) { assignments.value = assignments.value.filter(a => !(a.tech === techId && a.date === iso && a.shift === shift)) }
|
||
function clearLocal (techId, iso) { const before = cellSnap(techId, iso); assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)); logChange('Quart retiré · ' + iso, { tech: techId, date: iso, before }); scheduleDraftSave() }
|
||
// Ouvre le menu d'horaire (Jour/Soir/Garde/Absent + heures) ancré sur la cellule.
|
||
function openShiftMenu (t, d, ev, ti, di) {
|
||
selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d
|
||
if (ev && ev.clientX != null) { menu.x = ev.clientX; menu.y = ev.clientY; menu.target = menuAnchorEl.value } else { menu.target = ev && ev.currentTarget } // ancre au curseur si dispo
|
||
const wr = winOf(t.id, d.iso, false); quickEntry.value = ''
|
||
menuRange.value = wr ? { min: wr.s, max: wr.e } : { min: 8, max: 16 }
|
||
menu.show = true
|
||
}
|
||
// Y a-t-il une bande timeline cliquable sur cette case ? (shift régulier OU garde, et pas absent/pause)
|
||
// Clic gauche sur le FOND de cellule (zone sans quart ni job) → menu d'horaire (POSER / modifier un quart).
|
||
// Le clic sur la BANDE de quart/garde ou un BLOC de job ouvre la tournée (openDayFromCell, via @click.stop).
|
||
function onCellClick (t, d, ev, ti, di) {
|
||
if (justDragged.value) { justDragged.value = false; return }
|
||
activeCell.value = { id: t.id, name: t.name, iso: d.iso } // mémorise la case pour Cmd+C/V
|
||
if (ev.shiftKey && anchor.value) { selectBlock(ti, di); return }
|
||
if (ev.ctrlKey || ev.metaKey) { const k = t.id + '|' + d.iso; selection.value = selSet.value.has(k) ? selection.value.filter(x => x !== k) : [...selection.value, k]; anchor.value = { ti, di }; return }
|
||
// Clic = LISTE DE TÂCHES du tech (tournée du jour) dès que la cellule a du contenu (quart, jobs terrain, ou tickets sans déplacement).
|
||
// Cellule vide = menu d'horaire (on garde la pose de quart au clic). La pose en lot reste : glisser-sélectionner + saisie rapide / clic droit.
|
||
const occ = cellOcc(t.id, d.iso)
|
||
if (hasReg(t.id, d.iso) || onGarde(t.id, d.iso) || (occ && (occ.noShift || occ.officeN))) openDayFromCell(t, d)
|
||
else openShiftMenu(t, d, ev, ti, di)
|
||
}
|
||
// Clic sur la BANDE de quart/garde ou un BLOC de job → ouvre la tournée du jour. Guardé contre les fins de drag.
|
||
function openDayFromCell (t, d) { if (justDragged.value) { justDragged.value = false; return } activeCell.value = { id: t.id, name: t.name, iso: d.iso }; openDayEditor(t, d) }
|
||
// Clic DROIT sur une cellule → menu d'horaire aussi (bonus / accessibilité).
|
||
function onCellContext (t, d, ev, ti, di) {
|
||
if (justDragged.value) { justDragged.value = false; return }
|
||
activeCell.value = { id: t.id, name: t.name, iso: d.iso }
|
||
openShiftMenu(t, d, ev, ti, di)
|
||
}
|
||
function selectBlock (ti, di) { const a = anchor.value; const t0 = Math.min(a.ti, ti); const t1 = Math.max(a.ti, ti); const d0 = Math.min(a.di, di); const d1 = Math.max(a.di, di); const add = []; for (let i = t0; i <= t1; i++) for (let j = d0; j <= d1; j++) add.push(visibleTechs.value[i].id + '|' + dayList.value[j].iso); selection.value = [...new Set([...selection.value, ...add])] }
|
||
function maybeSelectCol (di) { const ks = visibleTechs.value.map(t => t.id + '|' + dayList.value[di].iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] }
|
||
function maybeSelectRow (ti) { const ks = dayList.value.map(d => visibleTechs.value[ti].id + '|' + d.iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] }
|
||
function isRowSelected (ti) { const t = visibleTechs.value[ti]; if (!t || !dayList.value.length) return false; return dayList.value.every(d => selSet.value.has(t.id + '|' + d.iso)) } // rangée entière sélectionnée ?
|
||
const menuCellShifts = computed(() => (menu.tech && menu.day) ? cellsOf(menu.tech.id, menu.day.iso) : [])
|
||
const menuIsAbsent = computed(() => (menu.tech && menu.day) ? isAbsent(menu.tech.id, menu.day.iso) : false)
|
||
function toggleAbsentMenu () { if (menu.tech && menu.day) { toggleAbsentCells([menu.tech.id + '|' + menu.day.iso]); menu.show = false } }
|
||
const menuIsGarde = computed(() => (menu.tech && menu.day) ? onGarde(menu.tech.id, menu.day.iso) : false)
|
||
function toggleGardeMenu () { if (menu.tech && menu.day) { toggleGardeCells([menu.tech.id + '|' + menu.day.iso]); menu.show = false } }
|
||
function removeShiftFromMenu (a) { pushHistory(); removeShift(a.tech, a.date, a.shift) }
|
||
function clearOne () { if (menu.tech && menu.day) { pushHistory(); clearLocal(menu.tech.id, menu.day.iso); menu.show = false } }
|
||
// Menu : copier / coller (marche au clic, sans Cmd+clic) + ajuster l'horaire au slider
|
||
function copyFromMenu () { if (!menu.tech || !menu.day) return; cellClipboard.value = cellsOf(menu.tech.id, menu.day.iso).map(a => a.shift); $q.notify({ type: 'positive', message: cellClipboard.value.length ? (cellClipboard.value.length + ' shift(s) copié(s) — ouvre une autre case puis Coller') : 'Case vide copiée (Coller la videra)' }) }
|
||
function pasteFromMenu () { if (!menu.tech || !menu.day) return; pushHistory(); if (!cellClipboard.value.length) { clearLocal(menu.tech.id, menu.day.iso) } else { for (const name of cellClipboard.value) { const tpl = tplByName.value[name]; if (tpl) addShift(menu.tech.id, menu.tech.name, menu.day.iso, tpl) } } menu.show = false }
|
||
// Applique une fenêtre [min,max] à la case du menu : trouve/crée un modèle auto-nommé puis remplace.
|
||
// Trouve OU crée un modèle de shift régulier pour une plage horaire (réutilisé par menu + barre de sélection).
|
||
async function ensureWindowTpl (min, max) {
|
||
const s = numToTime(min); const e = numToTime(max); const nm = fmtH(min) + 'h–' + fmtH(max) + 'h'
|
||
let tpl = templates.value.find(t => t.template_name === nm)
|
||
if (!tpl) { try { await roster.createTemplate({ template_name: nm, start_time: s + ':00', end_time: e + ':00', hours: calcHours(s, e), color: '#1976d2', default_required: 1, on_call: 0 }); await refreshTemplates(); tpl = templates.value.find(t => t.template_name === nm) } catch (e2) { err(e2); return null } }
|
||
return tpl
|
||
}
|
||
async function applyWindow (min, max) {
|
||
if (!menu.tech || !menu.day || max <= min) return
|
||
const tpl = await ensureWindowTpl(min, max)
|
||
if (tpl) { pushHistory(); setCellReplace(menu.tech.id, menu.tech.name, menu.day.iso, tpl); menu.show = false }
|
||
}
|
||
// 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{3,4}$/.test(s)) { const end = Number(s.slice(-2)); const start = Number(s.slice(0, -2)); if (end <= 24 && start >= 0 && start < end) return { min: start, max: end } } // « 816 »=8–16 · « 1016 »=10–16
|
||
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 assignBulk (tpl) { pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); const t = techs.value.find(x => x.id === tid); addShift(tid, t ? t.name : tid, iso, tpl) } selection.value = [] }
|
||
// ── Barre de sélection : mêmes 4 actions que le menu de cellule ──
|
||
async function bulkWindow (min, max) { if (!selection.value.length) return; const tpl = await ensureWindowTpl(min, max); if (tpl) assignBulk(tpl) }
|
||
function bulkQuick () { const r = parseQuickShift(quickEntry.value); if (!r) { $q.notify({ type: 'warning', message: 'Format : 8-17 · 8:30-16 · 85' }); return } quickEntry.value = ''; bulkWindow(r.min, r.max) }
|
||
function bulkGarde () { const t = selection.value.slice(); toggleGardeCells(t); selection.value = [] }
|
||
function bulkAbsent () { const t = selection.value.slice(); toggleAbsentCells(t); selection.value = [] }
|
||
function clearBulk () { pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); clearLocal(tid, iso) } selection.value = [] }
|
||
// Copier-coller une case (bâtir l'horaire vite) : copie les shifts de la 1re case sélectionnée → colle dans les autres
|
||
const cellClipboard = ref([])
|
||
function copyCell () {
|
||
const k = selection.value[0] || (activeCell.value && (activeCell.value.id + '|' + activeCell.value.iso))
|
||
if (!k) { $q.notify({ type: 'warning', message: 'Clique ou sélectionne une case d\'abord' }); return }
|
||
const [tid, iso] = k.split('|'); cellClipboard.value = cellsOf(tid, iso).map(a => a.shift)
|
||
$q.notify({ type: 'positive', message: cellClipboard.value.length ? (cellClipboard.value.length + ' shift(s) copié(s) — sélectionne des cases puis Coller (ou Cmd+V)') : 'Case vide copiée (Coller videra les cases)' })
|
||
}
|
||
function pasteCells () {
|
||
const targets = selection.value.length ? selection.value.slice() : (activeCell.value ? [activeCell.value.id + '|' + activeCell.value.iso] : [])
|
||
if (!targets.length) { $q.notify({ type: 'warning', message: 'Sélectionne les cases cibles' }); return }
|
||
pushHistory()
|
||
for (const k of targets) { const [tid, iso] = k.split('|'); const t = techs.value.find(x => x.id === tid); if (!cellClipboard.value.length) { clearLocal(tid, iso); continue } for (const name of cellClipboard.value) { const tpl = tplByName.value[name]; if (tpl) addShift(tid, t ? t.name : tid, iso, tpl) } }
|
||
if (selection.value.length) selection.value = []
|
||
}
|
||
|
||
async function togglePause (t) { try { const paused = !isPaused(t); await roster.pauseTechnician(t.id, paused); t.status = paused ? 'En pause' : 'Disponible'; $q.notify({ type: 'info', message: t.name + (paused ? ' en pause' : ' réactivé') }) } catch (e) { err(e) } }
|
||
function err (e) { $q.notify({ type: 'negative', message: '' + (e.message || e) }) }
|
||
|
||
function onKey (e) {
|
||
const tag = (e.target && e.target.tagName) || ''
|
||
if (/INPUT|TEXTAREA|SELECT/.test(tag) || (e.target && e.target.isContentEditable)) return // ne pas intercepter quand on tape dans un champ
|
||
const k = e.key.toLowerCase()
|
||
if ((e.ctrlKey || e.metaKey) && k === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return }
|
||
if ((e.ctrlKey || e.metaKey) && k === 'c' && (selection.value.length || activeCell.value)) { e.preventDefault(); menu.show = false; copyCell(); return }
|
||
if ((e.ctrlKey || e.metaKey) && k === 'v' && (selection.value.length || activeCell.value)) { e.preventDefault(); menu.show = false; pasteCells(); return }
|
||
if ((k === 'delete' || k === 'backspace') && (selection.value.length || activeCell.value)) {
|
||
e.preventDefault(); menu.show = false
|
||
const targets = selection.value.length ? selection.value.slice() : [activeCell.value.id + '|' + activeCell.value.iso]
|
||
pushHistory()
|
||
for (const key of targets) { const [tid, iso] = key.split('|'); clearLocal(tid, iso) }
|
||
if (selection.value.length) selection.value = []
|
||
}
|
||
if (k === 'a' && !e.altKey && (selection.value.length || activeCell.value)) { // « A » = bascule absent
|
||
e.preventDefault(); menu.show = false
|
||
const targets = selection.value.length ? selection.value.slice() : [activeCell.value.id + '|' + activeCell.value.iso]
|
||
toggleAbsentCells(targets); if (selection.value.length) selection.value = []
|
||
}
|
||
if (k === 'g' && !e.altKey && !e.ctrlKey && !e.metaKey && (selection.value.length || activeCell.value)) { // « G » = bascule garde (manuel)
|
||
e.preventDefault(); menu.show = false
|
||
const targets = selection.value.length ? selection.value.slice() : [activeCell.value.id + '|' + activeCell.value.iso]
|
||
toggleGardeCells(targets); if (selection.value.length) selection.value = []
|
||
}
|
||
}
|
||
function onUnload () { if (dirty.value && !autosaving.value) autosaveDraft() } // auto-save actif → aucune alerte ; on tente un dernier flush best-effort
|
||
// ── Veille Legacy → Ops (SSE topic 'dispatch') : reflète fermeture / réassignation hors-Ops / activité en direct ──
|
||
let _legacyRefreshT = null
|
||
function scheduleLegacyRefresh () {
|
||
clearTimeout(_legacyRefreshT)
|
||
_legacyRefreshT = setTimeout(async () => {
|
||
try { await reloadOccupancy(); if (assignPanel.open) { assignPanel.jobs = capPickups((await roster.unassignedJobs()).jobs) } } catch (e) { /* non bloquant */ }
|
||
}, 700)
|
||
}
|
||
function onLegacyUpdate (data) {
|
||
const ch = (data && data.changes) || []
|
||
if (!ch.length) return
|
||
const closed = ch.filter(c => c.kind === 'closed').length
|
||
const taken = ch.filter(c => c.kind === 'taken')
|
||
const moved = ch.filter(c => c.kind === 'reassigned' && !c.pool)
|
||
const stat = ch.filter(c => c.kind === 'status').length
|
||
if (closed) $q.notify({ type: 'info', icon: 'task_alt', message: `Legacy : ${closed} ticket(s) fermé(s) — retiré(s) du planning`, timeout: 3500 })
|
||
if (taken.length) $q.notify({ type: 'info', icon: 'person_remove', message: `Legacy : ${taken.length} ticket(s) pris par un tech dans Legacy — retiré(s) du pool`, timeout: 4000 })
|
||
for (const m of moved) $q.notify({ type: 'warning', icon: 'swap_horiz', message: `Ticket #${m.ticket} réassigné HORS-Ops (staff ${m.to_staff}) — à vérifier`, timeout: 6000 })
|
||
if (stat) $q.notify({ type: 'info', icon: 'sync', message: `Legacy : ${stat} changement(s) de statut`, timeout: 3000 })
|
||
for (const c of ch) { if (c.job) flashJob.value = c.job }
|
||
scheduleLegacyRefresh()
|
||
}
|
||
const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } })
|
||
|
||
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } if (route.query.day || route.query.skill) focusDay(route.query.day, route.query.skill); if (route.query.sched) { const _t = (techs.value || []).find(x => String(x.id) === String(route.query.sched)); if (_t) nextTick(() => openTechSchedule(_t)) } 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 */ } try { await reloadPool() } catch (e) { /* */ } /* charge le pool (compteurs badge + « N à assigner » par jour) SANS ouvrir le panneau flottant — il s'ouvre à la demande : bouton « À assigner » ou clic sur l'alerte d'un jour */ 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() })
|
||
|
||
// Actions principales de la Planif → joignables via la palette (⌘K), même reléguées dans un menu.
|
||
const { registerActions, unregisterActions } = useCommandPalette()
|
||
registerActions('planif', [
|
||
{ id: 'planif-publish', label: 'Publier l’horaire', icon: 'cloud_upload', group: 'Planification', keywords: 'publier horaire publish', run: doPublish },
|
||
{ id: 'planif-suggest', label: 'Suggérer (répartition auto)', icon: 'auto_awesome', group: 'Planification', keywords: 'suggerer repartition auto solveur', run: openSuggest },
|
||
{ id: 'planif-generate', label: 'Générer l’horaire (semaine)', icon: 'event_note', group: 'Planification', keywords: 'generer horaire quarts shift', run: doGenerate },
|
||
{ id: 'planif-create', label: 'Créer une tâche / intervention', icon: 'add', group: 'Planification', keywords: 'creer job tache intervention soumission', run: () => { createChooser.value = true } },
|
||
{ id: 'planif-assign', label: 'Jobs à assigner', icon: 'assignment_ind', group: 'Planification', keywords: 'assigner pool repartir', run: openAssignPanel },
|
||
{ id: 'planif-leave', label: 'Congés / absences', icon: 'beach_access', group: 'Planification', keywords: 'conges absences vacances', run: () => { showLeave.value = true } },
|
||
{ id: 'planif-templates', label: 'Modèles de semaine', icon: 'bookmark', group: 'Planification', keywords: 'modeles semaine patron template horaire', run: () => { showWeekTemplates.value = true } },
|
||
{ id: 'planif-apply-default', label: 'Appliquer le modèle par défaut (★)', icon: 'star', group: 'Planification', keywords: 'modele defaut appliquer patron star', run: applyDefault },
|
||
])
|
||
onUnmounted(() => unregisterActions('planif'))
|
||
onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 — flush le brouillon (attendu) en partant ; plus jamais d'« abandonner ? »
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* Barre d'actions de sélection : flottante (fixed) → hors flux, aucune incidence sur la hauteur de la grille. */
|
||
.sel-actions { position: fixed; left: 50%; transform: translateX(-50%); bottom: 18px; z-index: 4000; display: flex; align-items: center; flex-wrap: wrap; gap: 2px; max-width: 94vw; padding: 6px 12px; background: #e0f2f1; color: #00695c; border: 1px solid #4db6ac; border-radius: 9px; box-shadow: 0 6px 22px rgba(0,0,0,.20); }
|
||
.garde-editor { background: #faf8f6; border: 1px solid #e8e0d8; } /* sous-panneau éditeur de garde */
|
||
/* Panneau flottant « jobs à assigner » (déplaçable, glisser-déposer) */
|
||
.assign-panel { position: fixed; z-index: 5000; width: 320px; max-height: 72vh; background: #fff; border: 1px solid #cfd8dc; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,.24); display: flex; flex-direction: column; }
|
||
.assign-hdr { display: flex; align-items: center; gap: 5px; padding: 6px 10px; background: #5e35b1; color: #fff; border-radius: 8px 8px 0 0; cursor: move; font-weight: 600; font-size: 13px; user-select: none; }
|
||
.assign-sortbar { display: flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 11px; color: #555; background: #f3f0fa; border-bottom: 1px solid #e0e0e0; }
|
||
.assign-sortbar select { font-size: 11px; border: 1px solid #cfc4e8; border-radius: 5px; padding: 1px 4px; background: #fff; color: #333; flex: 1; }
|
||
.assign-body { overflow: auto; padding: 5px; flex: 1 1 auto; min-height: 60px; }
|
||
.assign-map-wrap { flex: 0 0 auto; padding: 0 5px 4px; position: relative; }
|
||
.map-sat-btn { position: absolute; top: 7px; left: 11px; z-index: 3; background: rgba(255,255,255,.9); }
|
||
.map-lasso-btn { position: absolute; top: 44px; left: 11px; z-index: 3; background: rgba(255,255,255,.9); }
|
||
.lasso-box { position: absolute; top: 0; left: 0; background: rgba(99,102,241,.14); border: 1.5px dashed #6366f1; border-radius: 3px; pointer-events: none; z-index: 4; }
|
||
.loc-map-wrap { position: relative; }
|
||
.loc-map { height: 360px; border-radius: 6px; overflow: hidden; border: 1px solid #cfd8dc; }
|
||
.loc-map-hint { position: absolute; bottom: 8px; left: 8px; background: rgba(255,255,255,.88); font-size: 11px; padding: 2px 7px; border-radius: 4px; color: #555; pointer-events: none; }
|
||
.loc-results { max-height: 190px; overflow: auto; border-radius: 6px; }
|
||
.loc-pick-link { cursor: pointer; text-decoration: underline; font-weight: 600; }
|
||
/* Bande de capacité restante par jour (AM/PM/Soir) dans l'en-tête */
|
||
/* Alerte cliquable « N à assigner » dans l'en-tête de jour (grille) — ouvre la liste du jour */
|
||
.day-unassigned { display: inline-flex; align-items: center; gap: 3px; margin: 2px auto 0; padding: 1px 7px; font-size: 10px; font-weight: 700; color: #b45309; background: #fef3c7; border: 1px solid #fde68a; border-radius: 20px; cursor: pointer; line-height: 1.5; }
|
||
.day-unassigned:hover { background: #fde68a; }
|
||
.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.assoc { background: #fbfbfd; border-style: dashed; } /* affiché par co-localisation (compétence hors filtre) */
|
||
.assign-linked { display: inline-flex; align-items: center; gap: 1px; color: #6d4c9f; background: #ede7f6; border-radius: 6px; padding: 0 4px; font-size: 9px; font-weight: 700; margin-right: 3px; }
|
||
.assign-addr { display: inline-flex; align-items: center; gap: 1px; color: #37474f; background: #eceff1; border-radius: 6px; padding: 0 4px; font-size: 9px; font-weight: 700; margin-right: 3px; }
|
||
.assign-addr.multi { color: #fff; background: #7e57c2; } /* ≥2 compétences distinctes à l'adresse → tech polyvalent requis */
|
||
.assign-job.job-flash { animation: jobflash 1.7s ease-out; }
|
||
@keyframes jobflash { 0% { background: #fff59d; box-shadow: inset 0 0 0 2px #fbc02d; } 100% { background: #fafafa; box-shadow: none; } }
|
||
.assign-sub { font-size: 10px; color: #888; margin-top: 1px; }
|
||
.assign-pick-hd { font-size: 11px; font-weight: 700; color: #455a64; padding: 6px 10px; border-bottom: 1px solid #eef2f7; background: #f7f5fc; }
|
||
.assign-pick-incap { opacity: 0.62; }
|
||
.assign-skill { display: inline-block; color: #fff; border-radius: 6px; padding: 0 5px; font-size: 9px; font-weight: 600; margin-right: 3px; }
|
||
.assign-lvl { display: inline-flex; align-items: center; gap: 1px; border: 1px solid #cfd8dc; border-radius: 6px; padding: 0 5px; font-size: 9px; font-weight: 600; color: #607d8b; margin-right: 3px; cursor: pointer; }
|
||
.assign-lvl.set { border-color: #6366f1; color: #4338ca; background: #eef2ff; }
|
||
.jtr-row { display: flex; align-items: center; gap: 10px; padding: 5px 6px; border-bottom: 1px solid #f0f2f5; }
|
||
.jtr-type { flex: 1; min-width: 0; font-size: 13px; font-weight: 600; color: #37474f; }
|
||
.jtr-stars { display: flex; align-items: center; gap: 1px; }
|
||
/* D — surcouche de revue de la répartition suggérée */
|
||
.suggest-tech { border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 8px; overflow: hidden; }
|
||
.suggest-wknd { border-color: #f59e0b; background: #fffbeb; } /* groupe placeholder « en attente du quart week-end » */
|
||
.suggest-map { height: 440px; border-radius: 8px; overflow: hidden; border: 1px solid #cfd8dc; }
|
||
.suggest-legend { display: flex; flex-wrap: wrap; gap: 4px 12px; padding: 6px 2px 2px; }
|
||
.suggest-leg { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: #37474f; }
|
||
.suggest-leg-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||
.suggest-tech-hd { display: flex; align-items: center; padding: 6px 10px; background: #f7f5fc; border-bottom: 1px solid #eef2f7; }
|
||
.suggest-occ { display: inline-flex; align-items: center; gap: 6px; margin-left: 12px; min-width: 130px; }
|
||
.suggest-occ-bar { flex: 1; height: 6px; border-radius: 3px; background: #e2e8f0; overflow: hidden; min-width: 60px; }
|
||
.suggest-occ-fill { height: 100%; background: #16a34a; border-radius: 3px; transition: width .2s; }
|
||
.suggest-occ-fill.high { background: #f59e0b; }
|
||
.suggest-occ-fill.over { background: #ef4444; }
|
||
.suggest-occ-txt { font-size: 11px; font-weight: 700; color: #475569; white-space: nowrap; }
|
||
.suggest-hd-skills { display: inline-flex; align-items: center; gap: 3px; cursor: pointer; }
|
||
.suggest-hd-skills:hover .assign-skill { filter: brightness(1.12); }
|
||
.suggest-hd-more { font-size: 10px; font-weight: 700; color: #64748b; background: #e2e8f0; border-radius: 8px; padding: 0 5px; line-height: 15px; }
|
||
.suggest-prev-toggle { display: inline-flex; align-items: center; gap: 2px; margin-left: 8px; font-size: 10.5px; font-weight: 600; color: #64748b; background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 8px; padding: 0 6px; cursor: pointer; user-select: none; }
|
||
.suggest-prev-toggle:hover { border-color: #94a3b8; }
|
||
.suggest-prev { opacity: 0.75; background: #f8fafc; border-radius: 6px; }
|
||
.suggest-clk { cursor: pointer; }
|
||
.suggest-clk:hover { color: #4f46e5; text-decoration: underline; }
|
||
.suggest-dur { font-size: 11px; white-space: nowrap; color: #475569; border-bottom: 1px dashed #cbd5e1; cursor: pointer; }
|
||
.suggest-dur:hover { color: #4f46e5; border-bottom-color: #4f46e5; }
|
||
.suggest-leg-time { font-size: 10px; color: #94a3b8; padding: 0 6px 2px 30px; line-height: 1.2; }
|
||
.suggest-leg-return { color: #64748b; font-style: italic; padding-top: 2px; border-top: 1px dashed #e2e8f0; margin-top: 1px; }
|
||
.assign-sla { display: inline-flex; align-items: center; gap: 2px; font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 8px; white-space: nowrap; margin-left: 4px; }
|
||
.assign-sla.sla-breached { background: #fee2e2; color: #b91c1c; }
|
||
.assign-sla.sla-at_risk { background: #ffedd5; color: #c2410c; }
|
||
.rt-chip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border: 1.5px solid #cbd5e1; border-radius: 14px; font-size: 12px; font-weight: 600; color: #334155; cursor: pointer; user-select: none; background: #fff; }
|
||
.rt-chip.off { opacity: 0.55; border-style: dashed; }
|
||
.rt-chip-all { border-color: #94a3b8; color: #475569; gap: 3px; }
|
||
.rt-chip-all:hover { border-color: #64748b; background: #f1f5f9; }
|
||
.rt-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||
.rt-n { display: inline-flex; align-items: center; justify-content: center; min-width: 17px; height: 17px; border-radius: 9px; padding: 0 4px; color: #fff; font-size: 10px; font-weight: 800; }
|
||
/* Bande « jobs dus non assignés » (vue Tournées) : chips secteur (filtrent liste+carte) + chips job + Suggérer */
|
||
.rt-unassigned { margin-bottom: 6px; padding: 6px 8px; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; }
|
||
.rt-ua-top { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; }
|
||
/* Carrousel : rangée unique qui défile horizontalement (pas de retour à la ligne). */
|
||
.rt-ua-jobs { display: flex; align-items: center; flex-wrap: nowrap; gap: 5px; margin-top: 5px; overflow-x: auto; overflow-y: hidden; padding-bottom: 4px; scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch; overscroll-behavior-x: contain; }
|
||
.rt-ua-jobs > .rt-ua-chip { flex: 0 0 auto; scroll-snap-align: start; }
|
||
.rt-ua-jobs::-webkit-scrollbar { height: 6px; }
|
||
.rt-ua-jobs::-webkit-scrollbar-thumb { background: #fdba74; border-radius: 3px; }
|
||
.rt-ua-lbl { font-size: 12px; color: #9a3412; display: inline-flex; align-items: center; gap: 3px; }
|
||
.rt-ua-open { background: #fff; border: 1px solid #fed7aa; border-radius: 12px; padding: 2px 8px; cursor: pointer; font-weight: 600; }
|
||
.rt-ua-open:hover { border-color: #fb923c; background: #fff7ed; }
|
||
.rt-sect { display: inline-flex; align-items: center; gap: 2px; padding: 2px 8px; border: 1px solid #cbd5e1; border-radius: 12px; background: #fff; font-size: 11.5px; font-weight: 600; color: #475569; cursor: pointer; user-select: none; }
|
||
.rt-sect.on { background: #6366f1; border-color: #6366f1; color: #fff; }
|
||
.rt-sect-n { font-weight: 800; margin-left: 2px; opacity: .85; }
|
||
.rt-sect-x { border-style: dashed; color: #94a3b8; }
|
||
.rt-chip-sep { width: 1px; align-self: stretch; min-height: 16px; background: #fed7aa; margin: 0 2px; }
|
||
.rt-ua-chip { display: inline-flex; align-items: center; gap: 3px; max-width: 230px; padding: 2px 8px 2px 6px; border: 1px solid #e2e8f0; border-left: 3px solid #9e9e9e; border-radius: 6px; background: #fff; font-size: 11.5px; color: #334155; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.rt-ua-chip:hover { border-color: #6366f1; }
|
||
.jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; }
|
||
.jt-chip:hover { border-color: #6366f1; }
|
||
.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||
.suggest-day { padding: 2px 8px 6px; }
|
||
.suggest-day-lbl { font-size: 11px; font-weight: 700; color: #475569; padding: 4px 2px 2px; }
|
||
.suggest-entry { display: flex; align-items: center; gap: 2px; padding: 4px 6px; border-radius: 6px; font-size: 12.5px; }
|
||
.suggest-entry:hover { background: #f8fafc; }
|
||
.suggest-entry-t { flex: 1; min-width: 0; }
|
||
/* Mobile : la rangée d'un job (badges, durée, AM/PM, actions) dépasse souvent l'écran →
|
||
défilement horizontal de la rangée (au lieu de rogner la fin), sujet borné mais lisible. */
|
||
@media (max-width: 600px) {
|
||
.suggest-entry { flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; }
|
||
.suggest-entry > * { flex-shrink: 0; }
|
||
.suggest-entry .suggest-entry-t, .suggest-entry .suggest-clk { flex: 0 0 auto; min-width: 130px; max-width: 62vw; }
|
||
.suggest-entry::-webkit-scrollbar { height: 5px; }
|
||
.suggest-entry::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
||
}
|
||
.suggest-tech-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 6px; }
|
||
.suggest-tech-chip { display: flex; align-items: center; gap: 6px; padding: 5px 8px; border: 1px solid #e2e8f0; border-radius: 8px; cursor: pointer; font-size: 12.5px; background: #fff; user-select: none; }
|
||
.suggest-tech-chip:hover { background: #f8fafc; }
|
||
.suggest-tech-chip.on { border-color: #6366f1; background: #eef2ff; }
|
||
.suggest-skill-chip { border: 1px solid; border-radius: 12px; padding: 1px 9px; font-size: 11px; cursor: pointer; user-select: none; background: #fff; }
|
||
.suggest-grp-chip { display: inline-flex; align-items: center; gap: 3px; border: 1px solid #c7d2fe; background: #eef2ff; color: #4338ca; border-radius: 12px; padding: 1px 8px; font-size: 11px; font-weight: 600; cursor: pointer; user-select: none; }
|
||
.suggest-grp-chip:hover { border-color: #6366f1; }
|
||
.suggest-grp-x { opacity: .55; margin-left: 1px; }
|
||
.suggest-grp-x:hover { opacity: 1; color: #dc2626; }
|
||
.suggest-grp-clear { border-color: #cbd5e1; background: #f8fafc; color: #64748b; }
|
||
.notify-preview { white-space: pre-wrap; word-break: break-word; font-family: inherit; font-size: 11.5px; line-height: 1.45; color: #475569; margin: 0; background: #f8fafc; border-radius: 6px; padding: 8px; }
|
||
.suggest-step { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; border-radius: 50%; background: #e0e7ff; color: #4338ca; font-size: 10px; font-weight: 700; margin-right: 2px; flex-shrink: 0; }
|
||
.suggest-mlvl { font-size: 10px; color: #b45309; font-weight: 700; white-space: nowrap; margin-right: 3px; }
|
||
.assign-foot { border-top: 1px solid #e0e0e0; padding: 6px 9px; font-size: 11px; color: #555; line-height: 1.45; background: #fafafa; border-radius: 0 0 8px 8px; }
|
||
.assign-job.dragging { opacity: .4; } /* source estompée pendant le glissé (le fantôme compact suit le curseur) */
|
||
/* Dialogue Timeline d'une ressource */
|
||
.tldlg-day { padding: 7px 0; border-bottom: 1px solid #eee; }
|
||
.tldlg-bar { position: relative; height: 18px; background: #f1f3f5; border-radius: 3px; overflow: hidden; margin-bottom: 4px; }
|
||
.tldlg-tick { position: absolute; top: 1px; font-size: 8px; color: #90a4ae; transform: translateX(-50%); pointer-events: none; }
|
||
.tldlg-job { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 2px 2px 2px 4px; border-radius: 4px; }
|
||
.tldlg-job:hover { background: #f5f5f5; }
|
||
.tldlg-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
|
||
.tldlg-time { font-variant-numeric: tabular-nums; color: #555; flex: 0 0 auto; min-width: 34px; }
|
||
.cell.drop-hover { outline: 2px dashed #5e35b1; outline-offset: -2px; background: #f3e9fb; }
|
||
.tl-proj { position: absolute; top: 0; bottom: 0; left: 0; border-radius: 2px; opacity: .42; outline: 1px dashed rgba(0,0,0,.35); outline-offset: -1px; } /* aperçu occupation projetée (fantôme) */
|
||
.drop-badge { position: absolute; top: -8px; right: 2px; z-index: 6; background: #5e35b1; color: #fff; font-size: 9px; font-weight: 700; padding: 0 4px; border-radius: 5px; white-space: nowrap; box-shadow: 0 1px 4px rgba(0,0,0,.3); pointer-events: none; }
|
||
.drop-badge.over { background: #e53935; } /* projection ≥ 100% = surbooké */
|
||
.grid-wrap { overflow-x: auto; border: 1px solid #e0e0e0; border-radius: 6px; max-width: 100%; }
|
||
.roster-grid { border-collapse: collapse; font-size: 12px; width: 100%; user-select: none; -webkit-user-select: none; }
|
||
.roster-grid th, .roster-grid td { border: 1px solid #c7d0dc; text-align: center; padding: 2px; } /* séparateurs plus contrastés (était #eee, invisible sur le gris des cellules) */
|
||
.roster-grid th + th, .roster-grid td + td { border-left-color: #aab6c6; } /* séparateur de COLONNE (jour) plus marqué que les lignes — en-tête ET rangées */
|
||
.roster-grid thead th { position: sticky; top: 0; background: #fafafa; z-index: 1; }
|
||
.tech-col { position: sticky; left: 0; background: #fff; text-align: left !important; white-space: nowrap; padding: 2px 8px !important; min-width: 300px; max-width: 360px; z-index: 2; }
|
||
.roster-grid thead .tech-col { z-index: 3; }
|
||
.roster-grid tbody tr:hover td { background: #f0f7ff; }
|
||
.roster-grid tbody tr:hover .tech-col { background: #f0f7ff; }
|
||
th.weekend, td.weekend { background: #f5f5f5; }
|
||
th.holiday, td.holiday { background: #fff3e0; }
|
||
th.clk, td.clk { cursor: pointer; }
|
||
.dow { font-size: 10px; color: #999; text-transform: uppercase; }
|
||
.dnum { font-size: 11px; font-weight: 600; }
|
||
.grp { font-size: 9px; color: #999; background: #f0f0f0; border-radius: 3px; padding: 0 4px; margin-left: 2px; }
|
||
.role-ic { color: #546e7a; vertical-align: middle; margin-right: 3px; display: inline-flex; } /* icône de rôle monochrome (Lucide) */
|
||
.tech-row { display: flex; align-items: center; gap: 3px; flex-wrap: nowrap; min-width: 0; }
|
||
.tech-name { white-space: nowrap; flex-shrink: 0; }
|
||
.th { white-space: nowrap; flex-shrink: 0; }
|
||
.tech-row > .q-btn, .tech-row > .q-badge, .tech-row > .role-ic, .tech-row > .grp, .tech-row > .eff { flex-shrink: 0; }
|
||
.tech-skills { display: flex; align-items: center; gap: 2px; overflow: hidden; flex: 1 1 auto; min-width: 0; } /* chips inline, débordement clippé */
|
||
.skill-edit-btn { flex-shrink: 0; }
|
||
.skill-chip { font-size: 10px; line-height: 15px; height: 15px; padding: 0 5px; border-radius: 8px; color: #fff; font-weight: 600; white-space: nowrap; flex-shrink: 0; display: inline-flex; align-items: center; gap: 2px; }
|
||
.skill-rank { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: #e2e8f0; color: #64748b; font-size: 9px; font-weight: 800; margin-right: 5px; flex-shrink: 0; }
|
||
.skill-rank-1 { background: #fef3c7; color: #b45309; box-shadow: 0 0 0 1.5px rgba(250,204,21,0.7); }
|
||
.chip-lvl { display: inline-flex; align-items: center; justify-content: center; min-width: 13px; height: 13px; padding: 0 1px; border-radius: 50%; background: rgba(0,0,0,.32); font-size: 8px; font-weight: 800; box-shadow: 0 0 0 1.5px #fff; margin-left: 1px; } /* niveau 1–5 · contour blanc pour détacher la couleur (vitesse) */
|
||
.add-skill-hint { font-size: 10px; color: #9e9e9e; font-style: italic; } /* invite quand aucune compétence */
|
||
/* Compétence compacte = cercle de la couleur du skill + chiffre du niveau ; survol → nom complet + niveau + vitesse */
|
||
/* Compétence : cercle de couleur avec ICÔNE vectorielle (Material, offline) ; devient un rectangle arrondi (pill) icône+chiffre si un niveau est défini */
|
||
.skill-dot { display: inline-flex; align-items: center; justify-content: center; gap: 1px; min-width: 16px; height: 16px; padding: 0 1px; border-radius: 50%; color: #fff; flex-shrink: 0; cursor: pointer; box-shadow: inset 0 0 0 1px rgba(0,0,0,.16); }
|
||
.skill-dot.has-lvl { border-radius: 8px; padding: 0 4px 0 2px; }
|
||
.skill-dot .q-icon { color: #fff; }
|
||
.sd-lvl { font-size: 9px; font-weight: 800; line-height: 1; color: #fff; text-shadow: 0 1px 1px rgba(0,0,0,.4); }
|
||
.skill-more { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; font-size: 9px; font-weight: 800; color: #5a6678; background: #e8ebf0; box-shadow: inset 0 0 0 1px rgba(0,0,0,.10); cursor: pointer; flex-shrink: 0; } /* « +N » compétences restantes (détail au survol) */
|
||
.skill-more:hover { background: #dde1e8; }
|
||
/* Barre de charge AGRÉGÉE par tech sur les journées visibles (occupé / offrable) */
|
||
.tech-load { display: inline-flex; align-items: center; gap: 4px; margin-left: 4px; flex-shrink: 0; cursor: default; }
|
||
.tech-load-bar { width: 44px; height: 6px; border-radius: 3px; background: #e3e7ea; overflow: hidden; display: inline-block; }
|
||
.tech-load-fill { display: block; height: 100%; border-radius: 3px; }
|
||
.tech-load-pct { font-size: 9px; font-weight: 700; color: #607d8b; }
|
||
.tech-load-pct.over { color: #c62828; }
|
||
.hide-eye { flex-shrink: 0; opacity: .45; } .tech-row:hover .hide-eye { opacity: 1; } /* œil masquer (discret, visible au survol) */
|
||
tr.res-hidden { opacity: .5; background: repeating-linear-gradient(45deg, #fafafa, #fafafa 6px, #f0f0f0 6px, #f0f0f0 12px); } /* ressource masquée affichée en grisé */
|
||
tr.res-hidden .hide-eye { opacity: 1; }
|
||
.tech-name.clk:hover { text-decoration: underline; }
|
||
.tech-name.tech-paused-pending { color: var(--ops-text-muted, #94a3b8); text-decoration: line-through; }
|
||
.hol-toggle { font-size: 9px; color: #ccc; cursor: pointer; border: 1px solid #eee; border-radius: 3px; width: 14px; margin: 1px auto 0; line-height: 12px; }
|
||
.hol-toggle.on { background: #ff9800; color: #fff; border-color: #ff9800; }
|
||
.cell { cursor: pointer; min-height: 24px; min-width: 132px; position: relative; background: #eef1f4; } /* gris pâle = INDISPONIBLE par défaut ; un quart crée une zone blanche (.tl-shift) */
|
||
/* Colonnes-jour à largeur lisible : ~1 semaine tient dans la vue, la 2e semaine défile (.grid-wrap overflow-x). */
|
||
.roster-grid thead th:not(.tech-col) { min-width: 132px; }
|
||
.cell.over { background: #fbe2cf; } /* SURCHARGE : la cellule entière se teinte orange pâle (le triangle reste dans le coin) */
|
||
.cell.noshift { background: #fff3e0; box-shadow: inset 0 0 0 1.5px #ffb74d; } /* WARNING : ticket F assigné mais AUCUN quart planifié ce jour → cellule ambre */
|
||
.cell:hover { outline: 2px solid #1976d2; outline-offset: -2px; }
|
||
.cell.sel { outline: 2px solid #00897b; outline-offset: -2px; background: #e0f2f1; }
|
||
.cell.dirty { box-shadow: inset 0 0 0 2px #ff9800; }
|
||
.cell.cov { cursor: default; font-size: 11px; }
|
||
.code-chip { display: inline-block; min-width: 18px; padding: 1px 5px; border-radius: 4px; font-weight: 700; font-size: 11px; line-height: 16px; margin: 1px; }
|
||
.cell-dirty-demo { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 4px; font-weight: 700; font-size: 11px; background: #1976d2; color: #fff; box-shadow: inset 0 0 0 2px #ff9800; }
|
||
.ch-h { opacity: .7; font-weight: 400; font-size: 9px; margin-left: 1px; }
|
||
.free { color: #ccc; }
|
||
.offshift-warn { display: inline-flex; align-items: center; gap: 1px; font-size: 10px; font-weight: 700; color: #ef6c00; cursor: pointer; line-height: 1; } /* job assigné un jour sans quart publié */
|
||
.office-dots { position: absolute; right: 2px; top: 2px; bottom: 2px; z-index: 2; display: flex; flex-direction: column; flex-wrap: wrap; align-content: flex-end; gap: 1.5px; cursor: pointer; } /* tickets SANS déplacement (admin/support/NP) = points ronds empilés (≈3-4 de haut puis nouvelle colonne vers la gauche), colorés par dept ; clic = liste cliquable, survol = aperçu ; n'occupent pas la charge */
|
||
.office-dot { width: 5px; height: 5px; min-width: 5px; border-radius: 50%; opacity: .9; display: inline-block; box-shadow: 0 0 0 .5px rgba(255,255,255,.5); }
|
||
/* tickets SANS déplacement (vue semaine) = 1 SEUL bloc POINTILLÉ ; largeur ∝ somme des estimés (plancher 1h) ; survol/clic = liste des tâches */
|
||
.office-blk { position: absolute; top: auto; bottom: 2px; height: 11px; z-index: 3; min-width: 7px; box-sizing: border-box; border-radius: 4px; cursor: pointer; border: 1px solid rgba(90,99,140,.5); background: repeating-linear-gradient(45deg, rgba(99,102,241,.24) 0 3px, rgba(99,102,241,.06) 3px 6px); display: flex; align-items: center; justify-content: center; } /* admin/sans-déplacement = bloc RÉGULIER (aligné, rayures diagonales pour le distinguer) mais plus MINCE (11px) et FLUSH au bas (2px) — fini le demi-bloc flottant décollé qui désorganisait */
|
||
.office-blk:hover { border-color: rgba(79,70,229,.9); filter: brightness(1.05); }
|
||
.office-blk-ic { color: rgba(79,70,229,.7); }
|
||
.hdr-ruler { position: relative; height: 11px; margin-top: 3px; }
|
||
.hdr-ruler .tick { position: absolute; top: 2px; transform: translateX(-50%); font-size: 8px; color: #aab; line-height: 1; font-weight: 400; }
|
||
.hdr-ruler .tick::before { content: ''; position: absolute; top: -3px; left: 50%; width: 1px; height: 2px; background: #d0d0d8; }
|
||
.tl { position: relative; height: 24px; min-width: 110px; background: transparent; border-radius: 4px; margin: 2px 0; overflow: hidden; cursor: pointer; } /* track TRANSPARENT : toute la cellule est blanche (pas d'élément blanc séparé) */
|
||
/* ── Mode JOUR (style Gaiia) : 1 colonne pleine largeur → timelines hautes + règle d'heures alignée → dispo des jobs facile ── */
|
||
.roster-grid.day-mode .tl { height: 30px; }
|
||
.roster-grid.day-mode .cell { min-height: 34px; }
|
||
.grid-axis { position: relative; width: 100%; height: 13px; margin: 2px 0 1px; border-bottom: 1px dashed #e3e7ee; }
|
||
.grid-axis-tick { position: absolute; top: 0; transform: translateX(-50%); font-size: 9px; line-height: 13px; color: #90a4ae; font-weight: 700; white-space: nowrap; }
|
||
.grid-axis-tick::after { content: 'h'; font-weight: 400; opacity: .65; }
|
||
/* ── Vue BOARD (Kanban horizontal) : pool vertical (recherche/tri) + techs en lanes horizontales à échelle d'heures ── */
|
||
.kbb-wrap { display: flex; gap: 10px; align-items: flex-start; padding: 2px 2px 12px; }
|
||
.kbb-pool { flex: 0 0 268px; width: 268px; background: #eef2f7; border: 1px solid #d7deea; border-radius: 8px; display: flex; flex-direction: column; max-height: calc(100vh - 205px); }
|
||
.kbb-pool.drop-hover { outline: 2px dashed #5e35b1; outline-offset: -2px; background: #f3e9fb; }
|
||
.kbb-pool-hd { display: flex; align-items: center; font-size: 12px; font-weight: 700; color: #2b3445; padding: 7px 9px 4px; }
|
||
.kbb-pool-tools { display: flex; flex-direction: column; gap: 5px; padding: 0 7px 7px; border-bottom: 1px solid #e0e6ef; }
|
||
.kbb-pool-body { flex: 1 1 auto; overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 5px; }
|
||
.kbb-board { flex: 1 1 auto; min-width: 0; }
|
||
.kbb-daybar { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #455; padding: 2px 4px 6px; flex-wrap: wrap; }
|
||
.kbb-scroll { overflow-x: auto; padding-bottom: 8px; }
|
||
.kbb-axis-row, .kbb-row { display: flex; align-items: stretch; width: max-content; min-width: 100%; }
|
||
.kbb-name-sp, .kbb-name { flex: 0 0 150px; width: 150px; box-sizing: border-box; position: sticky; left: 0; z-index: 5; background: #fff; }
|
||
.kbb-name { display: flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 600; color: #2b3445; padding: 0 4px 0 6px; border-right: 1px solid #e3e7ee; }
|
||
.kbb-name .ellipsis { flex: 1 1 auto; min-width: 0; }
|
||
.kbb-axis { position: relative; flex: 0 0 auto; height: 16px; border-bottom: 1px dashed #e3e7ee; }
|
||
.kbb-row { border-bottom: 1px solid #e2e6ed; } /* hairline très mince, centrée entre 2 lanes (marges symétriques) */
|
||
.kbb-lane { position: relative; flex: 0 0 auto; height: 60px; margin: 2px 0; background: #fff; border-radius: 4px; overflow: hidden; }
|
||
.kbb-lane.drop-hover { outline: 2px dashed #5e35b1; outline-offset: -2px; background: #f3e9fb; }
|
||
.kbb-lane-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 10.5px; color: #aab2bd; pointer-events: none; }
|
||
/* trajet d'approche (km/min) avant le job : zone pâle hachurée, plus courte que le bloc */
|
||
.kbb-leg { position: absolute; top: 15px; bottom: 15px; background: repeating-linear-gradient(45deg, rgba(120,144,176,.10) 0 4px, rgba(120,144,176,.18) 4px 8px); border: 1px dashed rgba(120,144,176,.5); border-radius: 3px; display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
||
.kbb-leg-t { font-size: 9px; font-weight: 700; color: #5b6b80; white-space: nowrap; }
|
||
.kbb-blk { position: absolute; top: 7px; bottom: 7px; border-radius: 5px; background: #fff; color: #1c2533; border: 1.5px solid #cfd6e0; border-left-width: 4px; display: flex; flex-direction: column; justify-content: center; gap: 1px; padding: 2px 7px; cursor: grab; box-shadow: 0 1px 3px rgba(0,0,0,.12); overflow: hidden; } /* carte BLANCHE, contour + icône colorés (skill), texte noir — style Gaiia */
|
||
.kbb-blk:hover { box-shadow: 0 2px 8px rgba(0,0,0,.22); z-index: 2; }
|
||
.kbb-blk.assist { border-style: dashed !important; background: #eceff1 !important; color: #90a4ae; opacity: .6; cursor: default; } /* assistant (renfort) = créneau GRISÉ (réservé, secondaire) */
|
||
.tl-blk.tl-blk-assist { opacity: .5; filter: grayscale(0.55); border: 1px dashed rgba(120,144,176,.7) !important; } /* idem dans la grille semaine : bloc assistant grisé */
|
||
.kbb-blk-l1 { display: flex; align-items: center; gap: 3px; font-size: 11px; font-weight: 700; line-height: 1.2; }
|
||
.kbb-blk-l2 { font-size: 9.5px; font-weight: 500; color: #6a7686; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.kbb-blk-t { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
/* Volet détails (double-clic) : grand panneau droit */
|
||
.jd-card { width: min(900px, 84vw); max-width: 94vw; height: 100vh; display: flex; flex-direction: column; }
|
||
.jd-body { flex: 1 1 auto; overflow: auto; }
|
||
.jd-meta { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: #455; }
|
||
.jd-meta > div { display: flex; align-items: center; gap: 6px; }
|
||
.jd-geolinks { padding-left: 22px; gap: 14px !important; }
|
||
.jd-geolink { display: inline-flex; align-items: center; gap: 3px; font-size: 12px; color: #2563eb; text-decoration: none; font-weight: 500; }
|
||
.jd-geolink:hover { text-decoration: underline; }
|
||
/* Sélecteur de vue (Semaine/Jour/Tournées) à gauche — mis en évidence */
|
||
.view-switch { border: 1px solid #d7dce3; border-radius: 9px; overflow: hidden; }
|
||
.view-switch :deep(.q-btn) { font-weight: 600; padding: 5px 13px; }
|
||
.jd-detail { margin-top: 10px; padding: 8px 10px; background: #f6f8fb; border-radius: 6px; white-space: pre-wrap; font-size: 12.5px; color: #333; }
|
||
.jd-team { margin-top: 12px; padding: 10px; border: 1px solid #e6e2f2; background: #faf9fe; border-radius: 8px; }
|
||
/* Suivi terrain (géofencing) : étapes horizontales façon suivi de colis. */
|
||
.jd-geo { margin-top: 12px; padding: 10px 8px; border: 1px solid #d6eeeb; background: #f3fbfa; border-radius: 8px; }
|
||
.jd-geo-track { display: flex; align-items: flex-start; }
|
||
.jd-geo-step { flex: 1; text-align: center; position: relative; }
|
||
.jd-geo-step::before { content: ''; position: absolute; top: 13px; left: -50%; width: 100%; height: 2px; background: #cfe3e0; z-index: 0; }
|
||
.jd-geo-step:first-child::before { display: none; }
|
||
.jd-geo-step.done::before { background: #26a69a; }
|
||
.jd-geo-ic { position: relative; z-index: 1; width: 28px; height: 28px; margin: 0 auto; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: #e0e0e0; color: #fff; }
|
||
.jd-geo-step.done .jd-geo-ic { background: #26a69a; }
|
||
.jd-geo-step.current .jd-geo-ic { box-shadow: 0 0 0 3px rgba(38,166,154,.3); }
|
||
.jd-geo-lbl { font-size: 10.5px; color: #607d8b; margin-top: 3px; line-height: 1.25; }
|
||
.jd-geo-step.done .jd-geo-lbl { color: #37474f; font-weight: 600; }
|
||
.jd-geo-at { display: block; font-size: 10px; color: #26a69a; font-weight: 700; }
|
||
.jd-map { width: 100%; height: 240px; border-radius: 8px; overflow: hidden; margin-bottom: 8px; background: #eceff3; }
|
||
.jd-track-row { display: flex; align-items: center; gap: 6px; margin: -2px 0 8px; }
|
||
.dt-toggle { cursor: pointer; margin-left: 8px; padding: 0 7px; border-radius: 10px; border: 1px solid #ef6c00; color: #ef6c00; font-weight: 600; white-space: nowrap; }
|
||
.dt-toggle.on { background: #ef6c00; color: #fff; }
|
||
.kbb-rows { position: relative; width: max-content; min-width: 100%; }
|
||
.kbb-now-full { position: absolute; top: 0; bottom: 0; width: 2px; background: #e53935; z-index: 4; pointer-events: none; box-shadow: 0 0 3px rgba(229,57,53,.5); }
|
||
.kbb-shift { position: absolute; top: 2px; bottom: 2px; border: 1px solid #dfe4ea; border-radius: 4px; background: #fff; pointer-events: none; } /* quart = zone BLANCHE (lumière = dispo) sur le fond gris */
|
||
.kbb-shift.oncall { border-style: dotted; border-color: #f9a825; background: rgba(249,168,37,.06); }
|
||
/* garde = shift SUR APPEL : bande jaune pâle + téléphone rouge */
|
||
.kbb-garde { position: absolute; top: 7px; bottom: 7px; background: rgba(255,213,79,.32); border: 1px solid rgba(245,166,35,.6); border-radius: 5px; display: flex; align-items: center; padding: 0 5px; overflow: hidden; }
|
||
.kbb-now-lbl { position: absolute; top: 0; transform: translateX(-50%); background: #e53935; color: #fff; font-size: 9px; font-weight: 800; line-height: 13px; padding: 0 4px; border-radius: 3px; white-space: nowrap; z-index: 6; pointer-events: none; }
|
||
/* Cartes du pool (réutilisées) */
|
||
.kb-card { background: #fff; border: 1px solid #e6e9ef; border-left: 4px solid #90a4ae; border-radius: 6px; padding: 5px 7px; cursor: grab; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
|
||
.kb-card:hover { box-shadow: 0 2px 6px rgba(0,0,0,.13); }
|
||
.kb-card:active { cursor: grabbing; }
|
||
.kb-card.dragging { opacity: .4; }
|
||
.kb-card.hold { opacity: .62; cursor: not-allowed; background: repeating-linear-gradient(45deg, #fafafa 0 6px, #f0f0f0 6px 12px); }
|
||
.kb-card-t { font-size: 12px; font-weight: 600; color: #222; line-height: 1.25; display: flex; align-items: center; }
|
||
.kb-card-m { font-size: 10.5px; color: #67707e; display: flex; align-items: center; gap: 4px; margin-top: 2px; }
|
||
.kb-dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto; }
|
||
.kb-count { margin-left: auto; background: #d7deea; color: #3a4658; border-radius: 9px; padding: 0 7px; font-size: 11px; }
|
||
.kb-load { font-size: 11px; font-weight: 700; color: #2e7d32; }
|
||
.kb-load.over { color: #e53935; }
|
||
.kb-empty { font-size: 11px; color: #9aa3b0; text-align: center; padding: 10px 4px; border: 1px dashed #d7deea; border-radius: 6px; }
|
||
.tl-click { cursor: pointer; } /* clic sur le progressbar → menu jobs (détail + réordonner) */
|
||
.tl-click:hover { outline: 1px solid #1976d2; outline-offset: 1px; }
|
||
/* Éditeur de journée (clic progressbar) — lignes draggables */
|
||
.de-row { display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-bottom: 1px solid #eee; background: #fff; cursor: default; }
|
||
.de-row.de-drag { opacity: .5; background: #ede7f6; }
|
||
.de-row.de-pinned { box-shadow: inset 3px 0 0 #5c6bc0; } /* position FIGÉE (1er/dernier) → liseré indigo */
|
||
.de-row:hover { background: #f7f5fc; }
|
||
.de-handle { touch-action: none; } /* indispensable pour le glisser TACTILE (vuedraggable/SortableJS) */
|
||
.de-ghost { opacity: .45; background: #ede7f6; } /* placeholder pendant le glisser */
|
||
.de-ghost .de-row { background: #ede7f6; }
|
||
.de-ord { font-size: 12px; font-weight: 700; color: #607d8b; min-width: 16px; text-align: center; }
|
||
.de-dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; }
|
||
/* minimap du jour (territoire des arrêts) */
|
||
.de-map-wrap { margin: 8px 0 4px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; position: relative; }
|
||
.de-map-gl { width: 100%; height: 240px; }
|
||
.de-map-cap { font-size: 10px; color: #777; padding: 3px 6px; background: #fafafa; border-top: 1px solid #eee; }
|
||
.de-prio { font-size: 11px; border: 1px solid #ccc; border-left-width: 4px; border-radius: 4px; padding: 2px 4px; background: #fff; }
|
||
.de-dur { display: flex; align-items: center; gap: 2px; font-size: 10px; color: #888; }
|
||
.de-dur input { width: 46px; font-size: 11px; text-align: right; border: 1px solid #cfc4e8; border-radius: 4px; padding: 2px 3px; }
|
||
.de-travel { font-size: 10px; color: #8a6d3b; padding: 1px 0 1px 40px; opacity: .85; } /* espace entre 2 jobs = transport */
|
||
.de-depart { color: #3949ab; font-weight: 600; opacity: 1; } /* trajet origine (dépôt/domicile) → 1er job */
|
||
.de-detail { font-size: 11px; line-height: 1.4; color: #444; background: #f7f5fc; border-left: 3px solid #b39ddb; border-radius: 4px; margin: 0 4px 6px 40px; padding: 6px 8px; max-height: 280px; overflow: auto; }
|
||
.de-msg { border-top: 1px solid #e6e0f0; padding: 4px 0; }
|
||
.de-msg:first-of-type { border-top: none; }
|
||
.de-msg-hdr { font-size: 11px; font-weight: 700; color: #5e35b1; }
|
||
.de-msg-txt { font-size: 11px; white-space: pre-wrap; color: #37474f; }
|
||
/* Dernier commentaire (le plus récent) mis en avant — info potentiellement la plus importante. */
|
||
.de-msg-latest { background: #f3f0fb; border-radius: 6px; padding: 6px 8px; margin-bottom: 4px; }
|
||
.de-msg-badge { display: inline-block; margin-left: 6px; font-size: 9px; font-weight: 700; color: #fff; background: #7e57c2; border-radius: 4px; padding: 0 5px; vertical-align: middle; }
|
||
/* Chooser « Créer » : options pleine largeur, contenu aligné à gauche (icône + titre + sous-titre). */
|
||
.cc-opt { width: 100%; border-radius: 10px; padding: 12px 14px; }
|
||
.cc-opt :deep(.q-btn__content) { flex-wrap: nowrap; justify-content: flex-start; }
|
||
.cc-sub { align-self: flex-start; margin: -4px 0 2px 8px; }
|
||
.assign-thread { background: #f5f5f5; border-radius: 5px; padding: 4px 7px; margin-top: 4px; max-height: 220px; overflow: auto; }
|
||
.assign-msg { font-size: 11px; border-top: 1px solid #e0e0e0; padding: 3px 0; }
|
||
.assign-msg:first-child { border-top: none; }
|
||
.assign-msg-txt { white-space: pre-wrap; color: #37474f; }
|
||
.tl-shift { position: absolute; top: 0; bottom: 0; background: #fff; border-radius: 4px; border: 1px solid #dfe4ea; box-sizing: border-box; } /* quart = zone BLANCHE (lumière = dispo) sur le fond gris de la cellule */
|
||
.tl-shift.oncall { background: rgba(255,213,79,.30); border: 1px solid rgba(245,166,35,.55); } /* garde = sur appel (jaune pâle, harmonisé Kanban) */
|
||
.tl-shift.over { background: #fff1e0; border-color: #ffc183; } /* zone de quart en surcharge = teinte orange (au lieu du blanc) */
|
||
.tl-absent { position: absolute; inset: 0; border-radius: 2px; box-sizing: border-box; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg, #cfcfcf 0, #cfcfcf 3px, #f0f0f0 3px, #f0f0f0 6px); } /* absent = hachuré gris */
|
||
.tl-blk { position: absolute; top: 2px; bottom: 2px; border-radius: 4px; border: 1px solid; display: flex; align-items: center; justify-content: center; overflow: hidden; } /* fond PASTEL + contour couleur (via inline) + icône couleur centrée si elle rentre */
|
||
.tl-travel { position: absolute; top: 0; bottom: 0; background-image: repeating-linear-gradient(90deg, #78909c 0 3px, transparent 3px 7px); background-size: 100% 3px; background-repeat: no-repeat; background-position: 0 center; opacity: .85; } /* déplacement = pointillés */
|
||
.tl-shift-click { cursor: pointer; } /* bande de quart/garde → clic ouvre la tournée (voir les jobs) */
|
||
.tl-shift-click:hover { filter: brightness(0.92); outline: 1px solid rgba(55,71,79,.4); outline-offset: -1px; }
|
||
.tl-blk-click { cursor: pointer; }
|
||
.tl-blk-click:hover { outline: 1px solid rgba(25,118,210,.7); outline-offset: -1px; filter: brightness(1.08); }
|
||
.tod-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(210,45%,91%), hsl(270,45%,83%)); }
|
||
.occ-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(122,68%,44%), hsl(32,68%,44%)); }
|
||
.leg-absent { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg,#cfcfcf 0,#cfcfcf 3px,#f0f0f0 3px,#f0f0f0 6px); }
|
||
.leg-garde { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; background: rgba(255,179,0,.14); border: 1px dashed #f9a825; }
|
||
tr.paused .tech-col { color: #aaa; }
|
||
.chg-reverted .text-caption { text-decoration: line-through; color: #9ca3af; }
|
||
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-ava-uav { flex: 0 0 auto; } /* avatar canonique dans la liste techs mobile (garde l'espacement via le gap du parent) */
|
||
.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>
|