- 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>
229 lines
7.8 KiB
Vue
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' }}
|
|
· {{ 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>
|