-
Gérer les tags ({{ allDispatchTags.length }})
-
+
Gérer les tags ({{ dispatchTags.length }})
+
Changer la couleur
@@ -155,7 +128,7 @@
Supprimer ce tag
-
Aucun tag
+
Aucun tag
@@ -325,13 +298,14 @@ import { useAuthStore } from 'src/stores/auth'
import { usePermissions } from 'src/composables/usePermissions'
import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub'
-import { fetchTags, updateTag, renameTag, deleteTag as deleteTagApi } from 'src/api/dispatch'
+import { useTagCatalog } from 'src/composables/useTagCatalog'
import InlineField from 'src/components/shared/InlineField.vue'
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
import TaskNode from 'src/components/shared/TaskNode.vue'
+import TagEditor from 'src/components/shared/TagEditor.vue'
const props = defineProps({
doc: { type: Object, required: true },
@@ -497,13 +471,11 @@ async function saveAssignees (newList) {
}
}
-onMounted(() => { loadUsers(); loadDispatchTags() })
+onMounted(() => { loadUsers(); loadTagCatalog() })
-// ── Tags (Dispatch Tags shared list) ──
-
-const allDispatchTags = ref([])
-const loadingTags = ref(false)
-const filteredTagOptions = ref([])
+// ── Tags — catalogue UNIFIÉ (Dispatch Tag ∪ compétences roster) via TagEditor ──
+// Le catalogue et son CRUD vivent dans useTagCatalog (source unique, réutilisable).
+const { allTags, dispatchTags, getColor: getTagColor, load: loadTagCatalog, createTag: createCatTag, recolorTag, renameTag: renameCatTag, removeTag: removeCatTag } = useTagCatalog()
function parseTags (raw) {
if (!raw) return []
@@ -512,73 +484,40 @@ function parseTags (raw) {
const currentTags = computed(() => parseTags(props.doc?._user_tags))
-function getTagColor (label) {
- const t = allDispatchTags.value.find(x => x.label === label || x.name === label)
- return t?.color || '#6b7280'
-}
-
-function filterTags (val, update) {
- update(() => {
- const current = parseTags(props.doc?._user_tags)
- const all = allDispatchTags.value
- .map(t => ({ label: t.label, value: t.label, color: t.color, category: t.category || '' }))
- .filter(o => !current.includes(o.value))
- if (!val) { filteredTagOptions.value = all; return }
- const q = val.toLowerCase()
- filteredTagOptions.value = all.filter(o => o.label.toLowerCase().includes(q))
+// Applique/retire une étiquette sur CE ticket (frappe add_tag/remove_tag → Issue._user_tags).
+async function tagIssue (label) {
+ await authFetch(BASE_URL + '/api/method/frappe.desk.doctype.tag.tag.add_tag', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tag: label, dt: 'Issue', dn: props.docName }),
})
+ const cur = parseTags(props.doc._user_tags)
+ if (!cur.includes(label)) cur.push(label)
+ props.doc._user_tags = cur.join(',')
+}
+async function untagIssue (label) {
+ await authFetch(BASE_URL + '/api/method/frappe.desk.doctype.tag.tag.remove_tag', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tag: label, dt: 'Issue', dn: props.docName }),
+ })
+ props.doc._user_tags = parseTags(props.doc._user_tags).filter(t => t !== label).join(',')
}
-async function addTag (tag) {
- if (!tag) return
- const label = typeof tag === 'string' ? tag : tag.value || tag
- if (currentTags.value.includes(label)) return
- try {
- await authFetch(BASE_URL + '/api/method/frappe.desk.doctype.tag.tag.add_tag', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ tag: label, dt: 'Issue', dn: props.docName }),
- })
- const cur = parseTags(props.doc._user_tags)
- cur.push(label)
- props.doc._user_tags = cur.join(',')
- Notify.create({ type: 'positive', message: `Tag "${label}" ajouté`, timeout: 1500 })
- } catch (e) {
- Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
- }
+// TagEditor émet la NOUVELLE liste complète de libellés → on diff avec l'actuelle.
+function onTagsChanged (newLabels) {
+ const cur = currentTags.value
+ const added = newLabels.filter(l => !cur.includes(l))
+ const removed = cur.filter(l => !newLabels.includes(l))
+ for (const l of added) tagIssue(l).catch(e => Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }))
+ for (const l of removed) untagIssue(l).catch(e => Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }))
}
-function addNewTag (val, done) {
- if (val.length > 1) {
- done(val, 'add-unique')
- addTag(val)
- }
+// Création à la volée : matérialise le tag dans Dispatch Tag (l'application au ticket
+// arrive séparément via l'émission update:modelValue de TagEditor → onTagsChanged).
+async function onCreateTag ({ label, color }) {
+ try { await createCatTag(label, color) } catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }) }
}
-async function removeTag (tag) {
- try {
- await authFetch(BASE_URL + '/api/method/frappe.desk.doctype.tag.tag.remove_tag', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ tag, dt: 'Issue', dn: props.docName }),
- })
- const cur = parseTags(props.doc._user_tags).filter(t => t !== tag)
- props.doc._user_tags = cur.join(',')
- Notify.create({ type: 'positive', message: `Tag "${tag}" retiré`, timeout: 1500 })
- } catch (e) {
- Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
- }
-}
-
-async function loadDispatchTags () {
- loadingTags.value = true
- try {
- allDispatchTags.value = await fetchTags()
- } catch { allDispatchTags.value = [] }
- loadingTags.value = false
-}
-
-// ── Tag Manager ──
+// ── Tag Manager (CRUD Dispatch Tag, délégué à useTagCatalog) ──
const showTagManager = ref(false)
const editingTagName = ref(null)
@@ -600,9 +539,7 @@ async function saveTagRename (t) {
editingTagName.value = null
if (!newLabel || newLabel === t.label) return
try {
- await renameTag(t.name, newLabel)
- t.label = newLabel
- t.name = newLabel
+ await renameCatTag(t, newLabel)
Notify.create({ type: 'positive', message: `Tag renommé → "${newLabel}"`, timeout: 1500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
@@ -613,8 +550,7 @@ async function cycleTagColor (t) {
const idx = TAG_COLORS.indexOf(t.color)
const next = TAG_COLORS[(idx + 1) % TAG_COLORS.length]
try {
- await updateTag(t.name, { color: next })
- t.color = next
+ await recolorTag(t, next)
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
@@ -628,8 +564,7 @@ async function deleteDispatchTag (t) {
ok: { color: 'red', label: 'Supprimer', unelevated: true },
}).onOk(async () => {
try {
- await deleteTagApi(t.name)
- allDispatchTags.value = allDispatchTags.value.filter(x => x.name !== t.name)
+ await removeCatTag(t)
Notify.create({ type: 'positive', message: `Tag "${t.label}" supprimé`, timeout: 1500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
diff --git a/apps/ops/src/composables/useTagCatalog.js b/apps/ops/src/composables/useTagCatalog.js
new file mode 100644
index 0000000..a97b1f6
--- /dev/null
+++ b/apps/ops/src/composables/useTagCatalog.js
@@ -0,0 +1,112 @@
+// ── useTagCatalog ─────────────────────────────────────────────────────────────
+// SOURCE UNIQUE des étiquettes de ticket. Avant : les tags de ticket venaient du
+// seul doctype ERPNext « Dispatch Tag » (liste seedée à part) — les vraies
+// compétences roster (« sans-fil », « fusionneur »…) en étaient absentes. Ici on
+// UNIFIE : Dispatch Tag ∪ compétences des techniciens (roster hub). Le même
+// catalogue alimente TagEditor sur la fiche ticket (IssueDetail) que sur la
+// Planification, cohérent avec « reason chips liés aux compétences ».
+//
+// Format de sortie (allTags) attendu par TagEditor : [{ name, label, color, category }].
+import { ref, computed } from 'vue'
+import { fetchTags, createTag as apiCreateTag, updateTag as apiUpdateTag, renameTag as apiRenameTag, deleteTag as apiDeleteTag } from 'src/api/dispatch'
+import { listTechnicians } from 'src/api/roster'
+
+// Même palette / hash que PlanificationPage → une compétence a la MÊME couleur
+// partout, qu'elle ait ou non un enregistrement Dispatch Tag.
+const TAG_PALETTE = [
+ '#6366f1', '#3b82f6', '#0ea5e9', '#06b6d4', '#14b8a6', '#10b981', '#22c55e', '#84cc16',
+ '#eab308', '#f59e0b', '#f97316', '#ef4444', '#f43f5e', '#fb7185', '#ec4899', '#f472b6',
+ '#db2777', '#d946ef', '#a855f7', '#8b5cf6', '#78716c', '#64748b', '#94a3b8', '#111827',
+]
+function hashColor (label) { let h = 0; for (const c of String(label)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return TAG_PALETTE[h % TAG_PALETTE.length] }
+// Couleurs de compétences créées à la volée (partagées avec la Planification).
+function readCustomColors () { try { return JSON.parse(localStorage.getItem('roster-skill-tags-v1') || '[]') } catch { return [] } }
+
+// Cache au niveau module : les ouvertures répétées du panneau ticket ne
+// refont pas les requêtes (Dispatch Tag + techniciens). reload() force.
+const _dispatchTags = ref([]) // [{ name, label, color, category }]
+const _rosterSkills = ref([]) // ['sans-fil', 'fusionneur', …]
+let _loaded = false
+let _inflight = null
+
+async function _load () {
+ const [tags, techs] = await Promise.all([
+ fetchTags().catch(() => []),
+ listTechnicians().catch(() => ({})),
+ ])
+ _dispatchTags.value = Array.isArray(tags) ? tags : []
+ const list = Array.isArray(techs) ? techs : (techs && Array.isArray(techs.technicians) ? techs.technicians : [])
+ const skills = new Set()
+ for (const t of list) for (const s of (t && Array.isArray(t.skills) ? t.skills : [])) { const v = String(s || '').trim(); if (v) skills.add(v) }
+ _rosterSkills.value = [...skills]
+ _loaded = true
+}
+
+export function useTagCatalog () {
+ const loading = ref(false)
+
+ const dispatchTags = _dispatchTags // exposé pour le gestionnaire de tags (CRUD Dispatch Tag)
+
+ // Catalogue unifié : Dispatch Tags d'abord (ils portent une couleur/catégorie),
+ // puis les compétences roster non déjà présentes (couleur = custom LS ou hash).
+ const allTags = computed(() => {
+ const m = new Map()
+ for (const t of _dispatchTags.value) {
+ const label = t.label || t.name
+ const key = String(label || '').toLowerCase()
+ if (!key) continue
+ m.set(key, { name: t.name, label, color: t.color || '#6b7280', category: t.category || 'Dispatch' })
+ }
+ const custom = readCustomColors()
+ for (const s of _rosterSkills.value) {
+ const key = String(s).toLowerCase()
+ if (m.has(key)) continue
+ const c = custom.find(x => String(x.label).toLowerCase() === key)
+ m.set(key, { name: s, label: s, color: (c && c.color) || hashColor(s), category: 'Compétence' })
+ }
+ return [...m.values()].sort((a, b) => a.label.localeCompare(b.label))
+ })
+
+ function getColor (label) {
+ const key = String(label || '').toLowerCase()
+ const t = allTags.value.find(x => x.label.toLowerCase() === key || x.name.toLowerCase() === key)
+ return (t && t.color) || hashColor(label)
+ }
+
+ async function load (force = false) {
+ if (_loaded && !force) return
+ loading.value = true
+ try { _inflight = _inflight && !force ? _inflight : _load(); await _inflight } finally { _inflight = null; loading.value = false }
+ }
+ const reload = () => load(true)
+
+ // ── CRUD Dispatch Tag (le gestionnaire de tags agit sur ces enregistrements) ──
+ // Une compétence roster PURE n'a pas d'enregistrement Dispatch Tag : on en crée
+ // un à la volée si on la (re)colore/renomme, pour la matérialiser une fois.
+ async function createTag (label, color) {
+ const rec = await apiCreateTag(label, 'Custom', color || hashColor(label))
+ const norm = { name: rec?.name || label, label: rec?.label || label, color: rec?.color || color || hashColor(label), category: rec?.category || 'Custom' }
+ if (!_dispatchTags.value.some(t => (t.label || t.name) === norm.label)) _dispatchTags.value = [..._dispatchTags.value, norm]
+ return norm
+ }
+ async function recolorTag (t, color) {
+ const existing = _dispatchTags.value.find(x => x.name === t.name || x.label === t.label)
+ if (!existing) { await createTag(t.label || t.name, color); return }
+ await apiUpdateTag(existing.name, { color })
+ existing.color = color
+ }
+ async function renameTag (t, newLabel) {
+ const existing = _dispatchTags.value.find(x => x.name === t.name || x.label === t.label)
+ if (!existing) { await createTag(newLabel, t.color); return newLabel }
+ await apiRenameTag(existing.name, newLabel)
+ existing.label = newLabel; existing.name = newLabel
+ return newLabel
+ }
+ async function removeTag (t) {
+ const existing = _dispatchTags.value.find(x => x.name === t.name || x.label === t.label)
+ if (existing) await apiDeleteTag(existing.name)
+ _dispatchTags.value = _dispatchTags.value.filter(x => x.name !== (existing?.name || t.name))
+ }
+
+ return { allTags, dispatchTags, getColor, loading, load, reload, createTag, recolorTag, renameTag, removeTag }
+}
diff --git a/apps/ops/src/pages/TicketsPage.vue b/apps/ops/src/pages/TicketsPage.vue
index a30cd6b..02fb707 100644
--- a/apps/ops/src/pages/TicketsPage.vue
+++ b/apps/ops/src/pages/TicketsPage.vue
@@ -114,6 +114,13 @@
—
+
+
+
+
+
+