#27 Standardise ticket-detail rendering (status / tags / row component)
Converge every ticket-detail surface onto the canonical IssueDetail (in
DetailModal) + TicketCard, per reference_ticket_detail_disparities.
STATUS — one model:
- IssueDetail no longer double-renders status. The green button (shared
TicketStatusControl) is relabelled "Reporter" with a schedule icon — it is
a postpone/snooze + quick-close control, not a 2nd status setter. The single
5-value status select (Open/Replied/On Hold/Resolved/Closed) is canonical.
TAGS — one source, via the shared TagEditor:
- New useTagCatalog composable unifies the tag catalogue: ERPNext "Dispatch Tag"
∪ roster technician skills (hub /roster/technicians). Roster skills like
"sans-fil"/"épissure" now appear on ticket tags (were missing before).
- IssueDetail replaces its bespoke q-select + chip row with TagEditor; the tag
manager (rename/recolor/delete) is delegated to the composable's CRUD.
ONE ticket-row component (TicketCard):
- LocationCard's ad-hoc per-address ticket list now renders TicketCard.
- TicketsPage mobile card mode (DataTable #item slot) renders TicketCard.
- assignee handling (assigned_staff/opened_by_staff avatars) is now consistent
across fiche, location, and /tickets.
Verified in local preview against live backend: Reporter button + single
status select, TagEditor dropdown surfacing roster skills ("sans-fil" under
"Compétence"), TicketCard rows on fiche + LocationCard, no component errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6224271e07
commit
d7412fcced
|
|
@ -246,29 +246,10 @@
|
||||||
<q-tooltip>Créer un ticket pour cette adresse</q-tooltip>
|
<q-tooltip>Créer un ticket pour cette adresse</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="t in locTickets(loc.name)" :key="t.name" class="ticket-row clickable-row" @click="openModal('Issue', t.name, t.subject)">
|
<!-- Rangée de ticket = composant CANONIQUE unique (TicketCard), réutilisé aussi
|
||||||
<div class="row items-center no-wrap">
|
sur la fiche client et la table /tickets — plus de rendu ad hoc divergent ici. -->
|
||||||
<q-icon v-if="t.is_important" name="star" color="warning" size="14px" class="q-mr-xs" style="flex-shrink:0" />
|
<TicketCard v-for="t in locTickets(loc.name)" :key="t.name" :ticket="t"
|
||||||
<div class="col" style="min-width:0">
|
@open="openModal('Issue', t.name, t.subject)" />
|
||||||
<span style="font-size:11px;color:#9e9e9e" class="q-mr-xs">{{ t.legacy_ticket_id || t.name }}</span>
|
|
||||||
<span style="font-size:12.5px">{{ t.subject }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="t.assigned_staff || t.opened_by_staff" class="avatar-stack q-mx-xs" style="flex-shrink:0">
|
|
||||||
<q-avatar v-if="t.opened_by_staff && t.opened_by_staff !== t.assigned_staff"
|
|
||||||
size="20px" class="avatar-chip" :style="{ background: staffColor(t.opened_by_staff), zIndex: 1 }">
|
|
||||||
{{ staffInitials(t.opened_by_staff) }}
|
|
||||||
<q-tooltip>{{ t.opened_by_staff }}</q-tooltip>
|
|
||||||
</q-avatar>
|
|
||||||
<q-avatar v-if="t.assigned_staff"
|
|
||||||
size="20px" class="avatar-chip" :style="{ background: staffColor(t.assigned_staff), zIndex: 2 }">
|
|
||||||
{{ staffInitials(t.assigned_staff) }}
|
|
||||||
<q-tooltip>{{ t.assigned_staff }}</q-tooltip>
|
|
||||||
</q-avatar>
|
|
||||||
</div>
|
|
||||||
<span style="font-size:11px;color:#9e9e9e;white-space:nowrap;flex-shrink:0" class="q-mx-xs">{{ t.opening_date }}</span>
|
|
||||||
<span class="ops-badge" style="font-size:10px;padding:1px 5px;flex-shrink:0" :class="ticketStatusClass(t.status)">{{ t.status }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -284,9 +265,10 @@
|
||||||
// comportement identique (mêmes closures, même invalidation de cache).
|
// comportement identique (mêmes closures, même invalidation de cache).
|
||||||
import InlineField from 'src/components/shared/InlineField.vue'
|
import InlineField from 'src/components/shared/InlineField.vue'
|
||||||
import DeviceStrip from 'src/components/customer/DeviceStrip.vue'
|
import DeviceStrip from 'src/components/customer/DeviceStrip.vue'
|
||||||
|
import TicketCard from 'src/components/customer/TicketCard.vue'
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import { formatMoney, formatDate, staffColor, staffInitials } from 'src/composables/useFormatters'
|
import { formatMoney, formatDate } from 'src/composables/useFormatters'
|
||||||
import { locStatusClass, ticketStatusClass } from 'src/composables/useStatusClasses'
|
import { locStatusClass } from 'src/composables/useStatusClasses'
|
||||||
import { isRebate, subMainLabel, sectionTotal, annualPrice } from 'src/composables/useSubscriptionGroups'
|
import { isRebate, subMainLabel, sectionTotal, annualPrice } from 'src/composables/useSubscriptionGroups'
|
||||||
import { usePermissions } from 'src/composables/usePermissions'
|
import { usePermissions } from 'src/composables/usePermissions'
|
||||||
import { locInlineFields } from 'src/data/client-constants'
|
import { locInlineFields } from 'src/data/client-constants'
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<!-- Contrôle STATUT / REPORTER partagé (page ticket · fiche client · Planification).
|
<!-- Contrôle REPORTER (report/mise en attente + fermeture rapide) partagé
|
||||||
|
(page ticket · fiche client · Planification). C'est un menu de report/snooze,
|
||||||
|
PAS un 2e sélecteur de statut : le statut canonique vit dans le select d'IssueDetail.
|
||||||
Doctype-conscient : Issue (support) ou Dispatch Job (terrain). -->
|
Doctype-conscient : Issue (support) ou Dispatch Job (terrain). -->
|
||||||
<q-btn-dropdown :label="label" dense no-caps unelevated :color="color" text-color="white" icon="flag" :loading="busy">
|
<q-btn-dropdown :label="label" dense no-caps unelevated :color="color" text-color="white" icon="schedule" :loading="busy">
|
||||||
<q-list dense style="min-width:250px">
|
<q-list dense style="min-width:250px">
|
||||||
<q-item-label header class="q-py-xs">⏰ Reporter</q-item-label>
|
<q-item-label header class="q-py-xs">⏰ Reporter</q-item-label>
|
||||||
<q-item clickable v-close-popup @click="postpone(1)"><q-item-section avatar><q-icon name="snooze" color="indigo" /></q-item-section><q-item-section>À demain (+1 jour)</q-item-section></q-item>
|
<q-item clickable v-close-popup @click="postpone(1)"><q-item-section avatar><q-icon name="snooze" color="indigo" /></q-item-section><q-item-section>À demain (+1 jour)</q-item-section></q-item>
|
||||||
|
|
@ -32,7 +34,7 @@ const busy = ref(false)
|
||||||
const isJob = computed(() => props.doctype === 'Dispatch Job')
|
const isJob = computed(() => props.doctype === 'Dispatch Job')
|
||||||
const isClosed = computed(() => ['Closed', 'Resolved', 'Completed', 'Cancelled'].includes(props.status))
|
const isClosed = computed(() => ['Closed', 'Resolved', 'Completed', 'Cancelled'].includes(props.status))
|
||||||
const isHold = computed(() => props.status === 'On Hold')
|
const isHold = computed(() => props.status === 'On Hold')
|
||||||
const label = computed(() => isClosed.value ? 'Fermé' : (isHold.value ? 'En attente' : 'Statut'))
|
const label = computed(() => isClosed.value ? 'Fermé' : (isHold.value ? 'En attente' : 'Reporter'))
|
||||||
const color = computed(() => isClosed.value ? 'grey-6' : (isHold.value ? 'brown' : 'primary'))
|
const color = computed(() => isClosed.value ? 'grey-6' : (isHold.value ? 'brown' : 'primary'))
|
||||||
|
|
||||||
function todayPlus (days) { const d = new Date(); d.setDate(d.getDate() + Number(days || 0)); return d.toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }
|
function todayPlus (days) { const d = new Date(); d.setDate(d.getDate() + Number(days || 0)); return d.toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags — SOURCE UNIQUE (Dispatch Tag ∪ compétences roster) via TagEditor partagé.
|
||||||
|
Avant : q-select maison + liste Dispatch Tag seule (sans les compétences « sans-fil »…). -->
|
||||||
<div class="q-mt-sm">
|
<div class="q-mt-sm">
|
||||||
<div class="info-block-title" style="display:flex;align-items:center">
|
<div class="info-block-title" style="display:flex;align-items:center">
|
||||||
Tags
|
Tags
|
||||||
|
|
@ -106,42 +107,14 @@
|
||||||
<q-tooltip>Gérer les tags</q-tooltip>
|
<q-tooltip>Gérer les tags</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
<div class="row items-center q-gutter-xs q-mb-xs">
|
<TagEditor :model-value="currentTags" :all-tags="allTags" :get-color="getTagColor"
|
||||||
<q-chip v-for="tag in currentTags" :key="tag" dense removable size="sm"
|
:can-edit="false" placeholder="Ajouter un tag (compétence, département…)…"
|
||||||
:style="{ background: getTagColor(tag), color: '#fff' }"
|
@update:model-value="onTagsChanged" @create="onCreateTag" />
|
||||||
@remove="removeTag(tag)">
|
|
||||||
{{ tag }}
|
|
||||||
</q-chip>
|
|
||||||
</div>
|
|
||||||
<q-select dense outlined use-input input-debounce="100" emit-value map-options
|
|
||||||
:options="filteredTagOptions" option-value="value" option-label="label"
|
|
||||||
:model-value="null" :loading="loadingTags"
|
|
||||||
placeholder="Ajouter un tag..."
|
|
||||||
style="max-width:250px" input-class="text-caption"
|
|
||||||
@filter="filterTags" @update:model-value="addTag"
|
|
||||||
new-value-mode="add-unique" @new-value="addNewTag">
|
|
||||||
<template #option="scope">
|
|
||||||
<q-item v-bind="scope.itemProps" dense>
|
|
||||||
<q-item-section side>
|
|
||||||
<div style="width:10px;height:10px;border-radius:50%" :style="{ background: scope.opt.color || '#6b7280' }" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>{{ scope.opt.label }}</q-item-label>
|
|
||||||
<q-item-label caption v-if="scope.opt.category">{{ scope.opt.category }}</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</template>
|
|
||||||
<template #no-option>
|
|
||||||
<q-item dense>
|
|
||||||
<q-item-section class="text-caption text-grey-6">Tapez pour créer un tag</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</template>
|
|
||||||
</q-select>
|
|
||||||
|
|
||||||
<!-- Tag Manager -->
|
<!-- Tag Manager (CRUD Dispatch Tag) -->
|
||||||
<div v-if="showTagManager" class="tag-manager q-mt-sm">
|
<div v-if="showTagManager" class="tag-manager q-mt-sm">
|
||||||
<div class="text-caption text-weight-bold text-grey-7 q-mb-xs">Gérer les tags ({{ allDispatchTags.length }})</div>
|
<div class="text-caption text-weight-bold text-grey-7 q-mb-xs">Gérer les tags ({{ dispatchTags.length }})</div>
|
||||||
<div v-for="t in allDispatchTags" :key="t.name" class="tag-mgr-row">
|
<div v-for="t in dispatchTags" :key="t.name" class="tag-mgr-row">
|
||||||
<div class="tag-mgr-dot" :style="{ background: t.color || '#6b7280' }"
|
<div class="tag-mgr-dot" :style="{ background: t.color || '#6b7280' }"
|
||||||
@click="cycleTagColor(t)">
|
@click="cycleTagColor(t)">
|
||||||
<q-tooltip>Changer la couleur</q-tooltip>
|
<q-tooltip>Changer la couleur</q-tooltip>
|
||||||
|
|
@ -155,7 +128,7 @@
|
||||||
<q-tooltip>Supprimer ce tag</q-tooltip>
|
<q-tooltip>Supprimer ce tag</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!allDispatchTags.length" class="text-caption text-grey-5">Aucun tag</div>
|
<div v-if="!dispatchTags.length" class="text-caption text-grey-5">Aucun tag</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -325,13 +298,14 @@ import { useAuthStore } from 'src/stores/auth'
|
||||||
import { usePermissions } from 'src/composables/usePermissions'
|
import { usePermissions } from 'src/composables/usePermissions'
|
||||||
import { BASE_URL } from 'src/config/erpnext'
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
import { HUB_URL } from 'src/config/hub'
|
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 InlineField from 'src/components/shared/InlineField.vue'
|
||||||
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
|
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
|
||||||
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
|
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
|
||||||
import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
|
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'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
doc: { type: Object, required: true },
|
doc: { type: Object, required: true },
|
||||||
|
|
@ -497,13 +471,11 @@ async function saveAssignees (newList) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { loadUsers(); loadDispatchTags() })
|
onMounted(() => { loadUsers(); loadTagCatalog() })
|
||||||
|
|
||||||
// ── Tags (Dispatch Tags shared list) ──
|
// ── Tags — catalogue UNIFIÉ (Dispatch Tag ∪ compétences roster) via TagEditor ──
|
||||||
|
// Le catalogue et son CRUD vivent dans useTagCatalog (source unique, réutilisable).
|
||||||
const allDispatchTags = ref([])
|
const { allTags, dispatchTags, getColor: getTagColor, load: loadTagCatalog, createTag: createCatTag, recolorTag, renameTag: renameCatTag, removeTag: removeCatTag } = useTagCatalog()
|
||||||
const loadingTags = ref(false)
|
|
||||||
const filteredTagOptions = ref([])
|
|
||||||
|
|
||||||
function parseTags (raw) {
|
function parseTags (raw) {
|
||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
|
|
@ -512,73 +484,40 @@ function parseTags (raw) {
|
||||||
|
|
||||||
const currentTags = computed(() => parseTags(props.doc?._user_tags))
|
const currentTags = computed(() => parseTags(props.doc?._user_tags))
|
||||||
|
|
||||||
function getTagColor (label) {
|
// Applique/retire une étiquette sur CE ticket (frappe add_tag/remove_tag → Issue._user_tags).
|
||||||
const t = allDispatchTags.value.find(x => x.label === label || x.name === label)
|
async function tagIssue (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))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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', {
|
await authFetch(BASE_URL + '/api/method/frappe.desk.doctype.tag.tag.add_tag', {
|
||||||
method: 'POST',
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ tag: label, dt: 'Issue', dn: props.docName }),
|
body: JSON.stringify({ tag: label, dt: 'Issue', dn: props.docName }),
|
||||||
})
|
})
|
||||||
const cur = parseTags(props.doc._user_tags)
|
const cur = parseTags(props.doc._user_tags)
|
||||||
cur.push(label)
|
if (!cur.includes(label)) cur.push(label)
|
||||||
props.doc._user_tags = cur.join(',')
|
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 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
async function untagIssue (label) {
|
||||||
function addNewTag (val, done) {
|
|
||||||
if (val.length > 1) {
|
|
||||||
done(val, 'add-unique')
|
|
||||||
addTag(val)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeTag (tag) {
|
|
||||||
try {
|
|
||||||
await authFetch(BASE_URL + '/api/method/frappe.desk.doctype.tag.tag.remove_tag', {
|
await authFetch(BASE_URL + '/api/method/frappe.desk.doctype.tag.tag.remove_tag', {
|
||||||
method: 'POST',
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: JSON.stringify({ tag: label, dt: 'Issue', dn: props.docName }),
|
||||||
body: JSON.stringify({ tag, dt: 'Issue', dn: props.docName }),
|
|
||||||
})
|
})
|
||||||
const cur = parseTags(props.doc._user_tags).filter(t => t !== tag)
|
props.doc._user_tags = parseTags(props.doc._user_tags).filter(t => t !== label).join(',')
|
||||||
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 () {
|
// TagEditor émet la NOUVELLE liste complète de libellés → on diff avec l'actuelle.
|
||||||
loadingTags.value = true
|
function onTagsChanged (newLabels) {
|
||||||
try {
|
const cur = currentTags.value
|
||||||
allDispatchTags.value = await fetchTags()
|
const added = newLabels.filter(l => !cur.includes(l))
|
||||||
} catch { allDispatchTags.value = [] }
|
const removed = cur.filter(l => !newLabels.includes(l))
|
||||||
loadingTags.value = false
|
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 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tag Manager ──
|
// 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 }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tag Manager (CRUD Dispatch Tag, délégué à useTagCatalog) ──
|
||||||
|
|
||||||
const showTagManager = ref(false)
|
const showTagManager = ref(false)
|
||||||
const editingTagName = ref(null)
|
const editingTagName = ref(null)
|
||||||
|
|
@ -600,9 +539,7 @@ async function saveTagRename (t) {
|
||||||
editingTagName.value = null
|
editingTagName.value = null
|
||||||
if (!newLabel || newLabel === t.label) return
|
if (!newLabel || newLabel === t.label) return
|
||||||
try {
|
try {
|
||||||
await renameTag(t.name, newLabel)
|
await renameCatTag(t, newLabel)
|
||||||
t.label = newLabel
|
|
||||||
t.name = newLabel
|
|
||||||
Notify.create({ type: 'positive', message: `Tag renommé → "${newLabel}"`, timeout: 1500 })
|
Notify.create({ type: 'positive', message: `Tag renommé → "${newLabel}"`, timeout: 1500 })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
|
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 idx = TAG_COLORS.indexOf(t.color)
|
||||||
const next = TAG_COLORS[(idx + 1) % TAG_COLORS.length]
|
const next = TAG_COLORS[(idx + 1) % TAG_COLORS.length]
|
||||||
try {
|
try {
|
||||||
await updateTag(t.name, { color: next })
|
await recolorTag(t, next)
|
||||||
t.color = next
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
|
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 },
|
ok: { color: 'red', label: 'Supprimer', unelevated: true },
|
||||||
}).onOk(async () => {
|
}).onOk(async () => {
|
||||||
try {
|
try {
|
||||||
await deleteTagApi(t.name)
|
await removeCatTag(t)
|
||||||
allDispatchTags.value = allDispatchTags.value.filter(x => x.name !== t.name)
|
|
||||||
Notify.create({ type: 'positive', message: `Tag "${t.label}" supprimé`, timeout: 1500 })
|
Notify.create({ type: 'positive', message: `Tag "${t.label}" supprimé`, timeout: 1500 })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
|
||||||
|
|
|
||||||
112
apps/ops/src/composables/useTagCatalog.js
Normal file
112
apps/ops/src/composables/useTagCatalog.js
Normal file
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -114,6 +114,13 @@
|
||||||
<span v-else class="text-grey-4">—</span>
|
<span v-else class="text-grey-4">—</span>
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- Mode cartes (<1024px) : rangée = composant CANONIQUE TicketCard (le même
|
||||||
|
que la fiche client + LocationCard), au lieu de la carte clé/valeur générique. -->
|
||||||
|
<template #item="props">
|
||||||
|
<div class="col-12 q-pa-xs">
|
||||||
|
<TicketCard :ticket="props.row" @open="openTicketModal(props.row)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
<DetailModal
|
<DetailModal
|
||||||
|
|
@ -188,6 +195,7 @@ function openIntervene (t) { if (t) intervene.value = { open: true, name: t.name
|
||||||
import DetailModal from 'src/components/shared/DetailModal.vue'
|
import DetailModal from 'src/components/shared/DetailModal.vue'
|
||||||
import InlineField from 'src/components/shared/InlineField.vue'
|
import InlineField from 'src/components/shared/InlineField.vue'
|
||||||
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
|
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
|
||||||
|
import TicketCard from 'src/components/customer/TicketCard.vue'
|
||||||
|
|
||||||
const me = useAuthStore().user // courriel de l'agent connecté (pour « Mes tickets »)
|
const me = useAuthStore().user // courriel de l'agent connecté (pour « Mes tickets »)
|
||||||
const { slaFor } = useSla() // calcul SLA (politiques par file+priorité éditées dans Paramètres)
|
const { slaFor } = useSla() // calcul SLA (politiques par file+priorité éditées dans Paramètres)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user