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:
parent
14530787bb
commit
dec8321823
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' })
|
||||||
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>
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
<div class="assign-block q-mb-sm">
|
<div class="assign-block q-mb-sm">
|
||||||
<div class="info-block-title" style="margin-bottom:4px">Assignation</div>
|
<div class="info-block-title" style="margin-bottom:4px">Assignation</div>
|
||||||
<AssignmentField doctype="Issue" :doc-name="docName"
|
<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" />
|
@assign="assignUser" @set-assistant="p => assignUser(p.value)" @unassign="unassignFirst" @remove-assistant="removeAssignee" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -232,6 +232,13 @@
|
||||||
<div class="msg-head">
|
<div class="msg-head">
|
||||||
<span class="msg-avatar">{{ initials(m.who) }}</span>
|
<span class="msg-avatar">{{ initials(m.who) }}</span>
|
||||||
<span class="msg-who">{{ 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>
|
<span class="msg-when">{{ formatDateTime(m.when) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="msg-body" v-html="richHtml(m.content)"></div>
|
<div class="msg-body" v-html="richHtml(m.content)"></div>
|
||||||
|
|
@ -264,39 +271,21 @@
|
||||||
placeholder="Écrire une réponse ou une note…"
|
placeholder="Écrire une réponse ou une note…"
|
||||||
:input-style="{ fontSize: '0.85rem', minHeight: '50px' }"
|
:input-style="{ fontSize: '0.85rem', minHeight: '50px' }"
|
||||||
@keydown.ctrl.enter="sendReply" @keydown.meta.enter="sendReply" />
|
@keydown.ctrl.enter="sendReply" @keydown.meta.enter="sendReply" />
|
||||||
<div class="row justify-end q-gutter-sm q-mt-xs">
|
<!-- UNE seule action + une case « Envoyer au client ». Défaut = interne (privé). Cocher = public + courriel (lien ticket ; la réponse revient au fil). -->
|
||||||
<q-btn outline dense size="sm" label="Note interne" color="grey-8" icon="sticky_note_2"
|
<div class="row items-center justify-between q-mt-xs no-wrap">
|
||||||
:disable="!replyContent?.trim()" :loading="sendingReply" @click="sendReply">
|
<q-checkbox v-model="replyToClient" dense size="sm" :color="replyToClient ? 'deep-orange-9' : 'grey-7'">
|
||||||
<q-tooltip>Ajoute une note au fil — non envoyée au client</q-tooltip>
|
<span class="reply-cb-lbl" :class="{ 'reply-cb-pub': replyToClient }">
|
||||||
</q-btn>
|
<q-icon :name="replyToClient ? 'warning_amber' : 'lock'" size="14px" />
|
||||||
<q-btn unelevated dense size="sm" label="Envoyer par courriel" color="primary" icon="mail" @click="openEmailDialog">
|
{{ replyToClient ? 'Envoyer au client (visible + courriel)' : 'Note interne (privée)' }}
|
||||||
<q-tooltip>Envoyer par courriel ; la réponse du destinataire reviendra dans ce ticket</q-tooltip>
|
</span>
|
||||||
</q-btn>
|
<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>
|
||||||
</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 -->
|
<!-- Create single Dispatch Job dialog -->
|
||||||
<UnifiedCreateModal
|
<UnifiedCreateModal
|
||||||
v-model="showCreateDialog"
|
v-model="showCreateDialog"
|
||||||
|
|
@ -343,6 +332,7 @@ import TaskNode from 'src/components/shared/TaskNode.vue'
|
||||||
import TagEditor from 'src/components/shared/TagEditor.vue'
|
import TagEditor from 'src/components/shared/TagEditor.vue'
|
||||||
import AssignmentField from 'src/components/shared/AssignmentField.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 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.
|
// 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 JobMapModule from 'src/components/shared/detail-sections/modules/JobMapModule.vue'
|
||||||
import GeofenceTimeline from 'src/components/shared/detail-sections/modules/GeofenceTimeline.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 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 senderFull = computed(() => ((userName.value || '').trim() || (authStore.user || '').split('@')[0] || 'Agent'))
|
||||||
const sendingReply = ref(false)
|
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 showCreateDialog = ref(false)
|
||||||
const showProjectWizard = ref(false)
|
const showProjectWizard = ref(false)
|
||||||
const availReasonOpen = ref(false)
|
const availReasonOpen = ref(false)
|
||||||
|
|
@ -402,11 +394,43 @@ function initials (name) {
|
||||||
return ((p[0]?.[0] || '') + (p[1]?.[0] || '')).toUpperCase() || '?'
|
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 thread = computed(() => {
|
||||||
const items = []
|
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' })
|
const ov = msgPublic.value
|
||||||
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' })
|
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 || '')))
|
return items.sort((a, b) => String(a.when || '').localeCompare(String(b.when || '')))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -495,20 +519,36 @@ async function loadUsers () {
|
||||||
} catch { userOptions.value = [] }
|
} catch { userOptions.value = [] }
|
||||||
loadingUsers.value = false
|
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é) ──
|
// ── 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).
|
// 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 issueAssignee = computed(() => {
|
||||||
const e = assignees.value[0]; if (!e) return null
|
const e = assignees.value[0]; if (!e) return null
|
||||||
const o = userOptions.value.find(u => u.value === e)
|
return { id: e, name: displayName(e) }
|
||||||
return { id: e, name: (o && o.label) || e }
|
|
||||||
})
|
})
|
||||||
const issueAssistants = computed(() => assignees.value.slice(1).map(e => {
|
const issueAssistants = computed(() => assignees.value.slice(1).map(e => ({ id: e, name: displayName(e) })))
|
||||||
const o = userOptions.value.find(u => u.value === e)
|
|
||||||
return { id: e, name: (o && o.label) || e }
|
|
||||||
}))
|
|
||||||
async function assignUser (email) {
|
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]
|
const next = [...assignees.value, e]
|
||||||
assignees.value = next
|
assignees.value = next
|
||||||
await saveAssignees(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 ──
|
// ── 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.
|
// 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: '' })
|
emit('dispatch-updated', j.name, { assigned_tech: '' })
|
||||||
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }) }
|
} 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.
|
// 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)
|
const showOnsite = ref(false)
|
||||||
|
watch(showOnsite, v => { if (v) loadJobTechs() })
|
||||||
const onsiteKey = () => 'ops-ticket-onsite-' + (props.docName || '')
|
const onsiteKey = () => 'ops-ticket-onsite-' + (props.docName || '')
|
||||||
watch([primaryJob, () => props.docName], () => {
|
watch([primaryJob, () => props.docName], () => {
|
||||||
let stored = null
|
let stored = null
|
||||||
|
|
@ -802,71 +843,77 @@ function onJobUpdated (jobName, data) {
|
||||||
|
|
||||||
// ── Reply ──
|
// ── 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 () {
|
async function sendReply () {
|
||||||
if (!replyContent.value?.trim() || sendingReply.value) return
|
if (!replyContent.value?.trim() || sendingReply.value) return
|
||||||
|
const isPublic = replyToClient.value
|
||||||
|
const text = replyContent.value.trim()
|
||||||
sendingReply.value = true
|
sendingReply.value = true
|
||||||
try {
|
try {
|
||||||
await createDoc('Communication', {
|
const created = await createDoc('Communication', {
|
||||||
communication_type: 'Communication',
|
communication_type: 'Communication',
|
||||||
communication_medium: 'Other',
|
communication_medium: 'Other',
|
||||||
sent_or_received: 'Sent',
|
sent_or_received: 'Sent',
|
||||||
subject: props.title || props.docName,
|
subject: props.title || props.docName,
|
||||||
content: replyContent.value.trim(),
|
content: text,
|
||||||
sender: authStore.user || '',
|
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_doctype: 'Issue',
|
||||||
reference_name: props.docName,
|
reference_name: props.docName,
|
||||||
})
|
})
|
||||||
replyContent.value = ''
|
const name = created && (created.name || (created.data && created.data.name))
|
||||||
emit('reply-sent', props.docName)
|
// Visibilité explicite (source unique = overlay hub) → rend correctement Interne/Client dès le rechargement.
|
||||||
Notify.create({ type: 'positive', message: 'Reponse envoyee', timeout: 2000 })
|
if (name) {
|
||||||
} catch {
|
try {
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: reponse non envoyee', timeout: 3000 })
|
await fetch(`${HUB_URL}/conversations/msg-visibility`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, public: isPublic }) })
|
||||||
} finally {
|
msgPublic.value = { ...msgPublic.value, [name]: isPublic }
|
||||||
sendingReply.value = false
|
} 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
|
||||||
// ── Envoi par courriel (aller-retour : la réponse revient au ticket) ──
|
if (isPublic) {
|
||||||
|
const to = await resolveClientEmail()
|
||||||
const showEmailDialog = ref(false)
|
if (to) {
|
||||||
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 {
|
try {
|
||||||
const res = await fetch(`${HUB_URL}/conversations/ticket-email`, {
|
const res = await fetch(`${HUB_URL}/conversations/ticket-email`, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ issue: props.docName, to, message: text, link: window.location.href, agentName: senderFull.value }),
|
||||||
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()
|
const d = await res.json(); emailed = !!(d && d.ok)
|
||||||
if (d.ok) {
|
} catch { /* le message reste public/visible même si le courriel échoue */ }
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
}
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3500 })
|
replyContent.value = ''
|
||||||
|
replyToClient.value = false // retour au défaut sûr (interne) après envoi
|
||||||
|
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 {
|
} finally {
|
||||||
sendingEmail.value = false
|
sendingReply.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -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;
|
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; }
|
.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; }
|
.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 { font-size: 0.85rem; color: #1f2937; line-height: 1.5; word-break: break-word; }
|
||||||
.msg-body :deep(img) { max-width: 100%; height: auto; }
|
.msg-body :deep(img) { max-width: 100%; height: auto; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
49
apps/ops/src/composables/useIdentity.js
Normal file
49
apps/ops/src/composables/useIdentity.js
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -25,8 +25,14 @@
|
||||||
<q-tab name="users" icon="people" label="Utilisateurs" />
|
<q-tab name="users" icon="people" label="Utilisateurs" />
|
||||||
<q-tab name="groups" icon="groups" label="Groupes" />
|
<q-tab name="groups" icon="groups" label="Groupes" />
|
||||||
<q-tab name="matrix" icon="grid_on" label="Matrice" />
|
<q-tab name="matrix" icon="grid_on" label="Matrice" />
|
||||||
|
<q-tab name="identities" icon="badge" label="Identités" />
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
|
|
||||||
|
<!-- TAB: Identités unifiées (label + alias courriel + lien tech + fusion) -->
|
||||||
|
<div v-show="permTab === 'identities'">
|
||||||
|
<IdentityManager />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- TAB: Utilisateurs -->
|
<!-- TAB: Utilisateurs -->
|
||||||
<div v-show="permTab === 'users'">
|
<div v-show="permTab === 'users'">
|
||||||
<div class="row q-gutter-sm items-end q-mb-md">
|
<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 { useAvatar } from 'src/composables/useAvatar'
|
||||||
import UserAvatar from 'src/components/shared/UserAvatar.vue'
|
import UserAvatar from 'src/components/shared/UserAvatar.vue'
|
||||||
import EmployeeEditDialog from 'src/components/shared/EmployeeEditDialog.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 { listDocs } from 'src/api/erp'
|
||||||
import QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue'
|
import QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue'
|
||||||
import DepartmentSubscriptions from 'src/components/settings/DepartmentSubscriptions.vue'
|
import DepartmentSubscriptions from 'src/components/settings/DepartmentSubscriptions.vue'
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,8 @@ const nameCache = new Map() // email → { name, ts }
|
||||||
async function getDisplayNameByEmail (email) {
|
async function getDisplayNameByEmail (email) {
|
||||||
const key = String(email || '').trim().toLowerCase()
|
const key = String(email || '').trim().toLowerCase()
|
||||||
if (!key) return ''
|
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)
|
const hit = nameCache.get(key)
|
||||||
if (hit && Date.now() - hit.ts < 300000) return hit.name
|
if (hit && Date.now() - hit.ts < 300000) return hit.name
|
||||||
let name = ''
|
let name = ''
|
||||||
|
|
|
||||||
126
services/targo-hub/lib/identity.js
Normal file
126
services/targo-hub/lib/identity.js
Normal 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 }
|
||||||
|
|
@ -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.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 === '/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 === '/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('/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('/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
|
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