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:
louispaulb 2026-07-08 17:40:36 -04:00
parent 2f81cff892
commit 6bf323b18e
4 changed files with 162 additions and 125 deletions

View File

@ -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>
À&nbsp;·&nbsp;{{ 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&nbsp;·&nbsp;{{ 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>
#&nbsp;·&nbsp;{{ 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 }}&nbsp;·&nbsp;{{ 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 = labonner aux mises à jour (follower #).'
: 'Choisir un utilisateur = lajouter en renfort (assistant CC).'
if (!props.canAssist) return 'Choisir un technicien = lassigner. « Assist » / « #follow » ouvrent la liste complète.'
return props.assignee && (props.assignee.name || props.assignee.id)
? 'Ajout = assistant (CC). « #follow » pour abonner quelquun. Retirer « À » = désassigner.'
: 'Choisir un tech = lassigner (À) ; 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>

View File

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

View File

@ -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é).

View File

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