- Add PostgreSQL performance indexes migration script (1000x faster queries) Sales Invoice: 1,248ms → 28ms, Payment Entry: 443ms → 31ms Indexes on customer/party columns for all major tables - Disable 3CX poller (PBX_ENABLED flag, using Twilio instead) - Add TelephonyPage: full CRUD UI for Routr/Fonoster resources (trunks, agents, credentials, numbers, domains, peers) - Add PhoneModal + usePhone composable (Twilio WebRTC softphone) - Lazy-load invoices/payments (initial 5, expand on demand) - Parallelize all API calls in ClientDetailPage (no waterfall) - Add targo-hub service (SSE relay, SMS, voice, telephony API) - Customer portal: invoice detail, ticket detail, messages pages - Remove dead Ollama nginx upstream Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
305 lines
9.3 KiB
Vue
305 lines
9.3 KiB
Vue
<template>
|
|
<q-page padding>
|
|
<!-- Header -->
|
|
<div class="flex items-center q-mb-md">
|
|
<q-btn flat round icon="arrow_back" @click="$router.push('/tickets')" />
|
|
<div class="q-ml-sm">
|
|
<div class="page-title q-mb-none">{{ ticket?.subject || ticketName }}</div>
|
|
<div v-if="ticket" class="text-caption text-grey-7">
|
|
{{ ticketName }} · Créé le {{ formatDate(ticket.creation) }}
|
|
</div>
|
|
</div>
|
|
<q-space />
|
|
<q-badge v-if="ticket" :color="statusColor(ticket.status)" :label="statusLabel(ticket.status)" class="text-body2 q-pa-sm" />
|
|
</div>
|
|
|
|
<!-- Ticket info -->
|
|
<div v-if="ticket" class="row q-col-gutter-md q-mb-md">
|
|
<div class="col-12 col-md-8">
|
|
<!-- Description -->
|
|
<div class="portal-card">
|
|
<div class="text-subtitle2 text-weight-medium q-mb-sm">Description</div>
|
|
<div class="ticket-description" v-html="ticket.description || '<em>Aucune description</em>'" />
|
|
</div>
|
|
|
|
<!-- Conversation thread -->
|
|
<div class="portal-card q-mt-md">
|
|
<div class="text-subtitle2 text-weight-medium q-mb-md">Conversation</div>
|
|
|
|
<!-- Empty state -->
|
|
<div v-if="!thread.length && !loading" class="text-grey-6 text-center q-pa-lg">
|
|
Aucun message pour le moment
|
|
</div>
|
|
|
|
<!-- Thread entries -->
|
|
<div v-for="(group, gi) in groupedThread" :key="gi" class="q-mb-md">
|
|
<div class="text-center q-mb-sm">
|
|
<q-chip size="sm" color="grey-3" text-color="grey-7" dense>
|
|
{{ group.date }}
|
|
</q-chip>
|
|
</div>
|
|
|
|
<div v-for="entry in group.items" :key="entry.name" class="thread-entry q-mb-sm"
|
|
:class="entry.sent_or_received === 'Sent' && entry.sender === 'portal@gigafibre.ca' ? 'thread-mine' : 'thread-theirs'">
|
|
<div class="thread-bubble">
|
|
<div class="flex items-center q-mb-xs">
|
|
<q-icon :name="mediumIcon(entry.communication_medium)" size="16px" class="q-mr-xs" />
|
|
<span class="text-caption text-weight-medium">
|
|
{{ entry.sender_full_name || entry.sender || 'Système' }}
|
|
</span>
|
|
<q-space />
|
|
<span class="text-caption text-grey-6">{{ formatTime(entry.creation) }}</span>
|
|
</div>
|
|
<div class="thread-content" v-html="entry.content" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Reply box -->
|
|
<div class="q-mt-md">
|
|
<q-input v-model="replyText" outlined placeholder="Répondre..." type="textarea" rows="3"
|
|
:disable="sending" autogrow />
|
|
<div class="flex justify-end q-mt-sm">
|
|
<q-btn color="primary" label="Envoyer" icon="send" no-caps
|
|
:loading="sending" :disable="!replyText.trim()"
|
|
@click="sendReply" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sidebar -->
|
|
<div class="col-12 col-md-4">
|
|
<div class="portal-card">
|
|
<div class="text-subtitle2 text-weight-medium q-mb-sm">Détails</div>
|
|
<q-list dense>
|
|
<q-item>
|
|
<q-item-section>
|
|
<q-item-label caption>Statut</q-item-label>
|
|
<q-item-label>
|
|
<q-badge :color="statusColor(ticket.status)" :label="statusLabel(ticket.status)" />
|
|
</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item>
|
|
<q-item-section>
|
|
<q-item-label caption>Priorité</q-item-label>
|
|
<q-item-label>
|
|
<q-badge :color="priorityColor(ticket.priority)" :label="ticket.priority || 'Medium'" outline />
|
|
</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item v-if="ticket.issue_type">
|
|
<q-item-section>
|
|
<q-item-label caption>Type</q-item-label>
|
|
<q-item-label>{{ ticket.issue_type }}</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item>
|
|
<q-item-section>
|
|
<q-item-label caption>Créé le</q-item-label>
|
|
<q-item-label>{{ formatDate(ticket.creation) }}</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item v-if="ticket.resolution_date">
|
|
<q-item-section>
|
|
<q-item-label caption>Résolu le</q-item-label>
|
|
<q-item-label>{{ formatDate(ticket.resolution_date) }}</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="loading" class="flex flex-center q-pa-xl">
|
|
<q-spinner-dots size="48px" color="primary" />
|
|
</div>
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useQuasar } from 'quasar'
|
|
import { useCustomerStore } from 'src/stores/customer'
|
|
import { fetchTicket, fetchCommunications, fetchComments, replyToTicket } from 'src/api/portal'
|
|
import { useFormatters } from 'src/composables/useFormatters'
|
|
import { useSSE } from 'src/composables/useSSE'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const $q = useQuasar()
|
|
const store = useCustomerStore()
|
|
const { formatDate } = useFormatters()
|
|
|
|
const ticketName = route.params.name
|
|
const ticket = ref(null)
|
|
const thread = ref([])
|
|
const loading = ref(true)
|
|
const replyText = ref('')
|
|
const sending = ref(false)
|
|
|
|
// SSE for real-time updates
|
|
const { connect: sseConnect } = useSSE({
|
|
onMessage: async (data) => {
|
|
if (data.customer === store.customerId) {
|
|
await loadThread()
|
|
if (data.direction === 'in') {
|
|
$q.notify({
|
|
type: 'info',
|
|
icon: 'chat',
|
|
message: `Nouvelle réponse sur ${ticketName}`,
|
|
timeout: 3000,
|
|
position: 'top-right',
|
|
})
|
|
}
|
|
}
|
|
},
|
|
})
|
|
|
|
function statusColor (s) {
|
|
if (s === 'Open') return 'primary'
|
|
if (s === 'Closed' || s === 'Resolved') return 'positive'
|
|
if (s === 'Replied') return 'info'
|
|
return 'grey'
|
|
}
|
|
|
|
function statusLabel (s) {
|
|
const map = { Open: 'Ouvert', Closed: 'Fermé', Resolved: 'Résolu', Replied: 'Répondu' }
|
|
return map[s] || s
|
|
}
|
|
|
|
function priorityColor (p) {
|
|
if (p === 'High' || p === 'Urgent') return 'negative'
|
|
if (p === 'Medium') return 'warning'
|
|
return 'grey'
|
|
}
|
|
|
|
function mediumIcon (m) {
|
|
if (m === 'SMS') return 'sms'
|
|
if (m === 'Phone') return 'phone'
|
|
if (m === 'Email') return 'email'
|
|
return 'chat_bubble_outline'
|
|
}
|
|
|
|
function formatTime (d) {
|
|
if (!d) return ''
|
|
return new Date(d).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
const groupedThread = computed(() => {
|
|
const groups = []
|
|
let currentDate = null
|
|
let currentGroup = null
|
|
for (const entry of thread.value) {
|
|
const d = new Date(entry.creation).toLocaleDateString('fr-CA', {
|
|
year: 'numeric', month: 'long', day: 'numeric',
|
|
})
|
|
if (d !== currentDate) {
|
|
currentDate = d
|
|
currentGroup = { date: d, items: [] }
|
|
groups.push(currentGroup)
|
|
}
|
|
currentGroup.items.push(entry)
|
|
}
|
|
return groups
|
|
})
|
|
|
|
async function loadThread () {
|
|
const [coms, comments] = await Promise.all([
|
|
fetchCommunications('Issue', ticketName),
|
|
fetchComments('Issue', ticketName),
|
|
])
|
|
// Merge communications and comments into unified thread sorted by creation
|
|
const merged = [
|
|
...coms.map(c => ({ ...c, _type: 'communication' })),
|
|
...comments.map(c => ({
|
|
name: c.name,
|
|
communication_medium: 'Other',
|
|
sent_or_received: 'Received',
|
|
sender: c.comment_by,
|
|
sender_full_name: c.comment_by,
|
|
content: c.content,
|
|
creation: c.creation,
|
|
_type: 'comment',
|
|
})),
|
|
]
|
|
merged.sort((a, b) => new Date(a.creation) - new Date(b.creation))
|
|
thread.value = merged
|
|
}
|
|
|
|
async function sendReply () {
|
|
if (!replyText.value.trim()) return
|
|
sending.value = true
|
|
try {
|
|
await replyToTicket(ticketName, replyText.value.trim())
|
|
replyText.value = ''
|
|
$q.notify({ type: 'positive', message: 'Message envoyé' })
|
|
await loadThread()
|
|
} catch (e) {
|
|
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
} finally {
|
|
sending.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (!store.customerId) return
|
|
try {
|
|
const t = await fetchTicket(ticketName)
|
|
// Security: verify ticket belongs to customer
|
|
if (t.customer !== store.customerId) {
|
|
router.replace('/tickets')
|
|
return
|
|
}
|
|
ticket.value = t
|
|
await loadThread()
|
|
// Connect SSE for real-time updates on this customer
|
|
sseConnect(['customer:' + store.customerId])
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.ticket-description {
|
|
line-height: 1.6;
|
|
word-break: break-word;
|
|
}
|
|
.ticket-description :deep(img) {
|
|
max-width: 100%;
|
|
}
|
|
.thread-entry {
|
|
display: flex;
|
|
}
|
|
.thread-mine {
|
|
justify-content: flex-end;
|
|
}
|
|
.thread-theirs {
|
|
justify-content: flex-start;
|
|
}
|
|
.thread-bubble {
|
|
max-width: 80%;
|
|
padding: 10px 14px;
|
|
border-radius: 12px;
|
|
font-size: 14px;
|
|
}
|
|
.thread-mine .thread-bubble {
|
|
background: #e3f2fd;
|
|
border-bottom-right-radius: 4px;
|
|
}
|
|
.thread-theirs .thread-bubble {
|
|
background: #f5f5f5;
|
|
border-bottom-left-radius: 4px;
|
|
}
|
|
.thread-content {
|
|
word-break: break-word;
|
|
}
|
|
.thread-content :deep(p) {
|
|
margin: 0;
|
|
}
|
|
</style>
|