gigafibre-fsm/apps/client/src/pages/MessagesPage.vue
louispaulb 4693bcf60c 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

229 lines
7.8 KiB
Vue

<template>
<q-page padding>
<div class="page-title">Messages</div>
<!-- Filter tabs -->
<q-tabs v-model="activeTab" dense align="left" class="q-mb-md bg-white rounded-borders"
active-color="primary" indicator-color="primary" narrow-indicator>
<q-tab name="all" label="Tous" no-caps />
<q-tab name="SMS" label="SMS" no-caps icon="sms" />
<q-tab name="Email" label="Courriel" no-caps icon="email" />
<q-tab name="Phone" label="Appels" no-caps icon="phone" />
</q-tabs>
<!-- Loading -->
<div v-if="loading" class="flex flex-center q-pa-xl">
<q-spinner-dots size="48px" color="primary" />
</div>
<!-- Empty state -->
<div v-else-if="!filteredMessages.length" class="text-center text-grey-6 q-pa-xl">
<q-icon name="chat_bubble_outline" size="64px" class="q-mb-md" />
<div class="text-h6">Aucun message</div>
</div>
<!-- Grouped messages -->
<div v-else>
<div v-for="group in groupedMessages" :key="group.key" class="q-mb-lg">
<!-- Group header -->
<div class="flex items-center q-mb-sm">
<q-icon :name="groupIcon(group)" size="20px" color="grey-7" class="q-mr-sm" />
<div class="text-subtitle2 text-weight-medium">
{{ group.label }}
</div>
<q-badge v-if="group.ticketStatus" :color="ticketStatusColor(group.ticketStatus)"
:label="group.ticketStatus" class="q-ml-sm" />
<q-space />
<span class="text-caption text-grey-6">{{ group.dateRange }}</span>
</div>
<!-- Messages in group -->
<q-list bordered separator class="rounded-borders bg-white">
<q-item v-for="msg in group.messages" :key="msg.name" class="q-pa-sm">
<q-item-section avatar>
<q-icon :name="mediumIcon(msg.communication_medium)"
:color="msg.sent_or_received === 'Sent' ? 'primary' : 'grey-7'" />
</q-item-section>
<q-item-section>
<q-item-label class="flex items-center">
<q-icon v-if="msg.sent_or_received === 'Sent'" name="arrow_upward" size="14px" color="primary" class="q-mr-xs" />
<q-icon v-else name="arrow_downward" size="14px" color="positive" class="q-mr-xs" />
<span class="text-caption text-grey-7">
{{ msg.sent_or_received === 'Sent' ? 'Envoyé' : 'Reçu' }}
&middot; {{ formatDateTime(msg.creation) }}
</span>
</q-item-label>
<q-item-label class="q-mt-xs message-content" v-html="truncateHTML(msg.content, 200)" />
<q-item-label v-if="msg.phone_no" caption>
<q-icon name="phone" size="12px" /> {{ msg.phone_no }}
</q-item-label>
</q-item-section>
<q-item-section side v-if="msg.reference_doctype === 'Issue'">
<q-btn flat dense size="sm" color="primary" no-caps
:label="msg.reference_name"
@click="$router.push('/tickets/' + msg.reference_name)" />
</q-item-section>
</q-item>
</q-list>
</div>
</div>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { useCustomerStore } from 'src/stores/customer'
import { fetchAllCommunications } from 'src/api/portal'
import { useFormatters } from 'src/composables/useFormatters'
import { useSSE } from 'src/composables/useSSE'
const $q = useQuasar()
const store = useCustomerStore()
const { formatDate } = useFormatters()
const messages = ref([])
const loading = ref(true)
const activeTab = ref('all')
// SSE for real-time updates
const { connect: sseConnect } = useSSE({
onMessage: async (data) => {
if (data.customer === store.customerId) {
// Reload full list on any new message
messages.value = await fetchAllCommunications(store.customerId)
if (data.direction === 'in') {
$q.notify({
type: 'info',
icon: 'chat',
message: `Nouveau message de ${data.customer_name || data.phone || 'Client'}`,
timeout: 3000,
position: 'top-right',
})
}
}
},
})
function mediumIcon (m) {
if (m === 'SMS') return 'sms'
if (m === 'Phone') return 'phone'
if (m === 'Email') return 'email'
return 'chat_bubble_outline'
}
function groupIcon (group) {
if (group.type === 'ticket') return 'confirmation_number'
if (group.medium === 'SMS') return 'sms'
if (group.medium === 'Email') return 'email'
if (group.medium === 'Phone') return 'phone'
return 'chat_bubble_outline'
}
function ticketStatusColor (s) {
if (s === 'Open') return 'primary'
if (s === 'Closed' || s === 'Resolved') return 'positive'
return 'grey'
}
function formatDateTime (d) {
if (!d) return ''
return new Date(d).toLocaleString('fr-CA', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function truncateHTML (html, maxLen) {
if (!html) return ''
// Strip HTML tags for length check, but return original if short enough
const text = html.replace(/<[^>]+>/g, '')
if (text.length <= maxLen) return html
return text.substring(0, maxLen) + '...'
}
const filteredMessages = computed(() => {
if (activeTab.value === 'all') return messages.value
return messages.value.filter(m => m.communication_medium === activeTab.value)
})
const groupedMessages = computed(() => {
const groups = new Map()
for (const msg of filteredMessages.value) {
let key, label, type, medium
if (msg.reference_doctype === 'Issue' && msg.reference_name) {
// Group by ticket
key = 'ticket:' + msg.reference_name
label = msg.reference_name + (msg.subject ? ' - ' + msg.subject : '')
type = 'ticket'
} else if (msg.communication_medium === 'SMS' && msg.phone_no) {
// Group SMS by phone number + date
const d = new Date(msg.creation).toISOString().slice(0, 10)
key = 'sms:' + msg.phone_no + ':' + d
label = 'SMS - ' + msg.phone_no
type = 'sms'
medium = 'SMS'
} else if (msg.communication_medium === 'Email' && msg.subject) {
// Group email by subject (strip Re: / Fwd: prefixes)
const cleanSubject = (msg.subject || '').replace(/^(Re|Fwd|TR|RE):\s*/gi, '').trim()
key = 'email:' + cleanSubject
label = msg.subject
type = 'email'
medium = 'Email'
} else {
// Group by date
const d = new Date(msg.creation).toISOString().slice(0, 10)
key = 'other:' + d
label = formatDate(msg.creation)
type = 'other'
medium = msg.communication_medium
}
if (!groups.has(key)) {
groups.set(key, { key, label, type, medium, messages: [], ticketStatus: null })
}
groups.get(key).messages.push(msg)
}
// Sort groups by latest message, compute dateRange
const result = Array.from(groups.values())
for (const g of result) {
g.messages.sort((a, b) => new Date(a.creation) - new Date(b.creation))
const first = g.messages[0].creation
const last = g.messages[g.messages.length - 1].creation
if (first === last) {
g.dateRange = formatDateTime(first)
} else {
g.dateRange = formatDateTime(first) + ' - ' + formatDateTime(last)
}
}
// Sort groups: most recent message first
result.sort((a, b) => {
const aLast = new Date(a.messages[a.messages.length - 1].creation)
const bLast = new Date(b.messages[b.messages.length - 1].creation)
return bLast - aLast
})
return result
})
onMounted(async () => {
if (!store.customerId) return
try {
messages.value = await fetchAllCommunications(store.customerId)
sseConnect(['customer:' + store.customerId])
} finally {
loading.value = false
}
})
</script>
<style scoped>
.message-content {
word-break: break-word;
}
.message-content :deep(p) {
margin: 0;
}
</style>