diff --git a/apps/ops/src/components/shared/AssignmentField.vue b/apps/ops/src/components/shared/AssignmentField.vue
new file mode 100644
index 0000000..86aa85f
--- /dev/null
+++ b/apps/ops/src/components/shared/AssignmentField.vue
@@ -0,0 +1,213 @@
+
+
+
+
+
+
+ {{ ini(assignee.name || assignee.id) }}
+ À · {{ assignee.name || assignee.id }}
+
+ Non assigné
+
+
+
+
+ {{ ini(a.name || a.id) }}
+ CC · {{ a.name || a.id }}
+ Assistant (renfort)
+
+
+
+
+
+
+ {{ ini(followerLabel(f)) }}
+ # · {{ followerLabel(f) }}
+ Follower (abonné aux mises à jour) · {{ f }}
+
+
+
+
+
+
+
+
+
+
{{ hint }}
+
+
+
+
+
+
diff --git a/apps/ops/src/components/shared/TechSelect.vue b/apps/ops/src/components/shared/TechSelect.vue
index 0c2d51f..03c61ee 100644
--- a/apps/ops/src/components/shared/TechSelect.vue
+++ b/apps/ops/src/components/shared/TechSelect.vue
@@ -11,7 +11,7 @@
:label="label" :style="style" behavior="menu"
@filter="onFilter">
- Aucun technicien
+ {{ noOptionLabel }}
@@ -23,11 +23,21 @@ const props = defineProps({
options: { type: Array, default: () => [] }, // [{label, value}]
label: { type: String, default: 'Technicien' },
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'])
const filtered = ref(props.options)
watch(() => props.options, (v) => { filtered.value = v }, { immediate: true })
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(() => {
const n = (val || '').toLowerCase()
filtered.value = !n ? props.options
diff --git a/apps/ops/src/components/shared/detail-sections/IssueDetail.vue b/apps/ops/src/components/shared/detail-sections/IssueDetail.vue
index 0b3136c..fbd9f1a 100644
--- a/apps/ops/src/components/shared/detail-sections/IssueDetail.vue
+++ b/apps/ops/src/components/shared/detail-sections/IssueDetail.vue
@@ -45,6 +45,15 @@
Non assigné
+
+
+
Assignation
+
assignUser(p.value)" @unassign="unassignFirst" @remove-assistant="removeAssignee" />
+
+
Statut
@@ -80,21 +89,6 @@
Soumis par
{{ doc.raised_by }}
-
- Assigné à
-
-
-
- {{ scope.opt.label || scope.opt }}
-
-
-
-
-
-
-
{{ initials(jobDetail.techName) }}À · {{ jobDetail.techName }}
-
Non assigné
-
- {{ initials(jdAssistantName(a)) }}Cc · {{ jdAssistantName(a) }}Assistant (renfort) — son créneau est réservé et grisé (anti double-booking)
-
-
-
+
+
+
{ if (p && p.status) jobDetail.status = p.status; reloadOccupancy(); reloadPool && reloadPool() }" />
-
-
v && jdAssignOrAssist(v)" />
- {{ 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).' }}
-
+
+
@@ -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 { 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 AssignmentField from 'src/components/shared/AssignmentField.vue'
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 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)
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é).
async function jdUnassign () {
if (!jobDetail.name) return
diff --git a/services/targo-hub/lib/conversation.js b/services/targo-hub/lib/conversation.js
index 3b09234..6a813a8 100644
--- a/services/targo-hub/lib/conversation.js
+++ b/services/targo-hub/lib/conversation.js
@@ -518,6 +518,14 @@ function saveFollows (all) {
writeJsonFile('/app/data/ticket_follows.json', tf)
}
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 ».
function loadInboxRules () { return readJsonFile('/app/data/inbox_rules.json', []) }
function matchInboxRule (email, subject) {
@@ -1311,13 +1319,22 @@ async function handle (req, res, method, p, url) {
}
try {
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' })
- const arr = new Set(followsFor(all, email, dt))
+ // Cible = courriel explicite (abonner N'IMPORTE QUEL utilisateur — champ d'assignation « # follower ») OU, à défaut, l'identité de l'appelant.
+ 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)
- ;(all[email] || (all[email] = {}))[dt] = [...arr]; saveFollows(all)
- return json(res, 200, { ok: true, doctype: dt, follows: all[email][dt], following: arr.has(nm) })
+ ;(all[target] || (all[target] = {}))[dt] = [...arr]; saveFollows(all)
+ 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 }) }
}
+ // 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é.
if (p === '/conversations/inbox-rules' && (method === 'GET' || method === 'POST')) {
if (method === 'GET') return json(res, 200, { rules: loadInboxRules() })