From 6bf323b18ec3a26de7faa218166a0b29ff55cfbd Mon Sep 17 00:00:00 2001 From: louispaulb Date: Wed, 8 Jul 2026 17:40:36 -0400 Subject: [PATCH] =?UTF-8?q?fix(ops):=20champ=20d'assignation=20=E2=80=94?= =?UTF-8?q?=20=C3=A9chelle=20d'implication=20+=20bascules=20=C2=AB=20moi?= =?UTF-8?q?=20=C2=BB=20+=20fix=20lecture=20=C3=A9quipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refonte du modèle d'interaction (retour user) + correction du bug d'affichage des assistants. Bug corrigé (racine = hub) : getJobTeam lisait le doctype-enfant « Dispatch Job Assistant » via erp.list → PermissionError pour le compte de service → renvoyait [] en silence. Résultat : chips assistants jamais affichées, tech non exclu de l'autosuggest, et POST add/remove partait de [] → écrasait les assistants existants. Fix : lire `assistants` VIA LE DOC PARENT (child table embarquée, sans vérif de perm sur l'enfant). Vérifié : getJobTeam(LEG-253958) renvoie l'assistant. Nouveau modèle (AssignmentField) : - UN champ « Ajouter un participant » (techs + compétences en tête, puis tous les utilisateurs). 1er ajouté = assigné/lead ; suivants = assistant par défaut. - Échelle d'implication par personne (menu sur la chip) : Suiveur ‹ Assistant ‹ Sur place (équipe). · Suiveur (#) = reçoit les MàJ, hors équipe (/conversations/follow). · Assistant (CC) = sur l'équipe, SANS bloc horaire (Dispatch Job Assistant pinned=0). · Sur place = assistant AVEC bloc réservé dans son horaire (pinned=1) — masqué sur ticket (can-onsite=false). - « Assist » / « Suivre » deviennent des BASCULES sur l'utilisateur connecté (moi), plus des liens d'expansion. - Suivi keyé par courriel si connu, sinon tech_id (repli). Câblage : PlanificationPage jdSetAssistant({value,label,onsite}) → addAssistant pinned=onsite?1:0 (le hub `add` remplace la ligne du même tech → sert aussi à changer de niveau). IssueDetail : can-onsite=false. Co-Authored-By: Claude Opus 4.8 --- .../src/components/shared/AssignmentField.vue | 260 ++++++++++-------- .../shared/detail-sections/IssueDetail.vue | 4 +- apps/ops/src/pages/PlanificationPage.vue | 16 +- services/targo-hub/lib/roster.js | 7 +- 4 files changed, 162 insertions(+), 125 deletions(-) diff --git a/apps/ops/src/components/shared/AssignmentField.vue b/apps/ops/src/components/shared/AssignmentField.vue index 86aa85f..3e61838 100644 --- a/apps/ops/src/components/shared/AssignmentField.vue +++ b/apps/ops/src/components/shared/AssignmentField.vue @@ -1,72 +1,79 @@ @@ -81,60 +88,82 @@ import TechSelect from 'src/components/shared/TechSelect.vue' const props = defineProps({ doctype: { type: String, required: true }, // pour le suivi : 'Dispatch Job' | 'Issue' … docName: { type: String, default: '' }, - assignee: { type: Object, default: null }, // { id, name } — chip « À » (propriété du parent) - assistants: { type: Array, default: () => [] }, // [{ id, name }] — chips « CC » - techOptions: { type: Array, default: () => [] }, // [{ label, value, capable }] — pool par défaut - canAssist: { type: Boolean, default: true }, // rôle assistant disponible ? + assignee: { type: Object, default: null }, // { id, name } — lead (chip « À »), propriété du parent + assistants: { type: Array, default: () => [] }, // [{ id, name, onsite }] — assistants (onsite = bloc horaire) + techOptions: { type: Array, default: () => [] }, // [{ label, value, capable }] — suggestions par défaut (techs + compétences) + canOnsite: { type: Boolean, default: true }, // niveau « Sur place » (bloc horaire) disponible ? (jobDetail oui, ticket non) assistLoading: { type: Boolean, default: false }, }) -const emit = defineEmits(['assign', 'assist', 'unassign', 'remove-assistant', 'followers-changed']) +const emit = defineEmits(['assign', 'unassign', 'set-assistant', 'remove-assistant', 'followers-changed']) const $q = useQuasar() const _authUser = useAuthStore().user -const me = (_authUser && _authUser !== 'authenticated') ? _authUser : '' +const me = (_authUser && _authUser !== 'authenticated') ? String(_authUser).toLowerCase() : '' +const meLabel = shortAgent(me) || me -// ── Portée de l'autosuggest : 'techs' (défaut) | 'users' (Assist/#follow) ── -const scope = ref('techs') -const intent = ref('assist') // en mode users : 'assist' | 'follow' -function setScope (which) { intent.value = which; scope.value = 'users' } -function resetScope () { scope.value = 'techs'; intent.value = 'assist' } +const hasLead = computed(() => !!(props.assignee && (props.assignee.name || props.assignee.id))) -const fieldLabel = computed(() => { - if (scope.value === 'users') return intent.value === 'follow' ? 'Ajouter un follower (#)…' : 'Ajouter un assistant (CC)…' - return props.assignee && (props.assignee.name || props.assignee.id) - ? (props.canAssist ? 'Ajouter un assistant (CC)…' : 'Réassigner…') - : 'Assigner à… (À)' -}) -const hint = computed(() => { - if (scope.value === 'users') return intent.value === 'follow' - ? 'Choisir un utilisateur = l’abonner aux mises à jour (follower #).' - : 'Choisir un utilisateur = l’ajouter en renfort (assistant CC).' - if (!props.canAssist) return 'Choisir un technicien = l’assigner. « Assist » / « #follow » ouvrent la liste complète.' - return props.assignee && (props.assignee.name || props.assignee.id) - ? 'Ajout = assistant (CC). « #follow » pour abonner quelqu’un. Retirer « À » = désassigner.' - : 'Choisir un tech = l’assigner (À) ; les ajouts suivants = assistants (CC).' +// ── Métadonnées d'affichage par niveau ── +function levelMeta (level) { + if (level === 'follower') return { badge: '#', chipColor: 'teal-1', chipText: 'teal-9', avaColor: 'teal-6', tip: 'Suiveur (reçoit les mises à jour)' } + if (level === 'onsite') return { badge: 'Sur place', chipColor: 'deep-orange-1', chipText: 'deep-orange-9', avaColor: 'deep-orange-6', tip: 'Sur place (équipe) — bloc réservé dans son horaire' } + return { badge: 'CC', chipColor: 'blue-grey-1', chipText: 'blue-grey-9', avaColor: 'blue-grey-6', tip: 'Assistant — sur l\'équipe, sans bloc horaire' } +} + +// ── Liste unifiée des participants (assistants + suiveurs), 1 personne = 1 niveau ── +// Clé unique par personne (id d'assistant = tech_id ; suiveur = courriel). L'assistant prime si doublon. +const participants = computed(() => { + const out = []; const seen = new Set() + for (const a of (props.assistants || [])) { + const id = String(a.id || ''); if (!id) continue; const k = id.toLowerCase(); if (seen.has(k)) continue; seen.add(k) + out.push({ id, name: a.name || id, level: a.onsite ? 'onsite' : 'assistant' }) + } + for (const e of followers.value) { + const id = String(e || ''); if (!id) continue; const k = id.toLowerCase(); if (seen.has(k)) continue; seen.add(k) + out.push({ id, name: followerLabel(id), level: 'follower' }) + } + return out }) -// Sélection dans l'autosuggest → route selon la portée + l'intention. +// ── Ajout via l'autosuggest : 1er = lead (assign) ; suivants = assistant par défaut ── function onPick (value) { if (!value) return - const opt = findOption(value) - const label = opt ? opt.label : String(value) - if (scope.value === 'users') { - if (intent.value === 'follow') addFollower(value, label) - else emit('assist', { value, label }) - resetScope() - return - } - // Mode techs : 1er choix = assigné ; sinon = assistant (si dispo). - if (props.assignee && (props.assignee.name || props.assignee.id) && props.canAssist) emit('assist', { value, label }) - else emit('assign', value) -} -function findOption (value) { - if (scope.value === 'users') return userOptCache.find(o => o.value === value) - return (props.techOptions || []).find(o => o.value === value) + const opt = [...(props.techOptions || []), ...userOptCache].find(o => o.value === value) + const label = opt ? (opt.label || String(value)) : String(value) + if (!hasLead.value) emit('assign', value) + else emit('set-assistant', { value, label, onsite: false }) } -// ── Recherche « tous les utilisateurs » (Authentik via hub, comme InterveneDialog) ── +// ── Changer le niveau d'un participant (transitions inter-magasins) ── +async function setLevel (p, level) { + if (!p || level === p.level) return + if (level === 'follower') { + if (p.level !== 'follower') emit('remove-assistant', p.id) // sortir de l'équipe + await addFollower(p.id, p.name) // suivi keyé par courriel si connu, sinon l'id (cf décision) + } else { + if (p.level === 'follower') await removeFollower(p.id) // sortir du suivi + emit('set-assistant', { value: p.id, label: p.name, onsite: level === 'onsite' }) + } +} +function removeParticipant (p) { + if (!p) return + if (p.level === 'follower') removeFollower(p.id) + else emit('remove-assistant', p.id) +} + +// ── Bascules « moi » (utilisateur connecté) ── +const assistSelfActive = computed(() => !!me && (props.assistants || []).some(a => String(a.id).toLowerCase() === me)) +const followSelfActive = computed(() => !!me && followers.value.map(e => String(e).toLowerCase()).includes(me)) +function toggleAssistSelf (val) { + if (!me) return + if (val) emit('set-assistant', { value: me, label: meLabel, onsite: false }) + else emit('remove-assistant', me) +} +function toggleFollowSelf (val) { + if (!me) return + if (val) addFollower(me, meLabel); else removeFollower(me) +} + +// ── Recherche du champ d'ajout : techs (avec compétences) puis tous les utilisateurs ── let userOptCache = [] async function userSearch (val) { const q = String(val || '').trim() @@ -142,18 +171,24 @@ async function userSearch (val) { const r = await fetch(`${HUB_URL}/auth/users?search=${encodeURIComponent(q)}`) if (!r.ok) return [] const d = await r.json() - userOptCache = (d.users || d || []) - .filter(u => u && u.email) - .map(u => ({ label: u.name || u.email, value: u.email, sub: u.email })) + userOptCache = (d.users || d || []).filter(u => u && u.email).map(u => ({ label: u.name || u.email, value: u.email, sub: u.email })) return userOptCache } catch { return [] } } +async function assistSearch (val) { + const q = String(val || '').toLowerCase().trim() + const techs = (props.techOptions || []).filter(o => !q || String(o.label || '').toLowerCase().includes(q) || String(o.value || '').toLowerCase().includes(q)) + if (!q) return techs // ouverture sans saisie : techs (avec compétences), rapide + const users = await userSearch(val) + const techVals = new Set(techs.map(t => t.value)) + return [...techs, ...users.filter(u => !techVals.has(u.value))] // techs en tête, puis les autres utilisateurs +} -// ── Followers (# — abonnés). Géré ICI : identique sur toutes les surfaces via /conversations/follow. ── -const followers = ref([]) // courriels -const followerNames = ref({}) // email → libellé connu (semé aux ajouts) +// ── Suivi (# — abonnés). Géré ICI (identique partout) via /conversations/follow. ── +const followers = ref([]) // ids (courriels, ou tech_id en repli) +const followerNames = ref({}) // id → libellé connu const followBusy = ref(false) -function followerLabel (email) { return followerNames.value[email] || shortAgent(email) || email } +function followerLabel (id) { return followerNames.value[id] || shortAgent(id) || id } const followHdr = () => (me ? { 'Content-Type': 'application/json', 'X-Authentik-Email': me } : { 'Content-Type': 'application/json' }) async function loadFollowers () { @@ -162,11 +197,11 @@ async function loadFollowers () { try { const r = await fetch(`${HUB_URL}/conversations/followers?doctype=${encodeURIComponent(props.doctype)}&name=${encodeURIComponent(props.docName)}`).then(x => x.json()) followers.value = (r.followers || []) - } catch { /* le hub peut ne pas être déployé (aperçu) → dégrade proprement */ } + } catch { /* hub non déployé (aperçu) → dégrade proprement */ } } -async function addFollower (email, label) { - const e = String(email || '').toLowerCase() - if (!e || !props.docName || followers.value.includes(e)) return +async function addFollower (id, label) { + const e = String(id || '').toLowerCase() + if (!e || !props.docName || followers.value.map(x => String(x).toLowerCase()).includes(e)) return followBusy.value = true try { const r = await fetch(`${HUB_URL}/conversations/follow`, { method: 'POST', headers: followHdr(), body: JSON.stringify({ doctype: props.doctype, name: props.docName, email: e, follow: true }) }).then(x => x.json()) @@ -178,16 +213,15 @@ async function addFollower (email, label) { } else $q.notify({ type: 'negative', message: r.error || 'Échec', timeout: 2500 }) } catch (err) { $q.notify({ type: 'negative', message: err.message, timeout: 2500 }) } finally { followBusy.value = false } } -async function removeFollower (email) { - const e = String(email || '').toLowerCase() +async function removeFollower (id) { + const e = String(id || '').toLowerCase() if (!e || !props.docName) return followBusy.value = true try { const r = await fetch(`${HUB_URL}/conversations/follow`, { method: 'POST', headers: followHdr(), body: JSON.stringify({ doctype: props.doctype, name: props.docName, email: e, follow: false }) }).then(x => x.json()) if (r.ok) { - followers.value = followers.value.filter(x => x !== e) + followers.value = followers.value.filter(x => String(x).toLowerCase() !== e) emit('followers-changed', followers.value.slice()) - $q.notify({ type: 'info', message: 'Follower retiré', timeout: 1600 }) } else $q.notify({ type: 'negative', message: r.error || 'Échec', timeout: 2500 }) } catch (err) { $q.notify({ type: 'negative', message: err.message, timeout: 2500 }) } finally { followBusy.value = false } } @@ -201,13 +235,9 @@ defineExpose({ reloadFollowers: loadFollowers }) .asg-field { min-width: 0; } .asg-chips { flex-wrap: wrap; } .asg-chip :deep(.q-chip__content) { white-space: nowrap; } -.asg-links { flex-wrap: wrap; } -.asg-link { - display: inline-flex; align-items: center; gap: 3px; - font-size: 0.72rem; font-weight: 600; color: #64748b; cursor: pointer; user-select: none; -} -.asg-link:hover { color: #4338ca; text-decoration: underline; } -.asg-link.active { color: #4338ca; text-decoration: underline; } -.asg-link-reset { color: #94a3b8; } -.asg-hint { line-height: 1.3; } +.asg-chip-part { cursor: pointer; } +.asg-caret { margin-left: 2px; opacity: 0.7; } +.asg-self { flex-wrap: wrap; } +.asg-self-lbl { display: inline-flex; align-items: center; gap: 3px; font-size: 0.75rem; font-weight: 500; color: #475569; } +.asg-active { background: #eef2ff; } diff --git a/apps/ops/src/components/shared/detail-sections/IssueDetail.vue b/apps/ops/src/components/shared/detail-sections/IssueDetail.vue index fbd9f1a..9c1a728 100644 --- a/apps/ops/src/components/shared/detail-sections/IssueDetail.vue +++ b/apps/ops/src/components/shared/detail-sections/IssueDetail.vue @@ -50,8 +50,8 @@
Assignation
+ :assignee="issueAssignee" :assistants="issueAssistants" :tech-options="userOptions" :can-onsite="false" + @assign="assignUser" @set-assistant="p => assignUser(p.value)" @unassign="unassignFirst" @remove-assistant="removeAssignee" />
diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index f51bd2d..093694b 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -1406,8 +1406,8 @@ + :tech-options="jdTeamOptions" :can-onsite="jobDetail.canTeam" :assist-loading="jobDetail.teamLoading" + @assign="jdAssignTech" @set-assistant="jdSetAssistant" @unassign="jdUnassign" @remove-assistant="jdRemoveAssistant">