feat(ops): staff console — surface dup accounts + per-user schedule deep-link
- Duplicate Authentik accounts now surfaced automatically: a "⚠ Doublons (N)" filter chip + the warning badge itself is clickable to clean up (the 7 dup people are status=ok, so they were hidden under the default orphan filter). - "Horaire" button on rows with a tech reuses the SAME Planification schedule module (TechScheduleDialog: recurring template + congés + pause) via a /planification?sched=<tech_id> deep-link — no inferior rebuild. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
11c53c956a
commit
d0f899cf6d
|
|
@ -11,6 +11,7 @@
|
|||
* · Supprimer (GARDÉ : refusé si tickets/jobs référencent la personne → propose la désactivation).
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Notify, Dialog } from 'quasar'
|
||||
import { listStaff, provisionStaff, setStaffActive, staffImpact, deleteStaff, dedupeAccounts } from 'src/api/staff'
|
||||
|
||||
|
|
@ -21,11 +22,18 @@ const filter = ref('orphan') // orphan | departed | ok | all
|
|||
const search = ref('')
|
||||
const busy = ref('') // email en cours d'action
|
||||
const prov = ref(null) // formulaire de provisioning
|
||||
const router = useRouter()
|
||||
// Horaire d'un membre = MÊME module que Planification (TechScheduleDialog : récurrent + congés + pause).
|
||||
// Deep-link vers la Planification qui ouvre le dialogue de ce tech — réutilise le module tel quel (pas de rebuild).
|
||||
function openSchedule (r) {
|
||||
if (!r.tech_id) { Notify.create({ type: 'info', message: 'Provisionner d\'abord un technicien pour ' + r.name + ' (l\'horaire est une ressource dispatch).' }); return }
|
||||
router.push({ path: '/planification', query: { sched: r.tech_id } })
|
||||
}
|
||||
|
||||
const counts = computed(() => { const c = { orphan: 0, departed: 0, ok: 0 }; for (const r of rows.value) c[r.status] = (c[r.status] || 0) + 1; return c })
|
||||
const counts = computed(() => { const c = { orphan: 0, departed: 0, ok: 0, dup: 0 }; for (const r of rows.value) { c[r.status] = (c[r.status] || 0) + 1; if (r.dup_accounts) c.dup++ } return c })
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
return rows.value.filter(r => (filter.value === 'all' || r.status === filter.value) &&
|
||||
return rows.value.filter(r => (filter.value === 'all' || (filter.value === 'dup' ? r.dup_accounts > 0 : r.status === filter.value)) &&
|
||||
(!q || (r.name + ' ' + r.email + ' ' + r.groups.join(' ')).toLowerCase().includes(q)))
|
||||
})
|
||||
const statusMeta = { orphan: { color: 'orange', icon: 'report_problem', label: 'à provisionner' }, departed: { color: 'blue-grey-5', icon: 'logout', label: 'parti / inactif' }, ok: { color: 'positive', icon: 'check_circle', label: 'provisionné' } }
|
||||
|
|
@ -79,7 +87,7 @@ async function tryDelete (r) {
|
|||
|
||||
<div class="row items-center q-gutter-xs q-mb-sm">
|
||||
<q-btn-toggle v-model="filter" dense no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
|
||||
:options="[{label:'À provisionner ('+(counts.orphan||0)+')',value:'orphan'},{label:'Partis / inactifs ('+(counts.departed||0)+')',value:'departed'},{label:'OK ('+(counts.ok||0)+')',value:'ok'},{label:'Tous',value:'all'}]" />
|
||||
:options="[{label:'À provisionner ('+(counts.orphan||0)+')',value:'orphan'},...(counts.dup?[{label:'⚠ Doublons ('+counts.dup+')',value:'dup'}]:[]),{label:'Partis / inactifs ('+(counts.departed||0)+')',value:'departed'},{label:'OK ('+(counts.ok||0)+')',value:'ok'},{label:'Tous',value:'all'}]" />
|
||||
<q-space />
|
||||
<q-input dense outlined v-model="search" placeholder="Chercher…" clearable style="min-width:180px"><template #prepend><q-icon name="search" /></template></q-input>
|
||||
<q-btn flat dense round icon="refresh" :loading="loading" @click="load" />
|
||||
|
|
@ -95,7 +103,7 @@ async function tryDelete (r) {
|
|||
<q-item-label class="text-weight-medium">{{ r.name }}
|
||||
<q-chip v-if="!r.is_active" dense size="sm" color="grey-4" text-color="grey-8" icon="logout">inactif</q-chip>
|
||||
<q-chip v-if="r.needs_group" dense size="sm" color="amber-2" text-color="amber-9" icon="group_off">sans groupe</q-chip>
|
||||
<q-chip v-if="r.dup_accounts" dense size="sm" color="red-2" text-color="red-9" icon="content_copy">{{ r.dup_accounts }} comptes Authentik<q-tooltip>Comptes en double pour ce courriel : {{ (r.usernames || []).join(' · ') }} — à nettoyer (garder un seul)</q-tooltip></q-chip>
|
||||
<q-chip v-if="r.dup_accounts" clickable dense size="sm" color="red-2" text-color="red-9" icon="warning" @click="tryDedupe(r)">⚠ {{ r.dup_accounts }} comptes Authentik — nettoyer<q-tooltip>Comptes en double pour ce courriel : {{ (r.usernames || []).join(' · ') }}. Cliquer pour garder l'utilisé et supprimer le doublon.</q-tooltip></q-chip>
|
||||
</q-item-label>
|
||||
<q-item-label caption>
|
||||
{{ r.email }}
|
||||
|
|
@ -110,6 +118,7 @@ async function tryDelete (r) {
|
|||
<q-item-section side class="row no-wrap items-center">
|
||||
<q-spinner v-if="busy === r.email" size="16px" color="primary" class="q-mr-sm" />
|
||||
<q-btn v-if="r.dup_accounts" dense unelevated no-caps size="sm" color="red-6" icon="content_copy" label="Nettoyer le doublon" class="q-mr-xs" @click="tryDedupe(r)" />
|
||||
<q-btn v-if="r.tech_id" flat dense round size="sm" icon="event" color="teal-7" @click="openSchedule(r)"><q-tooltip>Horaire (récurrent · congés · pause) — même module que Planification</q-tooltip></q-btn>
|
||||
<q-btn v-if="r.status === 'orphan'" dense unelevated no-caps size="sm" color="primary" icon="person_add" label="Provisionner" class="q-mr-xs" @click="openProvision(r)" />
|
||||
<q-btn flat dense round size="sm" :icon="r.is_active ? 'block' : 'person_add'" :color="r.is_active ? 'orange-7':'positive'" @click="toggleActive(r)"><q-tooltip>{{ r.is_active ? 'Désactiver (garde l\'historique)' : 'Réactiver' }}</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="tryDelete(r)"><q-tooltip>Supprimer (gardé : refusé si références)</q-tooltip></q-btn>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,19 @@ function labelOf (input) {
|
|||
const hit = emails.value[norm(s)] || techs.value[s]
|
||||
return (hit && hit.label) || ''
|
||||
}
|
||||
// Enregistrement d'identité complet pour un courriel OU un tech_id (via la clé) → { key, label, primary_email, … } ou null.
|
||||
function recordOf (input) {
|
||||
if (!input) return null
|
||||
const k = keyOf(input)
|
||||
return (list.value || []).find(r => r.key === k) || null
|
||||
}
|
||||
// Courriel canonique (primary_email) pour un courriel/tech_id → sert de clé photo + carte. Repli : l'entrée si elle EST un courriel.
|
||||
function primaryEmailOf (input) {
|
||||
const rec = recordOf(input)
|
||||
if (rec && rec.primary_email) return rec.primary_email
|
||||
const s = String(input || '')
|
||||
return /.+@.+\..+/.test(s) ? norm(s) : ''
|
||||
}
|
||||
// Deux entrées (courriels/tech) = la même personne ?
|
||||
function sameId (a, b) { return keyOf(a) === keyOf(b) }
|
||||
// Actif ? départ (active:false) = masqué des sélecteurs mais l'identité reste (historique). Inconnu → actif.
|
||||
|
|
@ -45,5 +58,5 @@ function activeOf (input) {
|
|||
|
||||
export function useIdentity () {
|
||||
ensure()
|
||||
return { ensure, reload, emails, techs, list, keyOf, labelOf, sameId, activeOf }
|
||||
return { ensure, reload, emails, techs, list, keyOf, labelOf, recordOf, primaryEmailOf, sameId, activeOf }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6010,7 +6010,7 @@ function onLegacyUpdate (data) {
|
|||
}
|
||||
const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } })
|
||||
|
||||
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } if (route.query.day || route.query.skill) focusDay(route.query.day, route.query.skill); await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } 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 */ })
|
||||
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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user