- 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>
156 lines
12 KiB
Vue
156 lines
12 KiB
Vue
<script setup>
|
|
/**
|
|
* StaffConsole — réconciliation Authentik ↔ OPS (admins).
|
|
*
|
|
* Chaque user Authentik INTERNE avec son état de provisioning : actif ? groupes ?
|
|
* System User / Employee / Dispatch Technician / identité présents ? Signale :
|
|
* · orphan = actif dans Authentik mais SANS System User → non provisionné (ex. Karim, Junior)
|
|
* · departed = inactif Authentik ou identité en départ → à retirer/garder pour l'historique
|
|
* · ok = provisionné
|
|
* Actions : Provisionner (crée les pièces manquantes) · Désactiver/Réactiver (non destructif)
|
|
* · 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'
|
|
|
|
const OPS_GROUPS = ['admin', 'sysadmin', 'tech', 'support', 'comptabilite', 'facturation', 'dev']
|
|
const rows = ref([])
|
|
const loading = ref(false)
|
|
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, 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' || (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é' } }
|
|
|
|
async function load () {
|
|
loading.value = true
|
|
try { const r = await listStaff(); rows.value = r.staff || [] }
|
|
catch (e) { Notify.create({ type: 'negative', message: 'Chargement impossible : ' + (e.message || e) }) } finally { loading.value = false }
|
|
}
|
|
onMounted(load)
|
|
|
|
function openProvision (r) { prov.value = { email: r.email, label: r.name || r.email, groups: r.groups.length ? [...r.groups] : ['tech'], create_employee: true, create_tech: false, kind: 'employee' } }
|
|
async function doProvision () {
|
|
const p = prov.value; busy.value = p.email
|
|
try {
|
|
const r = await provisionStaff(p)
|
|
Notify.create({ type: 'positive', message: p.label + ' provisionné(e)', caption: Object.entries(r.steps || {}).map(([k, v]) => k + ':' + v).join(' · '), timeout: 4000 })
|
|
prov.value = null; await load()
|
|
} catch (e) { Notify.create({ type: 'negative', message: e.message || e }) } finally { busy.value = '' }
|
|
}
|
|
async function toggleActive (r) {
|
|
busy.value = r.email
|
|
try { await setStaffActive(r.email, !r.is_active); await load() }
|
|
catch (e) { Notify.create({ type: 'negative', message: e.message || e }) } finally { busy.value = '' }
|
|
}
|
|
async function tryDedupe (r) {
|
|
Dialog.create({ title: 'Supprimer le compte en double', message: `${r.name} a ${r.dup_accounts} comptes Authentik pour ${r.email} (${(r.usernames || []).join(', ')}). Garder le compte utilisé et supprimer le doublon jamais connecté ? (n'affecte pas ERPNext ni l'historique)`, cancel: 'Annuler', ok: { label: 'Nettoyer', color: 'red-7' } }).onOk(async () => {
|
|
busy.value = r.email
|
|
try { const d = await dedupeAccounts(r.email); Notify.create({ type: 'positive', message: 'Gardé : ' + d.kept + (d.deleted && d.deleted.length ? ' · supprimé : ' + d.deleted.join(', ') : ''), caption: (d.skipped && d.skipped.length ? 'Ignoré : ' + d.skipped.join(', ') : ''), timeout: 4500 }); await load() }
|
|
catch (e) { Notify.create({ type: 'negative', message: e.message || e }) } finally { busy.value = '' }
|
|
})
|
|
}
|
|
async function tryDelete (r) {
|
|
busy.value = r.email
|
|
try {
|
|
const imp = await staffImpact(r.email)
|
|
if (!imp.clean) {
|
|
Dialog.create({ title: 'Suppression bloquée', message: `${r.name} a des références : ${imp.tickets} ticket(s), ${imp.jobs} job(s). Supprimer orphelinerait l'historique. Désactiver plutôt (garde l'historique) ?`, cancel: 'Annuler', ok: { label: 'Désactiver', color: 'orange-7' } })
|
|
.onOk(async () => { await setStaffActive(r.email, false); await load() })
|
|
return
|
|
}
|
|
Dialog.create({ title: 'Supprimer définitivement', message: `Aucune référence trouvée. Supprimer le compte Authentik + ERPNext de ${r.name} ? (irréversible)`, cancel: true, ok: { label: 'Supprimer', color: 'negative' } })
|
|
.onOk(async () => { const d = await deleteStaff(r.email); if (d.status === 409) { Notify.create({ type: 'warning', message: 'Références apparues — désactivé à la place' }); await setStaffActive(r.email, false) } else Notify.create({ type: 'positive', message: r.name + ' supprimé(e)' }); await load() })
|
|
} catch (e) { Notify.create({ type: 'negative', message: e.message || e }) } finally { busy.value = '' }
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="q-pa-sm">
|
|
<div class="text-caption text-grey-7 q-mb-sm">Réconcilie les comptes <b>Authentik</b> (source des utilisateurs actifs) avec les <b>System User / Employé / Technicien</b> d'OPS. Un compte actif sans System User = <b>non provisionné</b> (ex. Karim). Départ = désactiver (garde l'historique) ; suppression seulement si aucune référence.</div>
|
|
|
|
<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'},...(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" />
|
|
</div>
|
|
|
|
<div v-if="loading" class="text-center q-pa-md"><q-spinner size="24px" color="primary" /></div>
|
|
<q-list v-else bordered separator class="rounded-borders">
|
|
<q-item v-for="r in filtered" :key="r.email">
|
|
<q-item-section avatar style="min-width:34px">
|
|
<q-icon :name="statusMeta[r.status].icon" :color="statusMeta[r.status].color" size="20px"><q-tooltip>{{ statusMeta[r.status].label }}</q-tooltip></q-icon>
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<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" 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 }}
|
|
<span class="q-ml-sm">
|
|
<q-icon :name="r.has_system_user ? 'check' : 'close'" :color="r.has_system_user ? 'positive':'grey-5'" size="13px" /> user
|
|
<q-icon :name="r.employee ? 'check' : 'close'" :color="r.employee ? 'positive':'grey-5'" size="13px" class="q-ml-xs" /> employé
|
|
<q-icon :name="r.tech_id ? 'check' : 'close'" :color="r.tech_id ? 'positive':'grey-5'" size="13px" class="q-ml-xs" /> tech
|
|
<span v-if="r.groups.length" class="q-ml-xs text-grey-6">· {{ r.groups.join(', ') }}</span>
|
|
</span>
|
|
</q-item-label>
|
|
</q-item-section>
|
|
<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>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item v-if="!filtered.length"><q-item-section class="text-grey-6 text-caption">Aucun compte dans cette vue.</q-item-section></q-item>
|
|
</q-list>
|
|
|
|
<!-- Provisioning -->
|
|
<q-dialog :model-value="!!prov" @update:model-value="v => { if (!v) prov = null }">
|
|
<q-card style="min-width:400px;max-width:96vw" v-if="prov">
|
|
<q-card-section class="row items-center q-pb-none">
|
|
<q-icon name="person_add" color="primary" size="22px" class="q-mr-sm" />
|
|
<div class="text-subtitle1 text-weight-bold">Provisionner {{ prov.label }}</div>
|
|
<q-space /><q-btn flat round dense icon="close" @click="prov = null" />
|
|
</q-card-section>
|
|
<q-card-section class="q-gutter-sm">
|
|
<div class="text-caption text-grey-7">Crée les pièces manquantes : groupe Authentik + System User + Employé{{ prov.create_tech ? ' + Technicien' : '' }} + identité unifiée.</div>
|
|
<q-input dense outlined v-model="prov.label" label="Nom complet" />
|
|
<q-select dense outlined multiple use-chips v-model="prov.groups" :options="OPS_GROUPS" label="Groupe(s) OPS" />
|
|
<div class="row items-center q-gutter-md">
|
|
<q-toggle v-model="prov.create_employee" dense size="sm" label="Créer l'employé" />
|
|
<q-toggle v-model="prov.create_tech" dense size="sm" color="indigo" label="Créer le technicien (dispatch)" />
|
|
</div>
|
|
<q-option-group v-model="prov.kind" type="radio" inline dense :options="[{label:'Employé',value:'employee'},{label:'Sous-traitant',value:'subcontractor'}]" />
|
|
</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" @click="prov = null" />
|
|
<q-btn unelevated no-caps color="primary" icon="check" label="Provisionner" :loading="busy === prov.email" @click="doProvision" />
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
</div>
|
|
</template>
|