planif : extraction #4/2 — dialogue Synchroniser les techniciens → components/planif/TechSyncDialog.vue

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-07 22:02:10 -04:00
parent 659cfae12c
commit 4bed6bb9fd
2 changed files with 86 additions and 61 deletions

View File

@ -0,0 +1,80 @@
<template>
<!-- Synchroniser les techniciens : réconciliation staff legacy Dispatch Technician groupe Authentik tech -->
<q-dialog v-model="open">
<q-card style="width: 720px; max-width: 96vw">
<q-card-section class="row items-center q-pb-xs">
<div class="text-subtitle2">👥 Synchroniser les techniciens</div>
<q-space />
<q-btn dense flat round icon="close" v-close-popup />
</q-card-section>
<q-card-section v-if="techSync.loading" class="text-center q-pa-lg"><q-spinner size="28px" color="primary" /></q-card-section>
<template v-else-if="techSync.report">
<q-card-section class="q-pt-none">
<div class="text-caption text-grey-7 q-mb-sm">Staff legacy actifs : <b>{{ techSync.report.counts.staff_active }}</b> · Fiches roster : <b>{{ techSync.report.counts.fiches }}</b> · Groupe Authentik <code>tech</code> : <b>{{ techSync.report.authentik ? techSync.report.counts.tech_group : '—' }}</b></div>
<q-expansion-item default-opened dense icon="group_add" header-class="text-green-8" :label="`À ajouter au roster (${techSync.report.staff_missing_fiche.length})`">
<div v-if="!techSync.report.staff_missing_fiche.length" class="text-grey-6 q-pa-sm">Aucun le roster est à jour </div>
<q-list v-else dense bordered class="ts-list">
<q-item v-for="s in techSync.report.staff_missing_fiche" :key="s.staff_id" tag="label">
<q-item-section side><q-checkbox v-model="techSync.sel[s.staff_id]" dense color="green-7" /></q-item-section>
<q-item-section><q-item-label>{{ s.name }} <span class="text-grey-5">#{{ s.staff_id }}</span></q-item-label><q-item-label caption>{{ s.email || ' sans courriel ' }}</q-item-label></q-item-section>
<q-item-section side><q-chip dense size="sm" :color="s.in_tech_group ? 'green-2' : 'orange-2'" :text-color="s.in_tech_group ? 'green-9' : 'orange-9'">{{ s.in_tech_group ? 'accès tech ✓' : 'hors groupe tech' }}</q-chip></q-item-section>
</q-item>
</q-list>
</q-expansion-item>
<q-expansion-item v-if="techSync.report.authentik && techSync.report.fiche_no_access.length" dense icon="lock" header-class="text-warning" :label="`Fiches SANS accès Ops (${techSync.report.fiche_no_access.length})`">
<div class="text-caption text-grey-6 q-px-sm q-pt-xs">Pas dans le groupe Authentik <code>tech</code> à ajouter manuellement sur auth.targo.ca (lecture seule ici).</div>
<q-list dense class="ts-list"><q-item v-for="t in techSync.report.fiche_no_access" :key="t.technician_id"><q-item-section><q-item-label>{{ t.name }}</q-item-label><q-item-label caption>{{ t.email }}</q-item-label></q-item-section></q-item></q-list>
</q-expansion-item>
<q-expansion-item v-if="techSync.report.authentik && techSync.report.access_no_fiche.length" dense icon="help_outline" :label="`Dans le groupe tech, hors roster (${techSync.report.access_no_fiche.length})`">
<div class="text-caption text-grey-6 q-px-sm q-pt-xs">Ont l'accès Ops mais aucune fiche (ex-employé ? courriel legacy ?).</div>
<div class="q-pa-sm" style="font-size:11px;color:#78909c;line-height:1.6">{{ techSync.report.access_no_fiche.join(' · ') }}</div>
</q-expansion-item>
<q-banner v-if="!techSync.report.authentik" dense class="bg-orange-1 text-warning q-mt-sm"><template #avatar><q-icon name="warning" /></template>Authentik non joignable vérif d'accès indisponible, seul l'ajout au roster fonctionne.</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Fermer" v-close-popup />
<q-btn unelevated color="green-7" icon="group_add" :label="`Créer les fiches cochées (${techSyncSelCount})`" :loading="techSync.applying" :disable="!techSyncSelCount" @click="applyTechSync" />
</q-card-actions>
</template>
</q-card>
</q-dialog>
</template>
<script setup>
// Dialogue « Synchroniser les techniciens » extrait de PlanificationPage (décomposition #4).
// Auto-contenu : ne LIT aucun état partagé de la page. Rapport self-fetch à l'ouverture.
// Seul lien sortant : après création de fiches, émet `applied` le parent recharge sa liste (loadBase).
import { reactive, computed, watch } from 'vue'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
const props = defineProps({ modelValue: { type: Boolean, default: false } })
const emit = defineEmits(['update:modelValue', 'applied'])
const $q = useQuasar()
const err = (e) => $q.notify({ type: 'negative', message: '' + (e.message || e) })
const open = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
const techSync = reactive({ loading: false, applying: false, report: null, sel: {} })
const techSyncSelCount = computed(() => Object.values(techSync.sel).filter(Boolean).length)
// Charge le rapport de réconciliation à l'ouverture (remplace l'ancien openTechSync).
async function loadReport () {
techSync.loading = true; techSync.report = null; techSync.sel = {}
try {
const r = await roster.techSyncReport(); techSync.report = r
for (const s of (r.staff_missing_fiche || [])) techSync.sel[s.staff_id] = true // candidats cochés par défaut
} catch (e) { err(e) } finally { techSync.loading = false }
}
async function applyTechSync () {
const ids = Object.entries(techSync.sel).filter(([, v]) => v).map(([k]) => k)
if (!ids.length) return
techSync.applying = true
try {
const r = await roster.techSyncApply(ids)
$q.notify({ type: r.created ? 'positive' : 'info', icon: 'group_add', message: `${r.created} fiche(s) technicien créée(s)` + (r.results ? ` · ${r.results.filter(x => x.skipped).length} déjà présente(s)` : '') })
open.value = false
emit('applied') // le parent recharge la liste des techniciens dans la grille (loadBase)
} catch (e) { err(e) } finally { techSync.applying = false }
}
watch(() => props.modelValue, (v) => { if (v) loadReport() })
</script>

