gigafibre-fsm/apps/client/src/pages/TicketDetailPage.vue
louispaulb 656f9d638c feat: telephony UI, performance indexes, Twilio softphone, lazy-load invoices
- 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>
2026-04-02 13:59:59 -04:00

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 }} &middot; 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>