Compare commits

..

11 Commits

Author SHA1 Message Date
louispaulb
8e7838d1fb docs: prompt réutilisable de refactor UX (UX_REFACTORING_PROMPT.md)
Réécriture vérifiée du brouillon initial — 43 corrections issues d'un review
multi-agents contre le code réel @ 718698f :
- 16 règles UX calibrées au repo (chrome-only, CTA desktop top-right,
  Function-props, fr-CA, raccourcis clavier, états async, LTE, 44px, tokens)
- tableau des primitives canoniques (OverflowMenu, DisclosureSection, HelpHint…)
- audit anti-patterns avec colonnes Status+Anchor (repérage par nom d'état,
  pas par ligne) ; lignes déjà résolues marquées (T2, TechTasks, PL4/PL7)
- plan de décomposition PlanificationPage réaliste (décomposition #4 reconnue,
  vagues 1-2, règle script-mapping, pas de composable fourre-tout)
- Definition of Done (lint/build/preview + invariants DnD/SSE/MapLibre/Publier)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 19:27:51 -04:00
louispaulb
718698f0d0 feat(ops): gate ticket activation on pending subs + show consolidated proration preview
The "Activer + facture brouillon" button now appears ONLY when the ticket's
customer+location has En attente subscriptions (read-only /billing/activation-
preview) — defining which completed tickets trigger activation. The confirm
shows all services that would activate and the consolidated prorated total
(internet+tv+phone in one draft invoice). Preview writes nothing; commit gated
by PRORATION_WRITE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:24:27 -04:00
louispaulb
e1d3212bf7 feat(ops): ticket Statut control (Complété/Annulé) + sale activation → draft invoice
Add a "Statut" button on the ticket (distinct from the snooze "Reporter"):
Ouvert / En attente / Complété (Resolved) / Annulé (Closed). Additive — does not
touch the shared TicketStatusControl.

For a ticket with a linked open install job, an explicit "Activer + facture
brouillon" button (also offered when set to Complété) reuses the EXISTING hub
flow: POST /dispatch/job-status Completed → chain unblock → activateSubscription
ForJob → one consolidated prorated DRAFT invoice, gated by PRORATION_WRITE /
BILLING_APPROVAL_GATE (never auto-charges; billing manager edits/approves). No
new billing logic, no hub change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:04:08 -04:00
louispaulb
28a5789fe0 fix(ops): tickets list returned 0 — Issue query used fields ERPNext v16 rejects
An in-flight TicketsPage change added assigned_staff_email / opened_by_staff_email
to ISSUE_FIELDS, but ERPNext v16 refuses those in a `fields` query. The frontend
queries ERPNext directly (no bad-field stripping like the hub), so the whole Issue
list 400'd → 0 tickets for everyone. Removed the two rejected fields; UserAvatar
falls back to the assigned_staff name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:39:07 -04:00
louispaulb
e8dc86e058 feat(ops): one-click identity linking (Authentik ↔ Employee ↔ Tech), merge-safe
POST /auth/staff/sync-identities upserts a unified identity per internal
Authentik user, resolving Employee + Dispatch Technician and deriving email
aliases from the company email. Resolve-first: merges into an existing identity's
key and never overwrites a manual label/tech/alias (fixes Louis-Paul, whose
login louis@targo.ca ≠ employee email louispaul@); employee lookup is
alias-aware so louispaul@ finds HR-EMP-4. "Lier les identités" button in the
Staff console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:27:30 -04:00
louispaulb
6826f11587 fix(ops): dedupe removes inactive duplicate accounts, not just never-logged-in
Frédérique's duplicate (frederique@targointernet.com) had an old last_login but
was already deactivated, so the "never-logged-in only" rule skipped it and
nothing happened. Now a non-keeper duplicate is deleted when it's inactive OR
never logged in; only a genuinely second active+used account is skipped for
manual resolution. Keeper = active first, then most-recent login, then oldest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:16:10 -04:00
louispaulb
d0f899cf6d feat(ops): staff console — surface dup accounts + per-user schedule deep-link
- Duplicate Authentik accounts now surfaced automatically: a "⚠ Doublons (N)"
  filter chip + the warning badge itself is clickable to clean up (the 7 dup
  people are status=ok, so they were hidden under the default orphan filter).
- "Horaire" button on rows with a tech reuses the SAME Planification schedule
  module (TechScheduleDialog: recurring template + congés + pause) via a
  /planification?sched=<tech_id> deep-link — no inferior rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:44:56 -04:00
louispaulb
11c53c956a feat(ops): staff console — one-click dedupe of duplicate Authentik accounts
POST /auth/staff/dedupe {email}: keeps the used account (has last_login, else
oldest) and deletes ONLY never-logged-in duplicate Authentik records for that
email — safe (no sessions/history, ERPNext untouched since it's one User per
email). Console shows "Nettoyer le doublon" on flagged rows with a confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:33:22 -04:00
louispaulb
148cb9a320 fix(ops): staff recon — alias-aware employee, dedupe Authentik accounts, link-not-duplicate
- resolveEmployeeForEmail + /auth/staff now resolve the Employee through the
  unified identity's aliases: louis@targo.ca finds HR-EMP-4 (under
  louispaul@targointernet.com). Fixes "no employee linked" for Louis-Paul.
- /auth/staff collapses multiple Authentik accounts with the same email into one
  row (username `joseph` + `joseph@...` → one), flags dup_accounts for cleanup.
- provision LINKS an existing Employee (match user_id → company_email → name)
  instead of creating a duplicate — fixes the Aurélie HR-EMP-107 doublon risk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:20:09 -04:00
louispaulb
b43f092da3 feat(ops): staff reconciliation console (Authentik ↔ System User/Employee/Tech)
Settings → Staff tab: one row per internal Authentik user with provisioning
state (active, groups, System User / Employee / Dispatch Technician / identity),
flagging orphan (active but no System User → not provisioned, e.g. Karim
Takougang), departed (inactive/identity-departed), ok.

Hub /auth/staff (GET reconciliation) + /auth/staff/provision (groups + System
User + Employee + Tech + identity in one click) + /auth/staff/active
(deactivate/reactivate, non-destructive) + /auth/staff/impact + guarded DELETE
(refuses with 409 if tickets/jobs reference the person → offers deactivate).
Writes admin-gated. identity.js gains programmatic upsert/setActive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:01:33 -04:00
louispaulb
dec8321823 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>
2026-07-10 12:22:48 -04:00
15 changed files with 1385 additions and 105 deletions

View File