View File

@ -98,7 +98,7 @@
<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="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="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="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="openTechSync"><q-item-section avatar><q-icon name="group_add" color="green-7" /></q-item-section><q-item-section>Synchroniser les techniciens</q-item-section></q-item> <q-item clickable v-close-popup @click="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" /> <q-separator class="q-my-xs" />
<!-- 👁 AFFICHAGE : bascules de ce qui s'affiche sur le board --> <!-- 👁 AFFICHAGE : bascules de ce qui s'affiche sur le board -->
<q-item-label header class="q-py-xs text-weight-bold text-grey-8">👁&nbsp; Affichage</q-item-label> <q-item-label header class="q-py-xs text-weight-bold text-grey-8">👁&nbsp; Affichage</q-item-label>
@ -1643,45 +1643,8 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- Synchroniser les techniciens : réconciliation staff legacy Dispatch Technician groupe Authentik tech --> <!-- Synchroniser les techniciens extrait dans components/planif/TechSyncDialog.vue (décomposition #4) -->
<q-dialog v-model="techSync.open"> <TechSyncDialog v-model="showTechSync" @applied="loadBase" />
<q-card style="width: 720px; max-width: 96vw">
<q-card-section class="row items-center q-pb-xs">
<div class="text-subtitle2">👥 Synchroniser les techniciens</div>
<q-space />
<q-btn dense flat round icon="close" v-close-popup />
</q-card-section>
<q-card-section v-if="techSync.loading" class="text-center q-pa-lg"><q-spinner size="28px" color="primary" /></q-card-section>
<template v-else-if="techSync.report">
<q-card-section class="q-pt-none">
<div class="text-caption text-grey-7 q-mb-sm">Staff legacy actifs : <b>{{ techSync.report.counts.staff_active }}</b> · Fiches roster : <b>{{ techSync.report.counts.fiches }}</b> · Groupe Authentik <code>tech</code> : <b>{{ techSync.report.authentik ? techSync.report.counts.tech_group : '—' }}</b></div>
<q-expansion-item default-opened dense icon="group_add" header-class="text-green-8" :label="`À ajouter au roster (${techSync.report.staff_missing_fiche.length})`">
<div v-if="!techSync.report.staff_missing_fiche.length" class="text-grey-6 q-pa-sm">Aucun le roster est à jour </div>
<q-list v-else dense bordered class="ts-list">
<q-item v-for="s in techSync.report.staff_missing_fiche" :key="s.staff_id" tag="label">
<q-item-section side><q-checkbox v-model="techSync.sel[s.staff_id]" dense color="green-7" /></q-item-section>
<q-item-section><q-item-label>{{ s.name }} <span class="text-grey-5">#{{ s.staff_id }}</span></q-item-label><q-item-label caption>{{ s.email || ' sans courriel ' }}</q-item-label></q-item-section>
<q-item-section side><q-chip dense size="sm" :color="s.in_tech_group ? 'green-2' : 'orange-2'" :text-color="s.in_tech_group ? 'green-9' : 'orange-9'">{{ s.in_tech_group ? 'accès tech ✓' : 'hors groupe tech' }}</q-chip></q-item-section>
</q-item>
</q-list>
</q-expansion-item>
<q-expansion-item v-if="techSync.report.authentik && techSync.report.fiche_no_access.length" dense icon="lock" header-class="text-warning" :label="`Fiches SANS accès Ops (${techSync.report.fiche_no_access.length})`">
<div class="text-caption text-grey-6 q-px-sm q-pt-xs">Pas dans le groupe Authentik <code>tech</code> à ajouter manuellement sur auth.targo.ca (lecture seule ici).</div>
<q-list dense class="ts-list"><q-item v-for="t in techSync.report.fiche_no_access" :key="t.technician_id"><q-item-section><q-item-label>{{ t.name }}</q-item-label><q-item-label caption>{{ t.email }}</q-item-label></q-item-section></q-item></q-list>
</q-expansion-item>
<q-expansion-item v-if="techSync.report.authentik && techSync.report.access_no_fiche.length" dense icon="help_outline" :label="`Dans le groupe tech, hors roster (${techSync.report.access_no_fiche.length})`">
<div class="text-caption text-grey-6 q-px-sm q-pt-xs">Ont l'accès Ops mais aucune fiche (ex-employé ? courriel legacy ?).</div>
<div class="q-pa-sm" style="font-size:11px;color:#78909c;line-height:1.6">{{ techSync.report.access_no_fiche.join(' · ') }}</div>
</q-expansion-item>
<q-banner v-if="!techSync.report.authentik" dense class="bg-orange-1 text-warning q-mt-sm"><template #avatar><q-icon name="warning" /></template>Authentik non joignable vérif d'accès indisponible, seul l'ajout au roster fonctionne.</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Fermer" v-close-popup />
<q-btn unelevated color="green-7" icon="group_add" :label="`Créer les fiches cochées (${techSyncSelCount})`" :loading="techSync.applying" :disable="!techSyncSelCount" @click="applyTechSync" />
</q-card-actions>
</template>
</q-card>
</q-dialog>
<!-- Tableur « durées par caractéristique » (additif) édition inline --> <!-- Tableur « durées par caractéristique » (additif) édition inline -->
<q-dialog v-model="jobChar.open"> <q-dialog v-model="jobChar.open">
@ -2024,6 +1987,7 @@ import { useSla, SLA_BADGE } from 'src/composables/useSla' // SLA existant (poli
import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client 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 TechSelect from 'src/components/shared/TechSelect.vue'
import LeaveDialog from 'src/components/planif/LeaveDialog.vue' // Congés & disponibilités (extrait décomposition #4) import LeaveDialog from 'src/components/planif/LeaveDialog.vue' // Congés & disponibilités (extrait décomposition #4)
import TechSyncDialog from 'src/components/planif/TechSyncDialog.vue' // Synchroniser les techniciens (extrait décomposition #4)
import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue' import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags/compétences PARTAGÉ (chips colorées, création + palette, niveaux) SOURCE UNIQUE : jobs ET techs import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags/compétences PARTAGÉ (chips colorées, création + palette, niveaux) SOURCE UNIQUE : jobs ET techs
import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo import 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
@ -3435,27 +3399,8 @@ async function openLegacyBlock (b) {
try { legacyDetail.thread = await roster.ticketThread(b.lid) } catch (e) { err(e); legacyDetail.thread = { error: true, messages: [] } } finally { legacyDetail.loading = false } try { legacyDetail.thread = await roster.ticketThread(b.lid) } catch (e) { err(e); legacyDetail.thread = { error: true, messages: [] } } finally { legacyDetail.loading = false }
} }
// Synchroniser les techniciens : rapport de réconciliation (staff legacy Dispatch Technician groupe Authentik tech) // Synchroniser les techniciens extrait dans components/planif/TechSyncDialog.vue (état + fonctions déplacés ; @applied loadBase).
const techSync = reactive({ open: false, loading: false, applying: false, report: null, sel: {} }) const showTechSync = ref(false)
const techSyncSelCount = computed(() => Object.values(techSync.sel).filter(Boolean).length)
async function openTechSync () {
techSync.open = true; techSync.loading = true; techSync.report = null; techSync.sel = {}
try {
const r = await roster.techSyncReport(); techSync.report = r
for (const s of (r.staff_missing_fiche || [])) techSync.sel[s.staff_id] = true // candidats cochés par défaut
} catch (e) { err(e) } finally { techSync.loading = false }
}
async function applyTechSync () {
const ids = Object.entries(techSync.sel).filter(([, v]) => v).map(([k]) => k)
if (!ids.length) return
techSync.applying = true
try {
const r = await roster.techSyncApply(ids)
$q.notify({ type: r.created ? 'positive' : 'info', icon: 'group_add', message: `${r.created} fiche(s) technicien créée(s)` + (r.results ? ` · ${r.results.filter(x => x.skipped).length} déjà présente(s)` : '') })
techSync.open = false
await loadBase() // recharge la liste des techniciens dans la grille
} catch (e) { err(e) } finally { techSync.applying = false }
}
const _hasJobLL = (j) => j && j.latitude != null && j.longitude != null && isFinite(+j.latitude) && isFinite(+j.longitude) && (Math.abs(+j.latitude) > 0.01 || Math.abs(+j.longitude) > 0.01) const _hasJobLL = (j) => j && j.latitude != null && j.longitude != null && isFinite(+j.latitude) && isFinite(+j.longitude) && (Math.abs(+j.latitude) > 0.01 || Math.abs(+j.longitude) > 0.01)
const assignNoCoord = computed(() => assignJobsFiltered.value.filter(j => !_hasJobLL(j)).length) // « sans coords » = dans le filtre courant (cohérent avec la carte) const assignNoCoord = computed(() => assignJobsFiltered.value.filter(j => !_hasJobLL(j)).length) // « sans coords » = dans le filtre courant (cohérent avec la carte)
// LOT (pool « à assigner ») : faire correspondre l'adresse de chaque job sans coords à la base RQA GPS, écrit // LOT (pool « à assigner ») : faire correspondre l'adresse de chaque job sans coords à la base RQA GPS, écrit