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 @@ + + + + + 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"> @@ -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
+ +
+
Statut @@ -80,21 +89,6 @@ Soumis par {{ doc.raised_by }}
-
- Assigné à - - - -
-
-
- {{ initials(jobDetail.techName) }}À · {{ jobDetail.techName }} - Non assigné - - + + + +
@@ -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() })