@ -98,6 +98,30 @@ export async function updateJob (name, payload) {
return apiPut('Dispatch Job', name, payload)
}
// Complète un job VIA le chaînage canonique du hub (déblocage dépendances + activation abonnement
// + facture prorata BROUILLON en bout de chaîne, gatée PRORATION_WRITE / BILLING_APPROVAL_GATE).
// Réutilisé par la complétion d'un ticket de VENTE/installation. Renvoie { activated, invoices, pending_approval? }.
export async function setJobStatusChain (job, status) {
const res = await fetch(`${HUB_URL}/dispatch/job-status`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job, status }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || ('Dispatch API ' + res.status))
return data
}
// Aperçu d'activation (LECTURE SEULE) : abonnements « En attente » du client+lieu + facture prorata consolidée
// (TOUS services : internet+tv+téléphone en 1 facture). Sert à SAVOIR si une complétion activerait qqch + montre le total.
export async function activationPreview ({ customer, service_location }) {
const res = await fetch(`${HUB_URL}/billing/activation-preview`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer, service_location, statuses: ['En attente'] }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || ('Billing API ' + res.status))
return data
}
export async function createJob (payload) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Job`,

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

21
apps/ops/src/api/staff.js Normal file
View File

@ -0,0 +1,21 @@
// API console STAFF — réconciliation Authentik ↔ System User/Employee/Tech/identité (hub /auth/staff/*).
// Écritures réservées admin (gaté côté hub) ; suppression GARDÉE (409 si références → proposer désactivation).
import { HUB_URL as HUB } from 'src/config/hub'
async function j (path, init) {
const r = await fetch(HUB + path, init)
const d = await r.json().catch(() => ({}))
if (!r.ok && r.status !== 409) throw new Error(d.error || ('Staff API ' + r.status))
return { status: r.status, ...d }
}
const post = (path, body) => j(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })
export const listStaff = () => j('/auth/staff')
export const provisionStaff = (body) => post('/auth/staff/provision', body)
export const setStaffActive = (email, active) => post('/auth/staff/active', { email, active })
export const staffImpact = (email) => j('/auth/staff/impact?email=' + encodeURIComponent(email))
export const deleteStaff = (email) => j('/auth/staff?email=' + encodeURIComponent(email), { method: 'DELETE' })
// Dé-dup : garde le compte Authentik utilisé, supprime le(s) doublon(s) jamais connecté(s) pour ce courriel.
export const dedupeAccounts = (email) => post('/auth/staff/dedupe', { email })
// Backfill : lie automatiquement Authentik ↔ Employee ↔ Tech dans l'identité unifiée (alias depuis le courriel entreprise).
export const syncIdentities = () => post('/auth/staff/sync-identities', {})

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

@ -0,0 +1,162 @@
<script setup>
/**
* StaffConsole réconciliation Authentik OPS (admins).
*
* Chaque user Authentik INTERNE avec son état de provisioning : actif ? groupes ?
* System User / Employee / Dispatch Technician / identité présents ? Signale :
* · orphan = actif dans Authentik mais SANS System User non provisionné (ex. Karim, Junior)
* · departed = inactif Authentik ou identité en départ à retirer/garder pour l'historique
* · ok = provisionné
* Actions : Provisionner (crée les pièces manquantes) · Désactiver/Réactiver (non destructif)
* · Supprimer (GARDÉ : refusé si tickets/jobs référencent la personne propose la désactivation).
*/
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Notify, Dialog } from 'quasar'
import { listStaff, provisionStaff, setStaffActive, staffImpact, deleteStaff, dedupeAccounts, syncIdentities } from 'src/api/staff'
const OPS_GROUPS = ['admin', 'sysadmin', 'tech', 'support', 'comptabilite', 'facturation', 'dev']
const rows = ref([])
const loading = ref(false)
const filter = ref('orphan') // orphan | departed | ok | all
const search = ref('')
const busy = ref('') // email en cours d'action
const syncing = ref(false)
async function doSync () {
syncing.value = true
try { const r = await syncIdentities(); Notify.create({ type: 'positive', message: r.linked + ' identités liées (Authentik ↔ employé ↔ tech)' }); await load() }
catch (e) { Notify.create({ type: 'negative', message: e.message || e }) } finally { syncing.value = false }
}
const prov = ref(null) // formulaire de provisioning
const router = useRouter()
// Horaire d'un membre = MÊME module que Planification (TechScheduleDialog : récurrent + congés + pause).
// Deep-link vers la Planification qui ouvre le dialogue de ce tech réutilise le module tel quel (pas de rebuild).
function openSchedule (r) {
if (!r.tech_id) { Notify.create({ type: 'info', message: 'Provisionner d\'abord un technicien pour ' + r.name + ' (l\'horaire est une ressource dispatch).' }); return }
router.push({ path: '/planification', query: { sched: r.tech_id } })
}
const counts = computed(() => { const c = { orphan: 0, departed: 0, ok: 0, dup: 0 }; for (const r of rows.value) { c[r.status] = (c[r.status] || 0) + 1; if (r.dup_accounts) c.dup++ } return c })
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
return rows.value.filter(r => (filter.value === 'all' || (filter.value === 'dup' ? r.dup_accounts > 0 : r.status === filter.value)) &&
(!q || (r.name + ' ' + r.email + ' ' + r.groups.join(' ')).toLowerCase().includes(q)))
})
const statusMeta = { orphan: { color: 'orange', icon: 'report_problem', label: 'à provisionner' }, departed: { color: 'blue-grey-5', icon: 'logout', label: 'parti / inactif' }, ok: { color: 'positive', icon: 'check_circle', label: 'provisionné' } }
async function load () {
loading.value = true
try { const r = await listStaff(); rows.value = r.staff || [] }
catch (e) { Notify.create({ type: 'negative', message: 'Chargement impossible : ' + (e.message || e) }) } finally { loading.value = false }
}
onMounted(load)
function openProvision (r) { prov.value = { email: r.email, label: r.name || r.email, groups: r.groups.length ? [...r.groups] : ['tech'], create_employee: true, create_tech: false, kind: 'employee' } }
async function doProvision () {
const p = prov.value; busy.value = p.email
try {
const r = await provisionStaff(p)
Notify.create({ type: 'positive', message: p.label + ' provisionné(e)', caption: Object.entries(r.steps || {}).map(([k, v]) => k + ':' + v).join(' · '), timeout: 4000 })
prov.value = null; await load()
} catch (e) { Notify.create({ type: 'negative', message: e.message || e }) } finally { busy.value = '' }
}
async function toggleActive (r) {
busy.value = r.email
try { await setStaffActive(r.email, !r.is_active); await load() }
catch (e) { Notify.create({ type: 'negative', message: e.message || e }) } finally { busy.value = '' }
}
async function tryDedupe (r) {
Dialog.create({ title: 'Supprimer le compte en double', message: `${r.name} a ${r.dup_accounts} comptes Authentik pour ${r.email} (${(r.usernames || []).join(', ')}). Garder le compte utilisé et supprimer le doublon jamais connecté ? (n'affecte pas ERPNext ni l'historique)`, cancel: 'Annuler', ok: { label: 'Nettoyer', color: 'red-7' } }).onOk(async () => {
busy.value = r.email
try { const d = await dedupeAccounts(r.email); Notify.create({ type: 'positive', message: 'Gardé : ' + d.kept + (d.deleted && d.deleted.length ? ' · supprimé : ' + d.deleted.join(', ') : ''), caption: (d.skipped && d.skipped.length ? 'Ignoré : ' + d.skipped.join(', ') : ''), timeout: 4500 }); await load() }
catch (e) { Notify.create({ type: 'negative', message: e.message || e }) } finally { busy.value = '' }
})
}
async function tryDelete (r) {
busy.value = r.email
try {
const imp = await staffImpact(r.email)
if (!imp.clean) {
Dialog.create({ title: 'Suppression bloquée', message: `${r.name} a des références : ${imp.tickets} ticket(s), ${imp.jobs} job(s). Supprimer orphelinerait l'historique. Désactiver plutôt (garde l'historique) ?`, cancel: 'Annuler', ok: { label: 'Désactiver', color: 'orange-7' } })
.onOk(async () => { await setStaffActive(r.email, false); await load() })
return
}
Dialog.create({ title: 'Supprimer définitivement', message: `Aucune référence trouvée. Supprimer le compte Authentik + ERPNext de ${r.name} ? (irréversible)`, cancel: true, ok: { label: 'Supprimer', color: 'negative' } })
.onOk(async () => { const d = await deleteStaff(r.email); if (d.status === 409) { Notify.create({ type: 'warning', message: 'Références apparues — désactivé à la place' }); await setStaffActive(r.email, false) } else Notify.create({ type: 'positive', message: r.name + ' supprimé(e)' }); await load() })
} catch (e) { Notify.create({ type: 'negative', message: e.message || e }) } finally { busy.value = '' }
}
</script>
<template>
<div class="q-pa-sm">
<div class="text-caption text-grey-7 q-mb-sm">Réconcilie les comptes <b>Authentik</b> (source des utilisateurs actifs) avec les <b>System User / Employé / Technicien</b> d'OPS. Un compte actif sans System User = <b>non provisionné</b> (ex. Karim). Départ = désactiver (garde l'historique) ; suppression seulement si aucune référence.</div>
<div class="row items-center q-gutter-xs q-mb-sm">
<q-btn-toggle v-model="filter" dense no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
:options="[{label:'À provisionner ('+(counts.orphan||0)+')',value:'orphan'},...(counts.dup?[{label:'⚠ Doublons ('+counts.dup+')',value:'dup'}]:[]),{label:'Partis / inactifs ('+(counts.departed||0)+')',value:'departed'},{label:'OK ('+(counts.ok||0)+')',value:'ok'},{label:'Tous',value:'all'}]" />
<q-space />
<q-input dense outlined v-model="search" placeholder="Chercher…" clearable style="min-width:180px"><template #prepend><q-icon name="search" /></template></q-input>
<q-btn dense flat no-caps size="sm" color="indigo-7" icon="link" label="Lier les identités" :loading="syncing" @click="doSync"><q-tooltip>Lie automatiquement Authentik Employé Technicien dans l'identité unifiée (alias depuis le courriel entreprise)</q-tooltip></q-btn>
<q-btn flat dense round icon="refresh" :loading="loading" @click="load" />
</div>
<div v-if="loading" class="text-center q-pa-md"><q-spinner size="24px" color="primary" /></div>
<q-list v-else bordered separator class="rounded-borders">
<q-item v-for="r in filtered" :key="r.email">
<q-item-section avatar style="min-width:34px">
<q-icon :name="statusMeta[r.status].icon" :color="statusMeta[r.status].color" size="20px"><q-tooltip>{{ statusMeta[r.status].label }}</q-tooltip></q-icon>
</q-item-section>
<q-item-section>
<q-item-label class="text-weight-medium">{{ r.name }}
<q-chip v-if="!r.is_active" dense size="sm" color="grey-4" text-color="grey-8" icon="logout">inactif</q-chip>
<q-chip v-if="r.needs_group" dense size="sm" color="amber-2" text-color="amber-9" icon="group_off">sans groupe</q-chip>
<q-chip v-if="r.dup_accounts" clickable dense size="sm" color="red-2" text-color="red-9" icon="warning" @click="tryDedupe(r)"> {{ r.dup_accounts }} comptes Authentik nettoyer<q-tooltip>Comptes en double pour ce courriel : {{ (r.usernames || []).join(' · ') }}. Cliquer pour garder l'utilisé et supprimer le doublon.</q-tooltip></q-chip>
</q-item-label>
<q-item-label caption>
{{ r.email }}
<span class="q-ml-sm">
<q-icon :name="r.has_system_user ? 'check' : 'close'" :color="r.has_system_user ? 'positive':'grey-5'" size="13px" /> user
<q-icon :name="r.employee ? 'check' : 'close'" :color="r.employee ? 'positive':'grey-5'" size="13px" class="q-ml-xs" /> employé
<q-icon :name="r.tech_id ? 'check' : 'close'" :color="r.tech_id ? 'positive':'grey-5'" size="13px" class="q-ml-xs" /> tech
<span v-if="r.groups.length" class="q-ml-xs text-grey-6">· {{ r.groups.join(', ') }}</span>
</span>
</q-item-label>
</q-item-section>
<q-item-section side class="row no-wrap items-center">
<q-spinner v-if="busy === r.email" size="16px" color="primary" class="q-mr-sm" />
<q-btn v-if="r.dup_accounts" dense unelevated no-caps size="sm" color="red-6" icon="content_copy" label="Nettoyer le doublon" class="q-mr-xs" @click="tryDedupe(r)" />
<q-btn v-if="r.tech_id" flat dense round size="sm" icon="event" color="teal-7" @click="openSchedule(r)"><q-tooltip>Horaire (récurrent · congés · pause) même module que Planification</q-tooltip></q-btn>
<q-btn v-if="r.status === 'orphan'" dense unelevated no-caps size="sm" color="primary" icon="person_add" label="Provisionner" class="q-mr-xs" @click="openProvision(r)" />
<q-btn flat dense round size="sm" :icon="r.is_active ? 'block' : 'person_add'" :color="r.is_active ? 'orange-7':'positive'" @click="toggleActive(r)"><q-tooltip>{{ r.is_active ? 'Désactiver (garde l\'historique)' : 'Réactiver' }}</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="tryDelete(r)"><q-tooltip>Supprimer (gardé : refusé si références)</q-tooltip></q-btn>
</q-item-section>
</q-item>
<q-item v-if="!filtered.length"><q-item-section class="text-grey-6 text-caption">Aucun compte dans cette vue.</q-item-section></q-item>
</q-list>
<!-- Provisioning -->
<q-dialog :model-value="!!prov" @update:model-value="v => { if (!v) prov = null }">
<q-card style="min-width:400px;max-width:96vw" v-if="prov">
<q-card-section class="row items-center q-pb-none">
<q-icon name="person_add" color="primary" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Provisionner {{ prov.label }}</div>
<q-space /><q-btn flat round dense icon="close" @click="prov = null" />
</q-card-section>
<q-card-section class="q-gutter-sm">
<div class="text-caption text-grey-7">Crée les pièces manquantes : groupe Authentik + System User + Employé{{ prov.create_tech ? ' + Technicien' : '' }} + identité unifiée.</div>
<q-input dense outlined v-model="prov.label" label="Nom complet" />
<q-select dense outlined multiple use-chips v-model="prov.groups" :options="OPS_GROUPS" label="Groupe(s) OPS" />
<div class="row items-center q-gutter-md">
<q-toggle v-model="prov.create_employee" dense size="sm" label="Créer l'employé" />
<q-toggle v-model="prov.create_tech" dense size="sm" color="indigo" label="Créer le technicien (dispatch)" />
</div>
<q-option-group v-model="prov.kind" type="radio" inline dense :options="[{label:'Employé',value:'employee'},{label:'Sous-traitant',value:'subcontractor'}]" />
</q-card-section>
<q-card-actions align="right" class="q-px-md q-pb-md">
<q-btn flat no-caps label="Annuler" color="grey-7" @click="prov = null" />
<q-btn unelevated no-caps color="primary" icon="check" label="Provisionner" :loading="busy === prov.email" @click="doProvision" />
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>

View File

@ -0,0 +1,39 @@
<script setup>
/**
* TicketStatusMenu bouton « Statut » du ticket (distinct du bouton « Reporter » = snooze).
*
* Change le STATUT du billet Issue, avec la distinction demandée : fermer en **Complété**
* (résolu avec succès déclenche la fulfillment de vente : activation + facture) VS **Annulé**
* (fermé sans suite). Émet 'set-status' ; le parent écrit et gère la suite (activation vente).
* Purement additif ne touche PAS TicketStatusControl (partagé, = report/snooze).
*/
import { computed } from 'vue'
const props = defineProps({
status: { type: String, default: '' },
})
const emit = defineEmits(['set-status'])
// Complété = Resolved (résolu) · Annulé = Closed (fermé sans suite). Les autres = états de travail.
const META = {
Open: { label: 'Ouvert', color: 'blue-6', icon: 'radio_button_unchecked' },
Replied: { label: 'Répondu', color: 'teal-6', icon: 'reply' },
'On Hold': { label: 'En attente', color: 'orange-7', icon: 'pause_circle' },
Resolved: { label: 'Complété', color: 'green-7', icon: 'check_circle' },
Closed: { label: 'Annulé', color: 'grey-6', icon: 'cancel' },
}
const cur = computed(() => META[props.status] || { label: props.status || 'Statut', color: 'grey-7', icon: 'flag' })
</script>
<template>
<q-btn-dropdown unelevated no-caps :color="cur.color" :icon="cur.icon" :label="'Statut : ' + cur.label" dense>
<q-list dense style="min-width:210px">
<q-item-label header class="q-py-xs">Changer le statut</q-item-label>
<q-item clickable v-close-popup @click="emit('set-status', 'Open')"><q-item-section avatar><q-icon name="radio_button_unchecked" color="blue-6" /></q-item-section><q-item-section>Ouvert</q-item-section></q-item>
<q-item clickable v-close-popup @click="emit('set-status', 'On Hold')"><q-item-section avatar><q-icon name="pause_circle" color="orange-7" /></q-item-section><q-item-section>En attente</q-item-section></q-item>
<q-separator />
<q-item clickable v-close-popup @click="emit('set-status', 'Resolved')"><q-item-section avatar><q-icon name="check_circle" color="green-7" /></q-item-section><q-item-section><q-item-label>Complété</q-item-label><q-item-label caption>Résolu pour une vente : active + facture</q-item-label></q-item-section></q-item>
<q-item clickable v-close-popup @click="emit('set-status', 'Closed')"><q-item-section avatar><q-icon name="cancel" color="grey-6" /></q-item-section><q-item-section><q-item-label>Annulé</q-item-label><q-item-label caption>Fermé sans suite (pas de facturation)</q-item-label></q-item-section></q-item>
</q-list>
</q-btn-dropdown>
</template>

View File

@ -1,9 +1,14 @@
<template>
<!-- Quick actions -->
<!-- Flow retiré : la relance de flow sera pilotée par l'automatisation (Réglages généraux), pas manuellement depuis le ticket. -->
<div class="issue-actions q-mb-sm">
<div class="issue-actions q-mb-sm row items-center q-gutter-xs">
<TicketStatusMenu :status="doc.status" @set-status="onSetStatus" />
<TicketStatusControl doctype="Issue" :docname="docName" :status="doc.status"
@changed="p => { if (p && p.status) doc.status = p.status; $emit('reply-sent', docName) }" />
<!-- Fulfillment vente : complète l'installation liée → active l'abonnement + facture prorata BROUILLON (flux existant, gaté) -->
<q-btn v-if="canActivate" dense unelevated no-caps color="deep-purple-6" icon="paid" :label="'Activer + facture brouillon' + (actPreview && actPreview.prorated_total != null ? ' (' + actPreview.prorated_total + '$)' : '')" :loading="activating" @click="activateSale">
<q-tooltip>{{ (actPreview.lines || []).length }} abonnement(s) en attente active + facture prorata BROUILLON (à approuver). Complète l'installation « {{ saleJob.subject || saleJob.name }} ».</q-tooltip>
</q-btn>
</div>
<!-- Client lié (comme une conversation : cherchable par nom, association 1 clic).
@ -39,7 +44,7 @@
<q-icon name="engineering" size="18px" :color="assignedPeople.length ? 'indigo-6' : 'grey-5'" />
<span class="tech-banner-lbl">Technicien</span>
<template v-if="assignedPeople.length">
<q-chip v-for="p in assignedPeople" :key="p" dense color="indigo-1" text-color="indigo-9"><q-avatar color="indigo-6" text-color="white" size="20px">{{ initials(p) }}</q-avatar>{{ p }}</q-chip>
<q-chip v-for="p in assignedPeople" :key="p" dense color="indigo-1" text-color="indigo-9"><UserAvatar :name="p" :size="20" card class="q-mr-xs" />{{ p }}</q-chip>
</template>
<span v-else class="text-caption text-grey-6">Non assigné</span>
</div>
@ -49,7 +54,7 @@
<div class="assign-block q-mb-sm">
<div class="info-block-title" style="margin-bottom:4px">Assignation</div>
<AssignmentField doctype="Issue" :doc-name="docName"
:assignee="issueAssignee" :assistants="issueAssistants" :tech-options="userOptions" :can-onsite="false"
:assignee="issueAssignee" :assistants="issueAssistants" :tech-options="unifiedUserOptions" :can-onsite="false"
@assign="assignUser" @set-assistant="p => assignUser(p.value)" @unassign="unassignFirst" @remove-assistant="removeAssignee" />
</div>
@ -86,7 +91,10 @@
</div>
<div class="if-row" v-if="doc.raised_by">
<span class="if-label">Soumis par</span>
<span class="if-value">{{ doc.raised_by }}</span>
<span class="if-value" style="display:flex;align-items:center;gap:6px;min-width:0">
<UserAvatar :email="doc.raised_by" :name="doc.raised_by" :size="20" card />
<span class="ellipsis">{{ doc.raised_by }}</span>
</span>
</div>
</div>
@ -230,8 +238,15 @@
<div class="thread-wrap">
<div v-for="m in thread" :key="m.id" class="msg-bubble" :class="'msg-' + m.kind">
<div class="msg-head">
<span class="msg-avatar">{{ initials(m.who) }}</span>
<UserAvatar :email="m.email" :name="m.who" :size="22" card class="q-mr-xs" />
<span class="msg-who">{{ m.who }}</span>
<!-- Pastille de visibilité CLIQUABLE : bascule Interne Public (relabel seul, aucun courriel) -->
<span class="msg-vis" :class="[m.public ? 'vis-pub' : 'vis-int', { 'vis-busy': togglingMsg === m.name }]"
role="button" tabindex="0" @click="toggleMsgVisibility(m)" @keyup.enter="toggleMsgVisibility(m)">
<q-spinner v-if="togglingMsg === m.name" size="11px" />
<q-icon v-else :name="m.public ? 'visibility' : 'lock'" size="11px" />{{ m.public ? 'Client' : 'Interne' }}
<q-tooltip class="bg-grey-9" style="font-size:11px">{{ m.public ? 'Visible par le client — cliquer pour repasser en interne' : 'Note interne (privée) — cliquer pour rendre visible au client (sans courriel)' }}</q-tooltip>
</span>
<span class="msg-when">{{ formatDateTime(m.when) }}</span>
</div>
<div class="msg-body" v-html="richHtml(m.content)"></div>
@ -264,39 +279,21 @@
placeholder="Écrire une réponse ou une note…"
:input-style="{ fontSize: '0.85rem', minHeight: '50px' }"
@keydown.ctrl.enter="sendReply" @keydown.meta.enter="sendReply" />
<div class="row justify-end q-gutter-sm q-mt-xs">
<q-btn outline dense size="sm" label="Note interne" color="grey-8" icon="sticky_note_2"
:disable="!replyContent?.trim()" :loading="sendingReply" @click="sendReply">
<q-tooltip>Ajoute une note au fil non envoyée au client</q-tooltip>
</q-btn>
<q-btn unelevated dense size="sm" label="Envoyer par courriel" color="primary" icon="mail" @click="openEmailDialog">
<q-tooltip>Envoyer par courriel ; la réponse du destinataire reviendra dans ce ticket</q-tooltip>
</q-btn>
<!-- UNE seule action + une case « Envoyer au client ». Défaut = interne (privé). Cocher = public + courriel (lien ticket ; la réponse revient au fil). -->
<div class="row items-center justify-between q-mt-xs no-wrap">
<q-checkbox v-model="replyToClient" dense size="sm" :color="replyToClient ? 'deep-orange-9' : 'grey-7'">
<span class="reply-cb-lbl" :class="{ 'reply-cb-pub': replyToClient }">
<q-icon :name="replyToClient ? 'warning_amber' : 'lock'" size="14px" />
{{ replyToClient ? 'Envoyer au client (visible + courriel)' : 'Note interne (privée)' }}
</span>
<q-tooltip class="bg-grey-9" style="font-size:11px">{{ replyToClient ? '⚠️ Visible par le CLIENT : marqué public + courriel avec lien vers le ticket (sa réponse revient au fil)' : 'Reste PRIVÉ — non visible par le client. Cochez pour envoyer au client.' }}</q-tooltip>
</q-checkbox>
<q-btn unelevated dense size="sm" :label="replyToClient ? 'Envoyer au client' : 'Ajouter la note'"
:color="replyToClient ? 'deep-orange-9' : 'grey-8'" :icon="replyToClient ? 'send' : 'lock'"
:disable="!replyContent?.trim()" :loading="sendingReply" @click="sendReply" />
</div>
</div>
<!-- Aller-retour courriel : envoie le ticket, la réponse revient au fil du ticket -->
<q-dialog v-model="showEmailDialog">
<q-card style="min-width:min(92vw,460px)">
<q-card-section class="row items-center q-pb-none">
<q-icon name="mail" color="primary" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Envoyer par courriel</div>
<q-space /><q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-gutter-sm">
<q-input v-model="emailTo" dense outlined label="À (courriel)" type="email" autofocus />
<q-input v-model="emailCc" dense outlined label="Cc (optionnel)" />
<q-input v-model="emailMessage" dense outlined type="textarea" autogrow label="Message" :input-style="{ minHeight: '90px' }" />
<div class="text-caption text-grey-6"><q-icon name="info" size="13px" /> Un lien vers le ticket est ajouté. La réponse du destinataire s'ajoutera automatiquement à ce ticket.</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="primary" icon="send" label="Envoyer" :loading="sendingEmail"
:disable="!emailTo || !/.+@.+\..+/.test(emailTo)" @click="sendTicketEmail" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Create single Dispatch Job dialog -->
<UnifiedCreateModal
v-model="showCreateDialog"
@ -342,7 +339,11 @@ import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
import TaskNode from 'src/components/shared/TaskNode.vue'
import TagEditor from 'src/components/shared/TagEditor.vue'
import AssignmentField from 'src/components/shared/AssignmentField.vue'
import UserAvatar from 'src/components/shared/UserAvatar.vue'
import AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison compétence disponibilité (dénominateur filtré)
import { useIdentity } from 'src/composables/useIdentity' // identité unifiée : dé-dup assignation (alias/tech = même personne) + nom complet
import TicketStatusMenu from 'src/components/shared/TicketStatusMenu.vue' // bouton « Statut » (Complété/Annulé) distinct du snooze « Reporter »
import { setJobStatusChain, activationPreview } from 'src/api/dispatch' // complète le job lié active abonnement + facture brouillon (flux existant, gaté) + aperçu lecture seule
// Modules on-site / dispatch RÉUTILISABLES (mêmes composants que le volet Planification) montés ici pour la tâche dispatch principale du ticket.
import JobMapModule from 'src/components/shared/detail-sections/modules/JobMapModule.vue'
import GeofenceTimeline from 'src/components/shared/detail-sections/modules/GeofenceTimeline.vue'
@ -372,6 +373,20 @@ const { userName } = usePermissions()
const senderFirst = computed(() => { const n = (userName.value || '').trim(); return (n ? n.split(/\s+/)[0] : (authStore.user || '').split('@')[0]) || 'Agent' })
const senderFull = computed(() => ((userName.value || '').trim() || (authStore.user || '').split('@')[0] || 'Agent'))
const sendingReply = ref(false)
const replyToClient = ref(false) // case à cocher : OFF = note interne (défaut sûr) · ON = public + envoi au client
const EMAIL_RE = /.+@.+\..+/
// Ticket INITIÉ par le client ? (soumis par un courriel externe, OU le client a écrit dans le fil).
// si oui, la case « Envoyer au client » est PRÉ-COCHÉE (mais tout envoi public exige une confirmation à la soumission).
// Ticket interne (créé par un agent) case décochée par défaut = note interne.
const clientInitiated = computed(() => {
const me = (authStore.user || '').toLowerCase()
const rb = String(props.doc?.raised_by || '').trim().toLowerCase()
if (rb && EMAIL_RE.test(rb) && rb !== me) return true
return (props.comms || []).some(c => String(c.sent_or_received || '') === 'Received')
})
const clientEmailHint = computed(() => { const rb = props.doc?.raised_by; return (rb && EMAIL_RE.test(rb)) ? rb : '' })
// Cale le défaut de la case à l'ouverture d'un ticket (client coché · interne décoché). Ne clobbe pas un basculement manuel en cours.
watch(() => props.docName, () => { replyToClient.value = clientInitiated.value }, { immediate: true })
const showCreateDialog = ref(false)
const showProjectWizard = ref(false)
const availReasonOpen = ref(false)
@ -402,11 +417,43 @@ function initials (name) {
return ((p[0]?.[0] || '') + (p[1]?.[0] || '')).toUpperCase() || '?'
}
// Fusionne échanges (Communication) + notes (Comment) en UN fil chronologique
// Visibilité PAR MESSAGE, surchargeable au clic (Interne privé Public client)
// Défaut par doctype : Communication = échange CLIENT (public) · Comment = note INTERNE (privé). Mais on peut
// BASCULER un message précis : la surcharge explicite est stockée au hub (overlay, clé = docname du message) car
// les Communication mêlent notes internes ET courriels on ne peut PAS trancher au doctype seul. « Rendre public »
// = RELABEL uniquement, AUCUN courriel envoyé ; le client voit les messages publics via le portail/lien à jeton.
const msgPublic = ref({}) // surcharges explicites { [docname]: true|false }
const togglingMsg = ref('')
async function loadMsgVisibility () {
try {
const r = await fetch(`${HUB_URL}/conversations/msg-visibility`).then(x => x.json())
msgPublic.value = (r && r.public) || {}
} catch { /* hub indispo (aperçu) → on garde le défaut par doctype */ }
}
async function toggleMsgVisibility (m) {
if (!m || !m.name || togglingMsg.value) return
const next = !m.public
togglingMsg.value = m.name
try {
const r = await fetch(`${HUB_URL}/conversations/msg-visibility`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: m.name, public: next }),
}).then(x => x.json())
if (r && r.ok) {
msgPublic.value = { ...msgPublic.value, [m.name]: next }
Notify.create({ type: 'positive', icon: next ? 'visibility' : 'lock', message: next ? 'Rendu PUBLIC — visible par le client (aucun courriel envoyé)' : 'Repassé en INTERNE (privé)', timeout: 2200 })
} else Notify.create({ type: 'negative', message: (r && r.error) || 'Échec', timeout: 2500 })
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 2500 }) }
finally { togglingMsg.value = '' }
}
// Fusionne échanges (Communication) + notes (Comment) en UN fil chronologique.
// Visibilité = surcharge explicite (msgPublic) SI présente, sinon défaut par doctype (Communication=public, Comment=interne).
const thread = computed(() => {
const items = []
for (const c of props.comms || []) items.push({ id: 'e-' + c.name, who: c.sender_full_name || c.sender || c.owner || 'Système', when: c.creation, content: c.content, kind: 'comm' })
for (const c of props.comments || []) items.push({ id: 'c-' + c.name, who: c.comment_by || 'Système', when: c.creation, content: c.content, kind: 'note' })
const ov = msgPublic.value
for (const c of props.comms || []) items.push({ id: 'e-' + c.name, name: c.name, email: c.sender || c.owner || '', who: c.sender_full_name || c.sender || c.owner || 'Système', when: c.creation, content: c.content, kind: 'comm', public: (c.name in ov) ? !!ov[c.name] : true })
for (const c of props.comments || []) items.push({ id: 'c-' + c.name, name: c.name, email: c.comment_email || c.comment_by || '', who: c.comment_by || 'Système', when: c.creation, content: c.content, kind: 'note', public: (c.name in ov) ? !!ov[c.name] : false })
return items.sort((a, b) => String(a.when || '').localeCompare(String(b.when || '')))
})
@ -495,20 +542,36 @@ async function loadUsers () {
} catch { userOptions.value = [] }
loadingUsers.value = false
}
// Identité unifiée : nom complet + fusion des comptes-alias d'une même personne.
const ident = useIdentity()
// Options SANS doublon d'identité : un seul choix par personne (les alias courriel sont fusionnés),
// libellé = nom canonique (identité) sinon full_name. Recalcule quand la carte d'identité est chargée.
const unifiedUserOptions = computed(() => {
const seen = new Set(); const out = []
for (const o of userOptions.value) {
if (ident.activeOf(o.value) === false) continue // personne partie non assignable (identité gardée pour l'historique)
const k = ident.keyOf(o.value)
if (seen.has(k)) continue
seen.add(k)
out.push({ label: ident.labelOf(o.value) || o.label, value: o.value })
}
return out
})
const displayName = (email) => ident.labelOf(email) || (userOptions.value.find(u => u.value === email) || {}).label || email
// Adaptateurs pour AssignmentField (champ multifonction partagé)
// Le ticket assigne des Users via frappe _assign : 1er = assigné (À), suivants = assistants (CC). Followers gérés par le composant (doctype Issue).
const issueAssignee = computed(() => {
const e = assignees.value[0]; if (!e) return null
const o = userOptions.value.find(u => u.value === e)
return { id: e, name: (o && o.label) || e }
return { id: e, name: displayName(e) }
})
const issueAssistants = computed(() => assignees.value.slice(1).map(e => {
const o = userOptions.value.find(u => u.value === e)
return { id: e, name: (o && o.label) || e }
}))
const issueAssistants = computed(() => assignees.value.slice(1).map(e => ({ id: e, name: displayName(e) })))
async function assignUser (email) {
const e = String(email || '').trim(); if (!e || assignees.value.includes(e)) return
const e = String(email || '').trim(); if (!e) return
// Dé-dup par IDENTITÉ, pas par courriel : « m'ajouter » (louis@) + recherche « Louis-Paul » (louispaul@)
// = la même personne on n'ajoute pas de doublon (ni le même compte deux fois).
const k = ident.keyOf(e)
if (assignees.value.some(x => ident.keyOf(x) === k)) { Notify.create({ type: 'info', message: displayName(e) + ' est déjà sur ce ticket', timeout: 1800 }); return }
const next = [...assignees.value, e]
assignees.value = next
await saveAssignees(next)
@ -548,7 +611,7 @@ async function saveAssignees (newList) {
}
}
onMounted(() => { loadUsers(); loadTagCatalog() })
onMounted(() => { loadUsers(); loadTagCatalog(); loadMsgVisibility() })
// Intervention sur site / dispatch : modules montés pour la tâche dispatch PRINCIPALE du ticket
// Principale = une tâche avec coordonnées OU billet legacy (aspect terrain) ; sinon la 1re racine ; sinon la 1re.
@ -558,6 +621,57 @@ const primaryJob = computed(() => {
if (!jobs.length) return null
return jobs.find(j => (j.latitude && j.longitude) || j.legacy_ticket_id) || jobs.find(j => !j.parent_job) || jobs[0]
})
// Statut du ticket (bouton « Statut » : Complété/Annulé) + fulfillment de vente
const activating = ref(false)
// Tâche liée encore ouverte (installation à compléter) cible du bouton « Activer + facture brouillon ».
const saleJob = computed(() => (props.dispatchJobs || []).find(j => !['Completed', 'Cancelled'].includes(j.status)) || null)
// Aperçu d'activation (lecture seule) : abonnements EN ATTENTE du client+lieu + total prorata consolidé (tous services).
// C'est LUI qui définit « quel ticket déclenche une activation » : on n'affiche le bouton que s'il y a du EN ATTENTE.
const actPreview = ref(null)
async function loadActivationPreview () {
const cust = props.doc?.customer; const loc = props.doc?.service_location
if (!cust) { actPreview.value = null; return }
try { const p = await activationPreview({ customer: cust, service_location: loc }); actPreview.value = (p && (p.lines || []).length) ? p : null }
catch (e) { actPreview.value = null }
}
onMounted(loadActivationPreview)
// Le bouton d'activation n'apparaît QUE si (installation liée ouverte) ET (abonnements en attente à activer).
const canActivate = computed(() => !!(saleJob.value && actPreview.value))
async function onSetStatus (status) {
try {
await updateDoc('Issue', props.docName, { status })
props.doc.status = status
Notify.create({ type: 'positive', message: 'Statut : ' + status, timeout: 1400 })
emit('reply-sent', props.docName)
// « Complété » sur une vente avec une installation à compléter propose l'activation (pas d'auto : l'agent confirme).
if (status === 'Resolved' && canActivate.value) setTimeout(activateSale, 300)
} catch (e) { Notify.create({ type: 'negative', message: 'Statut : ' + (e.message || e) }) }
}
// Réutilise le flux hub EXISTANT : compléter le job lié déblocage chaîne + activation abonnement + facture prorata BROUILLON
// (gaté PRORATION_WRITE / BILLING_APPROVAL_GATE). N'AUTO-FACTURE jamais : draft à approuver par la facturation.
function activateSale () {
const j = saleJob.value; if (!j || activating.value) return
const p = actPreview.value || {}
const lines = (p.lines || []).map(l => `${l.label}${l.amount != null ? ' — ' + l.amount + '$' : ''}`).join('<br>')
Dialog.create({
title: 'Activer la vente',
message: `Compléter l'installation « ${j.subject || j.name} » et activer <b>${(p.lines || []).length} service(s)</b> en attente ?<br><br>${lines}<br><br><b>Facture prorata consolidée</b>${p.prorated_total != null ? ' ≈ ' + p.prorated_total + '$' : ''}${p.billed_days ? ' (' + p.billed_days + '/' + p.period_days + ' j)' : ''} → créée en <b>BROUILLON</b> (à éditer/approuver par la facturation) si la facturation est armée ; sinon activation sans facture.`,
html: true, cancel: 'Annuler', ok: { label: 'Activer', color: 'deep-purple-6' },
}).onOk(async () => {
activating.value = true
try {
const r = await setJobStatusChain(j.name, 'Completed')
const acts = (r.activated || []).length; const inv = (r.invoices || [])[0]
let msg
if (r.pending_approval) msg = 'Activation placée EN ATTENTE D\'APPROBATION (facturation)'
else if (inv) msg = acts + ' abonnement(s) activé(s) · facture brouillon ' + inv.name
else if (acts) msg = acts + ' abonnement(s) activé(s) · facture non émise (écriture désarmée)'
else msg = 'Installation complétée — aucun abonnement en attente à activer'
Notify.create({ type: 'positive', message: msg, timeout: 6000 })
j.status = 'Completed'; actPreview.value = null; emit('dispatch-updated', j.name)
} catch (e) { Notify.create({ type: 'negative', message: 'Activation : ' + (e.message || e), timeout: 5000 }) } finally { activating.value = false }
})
}
// Signaux « terrain » affichage AUTO de la section ; sinon bascule manuelle (« nécessite une visite sur place »).
const onsiteAuto = computed(() => {
const j = primaryJob.value; if (!j) return false
@ -601,9 +715,10 @@ async function onJobUnassignLead () {
emit('dispatch-updated', j.name, { assigned_tech: '' })
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }) }
}
watch(showOnsite, v => { if (v) loadJobTechs() })
// Bascule persistée par ticket (localStorage pas de champ ERPNext requis). Défaut = auto-détection.
// showOnsite DOIT être déclaré AVANT tout watch qui le référence (sinon ReferenceError TDZ au setup composant blanc).
const showOnsite = ref(false)
watch(showOnsite, v => { if (v) loadJobTechs() })
const onsiteKey = () => 'ops-ticket-onsite-' + (props.docName || '')
watch([primaryJob, () => props.docName], () => {
let stored = null
@ -802,73 +917,92 @@ function onJobUpdated (jobName, data) {
// Reply
async function sendReply () {
// Courriel du client (destinataire de « Envoyer au client ») : raised_by si c'est un courriel, sinon email_id du compte.
async function resolveClientEmail () {
const rb = props.doc?.raised_by
if (rb && EMAIL_RE.test(rb)) return rb
if (props.doc?.customer) {
try {
const rows = await listDocs('Customer', { filters: { name: props.doc.customer }, fields: ['email_id'], limit: 1 })
const e = rows?.[0]?.email_id
if (e && EMAIL_RE.test(e)) return e
} catch { /* pas d'email → visible au portail seulement */ }
}
return ''
}
// Soumission : une note INTERNE part directement ; un envoi PUBLIC (au client) demande une CONFIRMATION explicite
// (garde-fou, surtout quand la case est pré-cochée pour un ticket initié par le client).
function sendReply () {
if (!replyContent.value?.trim() || sendingReply.value) return
if (!replyToClient.value) { doSendReply(false); return }
Dialog.create({
title: 'Répondre au client ?',
message: `Ce message sera <b>visible par le client</b> et lui sera envoyé par courriel${clientEmailHint.value ? ' (' + clientEmailHint.value + ')' : ''}. Pour une note interne, décochez « Envoyer au client ».`,
html: true,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'deep-orange-9', unelevated: true, label: 'Envoyer au client', icon: 'send' },
}).onOk(() => doSendReply(true))
}
// UNE seule action. Défaut (case décochée) = NOTE INTERNE (privée). Cochée = PUBLIC + courriel au client.
// La visibilité est écrite EXPLICITEMENT dans l'overlay (interne=false / public=true) corrige le bug « note interne
// affichée Client » (le défaut par doctype classait toute Communication en public).
async function doSendReply (isPublic) {
if (!replyContent.value?.trim() || sendingReply.value) return
const text = replyContent.value.trim()
sendingReply.value = true
try {
await createDoc('Communication', {
const created = await createDoc('Communication', {
communication_type: 'Communication',
communication_medium: 'Other',
sent_or_received: 'Sent',
subject: props.title || props.docName,
content: replyContent.value.trim(),
content: text,
sender: authStore.user || '',
sender_full_name: senderFirst.value,
sender_full_name: senderFull.value, // nom réel de l'agent (jamais le courriel brut)
reference_doctype: 'Issue',
reference_name: props.docName,
})
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 = clientInitiated.value // retour au défaut selon l'origine (client coché · interne décoché)
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 +1102,18 @@ async function sendTicketEmail () {
font-size: 0.62rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.msg-who { font-size: 0.8rem; font-weight: 600; color: #374151; }
/* Badge visibilité par message (public/client vs interne/privé) */
.msg-vis { display: inline-flex; align-items: center; gap: 2px; font-size: 0.62rem; font-weight: 700; padding: 1px 6px; border-radius: 9px; letter-spacing: .02em; cursor: pointer; user-select: none; transition: filter .12s, box-shadow .12s; }
.msg-vis:hover { filter: brightness(0.96); box-shadow: 0 0 0 1px rgba(0,0,0,.08) inset; }
.msg-vis.vis-busy { opacity: .6; pointer-events: none; }
.msg-vis.vis-int { background: #fef3c7; color: #92400e; } /* interne = ambre (privé) */
.msg-vis.vis-pub { background: #dbeafe; color: #1e40af; } /* client = bleu (public) */
.msg-when { font-size: 0.7rem; color: #9ca3af; margin-left: auto; white-space: nowrap; }
/* Avertissement de visibilité au bas du ticket (avant envoi au client) */
.reply-warn { display: flex; align-items: flex-start; gap: 6px; margin-top: 8px; padding: 7px 10px; font-size: 0.75rem; line-height: 1.35; color: #7c2d12; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; }
.reply-warn .q-icon { color: #ea580c; flex-shrink: 0; margin-top: 1px; }
.reply-cb-lbl { display: inline-flex; align-items: center; gap: 4px; font-size: 0.78rem; font-weight: 500; color: #475569; }
.reply-cb-lbl.reply-cb-pub { color: #9a3412; font-weight: 600; }
.msg-body { font-size: 0.85rem; color: #1f2937; line-height: 1.5; word-break: break-word; }
.msg-body :deep(img) { max-width: 100%; height: auto; }
</style>

View File

@ -0,0 +1,62 @@
// useIdentity — identité unifiée du personnel (SOURCE UNIQUE, miroir de lib/identity.js du hub).
// Charge une fois /identity/map (courriel→{key,label}, tech→{key,label}) et expose de quoi
// DÉ-DUPLIQUER (un courriel alias + le tech_id + le user SSO = la même personne = même `key`)
// et AFFICHER le nom complet (label) partout au lieu du username/courriel.
import { ref } from 'vue'
import { HUB_URL as HUB } from 'src/config/hub'
const emails = ref({}) // 'courriel' → { key, label }
const techs = ref({}) // 'TECH-xxx' → { key, label }
const list = ref([]) // identités éditables (pour l'UI de gestion)
let _loading = null
const norm = (e) => String(e || '').trim().toLowerCase()
async function ensure () {
if (_loading) return _loading
_loading = fetch(HUB + '/identity/map')
.then(r => r.ok ? r.json() : { emails: {}, techs: {}, list: [] })
.then(d => { emails.value = d.emails || {}; techs.value = d.techs || {}; list.value = d.list || []; return d })
.catch(() => ({ emails: {}, techs: {}, list: [] }))
return _loading
}
function reload () { _loading = null; return ensure() }
// Clé d'identité pour un courriel OU un tech_id ; repli = le courriel normalisé (identité « inconnue » mais stable).
function keyOf (input) {
if (!input) return ''
const s = String(input)
return (emails.value[norm(s)] && emails.value[norm(s)].key) || (techs.value[s] && techs.value[s].key) || norm(s)
}
// Nom complet (label) pour un courriel/tech_id ; '' si l'identité n'a pas de label → l'appelant garde son repli.
function labelOf (input) {
if (!input) return ''
const s = String(input)
const hit = emails.value[norm(s)] || techs.value[s]
return (hit && hit.label) || ''
}
// Enregistrement d'identité complet pour un courriel OU un tech_id (via la clé) → { key, label, primary_email, … } ou null.
function recordOf (input) {
if (!input) return null
const k = keyOf(input)
return (list.value || []).find(r => r.key === k) || null
}
// Courriel canonique (primary_email) pour un courriel/tech_id → sert de clé photo + carte. Repli : l'entrée si elle EST un courriel.
function primaryEmailOf (input) {
const rec = recordOf(input)
if (rec && rec.primary_email) return rec.primary_email
const s = String(input || '')
return /.+@.+\..+/.test(s) ? norm(s) : ''
}
// Deux entrées (courriels/tech) = la même personne ?
function sameId (a, b) { return keyOf(a) === keyOf(b) }
// Actif ? départ (active:false) = masqué des sélecteurs mais l'identité reste (historique). Inconnu → actif.
function activeOf (input) {
if (!input) return true
const s = String(input); const hit = emails.value[norm(s)] || techs.value[s]
return hit ? hit.active !== false : true
}
export function useIdentity () {
ensure()
return { ensure, reload, emails, techs, list, keyOf, labelOf, recordOf, primaryEmailOf, sameId, activeOf }
}

View File

@ -6010,7 +6010,7 @@ function onLegacyUpdate (data) {
}
const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } })
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } if (route.query.day || route.query.skill) focusDay(route.query.day, route.query.skill); await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } try { await reloadPool() } catch (e) { /* */ } /* charge le pool (compteurs badge + « N à assigner » par jour) SANS ouvrir le panneau flottant — il s'ouvre à la demande : bouton « À assigner » ou clic sur l'alerte d'un jour */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ })
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } if (route.query.day || route.query.skill) focusDay(route.query.day, route.query.skill); if (route.query.sched) { const _t = (techs.value || []).find(x => String(x.id) === String(route.query.sched)); if (_t) nextTick(() => openTechSchedule(_t)) } await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } try { await reloadPool() } catch (e) { /* */ } /* charge le pool (compteurs badge + « N à assigner » par jour) SANS ouvrir le panneau flottant — il s'ouvre à la demande : bouton « À assigner » ou clic sur l'alerte d'un jour */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ })
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload); if (_nowTimer) clearInterval(_nowTimer); stopLivePositions(); if (_kbRO) _kbRO.disconnect() })
// Actions principales de la Planif joignables via la palette (K), même reléguées dans un menu.

View File

@ -25,8 +25,20 @@
<q-tab name="users" icon="people" label="Utilisateurs" />
<q-tab name="groups" icon="groups" label="Groupes" />
<q-tab name="matrix" icon="grid_on" label="Matrice" />
<q-tab name="identities" icon="badge" label="Identités" />
<q-tab name="staff" icon="manage_accounts" label="Staff" />
</q-tabs>
<!-- TAB: Identités unifiées (label + alias courriel + lien tech + fusion) -->
<div v-show="permTab === 'identities'">
<IdentityManager />
</div>
<!-- TAB: Console Staff (réconciliation Authentik System User/Employee/Tech ; provisionner/désactiver/supprimer) -->
<div v-show="permTab === 'staff'">
<StaffConsole />
</div>
<!-- TAB: Utilisateurs -->
<div v-show="permTab === 'users'">
<div class="row q-gutter-sm items-end q-mb-md">
@ -809,6 +821,8 @@ import { useUserGroups } from 'src/composables/useUserGroups'
import { useAvatar } from 'src/composables/useAvatar'
import UserAvatar from 'src/components/shared/UserAvatar.vue'
import EmployeeEditDialog from 'src/components/shared/EmployeeEditDialog.vue'
import IdentityManager from 'src/components/shared/IdentityManager.vue' // gestion des identités unifiées (label + alias courriel + lien tech + fusion)
import StaffConsole from 'src/components/shared/StaffConsole.vue' // réconciliation Authentik staff (provisionner/désactiver/supprimer gardé)
import { listDocs } from 'src/api/erp'
import QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue'
import DepartmentSubscriptions from 'src/components/settings/DepartmentSubscriptions.vue'

View File

@ -106,11 +106,7 @@
</template>
<template #body-cell-assigned="props">
<q-td :props="props" class="text-center">
<q-avatar v-if="props.row.assigned_staff" size="26px" text-color="white"
:style="{ background: staffColor(props.row.assigned_staff), fontSize: '10px', fontWeight: 600 }">
{{ staffInitials(props.row.assigned_staff) }}
<q-tooltip>{{ props.row.assigned_staff }}</q-tooltip>
</q-avatar>
<UserAvatar v-if="props.row.assigned_staff || props.row.assigned_staff_email" :ident="props.row.assigned_staff_email" :name="props.row.assigned_staff" :size="26" card />
<span v-else class="text-grey-4"></span>
</q-td>
</template>
@ -196,6 +192,7 @@ import DetailModal from 'src/components/shared/DetailModal.vue'
import InlineField from 'src/components/shared/InlineField.vue'
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
import TicketCard from 'src/components/customer/TicketCard.vue'
import UserAvatar from 'src/components/shared/UserAvatar.vue'
const me = useAuthStore().user // courriel de l'agent connecté (pour « Mes tickets »)
const { slaFor } = useSla() // calcul SLA (politiques par file+priorité éditées dans Paramètres)
@ -258,7 +255,10 @@ function resetAndLoad () {
loadTickets()
}
const ISSUE_FIELDS = ['name', 'subject', 'customer_name', 'customer', 'opening_date', 'priority', 'status', 'issue_type', 'assigned_staff', 'owner', 'creation', 'legacy_ticket_id', 'is_important', 'first_responded_on', '_user_tags']
// ERPNext v16 REFUSE `assigned_staff_email` / `opened_by_staff_email` dans une requête `fields` la liste Issue
// plantait ENTIÈREMENT (0 ticket) côté frontend (listDocs interroge ERPNext direct, sans le strip du hub). Retirés
// jusqu'à ce que ces champs custom soient interrogeables ; UserAvatar retombe sur le nom (`assigned_staff`).
const ISSUE_FIELDS = ['name', 'subject', 'customer_name', 'customer', 'opening_date', 'priority', 'status', 'issue_type', 'assigned_staff', 'opened_by_staff', 'owner', 'creation', 'legacy_ticket_id', 'is_important', 'first_responded_on', '_user_tags']
async function loadTickets () {
loading.value = true
const filters = buildFilters({

View File

@ -0,0 +1,259 @@
# UX Refactoring Prompt — Gigafibre FSM
> **Audited: 2026-07-14 @ commit `718698f`.** Line numbers below are hints only — they drift with every commit. ALWAYS locate code by the **Anchor** string (state/component/label name); if the anchor isn't where the table says, search the file and update this doc before refactoring.
## Project Context
Gigafibre FSM is the operations platform for a fiber ISP in Quebec. Stack: Vue 3 / Quasar v2 / Pinia / Vite. The app has two audiences:
- **Ops staff** (CSRs, dispatchers) on desktop/tablet → `apps/ops/src/pages/` — Quasar components, light theme, `--ops-*` tokens.
- **Field technicians** on mobile (often one-handed, gloves, spotty LTE) → `apps/ops/src/modules/tech/pages/`**its own design system**: `tg-*` classes from `src/css/tech.scss`, native `<button>`, NOT q-btn. Never inject Quasar desktop patterns into the tech module.
All strings are French (fr-CA). ERPNext v16 is the backend (REST API). The hub (`targo-hub`) is a Node.js API gateway.
---
## Existing Code — REUSE, never rebuild (standing team rule)
Before creating ANY new file, state which existing file you checked and why it doesn't fit.
- **Pinia stores** (`src/stores/`): `useAuthStore`, `useDispatchStore`, `useOfflineStore`. Extend these; do NOT create new stores without asking.
- **Composables** (`src/composables/`, ~60): check here FIRST — `useDragDrop`, `useSSE`, `useJobPool`, `useScheduler`, `useMediaQuery`, `useUndo`, `useSelection`, `usePeriodNavigation` already exist.
- **Shared components** (`src/components/shared/`, ~48): see the Canonical Primitives table below.
- **`src/components/planif/` already contains 8 extracted components** (décomposition #4): `JobPool`, `LeaveDialog`, `MonthOverview`, `PlanifCellMenu`, `ShiftTypesDialog`, `SkillCadenceTable`, `TechScheduleDialog`, `TechSyncDialog`. New Planification extractions go here and must not duplicate them.
**Where extracted components go:** PlanificationPage pieces → `src/components/planif/` · ClientDetailPage pieces → `src/components/customer/` · reused across pages → `src/components/shared/` (check the ~48 existing first) · tech (mobile) module pieces → `src/modules/tech/components/` (currently only `JobCard.vue`), styled with `tg-*` classes.
### Canonical primitives
All in `src/components/shared/` unless noted. Import these; do NOT rebuild an inferior equivalent.
| Need | Use |
|---|---|
| ⋮ overflow of secondary actions | `OverflowMenu.vue` (`:groups="[{label, items:[{icon,label,caption?,action|to,disabled?}]}]"` or flat `:items`) |
| Collapsible « ▾ Options avancées » / glance→detail | `DisclosureSection.vue` (`title`, `count`, `persist-key` = per-user persisted open state, slots `#summary`/`#actions`) |
| Rare actions removed from a toolbar | register in `composables/useCommandPalette.js` (⌘K) so they stay reachable |
| ⓘ contextual help | `HelpHint.vue` (title/text or slot) — global behavior in `boot/tooltip-ux.js` (click = hide, never 2 bubbles) |
| Tables & async states | `DataTable.vue` · `EmptyState.vue` · `TableSkeleton.vue` |
| Page top bar / record detail | `PageHeader.vue` · `DetailModal.vue` + `detail-sections/` (incl. `modules/JobThread.vue`, `JobReplyBox.vue`) |
| Stats / avatars | `StatCard.vue` · `UserAvatar.vue` (canonical identity avatar) |
| Skill chips & editor | `TagEditor.vue` · `components/planif/SkillCadenceTable.vue` |
| Maps / occupancy | `RouteMap.vue` · `OccupancyStrip.vue` · `OccupancyBands.vue` |
| Responsive breakpoints | `composables/useMediaQuery.js` |
---
## UX Rules (enforce these on every review)
1. **MAX 5 interactive CONTROLS per chrome zone** (toolbar, header, footer, dialog action row). Data cells/rows/chips inside grids, tables, calendars and kanban lanes do NOT count — a planning grid legitimately renders dozens of interactive cells. If a chrome zone exceeds 5 controls, group them behind a single trigger using `shared/OverflowMenu.vue` — never hand-roll a q-menu of secondary actions.
2. **Progressive disclosure**: show only what the CURRENT step needs. Hide rarely-used options with `shared/DisclosureSection.vue` (« ▾ Options avancées ») or an OverflowMenu. Actions removed from a toolbar must be registered in `useCommandPalette` (⌘K) so they stay reachable — PlanificationPage already does this.
3. **Every form field must justify its presence** — if it has a sensible default or is rarely edited, hide it. Only expose what the user needs to touch >50% of the time.
4. **No menu-in-menu**: if a dropdown item opens a submenu, extract it into its own dialog.
5. **Mobile-first for mobile surfaces**: on the tech module (`modules/tech/`) and on mobile breakpoints (`lt-md`) of ops pages, the primary CTA must be in thumb reach (bottom 1/3) — reuse the floating CTA pill pattern (`.tg-fab-cta`, TechJobDetailPage). On desktop (`gt-sm`) ops pages, the primary CTA belongs top-right of the header toolbar, visually dominant — do NOT relocate desktop CTAs to the bottom of the viewport. No hover-only affordances anywhere (desktop included).
6. **No `.vue` file > 800 lines** — decompose into child components with focused responsibilities.
7. **Props carry data/config; behaviour goes through emits or a shared composable/Pinia store.** HARD flag: any Function-typed prop used as a parent callback (see C1: LocationCard has 32 props, 26 of them Functions — the real anti-pattern). SOFT flag: >10 props (config-heavy shared components like DetailModal at 12 declarative props are acceptable).
8. **One primary action per view state**. If the user has to choose between 3+ equally prominent buttons, you've failed.
9. **Status feedback**: every mutation shows visible feedback within 200ms — using the app's existing mechanisms ONLY: `$q.notify` for results/errors (see the `err()` helper pattern), `:loading` / `:disabled="saving"` on the triggering button while pending, the changeLog journal + revert for grid edits, and OutboxPanel-style delayed send with « Tout annuler » for bulk/destructive sends. Do NOT introduce a new toast system. "Never silently `catch()`" applies to user-initiated mutations; background best-effort loads may catch silently but must carry a comment (existing convention: `catch (e) { /* … best-effort */ }`).
10. **Dev-only UI hidden in production**: gate ONLY on `import.meta.env.DEV` (compile-time, stripped from prod builds) — NEVER on a custom `VITE_*` flag: `.env.local` VITE_ vars are bundled even in prod builds (documented leak, `src/config/hub.js`). Canonical pattern: the `canTerminateDirect` computed in TechJobDetailPage.vue.
11. **French only (fr-CA)**: every user-facing string — labels, tooltips, toasts, aria-labels, empty states — including in NEW elements you create. Use « guillemets » and the existing domain vocabulary (quart, garde, tournée, « À assigner », « Publier »). Any English string in output is an automatic P1 review finding.
12. **Preserve ALL keyboard shortcuts.** Existing: ⌘K/Ctrl+K → CommandPalette (window listener in MainLayout); Planification grid: Ctrl/⌘+Z / ⇧Z undo-redo, Ctrl/⌘+C/V copy-paste cells, Delete/Backspace clear, `A` = absent, `G` = garde (document-level `onKey` listener, which skips events from INPUT/TEXTAREA/SELECT/contenteditable — preserve that guard). Any extraction must re-wire document/window-level key listeners explicitly and list the component's shortcuts in its top docblock.
13. **Every async view has three states**: loading → `shared/TableSkeleton.vue` (or q-skeleton), empty → `shared/EmptyState.vue` (icon + phrase FR + CTA), error → message + bouton « Réessayer ». When splitting a component, carry all three branches into the child. Never rebuild an equivalent skeleton/empty-state.
14. **Spotty-LTE resilience (tech module)**: every mutation button shows a pending state and is disabled while pending (`:disabled="saving"`, cf. TechJobDetailPage). Offline-sensitive mutations go through the IndexedDB queue in `src/stores/offline.js` — never fire-and-forget fetch. Queued work must be visible: count + tap-to-retry chip (cf. TechScanPage « N scans en attente · toucher pour réessayer »). Never gate messaging on `navigator.onLine` alone — it reports true on unusable 2-bar LTE (documented in `stores/offline.js`).
15. **Touch & focus (tech module)**: every tappable element ≥ 44×44 px (existing convention: `min-height: 44px` in TechJobDetailPage.vue). Keep visible `:focus-visible` states; never encode state by colour alone (add icon or label); use the tokens in `css/tech.scss` (`--ink-*`, `--t-*`) — no hardcoded colours.
16. **Design tokens**: any new or moved style uses the `--ops-*` variables from `src/css/app.scss` (`--ops-accent/-success/-warning/-danger/-info/-bg/-bg-hover/-bg-light/-surface/-border/-text/-text-muted`) — no new hardcoded hex. Use each extraction as the opportunity to convert copied colours. All pages are LIGHT theme; `DispatchPage` is the sole dark exception — never introduce dark styling elsewhere.
### Expert-density surfaces (declutter rules relaxed)
On these surfaces, Rules 13 and 8 apply ONLY to chrome (toolbars, dialogs), never to board/grid content: **Planification grid/day/month/routes views** (PlanificationPage) and **Dispatch timeline & calendars** (`modules/dispatch/components/`). Information density is a feature for dispatchers: do not reduce data shown per cell, collapse columns, or add clicks between the dispatcher and job details. Dispatch keeps its dark theme (`pages/dispatch-styles.scss`).
---
## Known Anti-Patterns (audited snapshot)
> ⚠️ This audit is a snapshot (2026-07-14 @ `718698f`) and remediation is ongoing — the code marks applied fixes with `#N` comments. Before acting on any row, VERIFY the pattern still exists at the anchor; if already fixed, say so and move on.
### PlanificationPage.vue (6,559 lines) — Priority: CRITICAL
File layout: template L11768 · script L17706033 (~4,264 lines — the real bulk) · style L60356559.
| ID | Anti-pattern | Anchor | Status | Problem |
|---|---|---|---|---|
| PL1 | WALL_OF_OPTIONS | desktop header rows (comment « Rangée 1 : actions principales ») ~L32-154 | open | 20+ interactive controls in one toolbar |
| PL2 | WALL_OF_OPTIONS | `label="Outils"` dropdown ~L71-125 | open | 13 items in 3 sections + nested submenu |
| PL3 | WALL_OF_OPTIONS | q-dialog `jobSheet.open` ~L281-356 | open | ~15 interactive zones on a phone screen. Reuse `detail-sections/modules/JobThread.vue` + `JobReplyBox.vue` instead of the hand-rolled pm-postbox composer |
| PL4 | HIDDEN_PRIMARY | `label` contains « Publier », split q-btn-dropdown ~L131-147 | **partially fixed** | Publier is now a unified split q-btn-dropdown (color=positive, dirty-count, outline-when-clean). Remaining: the row still has ~10 sibling controls (see PL1) |
| PL5 | MENU_IN_MENU | « Modèles de semaine » ~L85-97 | open | Submenu inside a dropdown |
| PL6 | ORPHAN_FIELD | `label="Max h/sem"` ~L159 | open | Floating numeric input with no context |
| PL7 | STATUS_MYSTERY | autosave journal ~L48-61 | **largely fixed** | Icon now swaps to cloud_sync + primary color while saving (persistent feedback); popover holds the change journal + per-change revert by design. Verify before changing |
| PL8 | MONOLITH | entire file | open | 6,559 lines = unmaintainable (see decomposition plan) |
### TechJobDetailPage.vue (679 lines) — Priority: LOW
| ID | Anti-pattern | Anchor | Status | Problem |
|---|---|---|---|---|
| T1 | WALL_OF_OPTIONS | "Détails" section ~L60-104 | open | Type, Priority, Time, Duration always visible — tech rarely edits these |
| T2 | ~~DEAD_CLICK~~ | « Terminer directement (test) » ~L160-166 | **RESOLVED** | Already gated by `import.meta.env.DEV` (`canTerminateDirect`, L296-298) — verify only, do not re-fix |
| T3 | ORPHAN_FIELD | "Créer un équipement" dialog ~L237-241 | open | 5 fields visible, only Serial is required |
### TechTasksPage.vue (748 lines) — Priority: NONE
No open issues — the dev test button (~L213-218) is already DEV-gated via `canTerminateDirect` (L332-335). Do not modify this page.
### ClientDetailPage.vue (1,728 lines) — Priority: MEDIUM
| ID | Anti-pattern | Anchor | Status | Problem |
|---|---|---|---|---|
| C1 | PROP_OVERLOAD | `<LocationCard` bindings ~L74-107 → `components/customer/LocationCard.vue` (defineProps L299-334) | open | **32 props, 0 emits** — 6 data props + 26 Function props (10 selectors drilled from the parent's `useSubscriptionGroups` instance + 16 action callbacks). The anti-pattern is callbacks-as-props. Fix: instantiate `useSubscriptionGroups` inside LocationCard (or provide/inject the instance), keep the data props, convert action callbacks to emits (Rule 7) |
---
## Decomposition plan — PlanificationPage.vue
**`components/planif/` ALREADY CONTAINS (décomposition #4 — do NOT re-create, extend these):**
`JobPool.vue` (pool « à assigner » unifié : variants floating/docked/mobile — the mobile swipe/tap-sheet logic lives HERE) · `LeaveDialog.vue` · `ShiftTypesDialog.vue` · `TechSyncDialog.vue` · `TechScheduleDialog.vue` · `MonthOverview.vue` · `PlanifCellMenu.vue` · `SkillCadenceTable.vue` (SHARED skill+cadence editor).
New extractions must follow the same props/emits conventions as ShiftTypesDialog/LeaveDialog.
**Still to extract (anchor by state/component name — line numbers drift):**
```
Wave 1
├── PlanifHeader.vue ← desktop toolbar + filters + skills chips, L32-187
│ (anchor: q-btn-toggle `boardView` → OverflowMenu).
│ Keep existing OverflowMenu / HelpHint / useCommandPalette wiring.
│ Mobile toolbar (`pm-toolbar`, ~L3-30): include in PlanifHeader
│ (render both breakpoints) or extract as PlanifMobileBar.vue —
│ decide before extracting.
├── PlanifGrid.vue ← week grid table
├── PlanifKanban.vue ← day view (already renders <JobPool variant="docked">)
├── PlanifRoutes.vue ← routes view (uses shared RouteMap.vue + OccupancyStrip.vue)
├── JobOptionsSheet.vue ← q-dialog `jobSheet.open` ONLY (the `pm-sheet` assignment overlay
│ and swipe/tap stay coupled to JobPool.vue — coordinate via its emits)
├── SkillEditorPopover.vue ← q-menu `skillMenuShown` (WRAPS existing SkillCadenceTable — keep it)
├── GardeDialog.vue ← q-dialog `showGarde` (keep its HelpHint)
└── TeamEditorDialog.vue ← q-dialog `showTeamEditor` (wraps SkillCadenceTable per tech)
Wave 2 (template L789-1768, currently unmapped)
├── SuggestReviewDialog.vue ← « Suggérer » review (q-dialog `suggestDlg.open`)
├── TagManagerDialog.vue ← `showTagManager` + `skillImpactOpen`
├── JobPoolPanel.vue ← floating unassigned-jobs panel + pins map (`assignPanel`)
├── LegacyPushDialog.vue ← `legacyPush.open` (write-back osTicket)
├── LocPickerDialog.vue ← `locPicker.open`
└── (also unmapped: notify-contacts block ~L1259, legacy job-detail dialog ~L1288)
```
**Script-mapping rule:** for each extracted component, move every function/ref referenced ONLY by the extracted template into the child (e.g. `kb*`/`kanban*` → PlanifKanban, `cell*`/`onCellDrop`/`onDown` → PlanifGrid, `garde*` → GardeDialog). State shared across views stays in the parent or a per-domain composable.
**Composables:** do NOT create a catch-all `usePlanification.js`. `useJobPool.js` already covers pool filter/sort/group (3 instances owned by the page — keep that ownership). Extract PER DOMAIN only when a child needs it: `useGardeRules.js` (rule CRUD + preview), `useWeekTemplates.js`, `usePlanifBoard.js` (cells/occupancy/capacity). Before writing a composable, check `src/composables/` (~60 existing).
**CSS:** Vue scoped styles do NOT reach into child components. Each extraction must copy the scoped CSS rules matching its markup (`.pm-*`, `.assign-*`, `.garde-*`…) into the new component's own `<style scoped>`. Do not rename classes.
**Target:** PlanificationPage.vue < 800 lines (Rule 6); ~400 lines only after ALL rows above are extracted. Extract top-down, ONE row per session.
---
## How to Use This Prompt
Text in `[square brackets]` is a placeholder — replace it with your actual target (anti-pattern IDs, component name, line range) before sending.
### Option A: Full-page refactor
Paste this file + the full `.vue` file content, then say:
```
Refactor this component following the UX rules above. Address anti-patterns
[PL1, PL2, PL3] specifically. Keep all v-model bindings, event names AND
keyboard shortcuts intact. Output ONE unified diff against the pasted file,
covering <template> AND any <script> changes the fix requires (new refs/handlers
for grouped menus, etc.). If a rule can only be satisfied in <script> (rules 7,
9, 10, 12) and the script was not pasted, do not guess: emit a `// TODO(script):`
comment in the diff describing the exact change needed. All new UI strings in
French (fr-CA).
```
### Option B: Extract a child component
Paste this file + the relevant template section of the parent **+ the script portions that move with it + the matching scoped CSS**, then say:
```
Extract the section anchored at [jobSheet.open] into a new `[JobOptionsSheet].vue`
component in [src/components/planif/]. The parent should pass only the job object
and emit events for mutations. Apply progressive disclosure: show Status + Reply
visible, rest under DisclosureSection « ▾ Options avancées ». Copy the scoped CSS
rules this markup uses into the new component. Output both the new component and
the parent's updated template section. All new UI strings in French (fr-CA).
```
### Option C: Review a new component
Paste this file + the new component, then say:
```
Review this component for cognitive load following the UX rules above.
For each issue found, output:
- ANCHOR: the element (state/label name) + approximate line range
- PROBLEM: what causes overload
- FIX: concrete code change (not just advice)
- PRIORITY: P0 (confusing) / P1 (cluttered) / P2 (polish)
Do NOT suggest adding features. Only simplify.
```
### Option D: Decompose a monolith
Paste this file + the full monolith (or the target section + its script), then say:
```
Decompose this monolith following the decomposition plan in the prompt.
Start with [PlanifHeader.vue]. Output:
1. The new child component (complete, working — template + script + scoped CSS)
2. The updated parent template section (replacing the extracted lines)
3. The props/emits interface between them
Keep all existing functionality. Do not rename CSS classes. Do not change any
API call, URL, payload shape, or emitted event name — refactors are UI-structure
only. Target: parent < 800 lines; ~400 only as the end-state after all
extractions (one extraction per session).
```
---
## Definition of Done (every session)
A refactor is complete only when:
1. `npm run lint` and `npm run build` pass in `apps/ops` (paste errors back if not).
2. Manually verified in `quasar dev` (port 9001): the page renders, no new console errors.
3. These invariants still work (PlanificationPage): **drag-and-drop** of jobs onto grid cells and kanban lanes (incl. drop preview badge, SortableJS day editor), **SSE live updates** (`legacy-update` listener), **the 4 MapLibre maps** (job-detail, location picker, assign panel, day route), **keyboard shortcuts** (Rule 12), and the **Publier** flow.
4. No behavior change: same API calls, same payloads, same emitted events.
If you (the LLM) cannot run these, end your output with a checklist of exactly what the human must verify.
---
## Token Optimization Tips
| Technique | Savings | How / caveats |
|---|---|---|
| Send only `<template>` section | 5873% (template is 2742% of these files) | ONLY for layout-only reviews (rules 15, 8). Include `<script setup>` whenever the task touches rules 7/9/10/12, extracts a component (Options B/D), or reviews error handling — key handlers and gating live in script |
| One component at a time | ~70% | Don't paste 5 files; review sequentially |
| Specify viewport | Response tokens | Add `"Target: 375×667 iPhone SE"` for tech-module work to avoid desktop suggestions |
| Ask for diff, not full file | ~60% response | `"Output changes as a unified diff, not the full file"` |
| "Do NOT suggest adding features" | ~30% response | Prevents AI from adding complexity |
| Strip `<style>` section | 826% (only ~8% on PlanificationPage/ClientDetailPage) | ONLY for reviews (Option C). NEVER for extractions (Options B/D): scoped styles don't reach child components — the extracted markup needs its CSS copied along |
**Section boundaries (at `718698f`)** — to slice without opening the file: PlanificationPage template L11768 / script L17706033 / style L60356559.
---
## Quick Reference: Quasar Components Used
| Component | Purpose | Notes |
|---|---|---|
| `q-btn` / `q-btn-group` / `q-btn-toggle` / `q-btn-dropdown` | Buttons | Split buttons = `q-btn-dropdown split` (cf. « Publier ») |
| `q-dialog` | Modal dialogs | `position="bottom"` = mobile bottom-sheet |
| `q-menu` | Dropdown/context menus | `context-menu` = right-click; `auto-close`. For secondary-action overflow, use `shared/OverflowMenu.vue` instead |
| `q-expansion-item` | Collapsible sections | Prefer `shared/DisclosureSection.vue` (persisted open state); `group` prop for accordion |
| `q-chip` | Tags, filters, status badges | `clickable` makes it interactive |
| `q-input` / `q-select` | Form fields | `dense outlined` is the standard style |
| `q-list` / `q-item` | Lists inside menus/dialogs | `q-item-section avatar` for icons |
| `q-tooltip` | Hover help text | Overused — not a substitute for clear labels. For contextual help use `shared/HelpHint.vue` (ⓘ with title/rich text); the global behavior (click = hide, never 2 bubbles) lives in `boot/tooltip-ux.js` — don't fix tooltips one by one |
| `q-banner` | Alerts, notifications | Status banners (offline, warnings) |
— End of prompt —

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 = ''
@ -94,12 +96,22 @@ async function resolveEmployeeForEmail (email) {
if (!e) return null
try {
const erp = require('./erp')
let rows = await erp.list('Employee', { filters: [['user_id', '=', e]], fields: EMPLOYEE_FIELDS, limit: 1 })
if (!rows.length) rows = await erp.list('Employee', { filters: [['company_email', '=', e]], fields: EMPLOYEE_FIELDS, limit: 1 })
// ALIAS-AWARE : le compte de session (louis@targo.ca) et l'Employee (sous louispaul@targointernet.com)
// sont la même personne → on cherche l'Employee sous N'IMPORTE QUEL courriel de l'identité unifiée.
let emails = [e]
try { const id = require('./identity').resolveIdentity(e); if (id) emails = [...new Set([id.primary_email, ...(id.alias_emails || []), e].filter(Boolean))] } catch (x) {}
let rows = await erp.list('Employee', { filters: [['user_id', 'in', emails]], fields: EMPLOYEE_FIELDS, limit: 1 })
if (!rows.length) rows = await erp.list('Employee', { filters: [['company_email', 'in', emails]], fields: EMPLOYEE_FIELDS, limit: 1 })
return rows[0] || null
} catch (err) { log('resolveEmployeeForEmail ' + e + ': ' + err.message); return null }
}
// Admin = mêmes groupes que identity/avatars/user-prefs (écritures staff sensibles).
function isAdminReq (req) {
const g = String(req.headers['x-authentik-groups'] || req.headers['x-authentik-group'] || '')
return /(^|[,|;])\s*(ops-?admin|admin(istrateurs?)?|superviseurs?)\s*([,|;]|$)/i.test(g)
}
function validatePermissions (body) {
const validKeys = new Set(CAPABILITIES.map(c => c.key))
const result = {}
@ -219,6 +231,215 @@ async function handle (req, res, method, path, url) {
return json(res, 200, { users, total: r.data.pagination?.count || users.length })
}
// ── CONSOLE STAFF : réconciliation Authentik ↔ System User / Employee / Dispatch Technician / identité ──
if (resource === 'staff' && !parts[1] && method === 'GET') {
const erp = require('./erp'); const idlib = require('./identity')
// 1. Users Authentik internes (toutes les pages ; exclut outposts + comptes de service).
const akUsers = []
for (let p = 1; p <= 12; p++) {
const r = await akFetch(`/core/users/?page=${p}&page_size=100&ordering=username`)
if (r.status !== 200) break
akUsers.push(...(r.data.results || []).filter(u => u.email && !u.username.startsWith('ak-outpost') && u.type !== 'internal_service_account'))
if (!r.data.pagination || !r.data.pagination.next) break
}
const allGroups = await fetchGroups(); const gmap = new Map(allGroups.map(g => [g.pk, g.name]))
// 2. ERPNext en lot (1 requête par doctype).
const [sysU, emps, techs] = await Promise.all([
erp.list('User', { filters: [['user_type', '=', 'System User']], fields: ['name', 'enabled'], limit: 1000 }).catch(() => []),
erp.list('Employee', { fields: ['name', 'employee_name', 'company_email', 'user_id', 'status'], limit: 2000 }).catch(() => []),
erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'full_name', 'employee', 'status'], limit: 1000 }).catch(() => []),
])
const sysSet = new Set(sysU.map(u => String(u.name).toLowerCase()))
const empByEmail = {}; for (const e of emps) { if (e.user_id) empByEmail[String(e.user_id).toLowerCase()] = e; if (e.company_email && !empByEmail[String(e.company_email).toLowerCase()]) empByEmail[String(e.company_email).toLowerCase()] = e }
const techByEmp = {}; for (const t of techs) { if (t.employee) techByEmp[t.employee] = t }
// DÉ-DUP par courriel : plusieurs comptes Authentik pour la même personne (username `joseph` + `joseph@…`)
// → UNE ligne, en listant les usernames en double à nettoyer. Actif si au MOINS un compte actif.
const byEmail = new Map()
for (const u of akUsers) {
const email = String(u.email || '').toLowerCase(); if (!email) continue
const g = byEmail.get(email) || { email, usernames: [], is_active: false, name: '', groupsSet: new Set() }
g.usernames.push(u.username)
g.is_active = g.is_active || !!u.is_active
if (!g.name && u.name) g.name = u.name
for (const pk of (u.groups || [])) { const n = gmap.get(pk); if (OPS_GROUPS.includes(n)) g.groupsSet.add(n) }
byEmail.set(email, g)
}
const rows = [...byEmail.values()].map(g => {
const email = g.email
const id = idlib.resolveIdentity(email)
// Employee résolu via l'identité (alias) : louis@targo.ca → Employee sous louispaul@targointernet.com.
const idEmails = id ? [id.primary_email, ...(id.alias_emails || []), email] : [email]
let emp = null; for (const e of idEmails) { if (empByEmail[String(e).toLowerCase()]) { emp = empByEmail[String(e).toLowerCase()]; break } }
const tech = (emp && techByEmp[emp.name]) || (id && id.tech_id ? { technician_id: id.tech_id } : null)
const hasSys = idEmails.some(e => sysSet.has(String(e).toLowerCase()))
const groups = [...g.groupsSet]
let status = 'ok'
if (!g.is_active || (id && id.active === false)) status = 'departed'
else if (!hasSys) status = 'orphan'
const needs_group = g.is_active && hasSys && !groups.length
const dup_accounts = g.usernames.length > 1 ? g.usernames.length : 0
return { email, username: g.usernames[0], usernames: g.usernames, dup_accounts, name: g.name || (id && id.label) || email, is_active: g.is_active, groups, has_system_user: hasSys, employee: emp ? emp.name : '', tech_id: tech ? (tech.technician_id || tech.name) : '', identity_key: id ? id.key : '', identity_active: id ? id.active !== false : null, label: id ? id.label : '', status, needs_group }
}).sort((a, b) => (a.status === 'orphan' ? 0 : 1) - (b.status === 'orphan' ? 0 : 1) || String(a.name).localeCompare(b.name))
return json(res, 200, { staff: rows })
}
// Impact d'un retrait : références qui empêchent une suppression propre (tickets assignés, jobs).
if (resource === 'staff' && parts[1] === 'impact' && method === 'GET') {
const erp = require('./erp'); const idlib = require('./identity')
const email = String(url.searchParams.get('email') || '').toLowerCase(); if (!email) return json(res, 400, { error: 'email requis' })
const id = idlib.resolveIdentity(email)
const emails = id ? [id.primary_email, ...(id.alias_emails || [])] : [email]
let tickets = 0
for (const e of emails) { try { const r = await erp.list('Issue', { filters: [['_assign', 'like', '%' + e + '%']], fields: ['name'], limit: 200 }); tickets += (r || []).length } catch (x) {} }
let jobs = 0
if (id && id.tech_id) { try { const r = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', id.tech_id]], fields: ['name'], limit: 500 }); jobs = (r || []).length } catch (x) {} }
return json(res, 200, { email, tickets, jobs, clean: tickets === 0 && jobs === 0 })
}
// ── Écritures staff : admin seulement ──
if (resource === 'staff' && method !== 'GET' && !isAdminReq(req)) return json(res, 403, { error: 'réservé aux administrateurs' })
// Provisionner (create-staff) : à partir d'un user Authentik EXISTANT (ou nouveau) → groupe + System User + Employee + Tech + identité.
if (resource === 'staff' && parts[1] === 'provision' && method === 'POST') {
const b = await parseBody(req); const erp = require('./erp'); const idlib = require('./identity')
const email = String(b.email || '').toLowerCase(); if (!email) return json(res, 400, { error: 'email requis' })
const label = String(b.label || email).trim(); const out = { email, steps: {} }
// 1. Authentik : assigner les groupes OPS + garder actif.
try {
const lookup = await findUserByEmail(email)
if (lookup.user && Array.isArray(b.groups) && b.groups.length) {
const allG = await fetchGroups(); const pks = b.groups.map(n => { const g = allG.find(x => x.name === n); return g && g.pk }).filter(Boolean)
await akFetch('/core/users/' + lookup.user.pk + '/', 'PATCH', { groups: pks, is_active: true }); invalidateCache()
out.steps.groups = b.groups
}
} catch (e) { out.steps.groups_err = e.message }
// 2. ERPNext System User (créer si absent, sinon promouvoir).
try {
const ex = await erp.list('User', { filters: [['name', '=', email]], fields: ['name', 'user_type'], limit: 1 })
if (!ex.length) { await erp.create('User', { email, first_name: label, send_welcome_email: 0, user_type: 'System User', roles: [{ role: 'Employee' }] }); out.steps.system_user = 'created' }
else if (ex[0].user_type !== 'System User') { await erp.update('User', email, { user_type: 'System User' }); out.steps.system_user = 'promoted' }
else out.steps.system_user = 'exists'
} catch (e) { out.steps.system_user_err = e.message }
// 3. Employee — LIER l'existant plutôt que dupliquer (évite le doublon craint : Aurélie a déjà HR-EMP-107 non lié).
let empName = b.employee || ''
if (b.create_employee !== false) {
try {
if (empName) { // l'admin a choisi un Employee précis → le lier
const cur = await erp.list('Employee', { filters: [['name', '=', empName]], fields: ['name', 'user_id'], limit: 1 })
if (cur.length && !cur[0].user_id) { await erp.update('Employee', empName, { user_id: email, company_email: email }); out.steps.employee = 'linked' } else out.steps.employee = cur.length ? 'exists' : 'introuvable'
} else {
let ex = await erp.list('Employee', { filters: [['user_id', '=', email]], fields: ['name', 'user_id'], limit: 1 })
if (!ex.length) ex = await erp.list('Employee', { filters: [['company_email', '=', email]], fields: ['name', 'user_id'], limit: 1 })
if (!ex.length && label) ex = await erp.list('Employee', { filters: [['employee_name', '=', label]], fields: ['name', 'user_id'], limit: 2 }) // match par NOM (ex. Aurélie Bourdon = HR-EMP-107)
if (ex.length === 1) { empName = ex[0].name; if (!ex[0].user_id) { await erp.update('Employee', empName, { user_id: email, company_email: email }); out.steps.employee = 'linked' } else out.steps.employee = 'exists' }
else if (ex.length > 1) { out.steps.employee = 'ambigu(' + ex.length + ') — choisir/lier manuellement, non créé' } // ne PAS deviner
else { const r = await erp.create('Employee', { employee_name: label, company_email: email, user_id: email, status: 'Active' }); empName = (r && (r.name || (r.data && r.data.name))) || ''; out.steps.employee = 'created' }
}
} catch (e) { out.steps.employee_err = e.message }
}
// 4. Dispatch Technician (optionnel).
let techId = b.tech_id || ''
if (b.create_tech) {
try { const r = await erp.create('Dispatch Technician', { full_name: label, resource_type: 'human', status: 'Disponible', employee: empName || undefined }); techId = (r && ((r.data && r.data.name) || r.name)) || ''; out.steps.tech = techId || 'created' } catch (e) { out.steps.tech_err = e.message }
}
// 5. Identité unifiée.
try { idlib.upsert({ label, primary_email: email, tech_id: techId, employee: empName, active: true, kind: b.kind || 'employee' }); out.steps.identity = 'ok' } catch (e) { out.steps.identity_err = e.message }
return json(res, 200, { ok: true, ...out })
}
// Désactiver / réactiver (non destructif) : Authentik is_active + identité départ.
if (resource === 'staff' && parts[1] === 'active' && method === 'POST') {
const b = await parseBody(req); const idlib = require('./identity')
const email = String(b.email || '').toLowerCase(); const active = b.active !== false
if (!email) return json(res, 400, { error: 'email requis' })
try { const lookup = await findUserByEmail(email); if (lookup.user) { await akFetch('/core/users/' + lookup.user.pk + '/', 'PATCH', { is_active: active }); invalidateCache() } } catch (e) { return json(res, 500, { error: e.message }) }
try { idlib.setActive(email, active) } catch (e) {}
return json(res, 200, { ok: true, email, is_active: active })
}
// Dé-dupliquer les comptes Authentik d'un même courriel : garde l'utilisé (last_login) sinon le plus ancien,
// supprime UNIQUEMENT les doublons JAMAIS connectés (sûr : aucune session/historique). Body: { email }.
if (resource === 'staff' && parts[1] === 'dedupe' && method === 'POST') {
const b = await parseBody(req); const email = String(b.email || '').toLowerCase(); if (!email) return json(res, 400, { error: 'email requis' })
const r = await akFetch('/core/users/?search=' + encodeURIComponent(email) + '&page_size=50')
const recs = (r.data.results || []).filter(u => String(u.email || '').toLowerCase() === email)
if (recs.length < 2) return json(res, 200, { ok: true, noop: true, count: recs.length })
// KEEPER = compte à GARDER : actif d'abord, puis dernier last_login, puis plus ancien (l'original).
const keeper = recs.slice().sort((a, b) =>
(b.is_active ? 1 : 0) - (a.is_active ? 1 : 0) ||
(new Date(b.last_login || 0) - new Date(a.last_login || 0)) ||
(new Date(a.date_joined || 0) - new Date(b.date_joined || 0)))[0]
const deleted = [], skipped = []
for (const u of recs) {
if (u.pk === keeper.pk) continue
// Supprimer un doublon SÛR : inactif OU jamais connecté (aucune session active). On NE supprime PAS
// un 2e compte ENCORE actif et utilisé (rare) → à résoudre manuellement.
if (u.is_active && u.last_login) { skipped.push(u.username + ' (2e compte actif utilisé — résoudre manuellement)'); continue }
const d = await akFetch('/core/users/' + u.pk + '/', 'DELETE')
if (d.status < 300) deleted.push(u.username); else skipped.push(u.username + ' (erreur ' + d.status + ')')
}
invalidateCache()
return json(res, 200, { ok: true, kept: keeper.username, deleted, skipped })
}
// Backfill : LIER automatiquement dans l'identité unifiée les comptes Authentik ↔ Employee ↔ Dispatch Technician.
// Pour chaque user Authentik interne : identité { label, primary_email=login, alias=company_email/user_id de l'Employee
// si différents, tech_id, employee, active }. Upsert (jamais de suppression) → alimente l'annuaire unifié d'un coup.
if (resource === 'staff' && parts[1] === 'sync-identities' && method === 'POST') {
const erp = require('./erp'); const idlib = require('./identity')
const akUsers = []
for (let p = 1; p <= 12; p++) { const r = await akFetch(`/core/users/?page=${p}&page_size=100&ordering=username`); if (r.status !== 200) break; akUsers.push(...(r.data.results || []).filter(u => u.email && !u.username.startsWith('ak-outpost') && u.type !== 'internal_service_account')); if (!r.data.pagination || !r.data.pagination.next) break }
const [emps, techs] = await Promise.all([
erp.list('Employee', { fields: ['name', 'employee_name', 'company_email', 'user_id'], limit: 2000 }).catch(() => []),
erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'full_name', 'employee'], limit: 1000 }).catch(() => []),
])
const empByEmail = {}; for (const e of emps) { if (e.user_id) empByEmail[String(e.user_id).toLowerCase()] = e; if (e.company_email && !empByEmail[String(e.company_email).toLowerCase()]) empByEmail[String(e.company_email).toLowerCase()] = e }
const techByEmp = {}; for (const t of techs) { if (t.employee) techByEmp[t.employee] = t }
// dédup par courriel (un seul upsert par personne)
const seen = new Set(); let count = 0
for (const u of akUsers) {
const email = String(u.email || '').toLowerCase(); if (!email || seen.has(email)) continue; seen.add(email)
// RÉSOUT D'ABORD l'identité existante (seed OU édition manuelle) → on FUSIONNE dans SA clé, sans jamais
// écraser un label/tech/alias déjà posé (ex. Louis-Paul : login louis@targo.ca ≠ courriel employé louispaul@).
const existing = idlib.resolveIdentity(email)
const key = existing ? existing.key : email
const primary = existing ? existing.primary_email : email
// Employee résolu sur TOUS les courriels de l'identité (alias) : louispaul@ trouve HR-EMP-4.
const candidateEmails = [email, primary, ...(existing ? existing.alias_emails || [] : [])].map(x => String(x || '').toLowerCase())
let emp = null; for (const e of candidateEmails) { if (empByEmail[e]) { emp = empByEmail[e]; break } }
const tech = emp ? techByEmp[emp.name] : null
const aliases = [...(existing ? existing.alias_emails || [] : []), email, ...(emp ? [emp.company_email, emp.user_id] : [])]
.map(x => String(x || '').toLowerCase()).filter(x => x && x !== primary)
const payload = {
key, primary_email: primary,
label: (existing && existing.label) || u.name || email, // garde le label manuel
alias_emails: [...new Set(aliases)],
tech_id: (existing && existing.tech_id) || (tech ? (tech.technician_id || tech.name) : ''),
employee: (existing && existing.employee) || (emp ? emp.name : ''),
}
if (!existing) payload.active = u.is_active // ne pas ré-activer un départ manuel ; les nouveaux suivent Authentik
idlib.upsert(payload)
count++
}
return json(res, 200, { ok: true, linked: count })
}
// Suppression GARDÉE : refuse si le retrait a un impact (tickets/jobs) → propose la désactivation.
if (resource === 'staff' && !parts[1] && method === 'DELETE') {
const erp = require('./erp'); const idlib = require('./identity')
const email = String(url.searchParams.get('email') || '').toLowerCase(); if (!email) return json(res, 400, { error: 'email requis' })
const id = idlib.resolveIdentity(email)
const emails = id ? [id.primary_email, ...(id.alias_emails || [])] : [email]
let tickets = 0; for (const e of emails) { try { const r = await erp.list('Issue', { filters: [['_assign', 'like', '%' + e + '%']], fields: ['name'], limit: 200 }); tickets += (r || []).length } catch (x) {} }
let jobs = 0; if (id && id.tech_id) { try { const r = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', id.tech_id]], fields: ['name'], limit: 500 }); jobs = (r || []).length } catch (x) {} }
if (tickets > 0 || jobs > 0) return json(res, 409, { ok: false, refused: true, reason: 'références existantes', tickets, jobs, hint: 'Désactiver plutôt que supprimer (garde l\'historique).' })
const done = {}
try { const lookup = await findUserByEmail(email); if (lookup.user) { await akFetch('/core/users/' + lookup.user.pk + '/', 'DELETE'); invalidateCache(); done.authentik = 'deleted' } } catch (e) { done.authentik_err = e.message }
try { await erp.remove('User', email); done.system_user = 'deleted' } catch (e) { done.system_user_err = e.message }
try { if (id) idlib.setActive(email, false) } catch (e) {} // l'identité reste (départ) même après suppression du compte
return json(res, 200, { ok: true, email, ...done })
}
if (resource === 'users' && parts[2] === 'overrides' && method === 'PUT') {
const email = decodeURIComponent(parts[1])
const lookup = await findUserByEmail(email)

View File

@ -0,0 +1,145 @@
'use strict'
// ── Identité unifiée du personnel — SOURCE UNIQUE ─────────────────────────────
// Problème résolu : une même personne a plusieurs COURRIELS (louis@targo.ca +
// louispaul@targointernet.com) et un enregistrement Dispatch Technician séparé,
// SANS identifiant unifié → doublons d'assignation, nom système (« louis ») au
// lieu du nom complet, user SSO ≠ tech. Ici : 1 personne = 1 identité canonique
// { key, label, primary_email, alias_emails[], tech_id, employee }
// Tous les courriels (primaire + alias) et le tech_id résolvent à CETTE identité.
//
// SSO partout → la clé = le courriel de login (primaire). Éditable depuis OPS,
// écritures réservées aux admins (mêmes groupes que avatars/user-prefs). Store
// JSON hub (data/identities.json) — pas de custom-field ERPNext (API instable).
const fs = require('fs')
const path = require('path')
const { json, parseBody, log } = require('./helpers')
const FILE = path.join(__dirname, '..', 'data', 'identities.json')
const norm = (e) => String(e || '').trim().toLowerCase()
const meOf = (req) => norm(req.headers['x-authentik-email'])
// Admin = mêmes groupes Authentik que les autres écritures self/admin (avatars.js, user-prefs.js).
function isAdmin (req) {
const g = String(req.headers['x-authentik-groups'] || req.headers['x-authentik-group'] || '')
return /(^|[,|;])\s*(ops-?admin|admin(istrateurs?)?|superviseurs?)\s*([,|;]|$)/i.test(g)
}
function load () { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')) || {} } catch { return {} } }
function save (m) { try { fs.mkdirSync(path.dirname(FILE), { recursive: true }) } catch (e) {} fs.writeFileSync(FILE, JSON.stringify(m, null, 1)); _index = null; return m }
// Index inverse (courriel→clé, tech→clé, nom→clé), reconstruit paresseusement, invalidé à chaque save().
let _index = null
function index () {
if (_index) return _index
const m = load(); const byEmail = {}, byTech = {}, byName = {}
for (const [key, id] of Object.entries(m)) {
for (const e of [id.primary_email, ...(id.alias_emails || [])].map(norm).filter(Boolean)) byEmail[e] = key
if (id.tech_id) byTech[String(id.tech_id)] = key
if (id.label) byName[norm(id.label)] = key
}
_index = { m, byEmail, byTech, byName }; return _index
}
// resolveIdentity(input) : input = courriel | tech_id | nom complet → { key, ...identité } | null
function resolveIdentity (input) {
if (!input) return null
const { m, byEmail, byTech, byName } = index()
const s = String(input).trim()
const key = byEmail[norm(s)] || byTech[s] || byName[norm(s)]
return key ? { key, ...m[key] } : null
}
// Libellé alias-aware (pour getDisplayNameByEmail) : '' si inconnu → l'appelant retombe sur Authentik.
function labelForEmail (email) { const id = resolveIdentity(email); return id ? id.label : '' }
// Courriel canonique (primaire) pour un alias — sert à DÉ-DUPLIQUER les assignations. Repli = le courriel donné.
function canonicalEmail (email) { const id = resolveIdentity(email); return id ? norm(id.primary_email) : norm(email) }
// Clé d'identité pour dé-dupliquer (courriel OU tech_id) — repli = le courriel normalisé (identité « inconnue » stable).
function identityKey (input) { const id = resolveIdentity(input); return id ? id.key : norm(input) }
// Deux entrées (courriels/tech) = la même personne ?
function sameIdentity (a, b) { return identityKey(a) === identityKey(b) }
// Actif ? (départ = active:false → masqué des sélecteurs/roster mais l'identité RESTE pour l'historique).
// Inconnu de la carte → true (on ne bloque pas quelqu'un qu'on ne connaît pas).
function isActive (input) { const id = resolveIdentity(input); return id ? id.active !== false : true }
// Écriture programmatique (utilisée par le provisioning staff) — upsert par clé (= primary_email par défaut).
function upsert ({ key, label, primary_email, alias_emails, tech_id, employee, active, kind } = {}) {
const primary = norm(primary_email); if (!primary || !label) return null
const m = load(); const k = key || primary; const cur = m[k] || {}
m[k] = {
label: String(label).slice(0, 120), primary_email: primary,
alias_emails: [...new Set([...(cur.alias_emails || []), ...(alias_emails || [])].map(norm).filter(e => e && e !== primary))],
tech_id: tech_id || cur.tech_id || '', employee: employee || cur.employee || '',
active: active === undefined ? (cur.active !== false) : (active !== false),
kind: kind || cur.kind || 'employee',
}
save(m); return m[k]
}
function setActive (keyOrEmail, active) {
const id = resolveIdentity(keyOrEmail); if (!id) return null
const m = load(); if (!m[id.key]) return null
m[id.key].active = active !== false; save(m); return m[id.key]
}
async function handle (req, res, method, path, url) {
// ── Lecture (tout membre du personnel authentifié — annuaire interne) ──
// Carte compacte pour le SPA : email→{key,label}, tech→{key,label}, + liste éditable.
if (path === '/identity/map' && method === 'GET') {
const { m } = index(); const emails = {}, techs = {}, list = []
for (const [key, id] of Object.entries(m)) {
const label = id.label || ''; const active = id.active !== false
for (const e of [id.primary_email, ...(id.alias_emails || [])].map(norm).filter(Boolean)) emails[e] = { key, label, active }
if (id.tech_id) techs[String(id.tech_id)] = { key, label, active }
list.push({ key, label, primary_email: norm(id.primary_email), alias_emails: (id.alias_emails || []).map(norm), tech_id: id.tech_id || '', employee: id.employee || '', active, kind: id.kind || 'employee' })
}
return json(res, 200, { emails, techs, list })
}
if (path === '/identity/resolve' && method === 'GET') {
const q = url.searchParams
return json(res, 200, { identity: resolveIdentity(q.get('email') || q.get('tech') || q.get('name') || '') })
}
// ── Écriture : admins seulement ──
if (method !== 'GET' && !isAdmin(req)) return json(res, 403, { error: 'réservé aux administrateurs' })
// Créer / mettre à jour une identité. Body: { key?, label, primary_email, alias_emails?, tech_id?, employee? }
if (path === '/identity' && method === 'POST') {
const b = await parseBody(req); const primary = norm(b.primary_email)
if (!primary || !b.label) return json(res, 400, { error: 'label + primary_email requis' })
const m = load(); const key = b.key || primary
m[key] = {
label: String(b.label).slice(0, 120), primary_email: primary,
alias_emails: [...new Set((b.alias_emails || []).map(norm).filter(e => e && e !== primary))],
tech_id: b.tech_id || '', employee: b.employee || '',
active: b.active !== false, // départ = false → masqué des sélecteurs, identité gardée pour l'historique
kind: b.kind || 'employee', // employee | subcontractor
}
save(m); log('[identity] upsert ' + key + ' by ' + meOf(req))
return json(res, 200, { ok: true, key, identity: m[key] })
}
// Ajouter / retirer un alias courriel. Body: { email, remove? }
const mAlias = path.match(/^\/identity\/([^/]+)\/alias$/)
if (mAlias && method === 'POST') {
const key = decodeURIComponent(mAlias[1]); const b = await parseBody(req); const e = norm(b.email); const m = load()
if (!m[key]) return json(res, 404, { error: 'identité introuvable' })
const set = new Set(m[key].alias_emails || [])
if (b.remove) set.delete(e); else if (e && e !== norm(m[key].primary_email)) set.add(e)
m[key].alias_emails = [...set]; save(m)
return json(res, 200, { ok: true, identity: m[key] })
}
// Fusionner deux identités (réconcilier user système ↔ tech en double). Body: { into, from }
if (path === '/identity/merge' && method === 'POST') {
const b = await parseBody(req); const m = load()
if (!m[b.into] || !m[b.from]) return json(res, 404, { error: 'clé(s) introuvable(s)' })
const set = new Set([...(m[b.into].alias_emails || []), norm(m[b.from].primary_email), ...(m[b.from].alias_emails || [])]
.filter(e => e && e !== norm(m[b.into].primary_email)))
m[b.into].alias_emails = [...set]
if (!m[b.into].tech_id && m[b.from].tech_id) m[b.into].tech_id = m[b.from].tech_id
if (!m[b.into].employee && m[b.from].employee) m[b.into].employee = m[b.from].employee
delete m[b.from]; save(m); log('[identity] merge ' + b.from + ' → ' + b.into + ' by ' + meOf(req))
return json(res, 200, { ok: true, identity: m[b.into] })
}
const mDel = path.match(/^\/identity\/([^/]+)$/)
if (mDel && method === 'DELETE') { const key = decodeURIComponent(mDel[1]); const m = load(); delete m[key]; save(m); return json(res, 200, { ok: true }) }
return json(res, 404, { error: 'identity endpoint not found' })
}
module.exports = { handle, resolveIdentity, labelForEmail, canonicalEmail, identityKey, sameIdentity, isActive, upsert, setActive }

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