#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:
louispaulb 2026-07-08 14:31:39 -04:00
parent 6224271e07
commit d7412fcced
5 changed files with 177 additions and 138 deletions

View File

@ -246,29 +246,10 @@
<q-tooltip>Créer un ticket pour cette adresse</q-tooltip>
</q-btn>
</div>
<div v-for="t in locTickets(loc.name)" :key="t.name" class="ticket-row clickable-row" @click="openModal('Issue', t.name, t.subject)">
<div class="row items-center no-wrap">
<q-icon v-if="t.is_important" name="star" color="warning" size="14px" class="q-mr-xs" style="flex-shrink:0" />
<div class="col" style="min-width:0">
<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>
<!-- Rangée de ticket = composant CANONIQUE unique (TicketCard), réutilisé aussi
sur la fiche client et la table /tickets plus de rendu ad hoc divergent ici. -->
<TicketCard v-for="t in locTickets(loc.name)" :key="t.name" :ticket="t"
@open="openModal('Issue', t.name, t.subject)" />
</div>
</div>
@ -284,9 +265,10 @@
// comportement identique (mêmes closures, même invalidation de cache).
import InlineField from 'src/components/shared/InlineField.vue'
import DeviceStrip from 'src/components/customer/DeviceStrip.vue'
import TicketCard from 'src/components/customer/TicketCard.vue'
import draggable from 'vuedraggable'
import { formatMoney, formatDate, staffColor, staffInitials } from 'src/composables/useFormatters'
import { locStatusClass, ticketStatusClass } from 'src/composables/useStatusClasses'
import { formatMoney, formatDate } from 'src/composables/useFormatters'
import { locStatusClass } from 'src/composables/useStatusClasses'
import { isRebate, subMainLabel, sectionTotal, annualPrice } from 'src/composables/useSubscriptionGroups'
import { usePermissions } from 'src/composables/usePermissions'
import { locInlineFields } from 'src/data/client-constants'

View File

@ -1,7 +1,9 @@
<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). -->
<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-item-label header class="q-py-xs">&nbsp; 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>
@ -32,7 +34,7 @@ const busy = ref(false)
const isJob = computed(() => props.doctype === 'Dispatch Job')
const isClosed = computed(() => ['Closed', 'Resolved', 'Completed', 'Cancelled'].includes(props.status))
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'))
function todayPlus (days) { const d = new Date(); d.setDate(d.getDate() + Number(days || 0)); return d.toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }

View File

@ -97,7 +97,8 @@
</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="info-block-title" style="display:flex;align-items:center">
Tags
@ -106,42 +107,14 @@
<q-tooltip>Gérer les tags</q-tooltip>
</q-btn>
</div>
<div class="row items-center q-gutter-xs q-mb-xs">
<q-chip v-for="tag in currentTags" :key="tag" dense removable size="sm"
:style="{ background: getTagColor(tag), color: '#fff' }"
@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>
<TagEditor :model-value="currentTags" :all-tags="allTags" :get-color="getTagColor"
:can-edit="false" placeholder="Ajouter un tag (compétence, département…)…"
@update:model-value="onTagsChanged" @create="onCreateTag" />
<!-- Tag Manager -->
<!-- Tag Manager (CRUD Dispatch Tag) -->
<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 v-for="t in allDispatchTags" :key="t.name" class="tag-mgr-row">
<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 dispatchTags" :key="t.name" class="tag-mgr-row">
<div class="tag-mgr-dot" :style="{ background: t.color || '#6b7280' }"
@click="cycleTagColor(t)">
<q-tooltip>Changer la couleur</q-tooltip>
@ -155,7 +128,7 @@
<q-tooltip>Supprimer ce tag</q-tooltip>
</q-btn>
</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>
@ -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))
})
}
async function addTag (tag) {
if (!tag) return
const label = typeof tag === 'string' ? tag : tag.value || tag
if (currentTags.value.includes(label)) return
try {
// 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' },
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)
if (!cur.includes(label)) 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 })
}
}
function addNewTag (val, done) {
if (val.length > 1) {
done(val, 'add-unique')
addTag(val)
}
}
async function removeTag (tag) {
try {
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, dt: 'Issue', dn: props.docName }),
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag: label, 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 })
}
props.doc._user_tags = parseTags(props.doc._user_tags).filter(t => t !== label).join(',')
}
async function loadDispatchTags () {
loadingTags.value = true
try {
allDispatchTags.value = await fetchTags()
} catch { allDispatchTags.value = [] }
loadingTags.value = false
// 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 }))
}
// 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 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 })

View 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 }
}

View File

@ -114,6 +114,13 @@
<span v-else class="text-grey-4"></span>
</q-td>
</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>
<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 InlineField from 'src/components/shared/InlineField.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 { slaFor } = useSla() // calcul SLA (politiques par file+priorité éditées dans Paramètres)