feat(ops): champ d'assignation MULTIFONCTION (À / CC / #) partagé
Fusionne l'assignation en UN autosuggest + chips à 3 rôles, réutilisé sur le volet tâche (PlanificationPage) ET le détail ticket (IssueDetail). - Nouveau composant partagé AssignmentField.vue : chips distinctes À (assigné / lead) · CC (assistant / renfort) · # (follower). Autosuggest par défaut = techniciens ; 1er choisi = assigné, 2e+ = assistant. Mini-liens « Assist » et « #follow » étendent la liste à TOUS les utilisateurs (assistant ou follower depuis n'importe qui). Le suivi (follower) est géré dans le composant via /conversations/follow (identique sur toutes les surfaces). - TechSelect : props optionnelles searchFn (recherche async « tous les utilisateurs ») + noOptionLabel — amélioration du composant partagé, pas de fork. - PlanificationPage : le champ To/Cc devient AssignmentField (assigné→jdAssignTech, assistant→addAssistant, désassigner→jdUnassign) ; adaptateurs jdAssignee / jdAssistants / jdFieldAssist. - IssueDetail : remplace le multiselect _assign par AssignmentField (1er _assign = À, suivants = CC ; frappe assign_to) ; pool par défaut = utilisateurs assignables. - Hub (conversation.js) : POST /conversations/follow accepte un courriel cible (abonner n'importe quel utilisateur) ; nouveau GET /conversations/followers ?doctype&name → liste des abonnés (pastilles #). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d15dec5e6c
commit
2f81cff892
213
apps/ops/src/components/shared/AssignmentField.vue
Normal file
213
apps/ops/src/components/shared/AssignmentField.vue
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
<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). -->
|
||||||
|
<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">
|
||||||
|
<q-avatar color="indigo-9" text-color="white" size="20px">{{ ini(assignee.name || assignee.id) }}</q-avatar>
|
||||||
|
À · {{ assignee.name || assignee.id }}
|
||||||
|
</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>
|
||||||
|
</q-chip>
|
||||||
|
<q-spinner v-if="followBusy" size="14px" color="teal-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Autosuggest unique : techs par défaut ; étendu à tous les utilisateurs en mode Assist/#follow -->
|
||||||
|
<TechSelect
|
||||||
|
:model-value="null"
|
||||||
|
:options="techOptions"
|
||||||
|
:search-fn="scope === 'users' ? userSearch : null"
|
||||||
|
:no-option-label="scope === 'users' ? 'Aucun utilisateur' : 'Aucun technicien'"
|
||||||
|
:label="fieldLabel"
|
||||||
|
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>
|
||||||
|
<q-space />
|
||||||
|
<slot name="right" />
|
||||||
|
</div>
|
||||||
|
<div class="asg-hint text-caption text-grey-6 q-mt-xs">{{ hint }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import { useQuasar } from 'quasar'
|
||||||
|
import { HUB_URL } from 'src/config/hub'
|
||||||
|
import { useAuthStore } from 'src/stores/auth'
|
||||||
|
import { initials as ini, shortAgent } from 'src/composables/useFormatters'
|
||||||
|
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 ?
|
||||||
|
assistLoading: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['assign', 'assist', 'unassign', 'remove-assistant', 'followers-changed'])
|
||||||
|
const $q = useQuasar()
|
||||||
|
const _authUser = useAuthStore().user
|
||||||
|
const me = (_authUser && _authUser !== 'authenticated') ? _authUser : ''
|
||||||
|
|
||||||
|
// ── 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 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).'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sélection dans l'autosuggest → route selon la portée + l'intention.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recherche « tous les utilisateurs » (Authentik via hub, comme InterveneDialog) ──
|
||||||
|
let userOptCache = []
|
||||||
|
async function userSearch (val) {
|
||||||
|
const q = String(val || '').trim()
|
||||||
|
try {
|
||||||
|
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 }))
|
||||||
|
return userOptCache
|
||||||
|
} catch { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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)
|
||||||
|
const followBusy = ref(false)
|
||||||
|
function followerLabel (email) { return followerNames.value[email] || shortAgent(email) || email }
|
||||||
|
const followHdr = () => (me ? { 'Content-Type': 'application/json', 'X-Authentik-Email': me } : { 'Content-Type': 'application/json' })
|
||||||
|
|
||||||
|
async function loadFollowers () {
|
||||||
|
followers.value = []
|
||||||
|
if (!props.docName || !props.doctype) return
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
|
async function addFollower (email, label) {
|
||||||
|
const e = String(email || '').toLowerCase()
|
||||||
|
if (!e || !props.docName || followers.value.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())
|
||||||
|
if (r.ok) {
|
||||||
|
if (!followers.value.includes(e)) followers.value.push(e)
|
||||||
|
if (label) followerNames.value[e] = String(label).split(' · ')[0].trim()
|
||||||
|
emit('followers-changed', followers.value.slice())
|
||||||
|
$q.notify({ type: 'positive', icon: 'visibility', message: followerLabel(e) + ' suit désormais cet élément', timeout: 2000 })
|
||||||
|
} 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()
|
||||||
|
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)
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.doctype, props.docName], loadFollowers)
|
||||||
|
onMounted(loadFollowers)
|
||||||
|
defineExpose({ reloadFollowers: loadFollowers })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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; }
|
||||||
|
</style>
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
:label="label" :style="style" behavior="menu"
|
:label="label" :style="style" behavior="menu"
|
||||||
@filter="onFilter">
|
@filter="onFilter">
|
||||||
<template #no-option>
|
<template #no-option>
|
||||||
<q-item><q-item-section class="text-grey-6">Aucun technicien</q-item-section></q-item>
|
<q-item><q-item-section class="text-grey-6">{{ noOptionLabel }}</q-item-section></q-item>
|
||||||
</template>
|
</template>
|
||||||
</q-select>
|
</q-select>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -23,11 +23,21 @@ const props = defineProps({
|
||||||
options: { type: Array, default: () => [] }, // [{label, value}]
|
options: { type: Array, default: () => [] }, // [{label, value}]
|
||||||
label: { type: String, default: 'Technicien' },
|
label: { type: String, default: 'Technicien' },
|
||||||
style: { type: String, default: 'min-width:200px' },
|
style: { type: String, default: 'min-width:200px' },
|
||||||
|
// Recherche ASYNCHRONE optionnelle : `async (val) => [{label, value}]`. Fournie → remplace le filtrage
|
||||||
|
// local sur `options` (ex. autosuggest « tous les utilisateurs » du champ d'assignation). Absente → filtre local.
|
||||||
|
searchFn: { type: Function, default: null },
|
||||||
|
noOptionLabel: { type: String, default: 'Aucun technicien' },
|
||||||
})
|
})
|
||||||
defineEmits(['update:modelValue'])
|
defineEmits(['update:modelValue'])
|
||||||
const filtered = ref(props.options)
|
const filtered = ref(props.options)
|
||||||
watch(() => props.options, (v) => { filtered.value = v }, { immediate: true })
|
watch(() => props.options, (v) => { filtered.value = v }, { immediate: true })
|
||||||
function onFilter (val, update) {
|
function onFilter (val, update) {
|
||||||
|
if (props.searchFn) {
|
||||||
|
// Recherche distante : on lance la promesse puis on pousse le résultat dans le menu (patron q-select remote).
|
||||||
|
Promise.resolve(props.searchFn(val)).then(opts => update(() => { filtered.value = Array.isArray(opts) ? opts : [] }))
|
||||||
|
.catch(() => update(() => { filtered.value = [] }))
|
||||||
|
return
|
||||||
|
}
|
||||||
update(() => {
|
update(() => {
|
||||||
const n = (val || '').toLowerCase()
|
const n = (val || '').toLowerCase()
|
||||||
filtered.value = !n ? props.options
|
filtered.value = !n ? props.options
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,15 @@
|
||||||
<span v-else class="text-caption text-grey-6">Non assigné</span>
|
<span v-else class="text-caption text-grey-6">Non assigné</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignation MULTIFONCTION (À = assigné · CC = assistant · # = follower) — champ PARTAGÉ, réutilisé depuis PlanificationPage.
|
||||||
|
Pool par défaut = utilisateurs assignables du ticket (le ticket assigne des Users, pas des tech_id) ; « Assist » / « #follow » ouvrent tous les utilisateurs. -->
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="issue-field-grid">
|
<div class="issue-field-grid">
|
||||||
<div class="if-row">
|
<div class="if-row">
|
||||||
<span class="if-label">Statut</span>
|
<span class="if-label">Statut</span>
|
||||||
|
|
@ -80,21 +89,6 @@
|
||||||
<span class="if-label">Soumis par</span>
|
<span class="if-label">Soumis par</span>
|
||||||
<span class="if-value">{{ doc.raised_by }}</span>
|
<span class="if-value">{{ doc.raised_by }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="if-row">
|
|
||||||
<span class="if-label">Assigné à</span>
|
|
||||||
<q-select v-model="assignees" dense borderless multiple use-chips emit-value map-options
|
|
||||||
:options="userOptions" option-value="value" option-label="label"
|
|
||||||
input-class="editable-input" style="min-width:140px;flex:1"
|
|
||||||
:loading="loadingUsers"
|
|
||||||
@update:model-value="saveAssignees">
|
|
||||||
<template #selected-item="scope">
|
|
||||||
<q-chip dense removable size="sm" color="green-1" text-color="green-8"
|
|
||||||
@remove="scope.removeAtIndex(scope.index)">
|
|
||||||
{{ scope.opt.label || scope.opt }}
|
|
||||||
</q-chip>
|
|
||||||
</template>
|
|
||||||
</q-select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags — SOURCE UNIQUE (Dispatch Tag ∪ compétences roster) via TagEditor partagé.
|
<!-- Tags — SOURCE UNIQUE (Dispatch Tag ∪ compétences roster) via TagEditor partagé.
|
||||||
|
|
@ -306,6 +300,7 @@ import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
|
||||||
import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
|
import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
|
||||||
import TaskNode from 'src/components/shared/TaskNode.vue'
|
import TaskNode from 'src/components/shared/TaskNode.vue'
|
||||||
import TagEditor from 'src/components/shared/TagEditor.vue'
|
import TagEditor from 'src/components/shared/TagEditor.vue'
|
||||||
|
import AssignmentField from 'src/components/shared/AssignmentField.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
doc: { type: Object, required: true },
|
doc: { type: Object, required: true },
|
||||||
|
|
@ -444,6 +439,31 @@ async function loadUsers () {
|
||||||
loadingUsers.value = false
|
loadingUsers.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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 }
|
||||||
|
})
|
||||||
|
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 }
|
||||||
|
}))
|
||||||
|
async function assignUser (email) {
|
||||||
|
const e = String(email || '').trim(); if (!e || assignees.value.includes(e)) return
|
||||||
|
const next = [...assignees.value, e]
|
||||||
|
assignees.value = next
|
||||||
|
await saveAssignees(next)
|
||||||
|
}
|
||||||
|
async function removeAssignee (email) {
|
||||||
|
const e = String(email || '').trim(); if (!e) return
|
||||||
|
const next = assignees.value.filter(x => x !== e)
|
||||||
|
assignees.value = next
|
||||||
|
await saveAssignees(next)
|
||||||
|
}
|
||||||
|
function unassignFirst () { const e = assignees.value[0]; if (e) return removeAssignee(e) }
|
||||||
|
|
||||||
async function saveAssignees (newList) {
|
async function saveAssignees (newList) {
|
||||||
const old = parseAssign(props.doc._assign)
|
const old = parseAssign(props.doc._assign)
|
||||||
const added = newList.filter(u => !old.includes(u))
|
const added = newList.filter(u => !old.includes(u))
|
||||||
|
|
|
||||||
|
|
@ -1401,22 +1401,18 @@
|
||||||
<div class="col" style="min-width:0">
|
<div class="col" style="min-width:0">
|
||||||
<div class="text-h6" style="line-height:1.25;word-break:break-word">{{ jobDetail.subject }}</div>
|
<div class="text-h6" style="line-height:1.25;word-break:break-word">{{ jobDetail.subject }}</div>
|
||||||
<div class="text-caption text-grey-7"><template v-if="jobDetail.customer">{{ jobDetail.customer }}</template><template v-if="jobDetail.lid"> · Ticket #{{ jobDetail.lid }}</template><template v-if="jobDetail.dept"> · {{ jobDetail.dept }}</template></div>
|
<div class="text-caption text-grey-7"><template v-if="jobDetail.customer">{{ jobDetail.customer }}</template><template v-if="jobDetail.lid"> · Ticket #{{ jobDetail.lid }}</template><template v-if="jobDetail.dept"> · {{ jobDetail.dept }}</template></div>
|
||||||
<!-- Assignation façon courriel To/Cc — UN champ : À = assigné (principal) · Cc = assistants (renfort, créneau grisé). -->
|
<!-- Assignation MULTIFONCTION — UN champ : À = assigné (lead) · CC = assistant (renfort, créneau grisé) · # = follower (abonné).
|
||||||
<div class="q-mt-xs jd-assign">
|
Champ partagé AssignmentField (réutilisé sur le détail ticket). « Assist » / « #follow » étendent l'autosuggest à tous les utilisateurs. -->
|
||||||
<div class="row items-center q-gutter-xs">
|
<AssignmentField class="q-mt-xs jd-assign"
|
||||||
<q-chip v-if="jobDetail.techName" dense removable @remove="jdUnassign" color="indigo-6" text-color="white" class="text-weight-bold jd-tech-chip"><q-avatar color="indigo-9" text-color="white" size="20px">{{ initials(jobDetail.techName) }}</q-avatar>À · {{ jobDetail.techName }}</q-chip>
|
doctype="Dispatch Job" :doc-name="jobDetail.name"
|
||||||
<q-chip v-else dense outline color="grey-7" text-color="grey-8" icon="person_off">Non assigné</q-chip>
|
:assignee="jdAssignee" :assistants="jdAssistants"
|
||||||
<template v-if="jobDetail.canTeam">
|
:tech-options="jdTeamOptions" :can-assist="jobDetail.canTeam" :assist-loading="jobDetail.teamLoading"
|
||||||
<q-chip v-for="a in jobDetail.team" :key="a.tech_id" dense removable @remove="jdRemoveAssistant(a.tech_id)" outline color="blue-grey-6" text-color="blue-grey-8"><q-avatar color="blue-grey-6" text-color="white" size="20px">{{ initials(jdAssistantName(a)) }}</q-avatar>Cc · {{ jdAssistantName(a) }}<q-tooltip class="bg-grey-9" style="font-size:11px">Assistant (renfort) — son créneau est réservé et grisé (anti double-booking)</q-tooltip></q-chip>
|
@assign="jdAssignTech" @assist="jdFieldAssist" @unassign="jdUnassign" @remove-assistant="jdRemoveAssistant">
|
||||||
<q-spinner v-if="jobDetail.teamLoading" size="14px" color="blue-grey-6" />
|
<template #right>
|
||||||
</template>
|
|
||||||
<q-space />
|
|
||||||
<TicketStatusControl v-if="jobDetail.canTeam" doctype="Dispatch Job" :docname="jobDetail.name" :status="jobDetail.status" :scheduled-date="jobDetail.iso"
|
<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() }" />
|
@changed="p => { if (p && p.status) jobDetail.status = p.status; reloadOccupancy(); reloadPool && reloadPool() }" />
|
||||||
</div>
|
</template>
|
||||||
<TechSelect v-model="jobDetail.assignTech" :options="jdTeamOptions" :label="jobDetail.techName ? (jobDetail.canTeam ? 'Ajouter un assistant (Cc)…' : 'Réassigner…') : 'Assigner à… (À)'" style="min-width:170px;flex:1" class="q-mt-xs" @update:model-value="v => v && jdAssignOrAssist(v)" />
|
</AssignmentField>
|
||||||
<div v-if="jobDetail.canTeam" class="text-caption text-grey-6 q-mt-xs">{{ jobDetail.techName ? 'Ajout = assistant (Cc / renfort) : son créneau est réservé et grisé. Retirer « À » = désassigner.' : 'Choisir un tech = l’assigner (À). Les ajouts suivants deviennent des assistants (Cc).' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
@ -1959,6 +1955,7 @@ import { messageIdentity, priorityMeta, PRIORITY_LEVELS } from 'src/composables/
|
||||||
import { useSla, SLA_BADGE } from 'src/composables/useSla' // SLA existant (politiques éditables) — réutilisé sur les jobs (échéance = création + résolution)
|
import { useSla, SLA_BADGE } from 'src/composables/useSla' // SLA existant (politiques éditables) — réutilisé sur les jobs (échéance = création + résolution)
|
||||||
import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client
|
import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client
|
||||||
import TechSelect from 'src/components/shared/TechSelect.vue'
|
import TechSelect from 'src/components/shared/TechSelect.vue'
|
||||||
|
import AssignmentField from 'src/components/shared/AssignmentField.vue'
|
||||||
import LeaveDialog from 'src/components/planif/LeaveDialog.vue' // Congés & disponibilités (extrait — décomposition #4)
|
import LeaveDialog from 'src/components/planif/LeaveDialog.vue' // Congés & disponibilités (extrait — décomposition #4)
|
||||||
import TechSyncDialog from 'src/components/planif/TechSyncDialog.vue' // Synchroniser les techniciens (extrait — décomposition #4)
|
import TechSyncDialog from 'src/components/planif/TechSyncDialog.vue' // Synchroniser les techniciens (extrait — décomposition #4)
|
||||||
import ShiftTypesDialog from 'src/components/planif/ShiftTypesDialog.vue' // Types de shift (extrait — décomposition #4)
|
import ShiftTypesDialog from 'src/components/planif/ShiftTypesDialog.vue' // Types de shift (extrait — décomposition #4)
|
||||||
|
|
@ -2485,6 +2482,22 @@ function jdAssignOrAssist (techId) {
|
||||||
else jdAssignTech(techId) // sinon → assigner / réassigner (vaut aussi pour les tickets legacy, sans assistant)
|
else jdAssignTech(techId) // sinon → assigner / réassigner (vaut aussi pour les tickets legacy, sans assistant)
|
||||||
jobDetail.assignTech = null // vider le champ après le choix
|
jobDetail.assignTech = null // vider le champ après le choix
|
||||||
}
|
}
|
||||||
|
// ── 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 } = {}) {
|
||||||
|
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 })
|
||||||
|
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 })
|
||||||
|
} catch (e) { err(e) }
|
||||||
|
}
|
||||||
// Retirer le chip « À » (assigné) → renvoyer le job au pool (non assigné).
|
// Retirer le chip « À » (assigné) → renvoyer le job au pool (non assigné).
|
||||||
async function jdUnassign () {
|
async function jdUnassign () {
|
||||||
if (!jobDetail.name) return
|
if (!jobDetail.name) return
|
||||||
|
|
|
||||||
|
|
@ -518,6 +518,14 @@ function saveFollows (all) {
|
||||||
writeJsonFile('/app/data/ticket_follows.json', tf)
|
writeJsonFile('/app/data/ticket_follows.json', tf)
|
||||||
}
|
}
|
||||||
function followsFor (all, email, doctype) { return (email && all[email] && all[email][doctype]) || [] }
|
function followsFor (all, email, doctype) { return (email && all[email] && all[email][doctype]) || [] }
|
||||||
|
// Qui suit ce doc ? Balaie tous les abonnés → liste des courriels qui suivent (doctype,name). Sert aux pastilles « # follower ».
|
||||||
|
function followersOf (all, doctype, name) {
|
||||||
|
const out = []
|
||||||
|
for (const [email, byType] of Object.entries(all || {})) {
|
||||||
|
if (byType && Array.isArray(byType[doctype]) && byType[doctype].includes(name)) out.push(email)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
// Règles d'inbox définies par l'agent (« Filtrer comme ceci ») : [{ id, field:'from'|'subject', contains, action:'mask'|'allow' }]. « allow » prime sur « mask ».
|
// Règles d'inbox définies par l'agent (« Filtrer comme ceci ») : [{ id, field:'from'|'subject', contains, action:'mask'|'allow' }]. « allow » prime sur « mask ».
|
||||||
function loadInboxRules () { return readJsonFile('/app/data/inbox_rules.json', []) }
|
function loadInboxRules () { return readJsonFile('/app/data/inbox_rules.json', []) }
|
||||||
function matchInboxRule (email, subject) {
|
function matchInboxRule (email, subject) {
|
||||||
|
|
@ -1311,13 +1319,22 @@ async function handle (req, res, method, p, url) {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const b = await parseBody(req); const dt = String(b.doctype || '').trim(); const nm = String(b.name || '').trim()
|
const b = await parseBody(req); const dt = String(b.doctype || '').trim(); const nm = String(b.name || '').trim()
|
||||||
if (!email || !dt || !nm) return json(res, 400, { error: 'email + doctype + name requis' })
|
// Cible = courriel explicite (abonner N'IMPORTE QUEL utilisateur — champ d'assignation « # follower ») OU, à défaut, l'identité de l'appelant.
|
||||||
const arr = new Set(followsFor(all, email, dt))
|
const target = String(b.email || email || '').toLowerCase()
|
||||||
|
if (!target || !dt || !nm) return json(res, 400, { error: 'email + doctype + name requis' })
|
||||||
|
const arr = new Set(followsFor(all, target, dt))
|
||||||
if (b.follow === false) arr.delete(nm); else arr.add(nm)
|
if (b.follow === false) arr.delete(nm); else arr.add(nm)
|
||||||
;(all[email] || (all[email] = {}))[dt] = [...arr]; saveFollows(all)
|
;(all[target] || (all[target] = {}))[dt] = [...arr]; saveFollows(all)
|
||||||
return json(res, 200, { ok: true, doctype: dt, follows: all[email][dt], following: arr.has(nm) })
|
return json(res, 200, { ok: true, doctype: dt, name: nm, email: target, follows: all[target][dt], following: arr.has(nm) })
|
||||||
} catch (e) { return json(res, 500, { error: e.message }) }
|
} catch (e) { return json(res, 500, { error: e.message }) }
|
||||||
}
|
}
|
||||||
|
// Qui suit ce doc : GET ?doctype=X&name=Y → { followers: [courriels] }. Alimente les pastilles « # » du champ d'assignation.
|
||||||
|
if (p === '/conversations/followers' && method === 'GET') {
|
||||||
|
const dt = String(url.searchParams.get('doctype') || '').trim()
|
||||||
|
const nm = String(url.searchParams.get('name') || '').trim()
|
||||||
|
if (!dt || !nm) return json(res, 400, { error: 'doctype + name requis' })
|
||||||
|
return json(res, 200, { doctype: dt, name: nm, followers: followersOf(loadFollows(), dt, nm) })
|
||||||
|
}
|
||||||
// Règles d'inbox (« Filtrer comme ceci ») : masquer/afficher par expéditeur ou sujet — l'agent choisit ce qui est masqué.
|
// Règles d'inbox (« Filtrer comme ceci ») : masquer/afficher par expéditeur ou sujet — l'agent choisit ce qui est masqué.
|
||||||
if (p === '/conversations/inbox-rules' && (method === 'GET' || method === 'POST')) {
|
if (p === '/conversations/inbox-rules' && (method === 'GET' || method === 'POST')) {
|
||||||
if (method === 'GET') return json(res, 200, { rules: loadInboxRules() })
|
if (method === 'GET') return json(res, 200, { rules: loadInboxRules() })
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user