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