feat(ops): unified staff identity (label + email aliases + tech link + departed flag)

One person = one identity { label, primary_email (SSO login), alias_emails[],
tech_id, active, kind }. Every email + tech ID resolves to it.

Hub: lib/identity.js (NEW, SOURCE UNIQUE) — resolver + secured endpoints
(/identity/map|resolve read; upsert/alias/merge/delete admin-only). server.js
mounts /identity. auth.getDisplayNameByEmail consults the identity label first
(any alias → full name everywhere). Seed: Louis-Paul Bourdon, Louis Morneau.

SPA: composables/useIdentity + api/identity. IssueDetail dedupes assignment by
identity (m'ajouter + search = same person → no double chip), shows full names,
and hides departed people (active:false) from the picker. IdentityManager.vue
(Settings → Identités tab): edit label/aliases/tech link, merge duplicates,
block/reactivate (departed = non-destructive: hidden from pickers, history kept).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-10 12:22:48 -04:00
parent 14530787bb
commit dec8321823
8 changed files with 521 additions and 91 deletions

View 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' })

View 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) ; -é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>

View File

@ -49,7 +49,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>
@ -232,6 +232,13 @@
<div class="msg-head">
<span class="msg-avatar">{{ initials(m.who) }}</span>
<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 +271,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"
@ -343,6 +332,7 @@ 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 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
// 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 +362,8 @@ 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 = /.+@.+\..+/
const showCreateDialog = ref(false)
const showProjectWizard = ref(false)
const availReasonOpen = ref(false)
@ -402,11 +394,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, 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, 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 +519,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 +588,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.
@ -601,9 +641,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,73 +843,79 @@ function onJobUpdated (jobName, data) {
// Reply
// 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 ''
}
// 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 sendReply () {
if (!replyContent.value?.trim() || sendingReply.value) return
const isPublic = replyToClient.value
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,
})
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 */ }
}
// 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, message: text, link: window.location.href, agentName: senderFull.value }),
})
const d = await res.json(); emailed = !!(d && d.ok)
} catch { /* le message reste public/visible même si le courriel échoue */ }
}
}
replyContent.value = ''
replyToClient.value = false // retour au défaut sûr (interne) après envoi
emit('reply-sent', props.docName)
Notify.create({ type: 'positive', message: 'Reponse envoyee', timeout: 2000 })
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: reponse non envoyee', timeout: 3000 })
Notify.create({ type: 'negative', message: 'Erreur : réponse non enregistrée', timeout: 3000 })
} finally {
sendingReply.value = false
}
}
// 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
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 »)
}),
})
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 lenvoi', timeout: 3500 })
}
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3500 })
} finally {
sendingEmail.value = false
}
}
</script>
<style scoped>
@ -968,7 +1015,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>

View File

@ -0,0 +1,49 @@
// 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) || ''
}
// 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, sameId, activeOf }
}

View File

@ -25,8 +25,14 @@
<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-tabs>
<!-- TAB: Identités unifiées (label + alias courriel + lien tech + fusion) -->
<div v-show="permTab === 'identities'">
<IdentityManager />
</div>
<!-- TAB: Utilisateurs -->
<div v-show="permTab === 'users'">
<div class="row q-gutter-sm items-end q-mb-md">
@ -809,6 +815,7 @@ 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 { listDocs } from 'src/api/erp'
import QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue'
import DepartmentSubscriptions from 'src/components/settings/DepartmentSubscriptions.vue'

View File

@ -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 = ''

View File

@ -0,0 +1,126 @@
'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 }
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 }

View File

@ -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