Compare commits
11 Commits
14530787bb
...
8e7838d1fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e7838d1fb | ||
|
|
718698f0d0 | ||
|
|
e1d3212bf7 | ||
|
|
28a5789fe0 | ||
|
|
e8dc86e058 | ||
|
|
6826f11587 | ||
|
|
d0f899cf6d | ||
|
|
11c53c956a | ||
|
|
148cb9a320 | ||
|
|
b43f092da3 | ||
|
|
dec8321823 |
|
|
@ -98,6 +98,30 @@ export async function updateJob (name, payload) {
|
|||
return apiPut('Dispatch Job', name, payload)
|
||||
}
|
||||
|
||||
// Complète un job VIA le chaînage canonique du hub (déblocage dépendances + activation abonnement
|
||||
// + facture prorata BROUILLON en bout de chaîne, gatée PRORATION_WRITE / BILLING_APPROVAL_GATE).
|
||||
// Réutilisé par la complétion d'un ticket de VENTE/installation. Renvoie { activated, invoices, pending_approval? }.
|
||||
export async function setJobStatusChain (job, status) {
|
||||
const res = await fetch(`${HUB_URL}/dispatch/job-status`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job, status }),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(data.error || ('Dispatch API ' + res.status))
|
||||
return data
|
||||
}
|
||||
|
||||
// Aperçu d'activation (LECTURE SEULE) : abonnements « En attente » du client+lieu + facture prorata consolidée
|
||||
// (TOUS services : internet+tv+téléphone en 1 facture). Sert à SAVOIR si une complétion activerait qqch + montre le total.
|
||||
export async function activationPreview ({ customer, service_location }) {
|
||||
const res = await fetch(`${HUB_URL}/billing/activation-preview`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ customer, service_location, statuses: ['En attente'] }),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(data.error || ('Billing API ' + res.status))
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createJob (payload) {
|
||||
const res = await authFetch(
|
||||
`${BASE_URL}/api/resource/Dispatch%20Job`,
|
||||
|
|
|
|||
17
apps/ops/src/api/identity.js
Normal file
17
apps/ops/src/api/identity.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// API identité unifiée — appelle le hub /identity/* (écritures réservées admin côté hub).
|
||||
import { HUB_URL as HUB } from 'src/config/hub'
|
||||
|
||||
async function j (path, init) {
|
||||
const r = await fetch(HUB + path, init)
|
||||
const d = await r.json().catch(() => ({}))
|
||||
if (!r.ok) throw new Error(d.error || ('Identity API ' + r.status))
|
||||
return d
|
||||
}
|
||||
const post = (path, body) => j(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })
|
||||
|
||||
export const getIdentityMap = () => j('/identity/map')
|
||||
// upsert : { key?, label, primary_email, alias_emails?, tech_id?, employee? }
|
||||
export const upsertIdentity = (body) => post('/identity', body)
|
||||
export const setAlias = (key, email, remove = false) => post('/identity/' + encodeURIComponent(key) + '/alias', { email, remove })
|
||||
export const mergeIdentity = (into, from) => post('/identity/merge', { into, from })
|
||||
export const deleteIdentity = (key) => j('/identity/' + encodeURIComponent(key), { method: 'DELETE' })
|
||||
21
apps/ops/src/api/staff.js
Normal file
21
apps/ops/src/api/staff.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// API console STAFF — réconciliation Authentik ↔ System User/Employee/Tech/identité (hub /auth/staff/*).
|
||||
// Écritures réservées admin (gaté côté hub) ; suppression GARDÉE (409 si références → proposer désactivation).
|
||||
import { HUB_URL as HUB } from 'src/config/hub'
|
||||
|
||||
async function j (path, init) {
|
||||
const r = await fetch(HUB + path, init)
|
||||
const d = await r.json().catch(() => ({}))
|
||||
if (!r.ok && r.status !== 409) throw new Error(d.error || ('Staff API ' + r.status))
|
||||
return { status: r.status, ...d }
|
||||
}
|
||||
const post = (path, body) => j(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })
|
||||
|
||||
export const listStaff = () => j('/auth/staff')
|
||||
export const provisionStaff = (body) => post('/auth/staff/provision', body)
|
||||
export const setStaffActive = (email, active) => post('/auth/staff/active', { email, active })
|
||||
export const staffImpact = (email) => j('/auth/staff/impact?email=' + encodeURIComponent(email))
|
||||
export const deleteStaff = (email) => j('/auth/staff?email=' + encodeURIComponent(email), { method: 'DELETE' })
|
||||
// Dé-dup : garde le compte Authentik utilisé, supprime le(s) doublon(s) jamais connecté(s) pour ce courriel.
|
||||
export const dedupeAccounts = (email) => post('/auth/staff/dedupe', { email })
|
||||
// Backfill : lie automatiquement Authentik ↔ Employee ↔ Tech dans l'identité unifiée (alias depuis le courriel entreprise).
|
||||
export const syncIdentities = () => post('/auth/staff/sync-identities', {})
|
||||
170
apps/ops/src/components/shared/IdentityManager.vue
Normal file
170
apps/ops/src/components/shared/IdentityManager.vue
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<script setup>
|
||||
/**
|
||||
* IdentityManager — gestion des IDENTITÉS UNIFIÉES (admins).
|
||||
*
|
||||
* Une personne = 1 identité { label (nom complet), primary_email (login SSO),
|
||||
* alias_emails[] (autres courriels utilisables), tech_id (lien Dispatch Technician) }.
|
||||
* Réconcilie le user système ↔ le tech, empêche les doublons d'assignation, et fait
|
||||
* afficher le nom complet partout (le hub résout tout alias/tech → label).
|
||||
* Écritures réservées admin (gaté côté hub) ; ré-échoue proprement sinon.
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Notify, Dialog } from 'quasar'
|
||||
import { getIdentityMap, upsertIdentity, setAlias, mergeIdentity, deleteIdentity } from 'src/api/identity'
|
||||
import { listTechnicians } from 'src/api/roster'
|
||||
import { useIdentity } from 'src/composables/useIdentity'
|
||||
|
||||
const list = ref([])
|
||||
const techs = ref([]) // [{ id, name }]
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const editing = ref(null) // identité en cours d'édition (copie)
|
||||
const aliasInput = ref('')
|
||||
const identStore = useIdentity()
|
||||
|
||||
const techOptions = computed(() => techs.value.map(t => ({ label: t.name + ' (' + t.id + ')', value: t.id })))
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (!q) return list.value
|
||||
return list.value.filter(i => (i.label + ' ' + i.primary_email + ' ' + (i.alias_emails || []).join(' ')).toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
async function load () {
|
||||
loading.value = true
|
||||
try {
|
||||
const [m, t] = await Promise.all([getIdentityMap(), listTechnicians().catch(() => ({ technicians: [] }))])
|
||||
list.value = (m.list || []).sort((a, b) => String(a.label).localeCompare(b.label))
|
||||
techs.value = (t.technicians || []).map(x => ({ id: x.id, name: x.name }))
|
||||
} catch (e) { Notify.create({ type: 'negative', message: 'Chargement impossible : ' + (e.message || e) }) } finally { loading.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
function newIdentity () { editing.value = { key: '', label: '', primary_email: '', alias_emails: [], tech_id: '', employee: '', active: true, kind: 'employee', _new: true } }
|
||||
function edit (i) { editing.value = JSON.parse(JSON.stringify(i)) }
|
||||
function cancel () { editing.value = null; aliasInput.value = '' }
|
||||
function techNameOf (id) { const t = techs.value.find(x => x.id === id); return t ? t.name : id }
|
||||
|
||||
function addAlias () {
|
||||
const e = aliasInput.value.trim().toLowerCase(); if (!e) return
|
||||
if (!/.+@.+\..+/.test(e)) { Notify.create({ type: 'warning', message: 'Courriel invalide' }); return }
|
||||
if (!editing.value.alias_emails.includes(e) && e !== editing.value.primary_email) editing.value.alias_emails.push(e)
|
||||
aliasInput.value = ''
|
||||
}
|
||||
function removeAlias (e) { editing.value.alias_emails = editing.value.alias_emails.filter(x => x !== e) }
|
||||
|
||||
async function saveEdit () {
|
||||
const it = editing.value
|
||||
if (!it.label.trim() || !it.primary_email.trim()) { Notify.create({ type: 'warning', message: 'Nom complet + courriel principal requis' }); return }
|
||||
try {
|
||||
await upsertIdentity({ key: it.key || it.primary_email, label: it.label.trim(), primary_email: it.primary_email.trim().toLowerCase(), alias_emails: it.alias_emails, tech_id: it.tech_id, employee: it.employee, active: it.active !== false, kind: it.kind || 'employee' })
|
||||
Notify.create({ type: 'positive', message: 'Identité enregistrée' })
|
||||
cancel(); await load(); identStore.reload()
|
||||
} catch (e) { Notify.create({ type: 'negative', message: (e.message || e) }) }
|
||||
}
|
||||
// Bloquer / réactiver SANS supprimer : départ = masqué des sélecteurs/roster, identité gardée pour l'historique.
|
||||
async function toggleActive (i) {
|
||||
try {
|
||||
await upsertIdentity({ ...i, active: i.active === false })
|
||||
await load(); identStore.reload()
|
||||
} catch (e) { Notify.create({ type: 'negative', message: e.message || e }) }
|
||||
}
|
||||
async function remove (i) {
|
||||
Dialog.create({ title: 'Supprimer l\'identité', message: 'Retirer « ' + i.label + ' » ? (n\'affecte pas les comptes, seulement la carte d\'identité unifiée)', cancel: true }).onOk(async () => {
|
||||
try { await deleteIdentity(i.key); await load(); identStore.reload() } catch (e) { Notify.create({ type: 'negative', message: e.message || e }) }
|
||||
})
|
||||
}
|
||||
// Fusion : réconcilier deux entrées de la même personne (ex. user système + tech en double).
|
||||
const mergeInto = ref(''); const mergeFrom = ref('')
|
||||
async function doMerge () {
|
||||
if (!mergeInto.value || !mergeFrom.value || mergeInto.value === mergeFrom.value) { Notify.create({ type: 'warning', message: 'Choisis 2 identités distinctes' }); return }
|
||||
try {
|
||||
await mergeIdentity(mergeInto.value, mergeFrom.value)
|
||||
Notify.create({ type: 'positive', message: 'Identités fusionnées' })
|
||||
mergeInto.value = ''; mergeFrom.value = ''; await load(); identStore.reload()
|
||||
} catch (e) { Notify.create({ type: 'negative', message: e.message || e }) }
|
||||
}
|
||||
const idOptions = computed(() => list.value.map(i => ({ label: i.label + ' · ' + i.primary_email, value: i.key })))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="q-pa-sm">
|
||||
<div class="row items-center q-gutter-sm q-mb-sm">
|
||||
<div class="text-caption text-grey-7 col">Une personne = un nom complet + le courriel de login SSO + ses <b>alias courriel</b> + son lien <b>technicien</b>. Empêche les doublons d'assignation et affiche le nom complet partout.</div>
|
||||
<q-btn dense unelevated no-caps color="primary" icon="person_add" label="Nouvelle identité" @click="newIdentity" />
|
||||
</div>
|
||||
<q-input dense outlined v-model="search" placeholder="Chercher un nom / courriel…" clearable class="q-mb-sm"><template #prepend><q-icon name="search" /></template></q-input>
|
||||
|
||||
<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="i in filtered" :key="i.key" :class="{ 'idm-departed': i.active === false }">
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-medium">
|
||||
{{ i.label }}
|
||||
<q-chip v-if="i.active === false" dense size="sm" color="grey-4" text-color="grey-8" icon="logout">parti(e)</q-chip>
|
||||
<q-chip v-if="i.kind === 'subcontractor'" dense size="sm" color="deep-purple-1" text-color="deep-purple-9" icon="handshake">sous-traitant</q-chip>
|
||||
<q-chip v-if="i.tech_id" dense size="sm" color="indigo-1" text-color="indigo-9" icon="engineering" class="q-ml-xs">{{ techNameOf(i.tech_id) }}</q-chip>
|
||||
</q-item-label>
|
||||
<q-item-label caption>
|
||||
<q-icon name="key" size="12px" /> {{ i.primary_email }}
|
||||
<span v-for="a in i.alias_emails" :key="a" class="q-ml-xs"><q-chip dense size="sm" color="grey-3" text-color="grey-8" icon="alternate_email">{{ a }}</q-chip></span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side class="row no-wrap">
|
||||
<q-btn flat dense round size="sm" :icon="i.active === false ? 'person_add' : 'block'" :color="i.active === false ? 'positive' : 'orange-7'" @click="toggleActive(i)"><q-tooltip>{{ i.active === false ? 'Réactiver' : 'Marquer parti(e) — bloque l\'assignation, garde l\'historique' }}</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="sm" icon="edit" color="primary" @click="edit(i)"><q-tooltip>Modifier</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="remove(i)"><q-tooltip>Supprimer</q-tooltip></q-btn>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="!filtered.length"><q-item-section class="text-grey-6 text-caption">Aucune identité{{ search ? ' pour « ' + search + ' »' : '' }}.</q-item-section></q-item>
|
||||
</q-list>
|
||||
|
||||
<!-- Fusion (réconcilier user système ↔ tech en double) -->
|
||||
<div class="q-mt-md q-pa-sm rounded-borders" style="border:1px dashed #cbd5e1">
|
||||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs"><q-icon name="merge" size="15px" /> Fusionner deux identités (même personne en double)</div>
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-select class="col" dense outlined emit-value map-options v-model="mergeInto" :options="idOptions" label="Garder (cible)" />
|
||||
<q-icon name="arrow_back" color="grey-6" />
|
||||
<q-select class="col" dense outlined emit-value map-options v-model="mergeFrom" :options="idOptions" label="Fusionner (disparaît)" />
|
||||
<q-btn dense unelevated no-caps color="deep-orange-6" icon="merge" label="Fusionner" :disable="!mergeInto || !mergeFrom" @click="doMerge" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Éditeur -->
|
||||
<q-dialog :model-value="!!editing" @update:model-value="v => { if (!v) cancel() }">
|
||||
<q-card style="min-width:420px;max-width:96vw" v-if="editing">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<q-icon name="badge" color="primary" size="22px" class="q-mr-sm" />
|
||||
<div class="text-subtitle1 text-weight-bold">{{ editing._new ? 'Nouvelle identité' : 'Modifier l\'identité' }}</div>
|
||||
<q-space /><q-btn flat round dense icon="close" @click="cancel" />
|
||||
</q-card-section>
|
||||
<q-card-section class="q-gutter-sm">
|
||||
<q-input dense outlined v-model="editing.label" label="Nom complet (affiché partout)" placeholder="Louis-Paul Bourdon" autofocus />
|
||||
<q-input dense outlined v-model="editing.primary_email" label="Courriel principal (login SSO)" placeholder="louispaul@targointernet.com" :hint="editing._new ? '' : 'Modifier avec précaution — c\'est la clé'" />
|
||||
<div>
|
||||
<div class="text-caption text-grey-7 q-mb-xs">Alias courriel (tous connectent au même user)</div>
|
||||
<div class="row items-center q-gutter-xs q-mb-xs">
|
||||
<q-chip v-for="a in editing.alias_emails" :key="a" dense removable color="grey-3" text-color="grey-9" @remove="removeAlias(a)">{{ a }}</q-chip>
|
||||
</div>
|
||||
<div class="row items-center q-gutter-xs">
|
||||
<q-input class="col" dense outlined v-model="aliasInput" placeholder="autre@courriel.com" @keyup.enter="addAlias" />
|
||||
<q-btn dense flat round icon="add" color="primary" @click="addAlias" />
|
||||
</div>
|
||||
</div>
|
||||
<q-select dense outlined clearable emit-value map-options v-model="editing.tech_id" :options="techOptions" label="Lié au technicien (Dispatch)" use-input @filter="(v,u)=>u()" />
|
||||
<div class="row items-center q-gutter-md">
|
||||
<q-toggle v-model="editing.active" dense size="sm" color="positive" :label="editing.active ? 'Actif' : 'Parti(e) — bloqué'" />
|
||||
<q-option-group v-model="editing.kind" type="radio" inline dense size="sm" :options="[{label:'Employé',value:'employee'},{label:'Sous-traitant',value:'subcontractor'}]" />
|
||||
</div>
|
||||
</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="cancel" />
|
||||
<q-btn unelevated no-caps color="primary" icon="save" label="Enregistrer" @click="saveEdit" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.idm-departed { opacity: .55; }
|
||||
</style>
|
||||
162
apps/ops/src/components/shared/StaffConsole.vue
Normal file
162
apps/ops/src/components/shared/StaffConsole.vue
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<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, syncIdentities } 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 syncing = ref(false)
|
||||
async function doSync () {
|
||||
syncing.value = true
|
||||
try { const r = await syncIdentities(); Notify.create({ type: 'positive', message: r.linked + ' identités liées (Authentik ↔ employé ↔ tech)' }); await load() }
|
||||
catch (e) { Notify.create({ type: 'negative', message: e.message || e }) } finally { syncing.value = false }
|
||||
}
|
||||
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 dense flat no-caps size="sm" color="indigo-7" icon="link" label="Lier les identités" :loading="syncing" @click="doSync"><q-tooltip>Lie automatiquement Authentik ↔ Employé ↔ Technicien dans l'identité unifiée (alias depuis le courriel entreprise)</q-tooltip></q-btn>
|
||||
<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>
|
||||
39
apps/ops/src/components/shared/TicketStatusMenu.vue
Normal file
39
apps/ops/src/components/shared/TicketStatusMenu.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script setup>
|
||||
/**
|
||||
* TicketStatusMenu — bouton « Statut » du ticket (distinct du bouton « Reporter » = snooze).
|
||||
*
|
||||
* Change le STATUT du billet Issue, avec la distinction demandée : fermer en **Complété**
|
||||
* (résolu avec succès → déclenche la fulfillment de vente : activation + facture) VS **Annulé**
|
||||
* (fermé sans suite). Émet 'set-status' ; le parent écrit et gère la suite (activation vente).
|
||||
* Purement additif — ne touche PAS TicketStatusControl (partagé, = report/snooze).
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
status: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['set-status'])
|
||||
|
||||
// Complété = Resolved (résolu) · Annulé = Closed (fermé sans suite). Les autres = états de travail.
|
||||
const META = {
|
||||
Open: { label: 'Ouvert', color: 'blue-6', icon: 'radio_button_unchecked' },
|
||||
Replied: { label: 'Répondu', color: 'teal-6', icon: 'reply' },
|
||||
'On Hold': { label: 'En attente', color: 'orange-7', icon: 'pause_circle' },
|
||||
Resolved: { label: 'Complété', color: 'green-7', icon: 'check_circle' },
|
||||
Closed: { label: 'Annulé', color: 'grey-6', icon: 'cancel' },
|
||||
}
|
||||
const cur = computed(() => META[props.status] || { label: props.status || 'Statut', color: 'grey-7', icon: 'flag' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-btn-dropdown unelevated no-caps :color="cur.color" :icon="cur.icon" :label="'Statut : ' + cur.label" dense>
|
||||
<q-list dense style="min-width:210px">
|
||||
<q-item-label header class="q-py-xs">Changer le statut</q-item-label>
|
||||
<q-item clickable v-close-popup @click="emit('set-status', 'Open')"><q-item-section avatar><q-icon name="radio_button_unchecked" color="blue-6" /></q-item-section><q-item-section>Ouvert</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="emit('set-status', 'On Hold')"><q-item-section avatar><q-icon name="pause_circle" color="orange-7" /></q-item-section><q-item-section>En attente</q-item-section></q-item>
|
||||
<q-separator />
|
||||
<q-item clickable v-close-popup @click="emit('set-status', 'Resolved')"><q-item-section avatar><q-icon name="check_circle" color="green-7" /></q-item-section><q-item-section><q-item-label>Complété</q-item-label><q-item-label caption>Résolu — pour une vente : active + facture</q-item-label></q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="emit('set-status', 'Closed')"><q-item-section avatar><q-icon name="cancel" color="grey-6" /></q-item-section><q-item-section><q-item-label>Annulé</q-item-label><q-item-label caption>Fermé sans suite (pas de facturation)</q-item-label></q-item-section></q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</template>
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
<template>
|
||||
<!-- Quick actions -->
|
||||
<!-- Flow retiré : la relance de flow sera pilotée par l'automatisation (Réglages généraux), pas manuellement depuis le ticket. -->
|
||||
<div class="issue-actions q-mb-sm">
|
||||
<div class="issue-actions q-mb-sm row items-center q-gutter-xs">
|
||||
<TicketStatusMenu :status="doc.status" @set-status="onSetStatus" />
|
||||
<TicketStatusControl doctype="Issue" :docname="docName" :status="doc.status"
|
||||
@changed="p => { if (p && p.status) doc.status = p.status; $emit('reply-sent', docName) }" />
|
||||
<!-- Fulfillment vente : complète l'installation liée → active l'abonnement + facture prorata BROUILLON (flux existant, gaté) -->
|
||||
<q-btn v-if="canActivate" dense unelevated no-caps color="deep-purple-6" icon="paid" :label="'Activer + facture brouillon' + (actPreview && actPreview.prorated_total != null ? ' (' + actPreview.prorated_total + '$)' : '')" :loading="activating" @click="activateSale">
|
||||
<q-tooltip>{{ (actPreview.lines || []).length }} abonnement(s) en attente → active + facture prorata BROUILLON (à approuver). Complète l'installation « {{ saleJob.subject || saleJob.name }} ».</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
<!-- Client lié (comme une conversation : cherchable par nom, association 1 clic).
|
||||
|
|
@ -39,7 +44,7 @@
|
|||
<q-icon name="engineering" size="18px" :color="assignedPeople.length ? 'indigo-6' : 'grey-5'" />
|
||||
<span class="tech-banner-lbl">Technicien</span>
|
||||
<template v-if="assignedPeople.length">
|
||||
<q-chip v-for="p in assignedPeople" :key="p" dense color="indigo-1" text-color="indigo-9"><q-avatar color="indigo-6" text-color="white" size="20px">{{ initials(p) }}</q-avatar>{{ p }}</q-chip>
|
||||
<q-chip v-for="p in assignedPeople" :key="p" dense color="indigo-1" text-color="indigo-9"><UserAvatar :name="p" :size="20" card class="q-mr-xs" />{{ p }}</q-chip>
|
||||
</template>
|
||||
<span v-else class="text-caption text-grey-6">Non assigné</span>
|
||||
</div>
|
||||
|
|
@ -49,7 +54,7 @@
|
|||
<div class="assign-block q-mb-sm">
|
||||
<div class="info-block-title" style="margin-bottom:4px">Assignation</div>
|
||||
<AssignmentField doctype="Issue" :doc-name="docName"
|
||||
:assignee="issueAssignee" :assistants="issueAssistants" :tech-options="userOptions" :can-onsite="false"
|
||||
:assignee="issueAssignee" :assistants="issueAssistants" :tech-options="unifiedUserOptions" :can-onsite="false"
|
||||
@assign="assignUser" @set-assistant="p => assignUser(p.value)" @unassign="unassignFirst" @remove-assistant="removeAssignee" />
|
||||
</div>
|
||||
|
||||
|
|
@ -86,7 +91,10 @@
|
|||
</div>
|
||||
<div class="if-row" v-if="doc.raised_by">
|
||||
<span class="if-label">Soumis par</span>
|
||||
<span class="if-value">{{ doc.raised_by }}</span>
|
||||
<span class="if-value" style="display:flex;align-items:center;gap:6px;min-width:0">
|
||||
<UserAvatar :email="doc.raised_by" :name="doc.raised_by" :size="20" card />
|
||||
<span class="ellipsis">{{ doc.raised_by }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -230,8 +238,15 @@
|
|||
<div class="thread-wrap">
|
||||
<div v-for="m in thread" :key="m.id" class="msg-bubble" :class="'msg-' + m.kind">
|
||||
<div class="msg-head">
|
||||
<span class="msg-avatar">{{ initials(m.who) }}</span>
|
||||
<UserAvatar :email="m.email" :name="m.who" :size="22" card class="q-mr-xs" />
|
||||
<span class="msg-who">{{ m.who }}</span>
|
||||
<!-- Pastille de visibilité CLIQUABLE : bascule Interne ↔ Public (relabel seul, aucun courriel) -->
|
||||
<span class="msg-vis" :class="[m.public ? 'vis-pub' : 'vis-int', { 'vis-busy': togglingMsg === m.name }]"
|
||||
role="button" tabindex="0" @click="toggleMsgVisibility(m)" @keyup.enter="toggleMsgVisibility(m)">
|
||||
<q-spinner v-if="togglingMsg === m.name" size="11px" />
|
||||
<q-icon v-else :name="m.public ? 'visibility' : 'lock'" size="11px" />{{ m.public ? 'Client' : 'Interne' }}
|
||||
<q-tooltip class="bg-grey-9" style="font-size:11px">{{ m.public ? 'Visible par le client — cliquer pour repasser en interne' : 'Note interne (privée) — cliquer pour rendre visible au client (sans courriel)' }}</q-tooltip>
|
||||
</span>
|
||||
<span class="msg-when">{{ formatDateTime(m.when) }}</span>
|
||||
</div>
|
||||
<div class="msg-body" v-html="richHtml(m.content)"></div>
|
||||
|
|
@ -264,39 +279,21 @@
|
|||
placeholder="Écrire une réponse ou une note…"
|
||||
:input-style="{ fontSize: '0.85rem', minHeight: '50px' }"
|
||||
@keydown.ctrl.enter="sendReply" @keydown.meta.enter="sendReply" />
|
||||
<div class="row justify-end q-gutter-sm q-mt-xs">
|
||||
<q-btn outline dense size="sm" label="Note interne" color="grey-8" icon="sticky_note_2"
|
||||
:disable="!replyContent?.trim()" :loading="sendingReply" @click="sendReply">
|
||||
<q-tooltip>Ajoute une note au fil — non envoyée au client</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn unelevated dense size="sm" label="Envoyer par courriel" color="primary" icon="mail" @click="openEmailDialog">
|
||||
<q-tooltip>Envoyer par courriel ; la réponse du destinataire reviendra dans ce ticket</q-tooltip>
|
||||
</q-btn>
|
||||
<!-- UNE seule action + une case « Envoyer au client ». Défaut = interne (privé). Cocher = public + courriel (lien ticket ; la réponse revient au fil). -->
|
||||
<div class="row items-center justify-between q-mt-xs no-wrap">
|
||||
<q-checkbox v-model="replyToClient" dense size="sm" :color="replyToClient ? 'deep-orange-9' : 'grey-7'">
|
||||
<span class="reply-cb-lbl" :class="{ 'reply-cb-pub': replyToClient }">
|
||||
<q-icon :name="replyToClient ? 'warning_amber' : 'lock'" size="14px" />
|
||||
{{ replyToClient ? 'Envoyer au client (visible + courriel)' : 'Note interne (privée)' }}
|
||||
</span>
|
||||
<q-tooltip class="bg-grey-9" style="font-size:11px">{{ replyToClient ? '⚠️ Visible par le CLIENT : marqué public + courriel avec lien vers le ticket (sa réponse revient au fil)' : 'Reste PRIVÉ — non visible par le client. Cochez pour envoyer au client.' }}</q-tooltip>
|
||||
</q-checkbox>
|
||||
<q-btn unelevated dense size="sm" :label="replyToClient ? 'Envoyer au client' : 'Ajouter la note'"
|
||||
:color="replyToClient ? 'deep-orange-9' : 'grey-8'" :icon="replyToClient ? 'send' : 'lock'"
|
||||
:disable="!replyContent?.trim()" :loading="sendingReply" @click="sendReply" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aller-retour courriel : envoie le ticket, la réponse revient au fil du ticket -->
|
||||
<q-dialog v-model="showEmailDialog">
|
||||
<q-card style="min-width:min(92vw,460px)">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<q-icon name="mail" color="primary" class="q-mr-sm" />
|
||||
<div class="text-subtitle1 text-weight-bold">Envoyer par courriel</div>
|
||||
<q-space /><q-btn flat dense round icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section class="q-gutter-sm">
|
||||
<q-input v-model="emailTo" dense outlined label="À (courriel)" type="email" autofocus />
|
||||
<q-input v-model="emailCc" dense outlined label="Cc (optionnel)" />
|
||||
<q-input v-model="emailMessage" dense outlined type="textarea" autogrow label="Message" :input-style="{ minHeight: '90px' }" />
|
||||
<div class="text-caption text-grey-6"><q-icon name="info" size="13px" /> Un lien vers le ticket est ajouté. La réponse du destinataire s'ajoutera automatiquement à ce ticket.</div>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Annuler" v-close-popup />
|
||||
<q-btn unelevated color="primary" icon="send" label="Envoyer" :loading="sendingEmail"
|
||||
:disable="!emailTo || !/.+@.+\..+/.test(emailTo)" @click="sendTicketEmail" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Create single Dispatch Job dialog -->
|
||||
<UnifiedCreateModal
|
||||
v-model="showCreateDialog"
|
||||
|
|
@ -342,7 +339,11 @@ import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
|
|||
import TaskNode from 'src/components/shared/TaskNode.vue'
|
||||
import TagEditor from 'src/components/shared/TagEditor.vue'
|
||||
import AssignmentField from 'src/components/shared/AssignmentField.vue'
|
||||
import UserAvatar from 'src/components/shared/UserAvatar.vue'
|
||||
import AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison → compétence → disponibilité (dénominateur filtré)
|
||||
import { useIdentity } from 'src/composables/useIdentity' // identité unifiée : dé-dup assignation (alias/tech = même personne) + nom complet
|
||||
import TicketStatusMenu from 'src/components/shared/TicketStatusMenu.vue' // bouton « Statut » (Complété/Annulé) — distinct du snooze « Reporter »
|
||||
import { setJobStatusChain, activationPreview } from 'src/api/dispatch' // complète le job lié → active abonnement + facture brouillon (flux existant, gaté) + aperçu lecture seule
|
||||
// Modules on-site / dispatch RÉUTILISABLES (mêmes composants que le volet Planification) — montés ici pour la tâche dispatch principale du ticket.
|
||||
import JobMapModule from 'src/components/shared/detail-sections/modules/JobMapModule.vue'
|
||||
import GeofenceTimeline from 'src/components/shared/detail-sections/modules/GeofenceTimeline.vue'
|
||||
|
|
@ -372,6 +373,20 @@ const { userName } = usePermissions()
|
|||
const senderFirst = computed(() => { const n = (userName.value || '').trim(); return (n ? n.split(/\s+/)[0] : (authStore.user || '').split('@')[0]) || 'Agent' })
|
||||
const senderFull = computed(() => ((userName.value || '').trim() || (authStore.user || '').split('@')[0] || 'Agent'))
|
||||
const sendingReply = ref(false)
|
||||
const replyToClient = ref(false) // case à cocher : OFF = note interne (défaut sûr) · ON = public + envoi au client
|
||||
const EMAIL_RE = /.+@.+\..+/
|
||||
// Ticket INITIÉ par le client ? (soumis par un courriel externe, OU le client a écrit dans le fil).
|
||||
// → si oui, la case « Envoyer au client » est PRÉ-COCHÉE (mais tout envoi public exige une confirmation à la soumission).
|
||||
// Ticket interne (créé par un agent) → case décochée par défaut = note interne.
|
||||
const clientInitiated = computed(() => {
|
||||
const me = (authStore.user || '').toLowerCase()
|
||||
const rb = String(props.doc?.raised_by || '').trim().toLowerCase()
|
||||
if (rb && EMAIL_RE.test(rb) && rb !== me) return true
|
||||
return (props.comms || []).some(c => String(c.sent_or_received || '') === 'Received')
|
||||
})
|
||||
const clientEmailHint = computed(() => { const rb = props.doc?.raised_by; return (rb && EMAIL_RE.test(rb)) ? rb : '' })
|
||||
// Cale le défaut de la case à l'ouverture d'un ticket (client → coché · interne → décoché). Ne clobbe pas un basculement manuel en cours.
|
||||
watch(() => props.docName, () => { replyToClient.value = clientInitiated.value }, { immediate: true })
|
||||
const showCreateDialog = ref(false)
|
||||
const showProjectWizard = ref(false)
|
||||
const availReasonOpen = ref(false)
|
||||
|
|
@ -402,11 +417,43 @@ function initials (name) {
|
|||
return ((p[0]?.[0] || '') + (p[1]?.[0] || '')).toUpperCase() || '?'
|
||||
}
|
||||
|
||||
// Fusionne échanges (Communication) + notes (Comment) en UN fil chronologique
|
||||
// ── Visibilité PAR MESSAGE, surchargeable au clic (Interne privé ↔ Public client) ──
|
||||
// Défaut par doctype : Communication = échange CLIENT (public) · Comment = note INTERNE (privé). Mais on peut
|
||||
// BASCULER un message précis : la surcharge explicite est stockée au hub (overlay, clé = docname du message) car
|
||||
// les Communication mêlent notes internes ET courriels → on ne peut PAS trancher au doctype seul. « Rendre public »
|
||||
// = RELABEL uniquement, AUCUN courriel envoyé ; le client voit les messages publics via le portail/lien à jeton.
|
||||
const msgPublic = ref({}) // surcharges explicites { [docname]: true|false }
|
||||
const togglingMsg = ref('')
|
||||
async function loadMsgVisibility () {
|
||||
try {
|
||||
const r = await fetch(`${HUB_URL}/conversations/msg-visibility`).then(x => x.json())
|
||||
msgPublic.value = (r && r.public) || {}
|
||||
} catch { /* hub indispo (aperçu) → on garde le défaut par doctype */ }
|
||||
}
|
||||
async function toggleMsgVisibility (m) {
|
||||
if (!m || !m.name || togglingMsg.value) return
|
||||
const next = !m.public
|
||||
togglingMsg.value = m.name
|
||||
try {
|
||||
const r = await fetch(`${HUB_URL}/conversations/msg-visibility`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: m.name, public: next }),
|
||||
}).then(x => x.json())
|
||||
if (r && r.ok) {
|
||||
msgPublic.value = { ...msgPublic.value, [m.name]: next }
|
||||
Notify.create({ type: 'positive', icon: next ? 'visibility' : 'lock', message: next ? 'Rendu PUBLIC — visible par le client (aucun courriel envoyé)' : 'Repassé en INTERNE (privé)', timeout: 2200 })
|
||||
} else Notify.create({ type: 'negative', message: (r && r.error) || 'Échec', timeout: 2500 })
|
||||
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 2500 }) }
|
||||
finally { togglingMsg.value = '' }
|
||||
}
|
||||
|
||||
// Fusionne échanges (Communication) + notes (Comment) en UN fil chronologique.
|
||||
// Visibilité = surcharge explicite (msgPublic) SI présente, sinon défaut par doctype (Communication=public, Comment=interne).
|
||||
const thread = computed(() => {
|
||||
const items = []
|
||||
for (const c of props.comms || []) items.push({ id: 'e-' + c.name, who: c.sender_full_name || c.sender || c.owner || 'Système', when: c.creation, content: c.content, kind: 'comm' })
|
||||
for (const c of props.comments || []) items.push({ id: 'c-' + c.name, who: c.comment_by || 'Système', when: c.creation, content: c.content, kind: 'note' })
|
||||
const ov = msgPublic.value
|
||||
for (const c of props.comms || []) items.push({ id: 'e-' + c.name, name: c.name, email: c.sender || c.owner || '', who: c.sender_full_name || c.sender || c.owner || 'Système', when: c.creation, content: c.content, kind: 'comm', public: (c.name in ov) ? !!ov[c.name] : true })
|
||||
for (const c of props.comments || []) items.push({ id: 'c-' + c.name, name: c.name, email: c.comment_email || c.comment_by || '', who: c.comment_by || 'Système', when: c.creation, content: c.content, kind: 'note', public: (c.name in ov) ? !!ov[c.name] : false })
|
||||
return items.sort((a, b) => String(a.when || '').localeCompare(String(b.when || '')))
|
||||
})
|
||||
|
||||
|
|
@ -495,20 +542,36 @@ async function loadUsers () {
|
|||
} catch { userOptions.value = [] }
|
||||
loadingUsers.value = false
|
||||
}
|
||||
// Identité unifiée : nom complet + fusion des comptes-alias d'une même personne.
|
||||
const ident = useIdentity()
|
||||
// Options SANS doublon d'identité : un seul choix par personne (les alias courriel sont fusionnés),
|
||||
// libellé = nom canonique (identité) sinon full_name. Recalcule quand la carte d'identité est chargée.
|
||||
const unifiedUserOptions = computed(() => {
|
||||
const seen = new Set(); const out = []
|
||||
for (const o of userOptions.value) {
|
||||
if (ident.activeOf(o.value) === false) continue // personne partie → non assignable (identité gardée pour l'historique)
|
||||
const k = ident.keyOf(o.value)
|
||||
if (seen.has(k)) continue
|
||||
seen.add(k)
|
||||
out.push({ label: ident.labelOf(o.value) || o.label, value: o.value })
|
||||
}
|
||||
return out
|
||||
})
|
||||
const displayName = (email) => ident.labelOf(email) || (userOptions.value.find(u => u.value === email) || {}).label || email
|
||||
|
||||
// ── Adaptateurs pour AssignmentField (champ multifonction partagé) ──
|
||||
// Le ticket assigne des Users via frappe _assign : 1er = assigné (À), suivants = assistants (CC). Followers gérés par le composant (doctype Issue).
|
||||
const issueAssignee = computed(() => {
|
||||
const e = assignees.value[0]; if (!e) return null
|
||||
const o = userOptions.value.find(u => u.value === e)
|
||||
return { id: e, name: (o && o.label) || e }
|
||||
return { id: e, name: displayName(e) }
|
||||
})
|
||||
const issueAssistants = computed(() => assignees.value.slice(1).map(e => {
|
||||
const o = userOptions.value.find(u => u.value === e)
|
||||
return { id: e, name: (o && o.label) || e }
|
||||
}))
|
||||
const issueAssistants = computed(() => assignees.value.slice(1).map(e => ({ id: e, name: displayName(e) })))
|
||||
async function assignUser (email) {
|
||||
const e = String(email || '').trim(); if (!e || assignees.value.includes(e)) return
|
||||
const e = String(email || '').trim(); if (!e) return
|
||||
// Dé-dup par IDENTITÉ, pas par courriel : « m'ajouter » (louis@…) + recherche « Louis-Paul » (louispaul@…)
|
||||
// = la même personne → on n'ajoute pas de doublon (ni le même compte deux fois).
|
||||
const k = ident.keyOf(e)
|
||||
if (assignees.value.some(x => ident.keyOf(x) === k)) { Notify.create({ type: 'info', message: displayName(e) + ' est déjà sur ce ticket', timeout: 1800 }); return }
|
||||
const next = [...assignees.value, e]
|
||||
assignees.value = next
|
||||
await saveAssignees(next)
|
||||
|
|
@ -548,7 +611,7 @@ async function saveAssignees (newList) {
|
|||
}
|
||||
}
|
||||
|
||||
onMounted(() => { loadUsers(); loadTagCatalog() })
|
||||
onMounted(() => { loadUsers(); loadTagCatalog(); loadMsgVisibility() })
|
||||
|
||||
// ── Intervention sur site / dispatch : modules montés pour la tâche dispatch PRINCIPALE du ticket ──
|
||||
// Principale = une tâche avec coordonnées OU billet legacy (aspect terrain) ; sinon la 1re racine ; sinon la 1re.
|
||||
|
|
@ -558,6 +621,57 @@ const primaryJob = computed(() => {
|
|||
if (!jobs.length) return null
|
||||
return jobs.find(j => (j.latitude && j.longitude) || j.legacy_ticket_id) || jobs.find(j => !j.parent_job) || jobs[0]
|
||||
})
|
||||
// ── Statut du ticket (bouton « Statut » : Complété/Annulé) + fulfillment de vente ──
|
||||
const activating = ref(false)
|
||||
// Tâche liée encore ouverte (installation à compléter) → cible du bouton « Activer + facture brouillon ».
|
||||
const saleJob = computed(() => (props.dispatchJobs || []).find(j => !['Completed', 'Cancelled'].includes(j.status)) || null)
|
||||
// Aperçu d'activation (lecture seule) : abonnements EN ATTENTE du client+lieu + total prorata consolidé (tous services).
|
||||
// C'est LUI qui définit « quel ticket déclenche une activation » : on n'affiche le bouton que s'il y a du EN ATTENTE.
|
||||
const actPreview = ref(null)
|
||||
async function loadActivationPreview () {
|
||||
const cust = props.doc?.customer; const loc = props.doc?.service_location
|
||||
if (!cust) { actPreview.value = null; return }
|
||||
try { const p = await activationPreview({ customer: cust, service_location: loc }); actPreview.value = (p && (p.lines || []).length) ? p : null }
|
||||
catch (e) { actPreview.value = null }
|
||||
}
|
||||
onMounted(loadActivationPreview)
|
||||
// Le bouton d'activation n'apparaît QUE si (installation liée ouverte) ET (abonnements en attente à activer).
|
||||
const canActivate = computed(() => !!(saleJob.value && actPreview.value))
|
||||
async function onSetStatus (status) {
|
||||
try {
|
||||
await updateDoc('Issue', props.docName, { status })
|
||||
props.doc.status = status
|
||||
Notify.create({ type: 'positive', message: 'Statut : ' + status, timeout: 1400 })
|
||||
emit('reply-sent', props.docName)
|
||||
// « Complété » sur une vente avec une installation à compléter → propose l'activation (pas d'auto : l'agent confirme).
|
||||
if (status === 'Resolved' && canActivate.value) setTimeout(activateSale, 300)
|
||||
} catch (e) { Notify.create({ type: 'negative', message: 'Statut : ' + (e.message || e) }) }
|
||||
}
|
||||
// Réutilise le flux hub EXISTANT : compléter le job lié → déblocage chaîne + activation abonnement + facture prorata BROUILLON
|
||||
// (gaté PRORATION_WRITE / BILLING_APPROVAL_GATE). N'AUTO-FACTURE jamais : draft à approuver par la facturation.
|
||||
function activateSale () {
|
||||
const j = saleJob.value; if (!j || activating.value) return
|
||||
const p = actPreview.value || {}
|
||||
const lines = (p.lines || []).map(l => `• ${l.label}${l.amount != null ? ' — ' + l.amount + '$' : ''}`).join('<br>')
|
||||
Dialog.create({
|
||||
title: 'Activer la vente',
|
||||
message: `Compléter l'installation « ${j.subject || j.name} » et activer <b>${(p.lines || []).length} service(s)</b> en attente ?<br><br>${lines}<br><br><b>Facture prorata consolidée</b>${p.prorated_total != null ? ' ≈ ' + p.prorated_total + '$' : ''}${p.billed_days ? ' (' + p.billed_days + '/' + p.period_days + ' j)' : ''} → créée en <b>BROUILLON</b> (à éditer/approuver par la facturation) si la facturation est armée ; sinon activation sans facture.`,
|
||||
html: true, cancel: 'Annuler', ok: { label: 'Activer', color: 'deep-purple-6' },
|
||||
}).onOk(async () => {
|
||||
activating.value = true
|
||||
try {
|
||||
const r = await setJobStatusChain(j.name, 'Completed')
|
||||
const acts = (r.activated || []).length; const inv = (r.invoices || [])[0]
|
||||
let msg
|
||||
if (r.pending_approval) msg = 'Activation placée EN ATTENTE D\'APPROBATION (facturation)'
|
||||
else if (inv) msg = acts + ' abonnement(s) activé(s) · facture brouillon ' + inv.name
|
||||
else if (acts) msg = acts + ' abonnement(s) activé(s) · facture non émise (écriture désarmée)'
|
||||
else msg = 'Installation complétée — aucun abonnement en attente à activer'
|
||||
Notify.create({ type: 'positive', message: msg, timeout: 6000 })
|
||||
j.status = 'Completed'; actPreview.value = null; emit('dispatch-updated', j.name)
|
||||
} catch (e) { Notify.create({ type: 'negative', message: 'Activation : ' + (e.message || e), timeout: 5000 }) } finally { activating.value = false }
|
||||
})
|
||||
}
|
||||
// Signaux « terrain » → affichage AUTO de la section ; sinon bascule manuelle (« nécessite une visite sur place »).
|
||||
const onsiteAuto = computed(() => {
|
||||
const j = primaryJob.value; if (!j) return false
|
||||
|
|
@ -601,9 +715,10 @@ async function onJobUnassignLead () {
|
|||
emit('dispatch-updated', j.name, { assigned_tech: '' })
|
||||
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }) }
|
||||
}
|
||||
watch(showOnsite, v => { if (v) loadJobTechs() })
|
||||
// Bascule persistée par ticket (localStorage — pas de champ ERPNext requis). Défaut = auto-détection.
|
||||
// ⚠️ showOnsite DOIT être déclaré AVANT tout watch qui le référence (sinon ReferenceError TDZ au setup → composant blanc).
|
||||
const showOnsite = ref(false)
|
||||
watch(showOnsite, v => { if (v) loadJobTechs() })
|
||||
const onsiteKey = () => 'ops-ticket-onsite-' + (props.docName || '')
|
||||
watch([primaryJob, () => props.docName], () => {
|
||||
let stored = null
|
||||
|
|
@ -802,71 +917,90 @@ function onJobUpdated (jobName, data) {
|
|||
|
||||
// ── Reply ──
|
||||
|
||||
async function sendReply () {
|
||||
// Courriel du client (destinataire de « Envoyer au client ») : raised_by si c'est un courriel, sinon email_id du compte.
|
||||
async function resolveClientEmail () {
|
||||
const rb = props.doc?.raised_by
|
||||
if (rb && EMAIL_RE.test(rb)) return rb
|
||||
if (props.doc?.customer) {
|
||||
try {
|
||||
const rows = await listDocs('Customer', { filters: { name: props.doc.customer }, fields: ['email_id'], limit: 1 })
|
||||
const e = rows?.[0]?.email_id
|
||||
if (e && EMAIL_RE.test(e)) return e
|
||||
} catch { /* pas d'email → visible au portail seulement */ }
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// Soumission : une note INTERNE part directement ; un envoi PUBLIC (au client) demande une CONFIRMATION explicite
|
||||
// (garde-fou, surtout quand la case est pré-cochée pour un ticket initié par le client).
|
||||
function sendReply () {
|
||||
if (!replyContent.value?.trim() || sendingReply.value) return
|
||||
if (!replyToClient.value) { doSendReply(false); return }
|
||||
Dialog.create({
|
||||
title: 'Répondre au client ?',
|
||||
message: `Ce message sera <b>visible par le client</b> et lui sera envoyé par courriel${clientEmailHint.value ? ' (' + clientEmailHint.value + ')' : ''}. Pour une note interne, décochez « Envoyer au client ».`,
|
||||
html: true,
|
||||
cancel: { flat: true, label: 'Annuler' },
|
||||
ok: { color: 'deep-orange-9', unelevated: true, label: 'Envoyer au client', icon: 'send' },
|
||||
}).onOk(() => doSendReply(true))
|
||||
}
|
||||
|
||||
// UNE seule action. Défaut (case décochée) = NOTE INTERNE (privée). Cochée = PUBLIC + courriel au client.
|
||||
// La visibilité est écrite EXPLICITEMENT dans l'overlay (interne=false / public=true) → corrige le bug « note interne
|
||||
// affichée Client » (le défaut par doctype classait toute Communication en public).
|
||||
async function doSendReply (isPublic) {
|
||||
if (!replyContent.value?.trim() || sendingReply.value) return
|
||||
const text = replyContent.value.trim()
|
||||
sendingReply.value = true
|
||||
try {
|
||||
await createDoc('Communication', {
|
||||
const created = await createDoc('Communication', {
|
||||
communication_type: 'Communication',
|
||||
communication_medium: 'Other',
|
||||
sent_or_received: 'Sent',
|
||||
subject: props.title || props.docName,
|
||||
content: replyContent.value.trim(),
|
||||
content: text,
|
||||
sender: authStore.user || '',
|
||||
sender_full_name: senderFirst.value,
|
||||
sender_full_name: senderFull.value, // nom réel de l'agent (jamais le courriel brut)
|
||||
reference_doctype: 'Issue',
|
||||
reference_name: props.docName,
|
||||
})
|
||||
replyContent.value = ''
|
||||
emit('reply-sent', props.docName)
|
||||
Notify.create({ type: 'positive', message: 'Reponse envoyee', timeout: 2000 })
|
||||
} catch {
|
||||
Notify.create({ type: 'negative', message: 'Erreur: reponse non envoyee', timeout: 3000 })
|
||||
} finally {
|
||||
sendingReply.value = false
|
||||
const name = created && (created.name || (created.data && created.data.name))
|
||||
// Visibilité explicite (source unique = overlay hub) → rend correctement Interne/Client dès le rechargement.
|
||||
if (name) {
|
||||
try {
|
||||
await fetch(`${HUB_URL}/conversations/msg-visibility`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, public: isPublic }) })
|
||||
msgPublic.value = { ...msgPublic.value, [name]: isPublic }
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Envoi par courriel (aller-retour : la réponse revient au ticket) ──
|
||||
|
||||
const showEmailDialog = ref(false)
|
||||
const emailTo = ref('')
|
||||
const emailCc = ref('')
|
||||
const emailMessage = ref('')
|
||||
const sendingEmail = ref(false)
|
||||
|
||||
function openEmailDialog () {
|
||||
emailTo.value = (props.doc?.raised_by && /.+@.+\..+/.test(props.doc.raised_by)) ? props.doc.raised_by : ''
|
||||
emailCc.value = ''
|
||||
emailMessage.value = replyContent.value?.trim() || ''
|
||||
showEmailDialog.value = true
|
||||
}
|
||||
|
||||
async function sendTicketEmail () {
|
||||
if (!emailTo.value || sendingEmail.value) return
|
||||
sendingEmail.value = true
|
||||
// Public → envoi au client (lien vers le ticket ; sa réponse revient au fil si l'ingestion courriel est active).
|
||||
let emailed = false
|
||||
if (isPublic) {
|
||||
const to = await resolveClientEmail()
|
||||
if (to) {
|
||||
try {
|
||||
const res = await fetch(`${HUB_URL}/conversations/ticket-email`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
issue: props.docName, to: emailTo.value.trim(), cc: emailCc.value.trim(),
|
||||
message: emailMessage.value.trim(), link: window.location.href,
|
||||
agentName: senderFull.value, // sortie courriel → expéditeur = prénom+nom de l'agent (pas « louis »)
|
||||
}),
|
||||
body: JSON.stringify({ issue: props.docName, to, message: text, link: window.location.href, agentName: senderFull.value }),
|
||||
})
|
||||
const d = await res.json()
|
||||
if (d.ok) {
|
||||
Notify.create({ type: 'positive', message: 'Courriel envoyé — la réponse reviendra à ce ticket', timeout: 2800 })
|
||||
showEmailDialog.value = false
|
||||
replyContent.value = ''
|
||||
emit('reply-sent', props.docName)
|
||||
} else {
|
||||
Notify.create({ type: 'negative', message: d.error || 'Échec de l’envoi', timeout: 3500 })
|
||||
const d = await res.json(); emailed = !!(d && d.ok)
|
||||
} catch { /* le message reste public/visible même si le courriel échoue */ }
|
||||
}
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3500 })
|
||||
}
|
||||
replyContent.value = ''
|
||||
replyToClient.value = clientInitiated.value // retour au défaut selon l'origine (client → coché · interne → décoché)
|
||||
emit('reply-sent', props.docName)
|
||||
Notify.create({
|
||||
type: 'positive',
|
||||
icon: isPublic ? (emailed ? 'send' : 'visibility') : 'lock',
|
||||
message: isPublic
|
||||
? (emailed ? 'Envoyé au client (courriel + fil du ticket)' : 'Publié — visible par le client au portail (aucune adresse courriel au dossier)')
|
||||
: 'Note interne ajoutée (privée)',
|
||||
timeout: 2800,
|
||||
})
|
||||
} catch {
|
||||
Notify.create({ type: 'negative', message: 'Erreur : réponse non enregistrée', timeout: 3000 })
|
||||
} finally {
|
||||
sendingEmail.value = false
|
||||
sendingReply.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -968,7 +1102,18 @@ async function sendTicketEmail () {
|
|||
font-size: 0.62rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.msg-who { font-size: 0.8rem; font-weight: 600; color: #374151; }
|
||||
/* Badge visibilité par message (public/client vs interne/privé) */
|
||||
.msg-vis { display: inline-flex; align-items: center; gap: 2px; font-size: 0.62rem; font-weight: 700; padding: 1px 6px; border-radius: 9px; letter-spacing: .02em; cursor: pointer; user-select: none; transition: filter .12s, box-shadow .12s; }
|
||||
.msg-vis:hover { filter: brightness(0.96); box-shadow: 0 0 0 1px rgba(0,0,0,.08) inset; }
|
||||
.msg-vis.vis-busy { opacity: .6; pointer-events: none; }
|
||||
.msg-vis.vis-int { background: #fef3c7; color: #92400e; } /* interne = ambre (privé) */
|
||||
.msg-vis.vis-pub { background: #dbeafe; color: #1e40af; } /* client = bleu (public) */
|
||||
.msg-when { font-size: 0.7rem; color: #9ca3af; margin-left: auto; white-space: nowrap; }
|
||||
/* Avertissement de visibilité au bas du ticket (avant envoi au client) */
|
||||
.reply-warn { display: flex; align-items: flex-start; gap: 6px; margin-top: 8px; padding: 7px 10px; font-size: 0.75rem; line-height: 1.35; color: #7c2d12; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; }
|
||||
.reply-warn .q-icon { color: #ea580c; flex-shrink: 0; margin-top: 1px; }
|
||||
.reply-cb-lbl { display: inline-flex; align-items: center; gap: 4px; font-size: 0.78rem; font-weight: 500; color: #475569; }
|
||||
.reply-cb-lbl.reply-cb-pub { color: #9a3412; font-weight: 600; }
|
||||
.msg-body { font-size: 0.85rem; color: #1f2937; line-height: 1.5; word-break: break-word; }
|
||||
.msg-body :deep(img) { max-width: 100%; height: auto; }
|
||||
</style>
|
||||
|
|
|
|||
62
apps/ops/src/composables/useIdentity.js
Normal file
62
apps/ops/src/composables/useIdentity.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// useIdentity — identité unifiée du personnel (SOURCE UNIQUE, miroir de lib/identity.js du hub).
|
||||
// Charge une fois /identity/map (courriel→{key,label}, tech→{key,label}) et expose de quoi
|
||||
// DÉ-DUPLIQUER (un courriel alias + le tech_id + le user SSO = la même personne = même `key`)
|
||||
// et AFFICHER le nom complet (label) partout au lieu du username/courriel.
|
||||
import { ref } from 'vue'
|
||||
import { HUB_URL as HUB } from 'src/config/hub'
|
||||
|
||||
const emails = ref({}) // 'courriel' → { key, label }
|
||||
const techs = ref({}) // 'TECH-xxx' → { key, label }
|
||||
const list = ref([]) // identités éditables (pour l'UI de gestion)
|
||||
let _loading = null
|
||||
const norm = (e) => String(e || '').trim().toLowerCase()
|
||||
|
||||
async function ensure () {
|
||||
if (_loading) return _loading
|
||||
_loading = fetch(HUB + '/identity/map')
|
||||
.then(r => r.ok ? r.json() : { emails: {}, techs: {}, list: [] })
|
||||
.then(d => { emails.value = d.emails || {}; techs.value = d.techs || {}; list.value = d.list || []; return d })
|
||||
.catch(() => ({ emails: {}, techs: {}, list: [] }))
|
||||
return _loading
|
||||
}
|
||||
function reload () { _loading = null; return ensure() }
|
||||
|
||||
// Clé d'identité pour un courriel OU un tech_id ; repli = le courriel normalisé (identité « inconnue » mais stable).
|
||||
function keyOf (input) {
|
||||
if (!input) return ''
|
||||
const s = String(input)
|
||||
return (emails.value[norm(s)] && emails.value[norm(s)].key) || (techs.value[s] && techs.value[s].key) || norm(s)
|
||||
}
|
||||
// Nom complet (label) pour un courriel/tech_id ; '' si l'identité n'a pas de label → l'appelant garde son repli.
|
||||
function labelOf (input) {
|
||||
if (!input) return ''
|
||||
const s = String(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.
|
||||
function activeOf (input) {
|
||||
if (!input) return true
|
||||
const s = String(input); const hit = emails.value[norm(s)] || techs.value[s]
|
||||
return hit ? hit.active !== false : true
|
||||
}
|
||||
|
||||
export function useIdentity () {
|
||||
ensure()
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -25,8 +25,20 @@
|
|||
<q-tab name="users" icon="people" label="Utilisateurs" />
|
||||
<q-tab name="groups" icon="groups" label="Groupes" />
|
||||
<q-tab name="matrix" icon="grid_on" label="Matrice" />
|
||||
<q-tab name="identities" icon="badge" label="Identités" />
|
||||
<q-tab name="staff" icon="manage_accounts" label="Staff" />
|
||||
</q-tabs>
|
||||
|
||||
<!-- TAB: Identités unifiées (label + alias courriel + lien tech + fusion) -->
|
||||
<div v-show="permTab === 'identities'">
|
||||
<IdentityManager />
|
||||
</div>
|
||||
|
||||
<!-- TAB: Console Staff (réconciliation Authentik ↔ System User/Employee/Tech ; provisionner/désactiver/supprimer) -->
|
||||
<div v-show="permTab === 'staff'">
|
||||
<StaffConsole />
|
||||
</div>
|
||||
|
||||
<!-- TAB: Utilisateurs -->
|
||||
<div v-show="permTab === 'users'">
|
||||
<div class="row q-gutter-sm items-end q-mb-md">
|
||||
|
|
@ -809,6 +821,8 @@ import { useUserGroups } from 'src/composables/useUserGroups'
|
|||
import { useAvatar } from 'src/composables/useAvatar'
|
||||
import UserAvatar from 'src/components/shared/UserAvatar.vue'
|
||||
import EmployeeEditDialog from 'src/components/shared/EmployeeEditDialog.vue'
|
||||
import IdentityManager from 'src/components/shared/IdentityManager.vue' // gestion des identités unifiées (label + alias courriel + lien tech + fusion)
|
||||
import StaffConsole from 'src/components/shared/StaffConsole.vue' // réconciliation Authentik ↔ staff (provisionner/désactiver/supprimer gardé)
|
||||
import { listDocs } from 'src/api/erp'
|
||||
import QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue'
|
||||
import DepartmentSubscriptions from 'src/components/settings/DepartmentSubscriptions.vue'
|
||||
|
|
|
|||
|
|
@ -106,11 +106,7 @@
|
|||
</template>
|
||||
<template #body-cell-assigned="props">
|
||||
<q-td :props="props" class="text-center">
|
||||
<q-avatar v-if="props.row.assigned_staff" size="26px" text-color="white"
|
||||
:style="{ background: staffColor(props.row.assigned_staff), fontSize: '10px', fontWeight: 600 }">
|
||||
{{ staffInitials(props.row.assigned_staff) }}
|
||||
<q-tooltip>{{ props.row.assigned_staff }}</q-tooltip>
|
||||
</q-avatar>
|
||||
<UserAvatar v-if="props.row.assigned_staff || props.row.assigned_staff_email" :ident="props.row.assigned_staff_email" :name="props.row.assigned_staff" :size="26" card />
|
||||
<span v-else class="text-grey-4">—</span>
|
||||
</q-td>
|
||||
</template>
|
||||
|
|
@ -196,6 +192,7 @@ import DetailModal from 'src/components/shared/DetailModal.vue'
|
|||
import InlineField from 'src/components/shared/InlineField.vue'
|
||||
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
|
||||
import TicketCard from 'src/components/customer/TicketCard.vue'
|
||||
import UserAvatar from 'src/components/shared/UserAvatar.vue'
|
||||
|
||||
const me = useAuthStore().user // courriel de l'agent connecté (pour « Mes tickets »)
|
||||
const { slaFor } = useSla() // calcul SLA (politiques par file+priorité éditées dans Paramètres)
|
||||
|
|
@ -258,7 +255,10 @@ function resetAndLoad () {
|
|||
loadTickets()
|
||||
}
|
||||
|
||||
const ISSUE_FIELDS = ['name', 'subject', 'customer_name', 'customer', 'opening_date', 'priority', 'status', 'issue_type', 'assigned_staff', 'owner', 'creation', 'legacy_ticket_id', 'is_important', 'first_responded_on', '_user_tags']
|
||||
// ⚠️ ERPNext v16 REFUSE `assigned_staff_email` / `opened_by_staff_email` dans une requête `fields` → la liste Issue
|
||||
// plantait ENTIÈREMENT (0 ticket) côté frontend (listDocs interroge ERPNext direct, sans le strip du hub). Retirés
|
||||
// jusqu'à ce que ces champs custom soient interrogeables ; UserAvatar retombe sur le nom (`assigned_staff`).
|
||||
const ISSUE_FIELDS = ['name', 'subject', 'customer_name', 'customer', 'opening_date', 'priority', 'status', 'issue_type', 'assigned_staff', 'opened_by_staff', 'owner', 'creation', 'legacy_ticket_id', 'is_important', 'first_responded_on', '_user_tags']
|
||||
async function loadTickets () {
|
||||
loading.value = true
|
||||
const filters = buildFilters({
|
||||
|
|
|
|||
259
docs/UX_REFACTORING_PROMPT.md
Normal file
259
docs/UX_REFACTORING_PROMPT.md
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
# UX Refactoring Prompt — Gigafibre FSM
|
||||
|
||||
> **Audited: 2026-07-14 @ commit `718698f`.** Line numbers below are hints only — they drift with every commit. ALWAYS locate code by the **Anchor** string (state/component/label name); if the anchor isn't where the table says, search the file and update this doc before refactoring.
|
||||
|
||||
## Project Context
|
||||
|
||||
Gigafibre FSM is the operations platform for a fiber ISP in Quebec. Stack: Vue 3 / Quasar v2 / Pinia / Vite. The app has two audiences:
|
||||
|
||||
- **Ops staff** (CSRs, dispatchers) on desktop/tablet → `apps/ops/src/pages/` — Quasar components, light theme, `--ops-*` tokens.
|
||||
- **Field technicians** on mobile (often one-handed, gloves, spotty LTE) → `apps/ops/src/modules/tech/pages/` — **its own design system**: `tg-*` classes from `src/css/tech.scss`, native `<button>`, NOT q-btn. Never inject Quasar desktop patterns into the tech module.
|
||||
|
||||
All strings are French (fr-CA). ERPNext v16 is the backend (REST API). The hub (`targo-hub`) is a Node.js API gateway.
|
||||
|
||||
---
|
||||
|
||||
## Existing Code — REUSE, never rebuild (standing team rule)
|
||||
|
||||
Before creating ANY new file, state which existing file you checked and why it doesn't fit.
|
||||
|
||||
- **Pinia stores** (`src/stores/`): `useAuthStore`, `useDispatchStore`, `useOfflineStore`. Extend these; do NOT create new stores without asking.
|
||||
- **Composables** (`src/composables/`, ~60): check here FIRST — `useDragDrop`, `useSSE`, `useJobPool`, `useScheduler`, `useMediaQuery`, `useUndo`, `useSelection`, `usePeriodNavigation` already exist.
|
||||
- **Shared components** (`src/components/shared/`, ~48): see the Canonical Primitives table below.
|
||||
- **`src/components/planif/` already contains 8 extracted components** (décomposition #4): `JobPool`, `LeaveDialog`, `MonthOverview`, `PlanifCellMenu`, `ShiftTypesDialog`, `SkillCadenceTable`, `TechScheduleDialog`, `TechSyncDialog`. New Planification extractions go here and must not duplicate them.
|
||||
|
||||
**Where extracted components go:** PlanificationPage pieces → `src/components/planif/` · ClientDetailPage pieces → `src/components/customer/` · reused across pages → `src/components/shared/` (check the ~48 existing first) · tech (mobile) module pieces → `src/modules/tech/components/` (currently only `JobCard.vue`), styled with `tg-*` classes.
|
||||
|
||||
### Canonical primitives
|
||||
|
||||
All in `src/components/shared/` unless noted. Import these; do NOT rebuild an inferior equivalent.
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| ⋮ overflow of secondary actions | `OverflowMenu.vue` (`:groups="[{label, items:[{icon,label,caption?,action|to,disabled?}]}]"` or flat `:items`) |
|
||||
| Collapsible « ▾ Options avancées » / glance→detail | `DisclosureSection.vue` (`title`, `count`, `persist-key` = per-user persisted open state, slots `#summary`/`#actions`) |
|
||||
| Rare actions removed from a toolbar | register in `composables/useCommandPalette.js` (⌘K) so they stay reachable |
|
||||
| ⓘ contextual help | `HelpHint.vue` (title/text or slot) — global behavior in `boot/tooltip-ux.js` (click = hide, never 2 bubbles) |
|
||||
| Tables & async states | `DataTable.vue` · `EmptyState.vue` · `TableSkeleton.vue` |
|
||||
| Page top bar / record detail | `PageHeader.vue` · `DetailModal.vue` + `detail-sections/` (incl. `modules/JobThread.vue`, `JobReplyBox.vue`) |
|
||||
| Stats / avatars | `StatCard.vue` · `UserAvatar.vue` (canonical identity avatar) |
|
||||
| Skill chips & editor | `TagEditor.vue` · `components/planif/SkillCadenceTable.vue` |
|
||||
| Maps / occupancy | `RouteMap.vue` · `OccupancyStrip.vue` · `OccupancyBands.vue` |
|
||||
| Responsive breakpoints | `composables/useMediaQuery.js` |
|
||||
|
||||
---
|
||||
|
||||
## UX Rules (enforce these on every review)
|
||||
|
||||
1. **MAX 5 interactive CONTROLS per chrome zone** (toolbar, header, footer, dialog action row). Data cells/rows/chips inside grids, tables, calendars and kanban lanes do NOT count — a planning grid legitimately renders dozens of interactive cells. If a chrome zone exceeds 5 controls, group them behind a single trigger using `shared/OverflowMenu.vue` — never hand-roll a q-menu of secondary actions.
|
||||
2. **Progressive disclosure**: show only what the CURRENT step needs. Hide rarely-used options with `shared/DisclosureSection.vue` (« ▾ Options avancées ») or an OverflowMenu. Actions removed from a toolbar must be registered in `useCommandPalette` (⌘K) so they stay reachable — PlanificationPage already does this.
|
||||
3. **Every form field must justify its presence** — if it has a sensible default or is rarely edited, hide it. Only expose what the user needs to touch >50% of the time.
|
||||
4. **No menu-in-menu**: if a dropdown item opens a submenu, extract it into its own dialog.
|
||||
5. **Mobile-first for mobile surfaces**: on the tech module (`modules/tech/`) and on mobile breakpoints (`lt-md`) of ops pages, the primary CTA must be in thumb reach (bottom 1/3) — reuse the floating CTA pill pattern (`.tg-fab-cta`, TechJobDetailPage). On desktop (`gt-sm`) ops pages, the primary CTA belongs top-right of the header toolbar, visually dominant — do NOT relocate desktop CTAs to the bottom of the viewport. No hover-only affordances anywhere (desktop included).
|
||||
6. **No `.vue` file > 800 lines** — decompose into child components with focused responsibilities.
|
||||
7. **Props carry data/config; behaviour goes through emits or a shared composable/Pinia store.** HARD flag: any Function-typed prop used as a parent callback (see C1: LocationCard has 32 props, 26 of them Functions — the real anti-pattern). SOFT flag: >10 props (config-heavy shared components like DetailModal at 12 declarative props are acceptable).
|
||||
8. **One primary action per view state**. If the user has to choose between 3+ equally prominent buttons, you've failed.
|
||||
9. **Status feedback**: every mutation shows visible feedback within 200ms — using the app's existing mechanisms ONLY: `$q.notify` for results/errors (see the `err()` helper pattern), `:loading` / `:disabled="saving"` on the triggering button while pending, the changeLog journal + revert for grid edits, and OutboxPanel-style delayed send with « Tout annuler » for bulk/destructive sends. Do NOT introduce a new toast system. "Never silently `catch()`" applies to user-initiated mutations; background best-effort loads may catch silently but must carry a comment (existing convention: `catch (e) { /* … best-effort */ }`).
|
||||
10. **Dev-only UI hidden in production**: gate ONLY on `import.meta.env.DEV` (compile-time, stripped from prod builds) — NEVER on a custom `VITE_*` flag: `.env.local` VITE_ vars are bundled even in prod builds (documented leak, `src/config/hub.js`). Canonical pattern: the `canTerminateDirect` computed in TechJobDetailPage.vue.
|
||||
11. **French only (fr-CA)**: every user-facing string — labels, tooltips, toasts, aria-labels, empty states — including in NEW elements you create. Use « guillemets » and the existing domain vocabulary (quart, garde, tournée, « À assigner », « Publier »). Any English string in output is an automatic P1 review finding.
|
||||
12. **Preserve ALL keyboard shortcuts.** Existing: ⌘K/Ctrl+K → CommandPalette (window listener in MainLayout); Planification grid: Ctrl/⌘+Z / ⇧Z undo-redo, Ctrl/⌘+C/V copy-paste cells, Delete/Backspace clear, `A` = absent, `G` = garde (document-level `onKey` listener, which skips events from INPUT/TEXTAREA/SELECT/contenteditable — preserve that guard). Any extraction must re-wire document/window-level key listeners explicitly and list the component's shortcuts in its top docblock.
|
||||
13. **Every async view has three states**: loading → `shared/TableSkeleton.vue` (or q-skeleton), empty → `shared/EmptyState.vue` (icon + phrase FR + CTA), error → message + bouton « Réessayer ». When splitting a component, carry all three branches into the child. Never rebuild an equivalent skeleton/empty-state.
|
||||
14. **Spotty-LTE resilience (tech module)**: every mutation button shows a pending state and is disabled while pending (`:disabled="saving"`, cf. TechJobDetailPage). Offline-sensitive mutations go through the IndexedDB queue in `src/stores/offline.js` — never fire-and-forget fetch. Queued work must be visible: count + tap-to-retry chip (cf. TechScanPage « N scans en attente · toucher pour réessayer »). Never gate messaging on `navigator.onLine` alone — it reports true on unusable 2-bar LTE (documented in `stores/offline.js`).
|
||||
15. **Touch & focus (tech module)**: every tappable element ≥ 44×44 px (existing convention: `min-height: 44px` in TechJobDetailPage.vue). Keep visible `:focus-visible` states; never encode state by colour alone (add icon or label); use the tokens in `css/tech.scss` (`--ink-*`, `--t-*`) — no hardcoded colours.
|
||||
16. **Design tokens**: any new or moved style uses the `--ops-*` variables from `src/css/app.scss` (`--ops-accent/-success/-warning/-danger/-info/-bg/-bg-hover/-bg-light/-surface/-border/-text/-text-muted`) — no new hardcoded hex. Use each extraction as the opportunity to convert copied colours. All pages are LIGHT theme; `DispatchPage` is the sole dark exception — never introduce dark styling elsewhere.
|
||||
|
||||
### Expert-density surfaces (declutter rules relaxed)
|
||||
|
||||
On these surfaces, Rules 1–3 and 8 apply ONLY to chrome (toolbars, dialogs), never to board/grid content: **Planification grid/day/month/routes views** (PlanificationPage) and **Dispatch timeline & calendars** (`modules/dispatch/components/`). Information density is a feature for dispatchers: do not reduce data shown per cell, collapse columns, or add clicks between the dispatcher and job details. Dispatch keeps its dark theme (`pages/dispatch-styles.scss`).
|
||||
|
||||
---
|
||||
|
||||
## Known Anti-Patterns (audited snapshot)
|
||||
|
||||
> ⚠️ This audit is a snapshot (2026-07-14 @ `718698f`) and remediation is ongoing — the code marks applied fixes with `#N` comments. Before acting on any row, VERIFY the pattern still exists at the anchor; if already fixed, say so and move on.
|
||||
|
||||
### PlanificationPage.vue (6,559 lines) — Priority: CRITICAL
|
||||
|
||||
File layout: template L1–1768 · script L1770–6033 (~4,264 lines — the real bulk) · style L6035–6559.
|
||||
|
||||
| ID | Anti-pattern | Anchor | Status | Problem |
|
||||
|---|---|---|---|---|
|
||||
| PL1 | WALL_OF_OPTIONS | desktop header rows (comment « Rangée 1 : actions principales ») ~L32-154 | open | 20+ interactive controls in one toolbar |
|
||||
| PL2 | WALL_OF_OPTIONS | `label="Outils"` dropdown ~L71-125 | open | 13 items in 3 sections + nested submenu |
|
||||
| PL3 | WALL_OF_OPTIONS | q-dialog `jobSheet.open` ~L281-356 | open | ~15 interactive zones on a phone screen. Reuse `detail-sections/modules/JobThread.vue` + `JobReplyBox.vue` instead of the hand-rolled pm-postbox composer |
|
||||
| PL4 | HIDDEN_PRIMARY | `label` contains « Publier », split q-btn-dropdown ~L131-147 | **partially fixed** | Publier is now a unified split q-btn-dropdown (color=positive, dirty-count, outline-when-clean). Remaining: the row still has ~10 sibling controls (see PL1) |
|
||||
| PL5 | MENU_IN_MENU | « Modèles de semaine » ~L85-97 | open | Submenu inside a dropdown |
|
||||
| PL6 | ORPHAN_FIELD | `label="Max h/sem"` ~L159 | open | Floating numeric input with no context |
|
||||
| PL7 | STATUS_MYSTERY | autosave journal ~L48-61 | **largely fixed** | Icon now swaps to cloud_sync + primary color while saving (persistent feedback); popover holds the change journal + per-change revert by design. Verify before changing |
|
||||
| PL8 | MONOLITH | entire file | open | 6,559 lines = unmaintainable (see decomposition plan) |
|
||||
|
||||
### TechJobDetailPage.vue (679 lines) — Priority: LOW
|
||||
|
||||
| ID | Anti-pattern | Anchor | Status | Problem |
|
||||
|---|---|---|---|---|
|
||||
| T1 | WALL_OF_OPTIONS | "Détails" section ~L60-104 | open | Type, Priority, Time, Duration always visible — tech rarely edits these |
|
||||
| T2 | ~~DEAD_CLICK~~ | « Terminer directement (test) » ~L160-166 | **RESOLVED** | Already gated by `import.meta.env.DEV` (`canTerminateDirect`, L296-298) — verify only, do not re-fix |
|
||||
| T3 | ORPHAN_FIELD | "Créer un équipement" dialog ~L237-241 | open | 5 fields visible, only Serial is required |
|
||||
|
||||
### TechTasksPage.vue (748 lines) — Priority: NONE
|
||||
|
||||
No open issues — the dev test button (~L213-218) is already DEV-gated via `canTerminateDirect` (L332-335). Do not modify this page.
|
||||
|
||||
### ClientDetailPage.vue (1,728 lines) — Priority: MEDIUM
|
||||
|
||||
| ID | Anti-pattern | Anchor | Status | Problem |
|
||||
|---|---|---|---|---|
|
||||
| C1 | PROP_OVERLOAD | `<LocationCard` bindings ~L74-107 → `components/customer/LocationCard.vue` (defineProps L299-334) | open | **32 props, 0 emits** — 6 data props + 26 Function props (10 selectors drilled from the parent's `useSubscriptionGroups` instance + 16 action callbacks). The anti-pattern is callbacks-as-props. Fix: instantiate `useSubscriptionGroups` inside LocationCard (or provide/inject the instance), keep the data props, convert action callbacks to emits (Rule 7) |
|
||||
|
||||
---
|
||||
|
||||
## Decomposition plan — PlanificationPage.vue
|
||||
|
||||
**`components/planif/` ALREADY CONTAINS (décomposition #4 — do NOT re-create, extend these):**
|
||||
`JobPool.vue` (pool « à assigner » unifié : variants floating/docked/mobile — the mobile swipe/tap-sheet logic lives HERE) · `LeaveDialog.vue` · `ShiftTypesDialog.vue` · `TechSyncDialog.vue` · `TechScheduleDialog.vue` · `MonthOverview.vue` · `PlanifCellMenu.vue` · `SkillCadenceTable.vue` (SHARED skill+cadence editor).
|
||||
|
||||
New extractions must follow the same props/emits conventions as ShiftTypesDialog/LeaveDialog.
|
||||
|
||||
**Still to extract (anchor by state/component name — line numbers drift):**
|
||||
|
||||
```
|
||||
Wave 1
|
||||
├── PlanifHeader.vue ← desktop toolbar + filters + skills chips, L32-187
|
||||
│ (anchor: q-btn-toggle `boardView` → OverflowMenu).
|
||||
│ Keep existing OverflowMenu / HelpHint / useCommandPalette wiring.
|
||||
│ Mobile toolbar (`pm-toolbar`, ~L3-30): include in PlanifHeader
|
||||
│ (render both breakpoints) or extract as PlanifMobileBar.vue —
|
||||
│ decide before extracting.
|
||||
├── PlanifGrid.vue ← week grid table
|
||||
├── PlanifKanban.vue ← day view (already renders <JobPool variant="docked">)
|
||||
├── PlanifRoutes.vue ← routes view (uses shared RouteMap.vue + OccupancyStrip.vue)
|
||||
├── JobOptionsSheet.vue ← q-dialog `jobSheet.open` ONLY (the `pm-sheet` assignment overlay
|
||||
│ and swipe/tap stay coupled to JobPool.vue — coordinate via its emits)
|
||||
├── SkillEditorPopover.vue ← q-menu `skillMenuShown` (WRAPS existing SkillCadenceTable — keep it)
|
||||
├── GardeDialog.vue ← q-dialog `showGarde` (keep its HelpHint)
|
||||
└── TeamEditorDialog.vue ← q-dialog `showTeamEditor` (wraps SkillCadenceTable per tech)
|
||||
|
||||
Wave 2 (template L789-1768, currently unmapped)
|
||||
├── SuggestReviewDialog.vue ← « Suggérer » review (q-dialog `suggestDlg.open`)
|
||||
├── TagManagerDialog.vue ← `showTagManager` + `skillImpactOpen`
|
||||
├── JobPoolPanel.vue ← floating unassigned-jobs panel + pins map (`assignPanel`)
|
||||
├── LegacyPushDialog.vue ← `legacyPush.open` (write-back osTicket)
|
||||
├── LocPickerDialog.vue ← `locPicker.open`
|
||||
└── (also unmapped: notify-contacts block ~L1259, legacy job-detail dialog ~L1288)
|
||||
```
|
||||
|
||||
**Script-mapping rule:** for each extracted component, move every function/ref referenced ONLY by the extracted template into the child (e.g. `kb*`/`kanban*` → PlanifKanban, `cell*`/`onCellDrop`/`onDown` → PlanifGrid, `garde*` → GardeDialog). State shared across views stays in the parent or a per-domain composable.
|
||||
|
||||
**Composables:** do NOT create a catch-all `usePlanification.js`. `useJobPool.js` already covers pool filter/sort/group (3 instances owned by the page — keep that ownership). Extract PER DOMAIN only when a child needs it: `useGardeRules.js` (rule CRUD + preview), `useWeekTemplates.js`, `usePlanifBoard.js` (cells/occupancy/capacity). Before writing a composable, check `src/composables/` (~60 existing).
|
||||
|
||||
**CSS:** Vue scoped styles do NOT reach into child components. Each extraction must copy the scoped CSS rules matching its markup (`.pm-*`, `.assign-*`, `.garde-*`…) into the new component's own `<style scoped>`. Do not rename classes.
|
||||
|
||||
**Target:** PlanificationPage.vue < 800 lines (Rule 6); ~400 lines only after ALL rows above are extracted. Extract top-down, ONE row per session.
|
||||
|
||||
---
|
||||
|
||||
## How to Use This Prompt
|
||||
|
||||
Text in `[square brackets]` is a placeholder — replace it with your actual target (anti-pattern IDs, component name, line range) before sending.
|
||||
|
||||
### Option A: Full-page refactor
|
||||
Paste this file + the full `.vue` file content, then say:
|
||||
|
||||
```
|
||||
Refactor this component following the UX rules above. Address anti-patterns
|
||||
[PL1, PL2, PL3] specifically. Keep all v-model bindings, event names AND
|
||||
keyboard shortcuts intact. Output ONE unified diff against the pasted file,
|
||||
covering <template> AND any <script> changes the fix requires (new refs/handlers
|
||||
for grouped menus, etc.). If a rule can only be satisfied in <script> (rules 7,
|
||||
9, 10, 12) and the script was not pasted, do not guess: emit a `// TODO(script):`
|
||||
comment in the diff describing the exact change needed. All new UI strings in
|
||||
French (fr-CA).
|
||||
```
|
||||
|
||||
### Option B: Extract a child component
|
||||
Paste this file + the relevant template section of the parent **+ the script portions that move with it + the matching scoped CSS**, then say:
|
||||
|
||||
```
|
||||
Extract the section anchored at [jobSheet.open] into a new `[JobOptionsSheet].vue`
|
||||
component in [src/components/planif/]. The parent should pass only the job object
|
||||
and emit events for mutations. Apply progressive disclosure: show Status + Reply
|
||||
visible, rest under DisclosureSection « ▾ Options avancées ». Copy the scoped CSS
|
||||
rules this markup uses into the new component. Output both the new component and
|
||||
the parent's updated template section. All new UI strings in French (fr-CA).
|
||||
```
|
||||
|
||||
### Option C: Review a new component
|
||||
Paste this file + the new component, then say:
|
||||
|
||||
```
|
||||
Review this component for cognitive load following the UX rules above.
|
||||
For each issue found, output:
|
||||
- ANCHOR: the element (state/label name) + approximate line range
|
||||
- PROBLEM: what causes overload
|
||||
- FIX: concrete code change (not just advice)
|
||||
- PRIORITY: P0 (confusing) / P1 (cluttered) / P2 (polish)
|
||||
|
||||
Do NOT suggest adding features. Only simplify.
|
||||
```
|
||||
|
||||
### Option D: Decompose a monolith
|
||||
Paste this file + the full monolith (or the target section + its script), then say:
|
||||
|
||||
```
|
||||
Decompose this monolith following the decomposition plan in the prompt.
|
||||
Start with [PlanifHeader.vue]. Output:
|
||||
1. The new child component (complete, working — template + script + scoped CSS)
|
||||
2. The updated parent template section (replacing the extracted lines)
|
||||
3. The props/emits interface between them
|
||||
|
||||
Keep all existing functionality. Do not rename CSS classes. Do not change any
|
||||
API call, URL, payload shape, or emitted event name — refactors are UI-structure
|
||||
only. Target: parent < 800 lines; ~400 only as the end-state after all
|
||||
extractions (one extraction per session).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done (every session)
|
||||
|
||||
A refactor is complete only when:
|
||||
|
||||
1. `npm run lint` and `npm run build` pass in `apps/ops` (paste errors back if not).
|
||||
2. Manually verified in `quasar dev` (port 9001): the page renders, no new console errors.
|
||||
3. These invariants still work (PlanificationPage): **drag-and-drop** of jobs onto grid cells and kanban lanes (incl. drop preview badge, SortableJS day editor), **SSE live updates** (`legacy-update` listener), **the 4 MapLibre maps** (job-detail, location picker, assign panel, day route), **keyboard shortcuts** (Rule 12), and the **Publier** flow.
|
||||
4. No behavior change: same API calls, same payloads, same emitted events.
|
||||
|
||||
If you (the LLM) cannot run these, end your output with a checklist of exactly what the human must verify.
|
||||
|
||||
---
|
||||
|
||||
## Token Optimization Tips
|
||||
|
||||
| Technique | Savings | How / caveats |
|
||||
|---|---|---|
|
||||
| Send only `<template>` section | 58–73% (template is 27–42% of these files) | ONLY for layout-only reviews (rules 1–5, 8). Include `<script setup>` whenever the task touches rules 7/9/10/12, extracts a component (Options B/D), or reviews error handling — key handlers and gating live in script |
|
||||
| One component at a time | ~70% | Don't paste 5 files; review sequentially |
|
||||
| Specify viewport | Response tokens | Add `"Target: 375×667 iPhone SE"` for tech-module work to avoid desktop suggestions |
|
||||
| Ask for diff, not full file | ~60% response | `"Output changes as a unified diff, not the full file"` |
|
||||
| "Do NOT suggest adding features" | ~30% response | Prevents AI from adding complexity |
|
||||
| Strip `<style>` section | 8–26% (only ~8% on PlanificationPage/ClientDetailPage) | ONLY for reviews (Option C). NEVER for extractions (Options B/D): scoped styles don't reach child components — the extracted markup needs its CSS copied along |
|
||||
|
||||
**Section boundaries (at `718698f`)** — to slice without opening the file: PlanificationPage template L1–1768 / script L1770–6033 / style L6035–6559.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Quasar Components Used
|
||||
|
||||
| Component | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `q-btn` / `q-btn-group` / `q-btn-toggle` / `q-btn-dropdown` | Buttons | Split buttons = `q-btn-dropdown split` (cf. « Publier ») |
|
||||
| `q-dialog` | Modal dialogs | `position="bottom"` = mobile bottom-sheet |
|
||||
| `q-menu` | Dropdown/context menus | `context-menu` = right-click; `auto-close`. For secondary-action overflow, use `shared/OverflowMenu.vue` instead |
|
||||
| `q-expansion-item` | Collapsible sections | Prefer `shared/DisclosureSection.vue` (persisted open state); `group` prop for accordion |
|
||||
| `q-chip` | Tags, filters, status badges | `clickable` makes it interactive |
|
||||
| `q-input` / `q-select` | Form fields | `dense outlined` is the standard style |
|
||||
| `q-list` / `q-item` | Lists inside menus/dialogs | `q-item-section avatar` for icons |
|
||||
| `q-tooltip` | Hover help text | Overused — not a substitute for clear labels. For contextual help use `shared/HelpHint.vue` (ⓘ with title/rich text); the global behavior (click = hide, never 2 bubbles) lives in `boot/tooltip-ux.js` — don't fix tooltips one by one |
|
||||
| `q-banner` | Alerts, notifications | Status banners (offline, warnings) |
|
||||
|
||||
— End of prompt —
|
||||
|
|
@ -66,6 +66,8 @@ const nameCache = new Map() // email → { name, ts }
|
|||
async function getDisplayNameByEmail (email) {
|
||||
const key = String(email || '').trim().toLowerCase()
|
||||
if (!key) return ''
|
||||
// Identité unifiée d'abord : un alias (louispaul@…) résout au LABEL canonique (« Louis-Paul Bourdon »).
|
||||
try { const lbl = require('./identity').labelForEmail(key); if (lbl) return lbl } catch (e) { /* store absent → Authentik */ }
|
||||
const hit = nameCache.get(key)
|
||||
if (hit && Date.now() - hit.ts < 300000) return hit.name
|
||||
let name = ''
|
||||
|
|
@ -94,12 +96,22 @@ async function resolveEmployeeForEmail (email) {
|
|||
if (!e) return null
|
||||
try {
|
||||
const erp = require('./erp')
|
||||
let rows = await erp.list('Employee', { filters: [['user_id', '=', e]], fields: EMPLOYEE_FIELDS, limit: 1 })
|
||||
if (!rows.length) rows = await erp.list('Employee', { filters: [['company_email', '=', e]], fields: EMPLOYEE_FIELDS, limit: 1 })
|
||||
// ALIAS-AWARE : le compte de session (louis@targo.ca) et l'Employee (sous louispaul@targointernet.com)
|
||||
// sont la même personne → on cherche l'Employee sous N'IMPORTE QUEL courriel de l'identité unifiée.
|
||||
let emails = [e]
|
||||
try { const id = require('./identity').resolveIdentity(e); if (id) emails = [...new Set([id.primary_email, ...(id.alias_emails || []), e].filter(Boolean))] } catch (x) {}
|
||||
let rows = await erp.list('Employee', { filters: [['user_id', 'in', emails]], fields: EMPLOYEE_FIELDS, limit: 1 })
|
||||
if (!rows.length) rows = await erp.list('Employee', { filters: [['company_email', 'in', emails]], fields: EMPLOYEE_FIELDS, limit: 1 })
|
||||
return rows[0] || null
|
||||
} catch (err) { log('resolveEmployeeForEmail ' + e + ': ' + err.message); return null }
|
||||
}
|
||||
|
||||
// Admin = mêmes groupes que identity/avatars/user-prefs (écritures staff sensibles).
|
||||
function isAdminReq (req) {
|
||||
const g = String(req.headers['x-authentik-groups'] || req.headers['x-authentik-group'] || '')
|
||||
return /(^|[,|;])\s*(ops-?admin|admin(istrateurs?)?|superviseurs?)\s*([,|;]|$)/i.test(g)
|
||||
}
|
||||
|
||||
function validatePermissions (body) {
|
||||
const validKeys = new Set(CAPABILITIES.map(c => c.key))
|
||||
const result = {}
|
||||
|
|
@ -219,6 +231,215 @@ async function handle (req, res, method, path, url) {
|
|||
return json(res, 200, { users, total: r.data.pagination?.count || users.length })
|
||||
}
|
||||
|
||||
// ── CONSOLE STAFF : réconciliation Authentik ↔ System User / Employee / Dispatch Technician / identité ──
|
||||
if (resource === 'staff' && !parts[1] && method === 'GET') {
|
||||
const erp = require('./erp'); const idlib = require('./identity')
|
||||
// 1. Users Authentik internes (toutes les pages ; exclut outposts + comptes de service).
|
||||
const akUsers = []
|
||||
for (let p = 1; p <= 12; p++) {
|
||||
const r = await akFetch(`/core/users/?page=${p}&page_size=100&ordering=username`)
|
||||
if (r.status !== 200) break
|
||||
akUsers.push(...(r.data.results || []).filter(u => u.email && !u.username.startsWith('ak-outpost') && u.type !== 'internal_service_account'))
|
||||
if (!r.data.pagination || !r.data.pagination.next) break
|
||||
}
|
||||
const allGroups = await fetchGroups(); const gmap = new Map(allGroups.map(g => [g.pk, g.name]))
|
||||
// 2. ERPNext en lot (1 requête par doctype).
|
||||
const [sysU, emps, techs] = await Promise.all([
|
||||
erp.list('User', { filters: [['user_type', '=', 'System User']], fields: ['name', 'enabled'], limit: 1000 }).catch(() => []),
|
||||
erp.list('Employee', { fields: ['name', 'employee_name', 'company_email', 'user_id', 'status'], limit: 2000 }).catch(() => []),
|
||||
erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'full_name', 'employee', 'status'], limit: 1000 }).catch(() => []),
|
||||
])
|
||||
const sysSet = new Set(sysU.map(u => String(u.name).toLowerCase()))
|
||||
const empByEmail = {}; for (const e of emps) { if (e.user_id) empByEmail[String(e.user_id).toLowerCase()] = e; if (e.company_email && !empByEmail[String(e.company_email).toLowerCase()]) empByEmail[String(e.company_email).toLowerCase()] = e }
|
||||
const techByEmp = {}; for (const t of techs) { if (t.employee) techByEmp[t.employee] = t }
|
||||
// DÉ-DUP par courriel : plusieurs comptes Authentik pour la même personne (username `joseph` + `joseph@…`)
|
||||
// → UNE ligne, en listant les usernames en double à nettoyer. Actif si au MOINS un compte actif.
|
||||
const byEmail = new Map()
|
||||
for (const u of akUsers) {
|
||||
const email = String(u.email || '').toLowerCase(); if (!email) continue
|
||||
const g = byEmail.get(email) || { email, usernames: [], is_active: false, name: '', groupsSet: new Set() }
|
||||
g.usernames.push(u.username)
|
||||
g.is_active = g.is_active || !!u.is_active
|
||||
if (!g.name && u.name) g.name = u.name
|
||||
for (const pk of (u.groups || [])) { const n = gmap.get(pk); if (OPS_GROUPS.includes(n)) g.groupsSet.add(n) }
|
||||
byEmail.set(email, g)
|
||||
}
|
||||
const rows = [...byEmail.values()].map(g => {
|
||||
const email = g.email
|
||||
const id = idlib.resolveIdentity(email)
|
||||
// Employee résolu via l'identité (alias) : louis@targo.ca → Employee sous louispaul@targointernet.com.
|
||||
const idEmails = id ? [id.primary_email, ...(id.alias_emails || []), email] : [email]
|
||||
let emp = null; for (const e of idEmails) { if (empByEmail[String(e).toLowerCase()]) { emp = empByEmail[String(e).toLowerCase()]; break } }
|
||||
const tech = (emp && techByEmp[emp.name]) || (id && id.tech_id ? { technician_id: id.tech_id } : null)
|
||||
const hasSys = idEmails.some(e => sysSet.has(String(e).toLowerCase()))
|
||||
const groups = [...g.groupsSet]
|
||||
let status = 'ok'
|
||||
if (!g.is_active || (id && id.active === false)) status = 'departed'
|
||||
else if (!hasSys) status = 'orphan'
|
||||
const needs_group = g.is_active && hasSys && !groups.length
|
||||
const dup_accounts = g.usernames.length > 1 ? g.usernames.length : 0
|
||||
return { email, username: g.usernames[0], usernames: g.usernames, dup_accounts, name: g.name || (id && id.label) || email, is_active: g.is_active, groups, has_system_user: hasSys, employee: emp ? emp.name : '', tech_id: tech ? (tech.technician_id || tech.name) : '', identity_key: id ? id.key : '', identity_active: id ? id.active !== false : null, label: id ? id.label : '', status, needs_group }
|
||||
}).sort((a, b) => (a.status === 'orphan' ? 0 : 1) - (b.status === 'orphan' ? 0 : 1) || String(a.name).localeCompare(b.name))
|
||||
return json(res, 200, { staff: rows })
|
||||
}
|
||||
|
||||
// Impact d'un retrait : références qui empêchent une suppression propre (tickets assignés, jobs).
|
||||
if (resource === 'staff' && parts[1] === 'impact' && method === 'GET') {
|
||||
const erp = require('./erp'); const idlib = require('./identity')
|
||||
const email = String(url.searchParams.get('email') || '').toLowerCase(); if (!email) return json(res, 400, { error: 'email requis' })
|
||||
const id = idlib.resolveIdentity(email)
|
||||
const emails = id ? [id.primary_email, ...(id.alias_emails || [])] : [email]
|
||||
let tickets = 0
|
||||
for (const e of emails) { try { const r = await erp.list('Issue', { filters: [['_assign', 'like', '%' + e + '%']], fields: ['name'], limit: 200 }); tickets += (r || []).length } catch (x) {} }
|
||||
let jobs = 0
|
||||
if (id && id.tech_id) { try { const r = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', id.tech_id]], fields: ['name'], limit: 500 }); jobs = (r || []).length } catch (x) {} }
|
||||
return json(res, 200, { email, tickets, jobs, clean: tickets === 0 && jobs === 0 })
|
||||
}
|
||||
|
||||
// ── Écritures staff : admin seulement ──
|
||||
if (resource === 'staff' && method !== 'GET' && !isAdminReq(req)) return json(res, 403, { error: 'réservé aux administrateurs' })
|
||||
|
||||
// Provisionner (create-staff) : à partir d'un user Authentik EXISTANT (ou nouveau) → groupe + System User + Employee + Tech + identité.
|
||||
if (resource === 'staff' && parts[1] === 'provision' && method === 'POST') {
|
||||
const b = await parseBody(req); const erp = require('./erp'); const idlib = require('./identity')
|
||||
const email = String(b.email || '').toLowerCase(); if (!email) return json(res, 400, { error: 'email requis' })
|
||||
const label = String(b.label || email).trim(); const out = { email, steps: {} }
|
||||
// 1. Authentik : assigner les groupes OPS + garder actif.
|
||||
try {
|
||||
const lookup = await findUserByEmail(email)
|
||||
if (lookup.user && Array.isArray(b.groups) && b.groups.length) {
|
||||
const allG = await fetchGroups(); const pks = b.groups.map(n => { const g = allG.find(x => x.name === n); return g && g.pk }).filter(Boolean)
|
||||
await akFetch('/core/users/' + lookup.user.pk + '/', 'PATCH', { groups: pks, is_active: true }); invalidateCache()
|
||||
out.steps.groups = b.groups
|
||||
}
|
||||
} catch (e) { out.steps.groups_err = e.message }
|
||||
// 2. ERPNext System User (créer si absent, sinon promouvoir).
|
||||
try {
|
||||
const ex = await erp.list('User', { filters: [['name', '=', email]], fields: ['name', 'user_type'], limit: 1 })
|
||||
if (!ex.length) { await erp.create('User', { email, first_name: label, send_welcome_email: 0, user_type: 'System User', roles: [{ role: 'Employee' }] }); out.steps.system_user = 'created' }
|
||||
else if (ex[0].user_type !== 'System User') { await erp.update('User', email, { user_type: 'System User' }); out.steps.system_user = 'promoted' }
|
||||
else out.steps.system_user = 'exists'
|
||||
} catch (e) { out.steps.system_user_err = e.message }
|
||||
// 3. Employee — LIER l'existant plutôt que dupliquer (évite le doublon craint : Aurélie a déjà HR-EMP-107 non lié).
|
||||
let empName = b.employee || ''
|
||||
if (b.create_employee !== false) {
|
||||
try {
|
||||
if (empName) { // l'admin a choisi un Employee précis → le lier
|
||||
const cur = await erp.list('Employee', { filters: [['name', '=', empName]], fields: ['name', 'user_id'], limit: 1 })
|
||||
if (cur.length && !cur[0].user_id) { await erp.update('Employee', empName, { user_id: email, company_email: email }); out.steps.employee = 'linked' } else out.steps.employee = cur.length ? 'exists' : 'introuvable'
|
||||
} else {
|
||||
let ex = await erp.list('Employee', { filters: [['user_id', '=', email]], fields: ['name', 'user_id'], limit: 1 })
|
||||
if (!ex.length) ex = await erp.list('Employee', { filters: [['company_email', '=', email]], fields: ['name', 'user_id'], limit: 1 })
|
||||
if (!ex.length && label) ex = await erp.list('Employee', { filters: [['employee_name', '=', label]], fields: ['name', 'user_id'], limit: 2 }) // match par NOM (ex. Aurélie Bourdon = HR-EMP-107)
|
||||
if (ex.length === 1) { empName = ex[0].name; if (!ex[0].user_id) { await erp.update('Employee', empName, { user_id: email, company_email: email }); out.steps.employee = 'linked' } else out.steps.employee = 'exists' }
|
||||
else if (ex.length > 1) { out.steps.employee = 'ambigu(' + ex.length + ') — choisir/lier manuellement, non créé' } // ne PAS deviner
|
||||
else { const r = await erp.create('Employee', { employee_name: label, company_email: email, user_id: email, status: 'Active' }); empName = (r && (r.name || (r.data && r.data.name))) || ''; out.steps.employee = 'created' }
|
||||
}
|
||||
} catch (e) { out.steps.employee_err = e.message }
|
||||
}
|
||||
// 4. Dispatch Technician (optionnel).
|
||||
let techId = b.tech_id || ''
|
||||
if (b.create_tech) {
|
||||
try { const r = await erp.create('Dispatch Technician', { full_name: label, resource_type: 'human', status: 'Disponible', employee: empName || undefined }); techId = (r && ((r.data && r.data.name) || r.name)) || ''; out.steps.tech = techId || 'created' } catch (e) { out.steps.tech_err = e.message }
|
||||
}
|
||||
// 5. Identité unifiée.
|
||||
try { idlib.upsert({ label, primary_email: email, tech_id: techId, employee: empName, active: true, kind: b.kind || 'employee' }); out.steps.identity = 'ok' } catch (e) { out.steps.identity_err = e.message }
|
||||
return json(res, 200, { ok: true, ...out })
|
||||
}
|
||||
|
||||
// Désactiver / réactiver (non destructif) : Authentik is_active + identité départ.
|
||||
if (resource === 'staff' && parts[1] === 'active' && method === 'POST') {
|
||||
const b = await parseBody(req); const idlib = require('./identity')
|
||||
const email = String(b.email || '').toLowerCase(); const active = b.active !== false
|
||||
if (!email) return json(res, 400, { error: 'email requis' })
|
||||
try { const lookup = await findUserByEmail(email); if (lookup.user) { await akFetch('/core/users/' + lookup.user.pk + '/', 'PATCH', { is_active: active }); invalidateCache() } } catch (e) { return json(res, 500, { error: e.message }) }
|
||||
try { idlib.setActive(email, active) } catch (e) {}
|
||||
return json(res, 200, { ok: true, email, is_active: active })
|
||||
}
|
||||
|
||||
// Dé-dupliquer les comptes Authentik d'un même courriel : garde l'utilisé (last_login) sinon le plus ancien,
|
||||
// supprime UNIQUEMENT les doublons JAMAIS connectés (sûr : aucune session/historique). Body: { email }.
|
||||
if (resource === 'staff' && parts[1] === 'dedupe' && method === 'POST') {
|
||||
const b = await parseBody(req); const email = String(b.email || '').toLowerCase(); if (!email) return json(res, 400, { error: 'email requis' })
|
||||
const r = await akFetch('/core/users/?search=' + encodeURIComponent(email) + '&page_size=50')
|
||||
const recs = (r.data.results || []).filter(u => String(u.email || '').toLowerCase() === email)
|
||||
if (recs.length < 2) return json(res, 200, { ok: true, noop: true, count: recs.length })
|
||||
// KEEPER = compte à GARDER : actif d'abord, puis dernier last_login, puis plus ancien (l'original).
|
||||
const keeper = recs.slice().sort((a, b) =>
|
||||
(b.is_active ? 1 : 0) - (a.is_active ? 1 : 0) ||
|
||||
(new Date(b.last_login || 0) - new Date(a.last_login || 0)) ||
|
||||
(new Date(a.date_joined || 0) - new Date(b.date_joined || 0)))[0]
|
||||
const deleted = [], skipped = []
|
||||
for (const u of recs) {
|
||||
if (u.pk === keeper.pk) continue
|
||||
// Supprimer un doublon SÛR : inactif OU jamais connecté (aucune session active). On NE supprime PAS
|
||||
// un 2e compte ENCORE actif et utilisé (rare) → à résoudre manuellement.
|
||||
if (u.is_active && u.last_login) { skipped.push(u.username + ' (2e compte actif utilisé — résoudre manuellement)'); continue }
|
||||
const d = await akFetch('/core/users/' + u.pk + '/', 'DELETE')
|
||||
if (d.status < 300) deleted.push(u.username); else skipped.push(u.username + ' (erreur ' + d.status + ')')
|
||||
}
|
||||
invalidateCache()
|
||||
return json(res, 200, { ok: true, kept: keeper.username, deleted, skipped })
|
||||
}
|
||||
|
||||
// Backfill : LIER automatiquement dans l'identité unifiée les comptes Authentik ↔ Employee ↔ Dispatch Technician.
|
||||
// Pour chaque user Authentik interne : identité { label, primary_email=login, alias=company_email/user_id de l'Employee
|
||||
// si différents, tech_id, employee, active }. Upsert (jamais de suppression) → alimente l'annuaire unifié d'un coup.
|
||||
if (resource === 'staff' && parts[1] === 'sync-identities' && method === 'POST') {
|
||||
const erp = require('./erp'); const idlib = require('./identity')
|
||||
const akUsers = []
|
||||
for (let p = 1; p <= 12; p++) { const r = await akFetch(`/core/users/?page=${p}&page_size=100&ordering=username`); if (r.status !== 200) break; akUsers.push(...(r.data.results || []).filter(u => u.email && !u.username.startsWith('ak-outpost') && u.type !== 'internal_service_account')); if (!r.data.pagination || !r.data.pagination.next) break }
|
||||
const [emps, techs] = await Promise.all([
|
||||
erp.list('Employee', { fields: ['name', 'employee_name', 'company_email', 'user_id'], limit: 2000 }).catch(() => []),
|
||||
erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'full_name', 'employee'], limit: 1000 }).catch(() => []),
|
||||
])
|
||||
const empByEmail = {}; for (const e of emps) { if (e.user_id) empByEmail[String(e.user_id).toLowerCase()] = e; if (e.company_email && !empByEmail[String(e.company_email).toLowerCase()]) empByEmail[String(e.company_email).toLowerCase()] = e }
|
||||
const techByEmp = {}; for (const t of techs) { if (t.employee) techByEmp[t.employee] = t }
|
||||
// dédup par courriel (un seul upsert par personne)
|
||||
const seen = new Set(); let count = 0
|
||||
for (const u of akUsers) {
|
||||
const email = String(u.email || '').toLowerCase(); if (!email || seen.has(email)) continue; seen.add(email)
|
||||
// RÉSOUT D'ABORD l'identité existante (seed OU édition manuelle) → on FUSIONNE dans SA clé, sans jamais
|
||||
// écraser un label/tech/alias déjà posé (ex. Louis-Paul : login louis@targo.ca ≠ courriel employé louispaul@).
|
||||
const existing = idlib.resolveIdentity(email)
|
||||
const key = existing ? existing.key : email
|
||||
const primary = existing ? existing.primary_email : email
|
||||
// Employee résolu sur TOUS les courriels de l'identité (alias) : louispaul@ trouve HR-EMP-4.
|
||||
const candidateEmails = [email, primary, ...(existing ? existing.alias_emails || [] : [])].map(x => String(x || '').toLowerCase())
|
||||
let emp = null; for (const e of candidateEmails) { if (empByEmail[e]) { emp = empByEmail[e]; break } }
|
||||
const tech = emp ? techByEmp[emp.name] : null
|
||||
const aliases = [...(existing ? existing.alias_emails || [] : []), email, ...(emp ? [emp.company_email, emp.user_id] : [])]
|
||||
.map(x => String(x || '').toLowerCase()).filter(x => x && x !== primary)
|
||||
const payload = {
|
||||
key, primary_email: primary,
|
||||
label: (existing && existing.label) || u.name || email, // garde le label manuel
|
||||
alias_emails: [...new Set(aliases)],
|
||||
tech_id: (existing && existing.tech_id) || (tech ? (tech.technician_id || tech.name) : ''),
|
||||
employee: (existing && existing.employee) || (emp ? emp.name : ''),
|
||||
}
|
||||
if (!existing) payload.active = u.is_active // ne pas ré-activer un départ manuel ; les nouveaux suivent Authentik
|
||||
idlib.upsert(payload)
|
||||
count++
|
||||
}
|
||||
return json(res, 200, { ok: true, linked: count })
|
||||
}
|
||||
|
||||
// Suppression GARDÉE : refuse si le retrait a un impact (tickets/jobs) → propose la désactivation.
|
||||
if (resource === 'staff' && !parts[1] && method === 'DELETE') {
|
||||
const erp = require('./erp'); const idlib = require('./identity')
|
||||
const email = String(url.searchParams.get('email') || '').toLowerCase(); if (!email) return json(res, 400, { error: 'email requis' })
|
||||
const id = idlib.resolveIdentity(email)
|
||||
const emails = id ? [id.primary_email, ...(id.alias_emails || [])] : [email]
|
||||
let tickets = 0; for (const e of emails) { try { const r = await erp.list('Issue', { filters: [['_assign', 'like', '%' + e + '%']], fields: ['name'], limit: 200 }); tickets += (r || []).length } catch (x) {} }
|
||||
let jobs = 0; if (id && id.tech_id) { try { const r = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', id.tech_id]], fields: ['name'], limit: 500 }); jobs = (r || []).length } catch (x) {} }
|
||||
if (tickets > 0 || jobs > 0) return json(res, 409, { ok: false, refused: true, reason: 'références existantes', tickets, jobs, hint: 'Désactiver plutôt que supprimer (garde l\'historique).' })
|
||||
const done = {}
|
||||
try { const lookup = await findUserByEmail(email); if (lookup.user) { await akFetch('/core/users/' + lookup.user.pk + '/', 'DELETE'); invalidateCache(); done.authentik = 'deleted' } } catch (e) { done.authentik_err = e.message }
|
||||
try { await erp.remove('User', email); done.system_user = 'deleted' } catch (e) { done.system_user_err = e.message }
|
||||
try { if (id) idlib.setActive(email, false) } catch (e) {} // l'identité reste (départ) même après suppression du compte
|
||||
return json(res, 200, { ok: true, email, ...done })
|
||||
}
|
||||
|
||||
if (resource === 'users' && parts[2] === 'overrides' && method === 'PUT') {
|
||||
const email = decodeURIComponent(parts[1])
|
||||
const lookup = await findUserByEmail(email)
|
||||
|
|
|
|||
145
services/targo-hub/lib/identity.js
Normal file
145
services/targo-hub/lib/identity.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
'use strict'
|
||||
// ── Identité unifiée du personnel — SOURCE UNIQUE ─────────────────────────────
|
||||
// Problème résolu : une même personne a plusieurs COURRIELS (louis@targo.ca +
|
||||
// louispaul@targointernet.com) et un enregistrement Dispatch Technician séparé,
|
||||
// SANS identifiant unifié → doublons d'assignation, nom système (« louis ») au
|
||||
// lieu du nom complet, user SSO ≠ tech. Ici : 1 personne = 1 identité canonique
|
||||
// { key, label, primary_email, alias_emails[], tech_id, employee }
|
||||
// Tous les courriels (primaire + alias) et le tech_id résolvent à CETTE identité.
|
||||
//
|
||||
// SSO partout → la clé = le courriel de login (primaire). Éditable depuis OPS,
|
||||
// écritures réservées aux admins (mêmes groupes que avatars/user-prefs). Store
|
||||
// JSON hub (data/identities.json) — pas de custom-field ERPNext (API instable).
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { json, parseBody, log } = require('./helpers')
|
||||
|
||||
const FILE = path.join(__dirname, '..', 'data', 'identities.json')
|
||||
const norm = (e) => String(e || '').trim().toLowerCase()
|
||||
const meOf = (req) => norm(req.headers['x-authentik-email'])
|
||||
// Admin = mêmes groupes Authentik que les autres écritures self/admin (avatars.js, user-prefs.js).
|
||||
function isAdmin (req) {
|
||||
const g = String(req.headers['x-authentik-groups'] || req.headers['x-authentik-group'] || '')
|
||||
return /(^|[,|;])\s*(ops-?admin|admin(istrateurs?)?|superviseurs?)\s*([,|;]|$)/i.test(g)
|
||||
}
|
||||
|
||||
function load () { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')) || {} } catch { return {} } }
|
||||
function save (m) { try { fs.mkdirSync(path.dirname(FILE), { recursive: true }) } catch (e) {} fs.writeFileSync(FILE, JSON.stringify(m, null, 1)); _index = null; return m }
|
||||
|
||||
// Index inverse (courriel→clé, tech→clé, nom→clé), reconstruit paresseusement, invalidé à chaque save().
|
||||
let _index = null
|
||||
function index () {
|
||||
if (_index) return _index
|
||||
const m = load(); const byEmail = {}, byTech = {}, byName = {}
|
||||
for (const [key, id] of Object.entries(m)) {
|
||||
for (const e of [id.primary_email, ...(id.alias_emails || [])].map(norm).filter(Boolean)) byEmail[e] = key
|
||||
if (id.tech_id) byTech[String(id.tech_id)] = key
|
||||
if (id.label) byName[norm(id.label)] = key
|
||||
}
|
||||
_index = { m, byEmail, byTech, byName }; return _index
|
||||
}
|
||||
|
||||
// resolveIdentity(input) : input = courriel | tech_id | nom complet → { key, ...identité } | null
|
||||
function resolveIdentity (input) {
|
||||
if (!input) return null
|
||||
const { m, byEmail, byTech, byName } = index()
|
||||
const s = String(input).trim()
|
||||
const key = byEmail[norm(s)] || byTech[s] || byName[norm(s)]
|
||||
return key ? { key, ...m[key] } : null
|
||||
}
|
||||
// Libellé alias-aware (pour getDisplayNameByEmail) : '' si inconnu → l'appelant retombe sur Authentik.
|
||||
function labelForEmail (email) { const id = resolveIdentity(email); return id ? id.label : '' }
|
||||
// Courriel canonique (primaire) pour un alias — sert à DÉ-DUPLIQUER les assignations. Repli = le courriel donné.
|
||||
function canonicalEmail (email) { const id = resolveIdentity(email); return id ? norm(id.primary_email) : norm(email) }
|
||||
// Clé d'identité pour dé-dupliquer (courriel OU tech_id) — repli = le courriel normalisé (identité « inconnue » stable).
|
||||
function identityKey (input) { const id = resolveIdentity(input); return id ? id.key : norm(input) }
|
||||
// Deux entrées (courriels/tech) = la même personne ?
|
||||
function sameIdentity (a, b) { return identityKey(a) === identityKey(b) }
|
||||
// Actif ? (départ = active:false → masqué des sélecteurs/roster mais l'identité RESTE pour l'historique).
|
||||
// Inconnu de la carte → true (on ne bloque pas quelqu'un qu'on ne connaît pas).
|
||||
function isActive (input) { const id = resolveIdentity(input); return id ? id.active !== false : true }
|
||||
|
||||
// Écriture programmatique (utilisée par le provisioning staff) — upsert par clé (= primary_email par défaut).
|
||||
function upsert ({ key, label, primary_email, alias_emails, tech_id, employee, active, kind } = {}) {
|
||||
const primary = norm(primary_email); if (!primary || !label) return null
|
||||
const m = load(); const k = key || primary; const cur = m[k] || {}
|
||||
m[k] = {
|
||||
label: String(label).slice(0, 120), primary_email: primary,
|
||||
alias_emails: [...new Set([...(cur.alias_emails || []), ...(alias_emails || [])].map(norm).filter(e => e && e !== primary))],
|
||||
tech_id: tech_id || cur.tech_id || '', employee: employee || cur.employee || '',
|
||||
active: active === undefined ? (cur.active !== false) : (active !== false),
|
||||
kind: kind || cur.kind || 'employee',
|
||||
}
|
||||
save(m); return m[k]
|
||||
}
|
||||
function setActive (keyOrEmail, active) {
|
||||
const id = resolveIdentity(keyOrEmail); if (!id) return null
|
||||
const m = load(); if (!m[id.key]) return null
|
||||
m[id.key].active = active !== false; save(m); return m[id.key]
|
||||
}
|
||||
|
||||
async function handle (req, res, method, path, url) {
|
||||
// ── Lecture (tout membre du personnel authentifié — annuaire interne) ──
|
||||
// Carte compacte pour le SPA : email→{key,label}, tech→{key,label}, + liste éditable.
|
||||
if (path === '/identity/map' && method === 'GET') {
|
||||
const { m } = index(); const emails = {}, techs = {}, list = []
|
||||
for (const [key, id] of Object.entries(m)) {
|
||||
const label = id.label || ''; const active = id.active !== false
|
||||
for (const e of [id.primary_email, ...(id.alias_emails || [])].map(norm).filter(Boolean)) emails[e] = { key, label, active }
|
||||
if (id.tech_id) techs[String(id.tech_id)] = { key, label, active }
|
||||
list.push({ key, label, primary_email: norm(id.primary_email), alias_emails: (id.alias_emails || []).map(norm), tech_id: id.tech_id || '', employee: id.employee || '', active, kind: id.kind || 'employee' })
|
||||
}
|
||||
return json(res, 200, { emails, techs, list })
|
||||
}
|
||||
if (path === '/identity/resolve' && method === 'GET') {
|
||||
const q = url.searchParams
|
||||
return json(res, 200, { identity: resolveIdentity(q.get('email') || q.get('tech') || q.get('name') || '') })
|
||||
}
|
||||
|
||||
// ── Écriture : admins seulement ──
|
||||
if (method !== 'GET' && !isAdmin(req)) return json(res, 403, { error: 'réservé aux administrateurs' })
|
||||
|
||||
// Créer / mettre à jour une identité. Body: { key?, label, primary_email, alias_emails?, tech_id?, employee? }
|
||||
if (path === '/identity' && method === 'POST') {
|
||||
const b = await parseBody(req); const primary = norm(b.primary_email)
|
||||
if (!primary || !b.label) return json(res, 400, { error: 'label + primary_email requis' })
|
||||
const m = load(); const key = b.key || primary
|
||||
m[key] = {
|
||||
label: String(b.label).slice(0, 120), primary_email: primary,
|
||||
alias_emails: [...new Set((b.alias_emails || []).map(norm).filter(e => e && e !== primary))],
|
||||
tech_id: b.tech_id || '', employee: b.employee || '',
|
||||
active: b.active !== false, // départ = false → masqué des sélecteurs, identité gardée pour l'historique
|
||||
kind: b.kind || 'employee', // employee | subcontractor
|
||||
}
|
||||
save(m); log('[identity] upsert ' + key + ' by ' + meOf(req))
|
||||
return json(res, 200, { ok: true, key, identity: m[key] })
|
||||
}
|
||||
// Ajouter / retirer un alias courriel. Body: { email, remove? }
|
||||
const mAlias = path.match(/^\/identity\/([^/]+)\/alias$/)
|
||||
if (mAlias && method === 'POST') {
|
||||
const key = decodeURIComponent(mAlias[1]); const b = await parseBody(req); const e = norm(b.email); const m = load()
|
||||
if (!m[key]) return json(res, 404, { error: 'identité introuvable' })
|
||||
const set = new Set(m[key].alias_emails || [])
|
||||
if (b.remove) set.delete(e); else if (e && e !== norm(m[key].primary_email)) set.add(e)
|
||||
m[key].alias_emails = [...set]; save(m)
|
||||
return json(res, 200, { ok: true, identity: m[key] })
|
||||
}
|
||||
// Fusionner deux identités (réconcilier user système ↔ tech en double). Body: { into, from }
|
||||
if (path === '/identity/merge' && method === 'POST') {
|
||||
const b = await parseBody(req); const m = load()
|
||||
if (!m[b.into] || !m[b.from]) return json(res, 404, { error: 'clé(s) introuvable(s)' })
|
||||
const set = new Set([...(m[b.into].alias_emails || []), norm(m[b.from].primary_email), ...(m[b.from].alias_emails || [])]
|
||||
.filter(e => e && e !== norm(m[b.into].primary_email)))
|
||||
m[b.into].alias_emails = [...set]
|
||||
if (!m[b.into].tech_id && m[b.from].tech_id) m[b.into].tech_id = m[b.from].tech_id
|
||||
if (!m[b.into].employee && m[b.from].employee) m[b.into].employee = m[b.from].employee
|
||||
delete m[b.from]; save(m); log('[identity] merge ' + b.from + ' → ' + b.into + ' by ' + meOf(req))
|
||||
return json(res, 200, { ok: true, identity: m[b.into] })
|
||||
}
|
||||
const mDel = path.match(/^\/identity\/([^/]+)$/)
|
||||
if (mDel && method === 'DELETE') { const key = decodeURIComponent(mDel[1]); const m = load(); delete m[key]; save(m); return json(res, 200, { ok: true }) }
|
||||
|
||||
return json(res, 404, { error: 'identity endpoint not found' })
|
||||
}
|
||||
|
||||
module.exports = { handle, resolveIdentity, labelForEmail, canonicalEmail, identityKey, sameIdentity, isActive, upsert, setActive }
|
||||
|
|
@ -226,6 +226,7 @@ const server = http.createServer(async (req, res) => {
|
|||
if (path.startsWith('/conversations')) return conversation.handle(req, res, method, path, url)
|
||||
if (path === '/prefs') return require('./lib/user-prefs').handle(req, res, method, path) // préférences d'affichage par utilisateur (filtres/tri/densité)
|
||||
if (path === '/avatars' || path.startsWith('/avatars/')) return require('./lib/avatars').handle(req, res, method, path) // photos de profil par utilisateur
|
||||
if (path.startsWith('/identity')) return require('./lib/identity').handle(req, res, method, path, url) // identité unifiée (courriels alias + label + lien tech) ; écritures admin
|
||||
if (path.startsWith('/sla/')) return require('./lib/sla').handle(req, res, method, path) // politiques SLA (réponse/résolution par file+priorité), éditables dans Settings
|
||||
if (path.startsWith('/chatwoot/')) return require('./lib/chatwoot').handle(req, res, method, path, url) // Dashboard App Chatwoot : fiche client ERP 360 (panel iframe + lookup protégé par clé)
|
||||
if (path.startsWith('/billing/')) return require('./lib/proration').handle(req, res, method, path) // moteur de prorata (aperçu lignes proratées par type) — gaté staff
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user