ticket panel: décodage entités HTML + fil unifié + lien client cherchable

- richHtml() (sanitize.js) : décode entités osTicket (é, ', double-échappées ')
  puis DOMPurify → plus de « codes html » dans les détails.
- IssueDetail : fusionne Échanges (Communication) + Fil (Comment) en UN fil chronologique
  en bulles (notes internes en ambre) ; aère la mise en page.
- Bandeau Client cherchable par nom, association/dissociation 1 clic (comme une conversation)
  — le ticket portait déjà .customer mais aucun champ pour le voir/lier. Résout le cas voicemail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-07 07:09:25 -04:00
parent bd7a077f46
commit cb16167f72
2 changed files with 147 additions and 24 deletions

View File

@ -12,6 +12,33 @@
category="incident" applies-to="Issue" trigger-event="on_issue_opened" /> category="incident" applies-to="Issue" trigger-event="on_issue_opened" />
</div> </div>
<!-- Client lié (comme une conversation : cherchable par nom, association 1 clic) -->
<div class="cust-banner q-mb-sm">
<q-icon name="person" size="18px" :color="doc.customer ? 'green-7' : 'grey-5'" />
<template v-if="doc.customer">
<span class="cust-name erp-link" @click="$emit('navigate', 'Customer', doc.customer)">{{ customerLabel || doc.customer }}</span>
<q-btn flat dense round size="xs" icon="link_off" color="grey-6" :loading="linkingCust" @click="unlinkCustomer">
<q-tooltip>Délier le client</q-tooltip>
</q-btn>
</template>
<q-select v-else dense borderless use-input hide-dropdown-icon input-debounce="300"
:options="custOptions" option-value="value" option-label="label" emit-value map-options
:loading="loadingCust" placeholder="Lier à un compte client (nom)…" style="flex:1;min-width:170px"
@filter="searchCustomers" @update:model-value="linkCustomer">
<template #option="scope">
<q-item v-bind="scope.itemProps" dense>
<q-item-section>
<q-item-label>{{ scope.opt.label }}</q-item-label>
<q-item-label caption>{{ scope.opt.sub }}</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 un nom de client</q-item-section></q-item>
</template>
</q-select>
</div>
<div class="issue-field-grid"> <div class="issue-field-grid">
<div class="if-row"> <div class="if-row">
<span class="if-label">Statut</span> <span class="if-label">Statut</span>
@ -185,19 +212,19 @@
</div> </div>
<div v-if="doc.resolution_details" class="q-mt-md"> <div v-if="doc.resolution_details" class="q-mt-md">
<div class="info-block-title">Resolution</div> <div class="info-block-title">Resolution</div>
<div class="modal-desc" v-html="sanitizeHtml(doc.resolution_details)"></div> <div class="modal-desc" v-html="richHtml(doc.resolution_details)"></div>
</div> </div>
<div v-if="comms.length" class="q-mt-md"> <!-- Fil unifié : échanges (Communication) + notes (Comment) fusionnés, chronologique, en bulles -->
<div class="info-block-title">Echanges ({{ comms.length }})</div> <div v-if="thread.length" class="q-mt-md">
<div v-for="comm in comms" :key="comm.name" class="comm-row"> <div class="info-block-title">Conversation ({{ thread.length }})</div>
<div class="row items-start"> <div class="thread-wrap">
<div class="col"> <div v-for="m in thread" :key="m.id" class="msg-bubble" :class="'msg-' + m.kind">
<div class="text-caption text-weight-bold">{{ comm.sender || comm.owner }}</div> <div class="msg-head">
<div class="modal-desc q-mt-xs" v-html="sanitizeHtml(comm.content)"></div> <span class="msg-avatar">{{ initials(m.who) }}</span>
</div> <span class="msg-who">{{ m.who }}</span>
<div class="col-auto text-caption text-grey-6 text-right" style="min-width:90px"> <span class="msg-when">{{ formatDateTime(m.when) }}</span>
{{ formatDate(comm.creation) }}
</div> </div>
<div class="msg-body" v-html="richHtml(m.content)"></div>
</div> </div>
</div> </div>
</div> </div>
@ -209,18 +236,7 @@
</a> </a>
</div> </div>
</div> </div>
<div v-if="comments.length" class="q-mt-md"> <div v-if="!doc.description && !doc.resolution_details && !thread.length" class="text-center text-grey-5 q-pa-lg">
<div class="info-block-title">Fil de discussion ({{ comments.length }})</div>
<div v-for="c in comments" :key="c.name" class="thread-msg">
<div class="thread-header">
<q-icon name="person" size="14px" color="grey-6" class="q-mr-xs" />
<span class="text-weight-bold">{{ c.comment_by || 'Systeme' }}</span>
<span class="text-grey-5 q-ml-auto">{{ formatDateTime(c.creation) }}</span>
</div>
<div class="thread-body" v-html="sanitizeHtml(c.content)"></div>
</div>
</div>
<div v-if="!doc.description && !doc.resolution_details && !comms.length && !comments.length" class="text-center text-grey-5 q-pa-lg">
Aucun contenu pour ce ticket Aucun contenu pour ce ticket
</div> </div>
@ -265,7 +281,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { Notify, Dialog } from 'quasar' import { Notify, Dialog } from 'quasar'
import { sanitizeHtml } from 'src/utils/sanitize' import { richHtml } from 'src/utils/sanitize'
import { formatDate, formatDateTime, erpFileUrl } from 'src/composables/useFormatters' import { formatDate, formatDateTime, erpFileUrl } from 'src/composables/useFormatters'
import { createDoc, updateDoc, deleteDoc, listDocs } from 'src/api/erp' import { createDoc, updateDoc, deleteDoc, listDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth' import { authFetch } from 'src/api/auth'
@ -303,6 +319,75 @@ const dispatchContext = computed(() => ({
priority: props.doc?.priority || 'medium', priority: props.doc?.priority || 'medium',
})) }))
// Client lié + fil unifié
const custOptions = ref([])
const loadingCust = ref(false)
const linkingCust = ref(false)
const customerLabel = ref('')
function initials (name) {
if (!name) return '?'
const p = String(name).replace(/@.*/, '').split(/[\s._-]+/).filter(Boolean)
return ((p[0]?.[0] || '') + (p[1]?.[0] || '')).toUpperCase() || '?'
}
// Fusionne échanges (Communication) + notes (Comment) en UN fil chronologique
const thread = computed(() => {
const items = []
for (const c of props.comms || []) items.push({ id: 'e-' + c.name, who: c.sender || c.sender_full_name || c.owner || 'Système', when: c.creation, content: c.content, kind: 'comm' })
for (const c of props.comments || []) items.push({ id: 'c-' + c.name, who: c.comment_by || 'Système', when: c.creation, content: c.content, kind: 'note' })
return items.sort((a, b) => String(a.when || '').localeCompare(String(b.when || '')))
})
async function loadCustomerLabel () {
customerLabel.value = ''
if (!props.doc?.customer) return
try {
const rows = await listDocs('Customer', { filters: { name: props.doc.customer }, fields: ['customer_name'], limit: 1 })
customerLabel.value = rows?.[0]?.customer_name || ''
} catch { /* garde l'ID affiché */ }
}
watch(() => props.doc?.customer, loadCustomerLabel, { immediate: true })
function searchCustomers (val, update) {
if (!val || val.length < 2) { update(() => { custOptions.value = [] }); return }
update(async () => {
loadingCust.value = true
try {
const rows = await listDocs('Customer', {
filters: { customer_name: ['like', '%' + val + '%'] },
fields: ['name', 'customer_name'], limit: 12, orderBy: 'customer_name asc',
})
custOptions.value = rows.map(r => ({ label: r.customer_name || r.name, value: r.name, sub: r.name }))
} catch { custOptions.value = [] }
loadingCust.value = false
})
}
async function linkCustomer (name) {
if (!name) return
linkingCust.value = true
try {
await updateDoc('Issue', props.docName, { customer: name })
props.doc.customer = name
await loadCustomerLabel()
Notify.create({ type: 'positive', message: 'Client lié', timeout: 1500 })
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }) }
finally { linkingCust.value = false }
}
async function unlinkCustomer () {
linkingCust.value = true
try {
await updateDoc('Issue', props.docName, { customer: '' })
props.doc.customer = ''
customerLabel.value = ''
Notify.create({ type: 'positive', message: 'Client délié', timeout: 1500 })
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }) }
finally { linkingCust.value = false }
}
// Assignees // Assignees
const userOptions = ref([]) const userOptions = ref([])
@ -717,4 +802,27 @@ async function sendReply () {
.tag-mgr-cat { flex-shrink: 0; font-size: 0.65rem; } .tag-mgr-cat { flex-shrink: 0; font-size: 0.65rem; }
.tag-mgr-del { opacity: 0.3; transition: opacity 0.15s; } .tag-mgr-del { opacity: 0.3; transition: opacity 0.15s; }
.tag-mgr-row:hover .tag-mgr-del { opacity: 1; } .tag-mgr-row:hover .tag-mgr-del { opacity: 1; }
/* Client lié */
.cust-banner {
display: flex; align-items: center; gap: 8px;
background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px;
padding: 5px 12px; min-height: 40px;
}
.cust-name { font-weight: 600; font-size: 0.9rem; color: #166534; cursor: pointer; }
.cust-name:hover { text-decoration: underline; }
/* Fil unifié en bulles */
.thread-wrap { display: flex; flex-direction: column; gap: 10px; }
.msg-bubble { border: 1px solid #e5e7eb; border-radius: 10px; padding: 8px 12px; background: #fff; }
.msg-bubble.msg-note { background: #fffbeb; border-color: #fde68a; } /* notes internes = ambre */
.msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
.msg-avatar {
width: 22px; height: 22px; border-radius: 50%; background: #e0e7ff; color: #4338ca;
font-size: 0.62rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.msg-who { font-size: 0.8rem; font-weight: 600; color: #374151; }
.msg-when { font-size: 0.7rem; color: #9ca3af; margin-left: auto; white-space: nowrap; }
.msg-body { font-size: 0.85rem; color: #1f2937; line-height: 1.5; word-break: break-word; }
.msg-body :deep(img) { max-width: 100%; height: auto; }
</style> </style>

View File

@ -8,3 +8,18 @@ export function sanitizeHtml (html) {
// ADD_ATTR target → garde target="_blank" sur les liens ; le reste (scripts, on*, javascript:) est retiré par défaut. // ADD_ATTR target → garde target="_blank" sur les liens ; le reste (scripts, on*, javascript:) est retiré par défaut.
return DOMPurify.sanitize(String(html), { ADD_ATTR: ['target'] }) return DOMPurify.sanitize(String(html), { ADD_ATTR: ['target'] })
} }
// Décode les entités HTML (légataire osTicket : &eacute; &#039; … parfois DOUBLE-échappées &amp;#039;)
// AVANT sanitisation, pour qu'elles s'affichent en caractères et non en « codes ». Sûr : DOMPurify passe en dernier.
function _decodeOnce (s) {
const t = document.createElement('textarea')
t.innerHTML = s
return t.value
}
export function richHtml (raw) {
if (raw == null || raw === '') return ''
let dec = _decodeOnce(String(raw))
// 2e passe si des entités subsistent (contenu double-échappé : &amp;#039; → &#039; → ')
if (dec !== String(raw) && /&(#\d+|[a-zA-Z]+);/.test(dec)) dec = _decodeOnce(dec)
return sanitizeHtml(dec)
}