ticket panel: décodage entités HTML + fil unifié + lien client cherchable
- richHtml() (sanitize.js) : décode entités osTicket (é, ', double-échappées &#039;) 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:
parent
bd7a077f46
commit
cb16167f72
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 : é ' … parfois DOUBLE-échappées &#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é : &#039; → ' → ')
|
||||||
|
if (dec !== String(raw) && /&(#\d+|[a-zA-Z]+);/.test(dec)) dec = _decodeOnce(dec)
|
||||||
|
return sanitizeHtml(dec)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user