fix(ops): champ d'assignation — échelle d'implication + bascules « moi » + fix lecture équipe
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 <noreply@anthropic.com>
This commit is contained in:
parent
2f81cff892
commit
6bf323b18e
|
|
@ -1,72 +1,79 @@
|
|||
<template>
|
||||
<!-- Champ d'assignation MULTIFONCTION (façon destinataires courriel), partagé entre le volet
|
||||
« tâche » (PlanificationPage) et le détail ticket (IssueDetail). UN seul autosuggest + des
|
||||
chips par rôle : À = assigné (lead) · CC = assistant (renfort) · # = follower (abonné).
|
||||
– Autosuggest par défaut = TECHNICIENS. 1er choisi = assigné ; 2e+ = assistant.
|
||||
– Mini-liens « Assist » / « #follow » → étendent l'autosuggest à TOUS les utilisateurs
|
||||
(ajouter un assistant OU un follower depuis n'importe qui).
|
||||
L'assigné/assistant sont délégués au parent (câblage propre à chaque surface) ; le suivi
|
||||
(follower) est géré ici via /conversations/follow (identique partout). -->
|
||||
<!-- Champ d'assignation MULTIFONCTION (partagé jobDetail + IssueDetail). UN champ pour ajouter des participants
|
||||
+ une échelle d'implication par personne + deux bascules « moi » (assister / suivre).
|
||||
– 1er ajouté = ASSIGNÉ / lead (chip À). Suivants = ASSISTANT par défaut.
|
||||
– Chaque participant (non-lead) a un menu d'implication : Suiveur ‹ Assistant ‹ Sur place (équipe).
|
||||
· Suiveur (#) = reçoit les mises à jour, aucune place dans l'équipe.
|
||||
· Assistant (CC)= sur l'équipe, SANS bloc horaire.
|
||||
· Sur place = assistant AVEC un bloc réservé dans son horaire (équipe) — si la surface le permet.
|
||||
– Deux bascules agissent sur MOI (l'utilisateur connecté) : m'ajouter comme assistant · suivre. -->
|
||||
<div class="asg-field">
|
||||
<div class="asg-chips row items-center q-gutter-xs">
|
||||
<!-- À : assigné / lead -->
|
||||
<q-chip v-if="assignee && (assignee.name || assignee.id)" dense removable
|
||||
@remove="$emit('unassign')" color="indigo-6" text-color="white" class="text-weight-bold asg-chip">
|
||||
<!-- À : lead / assigné -->
|
||||
<q-chip v-if="hasLead" dense removable @remove="$emit('unassign')" color="indigo-6" text-color="white" class="text-weight-bold asg-chip">
|
||||
<q-avatar color="indigo-9" text-color="white" size="20px">{{ ini(assignee.name || assignee.id) }}</q-avatar>
|
||||
À · {{ assignee.name || assignee.id }}
|
||||
<q-tooltip class="bg-grey-9" style="font-size:11px">Assigné / lead technique</q-tooltip>
|
||||
</q-chip>
|
||||
<q-chip v-else dense outline color="grey-7" text-color="grey-8" icon="person_off">Non assigné</q-chip>
|
||||
|
||||
<!-- CC : assistants (renfort) -->
|
||||
<template v-if="canAssist">
|
||||
<q-chip v-for="a in assistants" :key="'cc-' + (a.id || a.name)" dense removable
|
||||
@remove="$emit('remove-assistant', a.id)" outline color="blue-grey-6" text-color="blue-grey-8" class="asg-chip">
|
||||
<q-avatar color="blue-grey-6" text-color="white" size="20px">{{ ini(a.name || a.id) }}</q-avatar>
|
||||
CC · {{ a.name || a.id }}
|
||||
<q-tooltip class="bg-grey-9" style="font-size:11px">Assistant (renfort)</q-tooltip>
|
||||
</q-chip>
|
||||
<q-spinner v-if="assistLoading" size="14px" color="blue-grey-6" />
|
||||
</template>
|
||||
|
||||
<!-- # : followers (abonnés — n'importe quel utilisateur) -->
|
||||
<q-chip v-for="f in followers" :key="'f-' + f" dense removable
|
||||
@remove="removeFollower(f)" color="teal-1" text-color="teal-9" class="asg-chip">
|
||||
<q-avatar color="teal-6" text-color="white" size="20px">{{ ini(followerLabel(f)) }}</q-avatar>
|
||||
# · {{ followerLabel(f) }}
|
||||
<q-tooltip class="bg-grey-9" style="font-size:11px">Follower (abonné aux mises à jour) · {{ f }}</q-tooltip>
|
||||
<!-- Participants (assistants + suiveurs) — chip par personne avec menu d'implication -->
|
||||
<q-chip v-for="p in participants" :key="p.id" dense removable
|
||||
@remove="removeParticipant(p)" :color="levelMeta(p.level).chipColor" :text-color="levelMeta(p.level).chipText" class="asg-chip asg-chip-part">
|
||||
<q-avatar :color="levelMeta(p.level).avaColor" text-color="white" size="20px">{{ ini(p.name) }}</q-avatar>
|
||||
{{ levelMeta(p.level).badge }} · {{ p.name }}
|
||||
<q-icon name="arrow_drop_down" size="16px" class="asg-caret" />
|
||||
<q-menu anchor="bottom left" self="top left" auto-close>
|
||||
<q-list dense style="min-width:210px">
|
||||
<q-item-label header class="text-caption q-py-xs">Niveau d'implication</q-item-label>
|
||||
<q-item clickable :active="p.level === 'follower'" active-class="asg-active" @click="setLevel(p, 'follower')">
|
||||
<q-item-section avatar style="min-width:28px"><q-icon name="visibility" size="18px" color="teal-6" /></q-item-section>
|
||||
<q-item-section><q-item-label>Suiveur</q-item-label><q-item-label caption>Reçoit les mises à jour</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable :active="p.level === 'assistant'" active-class="asg-active" @click="setLevel(p, 'assistant')">
|
||||
<q-item-section avatar style="min-width:28px"><q-icon name="group" size="18px" color="blue-grey-6" /></q-item-section>
|
||||
<q-item-section><q-item-label>Assistant</q-item-label><q-item-label caption>Sur l'équipe · sans bloc horaire</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="canOnsite" clickable :active="p.level === 'onsite'" active-class="asg-active" @click="setLevel(p, 'onsite')">
|
||||
<q-item-section avatar style="min-width:28px"><q-icon name="construction" size="18px" color="deep-orange-6" /></q-item-section>
|
||||
<q-item-section><q-item-label>Sur place (équipe)</q-item-label><q-item-label caption>Réserve un bloc dans son horaire</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
<q-item clickable @click="removeParticipant(p)">
|
||||
<q-item-section avatar style="min-width:28px"><q-icon name="close" size="18px" color="grey-6" /></q-item-section>
|
||||
<q-item-section><q-item-label>Retirer</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
<q-tooltip class="bg-grey-9" style="font-size:11px">{{ levelMeta(p.level).tip }} — cliquer pour changer</q-tooltip>
|
||||
</q-chip>
|
||||
<q-spinner v-if="followBusy" size="14px" color="teal-6" />
|
||||
<q-spinner v-if="assistLoading || followBusy" size="14px" color="blue-grey-6" />
|
||||
</div>
|
||||
|
||||
<!-- Autosuggest unique : techs par défaut ; étendu à tous les utilisateurs en mode Assist/#follow -->
|
||||
<!-- Ajouter un participant : techs (avec compétences) en tête, puis tous les utilisateurs -->
|
||||
<TechSelect
|
||||
:model-value="null"
|
||||
:options="techOptions"
|
||||
:search-fn="scope === 'users' ? userSearch : null"
|
||||
:no-option-label="scope === 'users' ? 'Aucun utilisateur' : 'Aucun technicien'"
|
||||
:label="fieldLabel"
|
||||
:search-fn="assistSearch"
|
||||
no-option-label="Aucun résultat"
|
||||
:label="hasLead ? 'Ajouter un participant…' : 'Assigner (lead)…'"
|
||||
style="min-width:170px;flex:1" class="q-mt-xs"
|
||||
@update:model-value="onPick" />
|
||||
|
||||
<!-- Mini-liens : basculent l'autosuggest sur TOUS les utilisateurs pour ajouter un assistant / un follower -->
|
||||
<div class="asg-links row items-center q-gutter-md q-mt-xs">
|
||||
<a class="asg-link" :class="{ active: scope === 'users' && intent === 'assist' }"
|
||||
v-if="canAssist" @click="setScope('assist')">
|
||||
<q-icon name="group_add" size="14px" /> Assist
|
||||
<q-tooltip class="bg-grey-9" style="font-size:11px">Ajouter un assistant (CC) depuis n'importe quel utilisateur</q-tooltip>
|
||||
</a>
|
||||
<a class="asg-link" :class="{ active: scope === 'users' && intent === 'follow' }"
|
||||
@click="setScope('follow')">
|
||||
<q-icon name="visibility" size="14px" /> #follow
|
||||
<q-tooltip class="bg-grey-9" style="font-size:11px">Ajouter un follower (abonné aux mises à jour) — n'importe quel utilisateur</q-tooltip>
|
||||
</a>
|
||||
<a v-if="scope === 'users'" class="asg-link asg-link-reset" @click="resetScope">
|
||||
<q-icon name="close" size="13px" /> techniciens
|
||||
</a>
|
||||
<!-- Bascules « moi » + slot droite (ex. contrôle de statut) -->
|
||||
<div class="asg-self row items-center q-gutter-md q-mt-xs">
|
||||
<q-toggle :model-value="assistSelfActive" :disable="!me" dense size="sm" color="blue-grey-6"
|
||||
@update:model-value="toggleAssistSelf">
|
||||
<span class="asg-self-lbl"><q-icon name="group_add" size="14px" /> M'ajouter comme assistant</span>
|
||||
<q-tooltip v-if="!me" class="bg-grey-9" style="font-size:11px">Session utilisateur requise</q-tooltip>
|
||||
</q-toggle>
|
||||
<q-toggle :model-value="followSelfActive" :disable="!me" dense size="sm" color="teal-6"
|
||||
@update:model-value="toggleFollowSelf">
|
||||
<span class="asg-self-lbl"><q-icon name="visibility" size="14px" /> Suivre</span>
|
||||
</q-toggle>
|
||||
<q-space />
|
||||
<slot name="right" />
|
||||
</div>
|
||||
<div class="asg-hint text-caption text-grey-6 q-mt-xs">{{ hint }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -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; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@
|
|||
<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-assist="true"
|
||||
@assign="assignUser" @assist="p => assignUser(p.value)" @unassign="unassignFirst" @remove-assistant="removeAssignee" />
|
||||
:assignee="issueAssignee" :assistants="issueAssistants" :tech-options="userOptions" :can-onsite="false"
|
||||
@assign="assignUser" @set-assistant="p => assignUser(p.value)" @unassign="unassignFirst" @remove-assistant="removeAssignee" />
|
||||
</div>
|
||||
|
||||
<div class="issue-field-grid">
|
||||
|
|
|
|||
|
|
@ -1406,8 +1406,8 @@
|
|||
<AssignmentField class="q-mt-xs jd-assign"
|
||||
doctype="Dispatch Job" :doc-name="jobDetail.name"
|
||||
:assignee="jdAssignee" :assistants="jdAssistants"
|
||||
:tech-options="jdTeamOptions" :can-assist="jobDetail.canTeam" :assist-loading="jobDetail.teamLoading"
|
||||
@assign="jdAssignTech" @assist="jdFieldAssist" @unassign="jdUnassign" @remove-assistant="jdRemoveAssistant">
|
||||
:tech-options="jdTeamOptions" :can-onsite="jobDetail.canTeam" :assist-loading="jobDetail.teamLoading"
|
||||
@assign="jdAssignTech" @set-assistant="jdSetAssistant" @unassign="jdUnassign" @remove-assistant="jdRemoveAssistant">
|
||||
<template #right>
|
||||
<TicketStatusControl v-if="jobDetail.canTeam" doctype="Dispatch Job" :docname="jobDetail.name" :status="jobDetail.status" :scheduled-date="jobDetail.iso"
|
||||
@changed="p => { if (p && p.status) jobDetail.status = p.status; reloadOccupancy(); reloadPool && reloadPool() }" />
|
||||
|
|
@ -2485,17 +2485,19 @@ function jdAssignOrAssist (techId) {
|
|||
// ── Adaptateurs pour AssignmentField (champ multifonction partagé) ──
|
||||
// Chips lues depuis jobDetail (le parent reste propriétaire de la vérité) ; les followers sont gérés par le composant.
|
||||
const jdAssignee = computed(() => (jobDetail.techName || jobDetail.techId) ? { id: jobDetail.techId, name: jobDetail.techName } : null)
|
||||
const jdAssistants = computed(() => (jobDetail.team || []).map(a => ({ id: a.tech_id, name: jdAssistantName(a) })))
|
||||
// Ajout d'un assistant depuis le champ : { value=id, label } — un tech (mode techs) OU un utilisateur (mode « Assist »/tous). Nom robuste.
|
||||
async function jdFieldAssist ({ value, label } = {}) {
|
||||
// onsite = pinned (bloc horaire réservé). Assistant simple (pinned 0) = sur l'équipe sans bloc.
|
||||
const jdAssistants = computed(() => (jobDetail.team || []).map(a => ({ id: a.tech_id, name: jdAssistantName(a), onsite: !!a.pinned })))
|
||||
// Ajouter/mettre à jour un assistant : { value=id, label, onsite }. onsite → pinned=1 (bloc horaire) ; sinon pinned=0.
|
||||
// Le hub `add` remplace la ligne du même tech_id → sert aussi à CHANGER le niveau (assistant ↔ sur place). Nom robuste.
|
||||
async function jdSetAssistant ({ value, label, onsite } = {}) {
|
||||
const id = value; if (!id || !jobDetail.name) return
|
||||
const tech = (techs.value || []).find(t => t.id === id)
|
||||
const name = (tech && tech.name) || (label && String(label).split(' · ')[0].trim()) || String(id)
|
||||
try {
|
||||
await roster.addAssistant(jobDetail.name, { tech_id: String(id), tech_name: name, duration_h: 0, pinned: 1 })
|
||||
await roster.addAssistant(jobDetail.name, { tech_id: String(id), tech_name: name, duration_h: 0, pinned: onsite ? 1 : 0 })
|
||||
const r = await roster.getJobTeam(jobDetail.name); jobDetail.team = r.assistants || []
|
||||
await reloadOccupancy()
|
||||
$q.notify({ type: 'positive', icon: 'group_add', message: name + ' ajouté en renfort', timeout: 2400 })
|
||||
$q.notify({ type: 'positive', icon: onsite ? 'construction' : 'group_add', message: name + (onsite ? ' — sur place (bloc réservé)' : ' — assistant'), timeout: 2400 })
|
||||
} catch (e) { err(e) }
|
||||
}
|
||||
// Retirer le chip « À » (assigné) → renvoyer le job au pool (non assigné).
|
||||
|
|
|
|||
|
|
@ -1733,8 +1733,13 @@ async function handle (req, res, method, path, url) {
|
|||
const job = b.job || qs.get('job')
|
||||
if (!job) return json(res, 400, { error: 'job requis' })
|
||||
const norm = (a) => ({ tech_id: String(a.tech_id), tech_name: String(a.tech_name || ''), duration_h: Number(a.duration_h) || 0, note: String(a.note || '').slice(0, 140), pinned: a.pinned ? 1 : 0 })
|
||||
// ⚠️ Lire les assistants VIA LE DOC PARENT (child table embarquée), PAS via erp.list('Dispatch Job Assistant') :
|
||||
// le compte de service ERP n'a pas la permission de LIRE le doctype-enfant directement → erp.list lève
|
||||
// PermissionError → renvoyait [] en silence. Symptômes : chips assistants jamais affichées, tech non exclu de
|
||||
// l'autosuggest, et surtout POST add/remove partait de [] → ÉCRASAIT les assistants existants. Le doc parent
|
||||
// embarque `assistants` sans vérif de perm sur l'enfant.
|
||||
let cur = []
|
||||
try { cur = await erp.list('Dispatch Job Assistant', { filters: [['parent', '=', job]], fields: ['tech_id', 'tech_name', 'duration_h', 'note', 'pinned'], limit: 50 }) } catch (e) { cur = [] }
|
||||
try { const doc = await erp.get('Dispatch Job', job); cur = Array.isArray(doc && doc.assistants) ? doc.assistants : [] } catch (e) { cur = [] }
|
||||
if (method === 'GET') return json(res, 200, { job, assistants: cur.map(norm) })
|
||||
let rows
|
||||
if (b.add && b.add.tech_id) { rows = cur.filter(a => String(a.tech_id) !== String(b.add.tech_id)).map(norm); rows.push(norm(b.add)) }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user