gigafibre-fsm/apps/ops/src/components/customer/PhoneModal.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

697 lines
21 KiB
Vue

<template>
<teleport to="body">
<transition name="slide-up">
<div v-if="show" class="phone-modal" :class="{ 'phone-minimized': minimized }">
<!-- Minimized bar -->
<div v-if="minimized" class="phone-mini-bar" @click="minimized = false">
<q-icon name="phone_in_talk" size="18px" color="green" />
<span class="text-weight-medium q-ml-xs">{{ dialNumber || 'Telephone' }}</span>
<q-space />
<span class="text-caption text-green-7">{{ formattedDuration }}</span>
<q-icon name="open_in_full" size="14px" color="grey-6" class="q-ml-sm" />
</div>
<!-- Full panel -->
<div v-else class="phone-panel">
<!-- Header -->
<div class="phone-header">
<q-icon name="phone" size="20px" color="indigo-6" class="q-mr-xs" />
<span class="text-weight-bold" style="font-size:1rem">Telephone</span>
<q-space />
<q-badge v-if="deviceReady" color="green" class="q-mr-sm">
{{ provider === 'sip' ? 'SIP' : 'Twilio' }}
</q-badge>
<q-badge v-else-if="deviceError" color="red" class="q-mr-sm">Erreur</q-badge>
<q-badge v-else color="orange" class="q-mr-sm">
<q-spinner-dots size="10px" class="q-mr-xs" />Init
</q-badge>
<q-btn flat dense round size="xs" icon="minimize" color="grey-6" @click="minimized = true" />
<q-btn flat dense round size="xs" icon="close" color="grey-6" @click="close" />
</div>
<!-- Customer context -->
<div v-if="customerName" class="phone-contact">
<q-icon name="person" size="18px" color="indigo-5" />
<span class="q-ml-xs text-weight-medium">{{ customerName }}</span>
</div>
<!-- Error banner -->
<div v-if="deviceError" class="phone-error">
<q-icon name="error_outline" size="16px" color="red" class="q-mr-xs" />
<span class="text-caption text-red">{{ deviceError }}</span>
</div>
<!-- Dialer (before call) -->
<div v-if="!inCall" class="phone-dialer">
<q-input v-model="dialNumber" dense outlined placeholder="+1 514 555 1234"
:input-style="{ fontSize: '1.1rem', textAlign: 'center', letterSpacing: '1px' }"
class="q-mb-sm" @keydown.enter="startCall">
<template #prepend><q-icon name="dialpad" color="grey-5" /></template>
<template #append>
<q-icon v-if="dialNumber" name="backspace" color="grey-5" class="cursor-pointer"
@click="dialNumber = dialNumber.slice(0, -1)" size="18px" />
</template>
</q-input>
<!-- Dialpad -->
<div class="dialpad q-mb-sm">
<q-btn v-for="key in dialpadKeys" :key="key" flat dense class="dialpad-key"
:label="key" @click="pressKey(key)" />
</div>
<div class="flex flex-center q-gutter-sm">
<q-btn round color="green" icon="call" size="lg"
:disable="!dialNumber.trim() || !deviceReady"
:loading="connecting" @click="startCall">
<q-tooltip>Appeler via WebRTC</q-tooltip>
</q-btn>
</div>
</div>
<!-- In-call controls -->
<div v-if="inCall" class="phone-call-view">
<div class="call-status-bar">
<div class="call-info">
<div class="text-weight-bold" style="font-size:1.1rem">{{ dialNumber }}</div>
<div class="text-caption" :class="connected ? 'text-green' : 'text-orange'">
<q-spinner-dots v-if="!connected" size="12px" class="q-mr-xs" />
{{ connected ? formattedDuration : callStatusText }}
</div>
</div>
</div>
<!-- In-call actions -->
<div class="call-controls">
<div class="control-row">
<div class="control-btn" @click="toggleMute">
<q-btn round flat :icon="muted ? 'mic_off' : 'mic'" size="md"
:color="muted ? 'red' : 'grey-7'" />
<span class="text-caption">{{ muted ? 'Unmute' : 'Mute' }}</span>
</div>
<div class="control-btn" @click="toggleDialpad">
<q-btn round flat icon="dialpad" size="md" color="grey-7" />
<span class="text-caption">Clavier</span>
</div>
<div class="control-btn" @click="toggleSpeaker">
<q-btn round flat :icon="speakerOn ? 'volume_up' : 'volume_off'" size="md"
:color="speakerOn ? 'indigo-6' : 'grey-7'" />
<span class="text-caption">HP</span>
</div>
</div>
<!-- DTMF dialpad (toggled) -->
<div v-if="showDtmf" class="dialpad q-my-sm">
<q-btn v-for="key in dialpadKeys" :key="'dtmf-'+key" flat dense class="dialpad-key"
:label="key" @click="sendDtmf(key)" />
</div>
<!-- Hangup -->
<div class="flex flex-center q-mt-sm">
<q-btn round color="red" icon="call_end" size="lg" @click="endCall" />
</div>
</div>
</div>
<!-- Call ended — log bar -->
<div v-if="callEnded" class="phone-log-bar">
<q-icon name="check_circle" size="18px" color="green" class="q-mr-xs" />
<span class="text-caption">Appel termine — {{ formattedDuration }}</span>
<q-space />
<q-btn dense unelevated size="sm" color="indigo-6" icon="save" label="Logger"
@click="logCall" :loading="loggingCall" />
</div>
<!-- Footer -->
<div class="phone-footer">
<q-icon name="circle" size="8px" :color="deviceReady ? 'green' : 'grey-4'" class="q-mr-xs" />
<span class="text-caption text-grey-6">
{{ provider === 'sip' ? 'Fonoster SIP' : 'Twilio WebRTC' }} {{ identity }}
</span>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<script setup>
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import { Notify } from 'quasar'
import { createDoc } from 'src/api/erp'
import { Device } from '@twilio/voice-sdk'
import { UserAgent, Registerer, Inviter, SessionState } from 'sip.js'
const HUB_URL = (window.location.hostname === 'localhost')
? 'http://localhost:3300'
: 'https://msg.gigafibre.ca'
const SIP_WSS_URL = 'wss://voice.gigafibre.ca/ws' // Routr WSS endpoint
const props = defineProps({
modelValue: { type: Boolean, default: false },
initialNumber: { type: String, default: '' },
customerName: { type: String, default: '' },
customerErpName: { type: String, default: '' },
provider: { type: String, default: 'twilio' }, // 'twilio' or 'sip'
})
const emit = defineEmits(['update:modelValue', 'call-ended'])
// UI state
const show = ref(props.modelValue)
const minimized = ref(false)
const dialNumber = ref(props.initialNumber || '')
const inCall = ref(false)
const connected = ref(false)
const connecting = ref(false)
const callEnded = ref(false)
const muted = ref(false)
const speakerOn = ref(true)
const showDtmf = ref(false)
const loggingCall = ref(false)
const callStatusText = ref('Connexion...')
// Twilio state
const deviceReady = ref(false)
const deviceError = ref('')
const identity = ref('')
const callStartTime = ref(null)
const callDurationSec = ref(0)
let durationTimer = null
let device = null // Twilio Device
let activeCall = null // Twilio Call or SIP Session
let sipUA = null // SIP.js UserAgent
let sipRegisterer = null
let audioElement = null
const dialpadKeys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']
watch(() => props.modelValue, (v) => { show.value = v })
watch(show, (v) => {
emit('update:modelValue', v)
if (v) initDevice()
})
watch(() => props.provider, () => {
// Provider changed — re-init on next open
cleanupDevices()
})
watch(() => props.initialNumber, (v) => { if (v) dialNumber.value = v })
const formattedDuration = computed(() => {
const m = Math.floor(callDurationSec.value / 60)
const s = callDurationSec.value % 60
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
})
// ── Initialize Device (Twilio or SIP) ──
async function initDevice () {
if (props.provider === 'sip') {
if (sipUA) return
await initSipDevice()
} else {
if (device) return
await initTwilioDevice()
}
}
async function initTwilioDevice () {
deviceError.value = ''
try {
const res = await fetch(HUB_URL + '/voice/token')
if (!res.ok) throw new Error('Token fetch failed: ' + res.status)
const data = await res.json()
identity.value = data.identity
device = new Device(data.token, {
edge: 'ashburn',
logLevel: 'warn',
codecPreferences: ['opus', 'pcmu'],
})
device.on('registered', () => { deviceReady.value = true; deviceError.value = '' })
device.on('error', (err) => { deviceError.value = err.message || 'Device error' })
device.on('tokenWillExpire', async () => {
try {
const r = await fetch(HUB_URL + '/voice/token')
const d = await r.json()
device.updateToken(d.token)
} catch {}
})
await device.register()
deviceReady.value = true
device.on('incoming', (call) => {
show.value = true; minimized.value = false
dialNumber.value = call.parameters.From || 'Inconnu'
Notify.create({
type: 'info', message: `Appel entrant de ${dialNumber.value}`,
actions: [
{ label: 'Repondre', color: 'green', handler: () => answerIncoming(call) },
{ label: 'Refuser', color: 'red', handler: () => call.reject() },
],
timeout: 30000,
})
})
} catch (e) {
deviceError.value = e.message
console.error('[PhoneModal] Twilio init failed:', e)
}
}
async function initSipDevice () {
deviceError.value = ''
try {
// Get SIP credentials from Fonoster via targo-hub
const res = await fetch(HUB_URL + '/voice/sip-config')
if (!res.ok) throw new Error('SIP config fetch failed: ' + res.status)
const cfg = await res.json()
identity.value = cfg.extension || cfg.identity || 'sip-agent'
const sipDomain = cfg.domain || 'voice.gigafibre.ca'
const uri = UserAgent.makeURI(`sip:${cfg.extension}@${sipDomain}`)
if (!uri) throw new Error('Invalid SIP URI')
sipUA = new UserAgent({
uri,
transportOptions: { server: cfg.wssUrl || SIP_WSS_URL },
authorizationUsername: cfg.authId || cfg.extension,
authorizationPassword: cfg.authPassword,
displayName: cfg.displayName || 'Targo Ops',
logLevel: 'warn',
delegate: {
onInvite: (invitation) => {
show.value = true; minimized.value = false
const remote = invitation.remoteIdentity?.uri?.user || 'Inconnu'
dialNumber.value = remote
Notify.create({
type: 'info', message: `Appel SIP entrant de ${remote}`,
actions: [
{ label: 'Repondre', color: 'green', handler: () => answerSipIncoming(invitation) },
{ label: 'Refuser', color: 'red', handler: () => { try { invitation.reject() } catch {} } },
],
timeout: 30000,
})
},
},
})
await sipUA.start()
sipRegisterer = new Registerer(sipUA)
await sipRegisterer.register()
deviceReady.value = true
} catch (e) {
deviceError.value = e.message
console.error('[PhoneModal] SIP init failed:', e)
}
}
function cleanupDevices () {
deviceReady.value = false
deviceError.value = ''
if (device) { try { device.unregister(); device.destroy() } catch {} device = null }
if (sipRegisterer) { try { sipRegisterer.unregister() } catch {} sipRegisterer = null }
if (sipUA) { try { sipUA.stop() } catch {} sipUA = null }
if (audioElement) { audioElement.srcObject = null }
}
function getAudioElement () {
if (!audioElement) {
audioElement = document.createElement('audio')
audioElement.autoplay = true
document.body.appendChild(audioElement)
}
return audioElement
}
function answerIncoming (call) {
activeCall = call
setupCallListeners(call)
call.accept()
inCall.value = true
connected.value = true
callEnded.value = false
startDurationTimer()
}
function answerSipIncoming (invitation) {
activeCall = invitation
setupSipSessionListeners(invitation)
invitation.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } })
inCall.value = true
callEnded.value = false
}
function setupSipSessionListeners (session) {
session.stateChange.addListener((state) => {
if (state === SessionState.Established) {
connected.value = true; connecting.value = false
startDurationTimer()
// Attach remote audio
const pc = session.sessionDescriptionHandler?.peerConnection
if (pc) {
const audio = getAudioElement()
pc.getReceivers().forEach((r) => {
if (r.track?.kind === 'audio') audio.srcObject = new MediaStream([r.track])
})
}
} else if (state === SessionState.Terminated) {
stopDurationTimer()
inCall.value = false; connected.value = false; connecting.value = false
callEnded.value = true; muted.value = false; showDtmf.value = false
activeCall = null
if (audioElement) audioElement.srcObject = null
}
})
}
// ── Make Call ──
async function startCall () {
if (!dialNumber.value.trim() || !deviceReady.value) return
connecting.value = true
callEnded.value = false
callStatusText.value = 'Connexion...'
// Normalize number
let number = dialNumber.value.trim().replace(/[\s\-\(\)]/g, '')
if (!number.startsWith('+')) {
if (number.length === 10) number = '+1' + number
else if (number.length === 11 && number.startsWith('1')) number = '+' + number
else number = '+' + number
}
if (props.provider === 'sip') {
await startSipCall(number)
} else {
await startTwilioCall(number)
}
}
async function startTwilioCall (number) {
try {
activeCall = await device.connect({ params: { To: number } })
setupCallListeners(activeCall)
inCall.value = true
} catch (e) {
deviceError.value = 'Appel echoue: ' + e.message
connecting.value = false
}
}
async function startSipCall (number) {
if (!sipUA) { deviceError.value = 'SIP not registered'; connecting.value = false; return }
try {
const domain = 'voice.gigafibre.ca'
const target = UserAgent.makeURI(`sip:${number}@${domain}`)
if (!target) throw new Error('Invalid SIP target')
const inviter = new Inviter(sipUA, target, {
sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } },
})
activeCall = inviter
setupSipSessionListeners(inviter)
await inviter.invite()
inCall.value = true
} catch (e) {
deviceError.value = 'SIP call failed: ' + e.message
connecting.value = false
}
}
function setupCallListeners (call) {
call.on('ringing', () => {
callStatusText.value = 'Sonnerie...'
connecting.value = false
})
call.on('accept', () => {
connected.value = true
connecting.value = false
callStatusText.value = ''
startDurationTimer()
})
call.on('disconnect', () => {
stopDurationTimer()
inCall.value = false
connected.value = false
connecting.value = false
callEnded.value = true
muted.value = false
showDtmf.value = false
activeCall = null
})
call.on('cancel', () => {
inCall.value = false
connected.value = false
connecting.value = false
callEnded.value = false
activeCall = null
})
call.on('error', (err) => {
deviceError.value = err.message || 'Call error'
inCall.value = false
connected.value = false
connecting.value = false
activeCall = null
})
}
// ── Call Controls ──
function endCall () {
if (!activeCall) return
if (props.provider === 'sip') {
try {
if (activeCall.state === SessionState.Established) activeCall.bye()
else activeCall.cancel?.() || activeCall.reject?.()
} catch { /* session already terminated */ }
} else {
activeCall.disconnect()
}
}
function toggleMute () {
if (!activeCall) return
muted.value = !muted.value
if (props.provider === 'sip') {
const pc = activeCall.sessionDescriptionHandler?.peerConnection
if (pc) pc.getSenders().forEach(s => { if (s.track?.kind === 'audio') s.track.enabled = !muted.value })
} else {
activeCall.mute(muted.value)
}
}
function toggleSpeaker () {
speakerOn.value = !speakerOn.value
// Browser doesn't support speaker toggle natively — this is a visual indicator
}
function toggleDialpad () {
showDtmf.value = !showDtmf.value
}
function sendDtmf (digit) {
if (!activeCall) return
if (props.provider === 'sip') {
try { activeCall.sessionDescriptionHandler?.sendDtmf(digit) } catch {}
} else {
activeCall.sendDigits(digit)
}
}
function pressKey (key) {
dialNumber.value += key
}
// ── Duration Timer ──
function startDurationTimer () {
callStartTime.value = Date.now()
callDurationSec.value = 0
durationTimer = setInterval(() => {
if (callStartTime.value) {
callDurationSec.value = Math.floor((Date.now() - callStartTime.value) / 1000)
}
}, 1000)
}
function stopDurationTimer () {
if (durationTimer) {
clearInterval(durationTimer)
durationTimer = null
}
}
// ── Log Call ──
async function logCall () {
loggingCall.value = true
const phone = dialNumber.value.trim()
const duration = callDurationSec.value
const durationMin = Math.floor(duration / 60)
const durationSec = duration % 60
const durationStr = `${durationMin}m${durationSec.toString().padStart(2, '0')}s`
try {
await createDoc('Communication', {
subject: 'Appel vers ' + phone,
communication_type: 'Communication',
communication_medium: 'Phone',
sent_or_received: 'Sent',
status: 'Linked',
phone_no: phone,
sender: 'sms@gigafibre.ca',
sender_full_name: 'Targo Ops',
content: `Appel vers ${phone} — Duree: ${durationStr}`,
reference_doctype: 'Customer',
reference_name: props.customerErpName || props.customerName,
})
Notify.create({ type: 'positive', message: 'Appel enregistre', timeout: 2000 })
emit('call-ended', { direction: 'out', remote: phone, duration })
callEnded.value = false
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
} finally {
loggingCall.value = false
}
}
// ── Close ──
function close () {
if (inCall.value) {
minimized.value = true
return
}
stopDurationTimer()
show.value = false
inCall.value = false
connected.value = false
callEnded.value = false
callStartTime.value = null
callDurationSec.value = 0
}
// ── Lifecycle ──
onMounted(() => {
if (show.value) initDevice()
})
onUnmounted(() => {
stopDurationTimer()
cleanupDevices()
})
</script>
<style scoped>
.phone-modal {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 9998;
width: 340px;
background: white;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.phone-minimized {
width: 260px;
border-radius: 24px;
}
.phone-mini-bar {
display: flex;
align-items: center;
padding: 10px 16px;
cursor: pointer;
}
.phone-mini-bar:hover { background: #f5f5f5; }
.phone-panel { display: flex; flex-direction: column; }
.phone-header {
display: flex;
align-items: center;
padding: 10px 12px 8px;
border-bottom: 1px solid #f0f0f0;
}
.phone-contact {
display: flex;
align-items: center;
padding: 6px 16px;
background: #f1f5f9;
font-size: 0.88rem;
}
.phone-error {
display: flex;
align-items: center;
padding: 6px 12px;
background: #fef2f2;
}
.phone-dialer { padding: 12px 16px; }
.dialpad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2px;
max-width: 200px;
margin: 0 auto;
}
.dialpad-key {
font-size: 1.1rem !important;
font-weight: 600;
min-height: 38px;
border-radius: 50%;
}
.phone-call-view { display: flex; flex-direction: column; }
.call-status-bar {
display: flex;
align-items: center;
padding: 16px;
background: linear-gradient(135deg, #f0fdf0, #e8f5e9);
}
.call-info { flex: 1; text-align: center; }
.call-controls { padding: 12px 16px; }
.control-row {
display: flex;
justify-content: space-around;
align-items: center;
}
.control-btn {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
gap: 2px;
}
.control-btn .text-caption { font-size: 0.7rem; color: #666; }
.phone-log-bar {
display: flex;
align-items: center;
padding: 8px 12px;
background: #f0fdf0;
border-top: 1px solid #e0e0e0;
}
.phone-footer {
display: flex;
align-items: center;
padding: 5px 12px;
border-top: 1px solid #f0f0f0;
background: #fafafa;
}
.slide-up-enter-active, .slide-up-leave-active { transition: all 0.3s ease; }
.slide-up-enter-from, .slide-up-leave-to { transform: translateY(20px); opacity: 0; }
</style>