gigafibre-fsm/apps/ops/src/pages/TelephonyPage.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

360 lines
14 KiB
Vue

<template>
<q-page padding>
<!-- Overview KPIs -->
<div class="row q-col-gutter-sm q-mb-md">
<div v-for="kpi in kpis" :key="kpi.key" class="col-6 col-md-2">
<q-card flat bordered class="kpi-card" :class="{ 'kpi-active': activeTab === kpi.key }" @click="activeTab = kpi.key">
<q-card-section class="q-pa-sm text-center">
<div class="text-h5 text-weight-bold" :style="{ color: kpi.color }">{{ overview[kpi.key] ?? '—' }}</div>
<div class="text-caption text-grey-7">{{ kpi.label }}</div>
</q-card-section>
</q-card>
</div>
</div>
<!-- Tab content -->
<q-card flat bordered>
<q-card-section class="q-pb-none">
<div class="row items-center">
<q-tabs v-model="activeTab" dense no-caps active-color="indigo-6" indicator-color="indigo-6" class="text-grey-7">
<q-tab v-for="kpi in kpis" :key="kpi.key" :name="kpi.key" :label="kpi.label" />
</q-tabs>
<q-space />
<q-btn dense unelevated color="indigo-6" icon="add" :label="'Ajouter ' + activeLabel" no-caps
@click="openCreate" class="q-ml-sm" />
<q-btn dense flat icon="refresh" @click="loadTab" class="q-ml-xs">
<q-tooltip>Rafraîchir</q-tooltip>
</q-btn>
</div>
</q-card-section>
<q-card-section>
<q-table
:rows="rows" :columns="currentColumns" row-key="ref"
flat dense class="ops-table"
:loading="tabLoading"
hide-pagination :pagination="{ rowsPerPage: 0 }"
>
<template #body-cell-actions="props">
<q-td :props="props" class="text-right">
<q-btn flat dense round icon="edit" size="sm" @click.stop="openEdit(props.row)">
<q-tooltip>Modifier</q-tooltip>
</q-btn>
<q-btn flat dense round icon="delete" size="sm" color="red-6" @click.stop="confirmDelete(props.row)">
<q-tooltip>Supprimer</q-tooltip>
</q-btn>
</q-td>
</template>
<template #body-cell-enabled="props">
<q-td :props="props">
<q-icon :name="props.value ? 'check_circle' : 'cancel'" :color="props.value ? 'green-6' : 'grey-5'" size="18px" />
</q-td>
</template>
<template #no-data>
<div class="text-center text-grey-5 q-pa-lg">
Aucun élément. Cliquez sur « Ajouter » pour créer.
</div>
</template>
</q-table>
</q-card-section>
</q-card>
<!-- Create / Edit Dialog -->
<q-dialog v-model="dialogOpen" persistent>
<q-card style="width:500px;max-width:90vw">
<q-card-section>
<div class="text-h6">{{ editingRow ? 'Modifier' : 'Créer' }} {{ activeLabel }}</div>
</q-card-section>
<q-card-section class="q-gutter-y-sm">
<template v-for="f in currentFormFields" :key="f.field">
<q-input v-if="f.type === 'text' || f.type === 'number' || f.type === 'password'"
v-model="formData[f.field]"
:label="f.label" :type="f.type === 'password' ? 'password' : f.type === 'number' ? 'number' : 'text'"
dense outlined />
<q-select v-else-if="f.type === 'select'"
v-model="formData[f.field]"
:label="f.label" :options="f.options" emit-value map-options
dense outlined />
<q-toggle v-else-if="f.type === 'toggle'"
v-model="formData[f.field]"
:label="f.label" color="indigo-6" />
</template>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" @click="dialogOpen = false" />
<q-btn unelevated color="indigo-6" :label="editingRow ? 'Sauvegarder' : 'Créer'"
:loading="saving" @click="saveForm" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Delete confirmation -->
<q-dialog v-model="deleteDialogOpen">
<q-card style="width:400px">
<q-card-section>
<div class="text-h6">Confirmer la suppression</div>
<div class="q-mt-sm">Supprimer <strong>{{ deletingRow?.name || deletingRow?.ref }}</strong> ?</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" @click="deleteDialogOpen = false" />
<q-btn unelevated color="red-6" label="Supprimer" :loading="deleting" @click="doDelete" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { Notify } from 'quasar'
const HUB_URL = (window.location.hostname === 'localhost')
? 'http://localhost:3300'
: 'https://msg.gigafibre.ca'
// ── KPI / Tabs ──
const kpis = [
{ key: 'trunks', label: 'Trunks', color: '#6366f1' },
{ key: 'agents', label: 'Agents', color: '#0ea5e9' },
{ key: 'credentials', label: 'Identifiants', color: '#8b5cf6' },
{ key: 'numbers', label: 'Numéros', color: '#10b981' },
{ key: 'domains', label: 'Domaines', color: '#f59e0b' },
{ key: 'peers', label: 'Peers', color: '#ef4444' },
]
const activeTab = ref('trunks')
const overview = ref({})
const rows = ref([])
const tabLoading = ref(false)
const activeLabel = computed(() => kpis.find(k => k.key === activeTab.value)?.label || activeTab.value)
// ── Column definitions per resource ──
const columnDefs = {
trunks: [
{ name: 'name', label: 'Nom', field: 'name', align: 'left', sortable: true },
{ name: 'inbound_uri', label: 'URI Entrant', field: 'inbound_uri', align: 'left' },
{ name: 'send_register', label: 'Register', field: 'send_register', align: 'center' },
{ name: 'enabled', label: 'Actif', field: 'enabled', align: 'center' },
{ name: 'actions', label: '', field: 'actions', align: 'right' },
],
agents: [
{ name: 'name', label: 'Nom', field: 'name', align: 'left', sortable: true },
{ name: 'username', label: 'Utilisateur', field: 'username', align: 'left' },
{ name: 'domain', label: 'Domaine', field: r => r.domain_ref || r.domain, align: 'left' },
{ name: 'enabled', label: 'Actif', field: 'enabled', align: 'center' },
{ name: 'actions', label: '', field: 'actions', align: 'right' },
],
credentials: [
{ name: 'name', label: 'Nom', field: 'name', align: 'left', sortable: true },
{ name: 'username', label: 'Utilisateur', field: 'username', align: 'left' },
{ name: 'ref', label: 'Ref', field: 'ref', align: 'left' },
{ name: 'actions', label: '', field: 'actions', align: 'right' },
],
numbers: [
{ name: 'name', label: 'Nom', field: 'name', align: 'left', sortable: true },
{ name: 'tel_url', label: 'Tel URL', field: 'tel_url', align: 'left' },
{ name: 'aor_link', label: 'AOR Link', field: 'aor_link', align: 'left' },
{ name: 'city', label: 'Ville', field: 'city', align: 'left' },
{ name: 'country', label: 'Pays', field: 'country', align: 'left' },
{ name: 'actions', label: '', field: 'actions', align: 'right' },
],
domains: [
{ name: 'name', label: 'Nom', field: 'name', align: 'left', sortable: true },
{ name: 'domain_uri', label: 'URI', field: 'domain_uri', align: 'left' },
{ name: 'ref', label: 'Ref', field: 'ref', align: 'left' },
{ name: 'actions', label: '', field: 'actions', align: 'right' },
],
peers: [
{ name: 'name', label: 'Nom', field: 'name', align: 'left', sortable: true },
{ name: 'aor', label: 'AOR', field: 'aor', align: 'left' },
{ name: 'contact_addr', label: 'Contact', field: 'contact_addr', align: 'left' },
{ name: 'enabled', label: 'Actif', field: 'enabled', align: 'center' },
{ name: 'actions', label: '', field: 'actions', align: 'right' },
],
}
const currentColumns = computed(() => columnDefs[activeTab.value] || [])
// ── Form fields per resource ──
const formFieldDefs = {
trunks: [
{ field: 'name', label: 'Nom', type: 'text' },
{ field: 'inbound_uri', label: 'URI Entrant', type: 'text' },
{ field: 'send_register', label: 'Envoyer REGISTER', type: 'toggle' },
{ field: 'inbound_credentials_ref', label: 'Ref Identifiants', type: 'text' },
{ field: 'outbound_credentials_ref', label: 'Ref Sortant', type: 'text' },
{ field: 'uris', label: 'URIs (JSON array)', type: 'text' },
{ field: 'enabled', label: 'Actif', type: 'toggle' },
],
agents: [
{ field: 'name', label: 'Nom', type: 'text' },
{ field: 'username', label: 'Utilisateur', type: 'text' },
{ field: 'domain_ref', label: 'Ref Domaine', type: 'text' },
{ field: 'credentials_ref', label: 'Ref Identifiants', type: 'text' },
{ field: 'max_contacts', label: 'Max contacts', type: 'number' },
{ field: 'enabled', label: 'Actif', type: 'toggle' },
],
credentials: [
{ field: 'name', label: 'Nom', type: 'text' },
{ field: 'username', label: 'Utilisateur', type: 'text' },
{ field: 'password', label: 'Mot de passe', type: 'password' },
],
numbers: [
{ field: 'name', label: 'Nom', type: 'text' },
{ field: 'tel_url', label: 'Tel URL (tel:+15551234567)', type: 'text' },
{ field: 'aor_link', label: 'AOR Link', type: 'text' },
{ field: 'city', label: 'Ville', type: 'text' },
{ field: 'country', label: 'Pays', type: 'text' },
{ field: 'country_iso_code', label: 'Code ISO', type: 'text' },
{ field: 'trunk_ref', label: 'Ref Trunk', type: 'text' },
],
domains: [
{ field: 'name', label: 'Nom', type: 'text' },
{ field: 'domain_uri', label: 'URI du domaine', type: 'text' },
{ field: 'rule', label: 'Règle', type: 'text' },
{ field: 'egressPolicies', label: 'Politiques sortantes (JSON)', type: 'text' },
],
peers: [
{ field: 'name', label: 'Nom', type: 'text' },
{ field: 'aor', label: 'AOR (sip:host:port)', type: 'text' },
{ field: 'contact_addr', label: 'Adresse contact', type: 'text' },
{ field: 'credentials_ref', label: 'Ref Identifiants', type: 'text' },
{ field: 'balancing_algorithm', label: 'Algorithme', type: 'select', options: [
{ label: 'Round Robin', value: 'round-robin' },
{ label: 'Least Sessions', value: 'least-sessions' },
] },
{ field: 'with_session_affinity', label: 'Affinité session', type: 'toggle' },
{ field: 'enabled', label: 'Actif', type: 'toggle' },
],
}
const currentFormFields = computed(() => formFieldDefs[activeTab.value] || [])
// ── Dialog state ──
const dialogOpen = ref(false)
const editingRow = ref(null)
const formData = ref({})
const saving = ref(false)
const deleteDialogOpen = ref(false)
const deletingRow = ref(null)
const deleting = ref(false)
// ── API helpers ──
async function hubFetch (path, opts = {}) {
const res = await fetch(HUB_URL + path, {
...opts,
headers: { 'Content-Type': 'application/json', ...opts.headers },
})
if (!res.ok) {
const txt = await res.text().catch(() => res.statusText)
throw new Error(txt || `HTTP ${res.status}`)
}
return res.json()
}
async function loadOverview () {
try {
overview.value = await hubFetch('/telephony/overview')
} catch (e) {
console.error('Telephony overview error:', e)
}
}
async function loadTab () {
tabLoading.value = true
try {
const data = await hubFetch(`/telephony/${activeTab.value}`)
rows.value = data.items || []
} catch (e) {
console.error(`Load ${activeTab.value} error:`, e)
rows.value = []
}
tabLoading.value = false
}
function openCreate () {
editingRow.value = null
formData.value = {}
// Set default toggles
for (const f of currentFormFields.value) {
if (f.type === 'toggle') formData.value[f.field] = true
else if (f.type === 'number') formData.value[f.field] = 0
else formData.value[f.field] = ''
}
dialogOpen.value = true
}
function openEdit (row) {
editingRow.value = row
formData.value = { ...row }
dialogOpen.value = true
}
async function saveForm () {
saving.value = true
try {
const body = { ...formData.value }
if (editingRow.value) {
await hubFetch(`/telephony/${activeTab.value}/${editingRow.value.ref}`, {
method: 'PUT',
body: JSON.stringify(body),
})
Notify.create({ type: 'positive', message: 'Sauvegardé', timeout: 2000 })
} else {
await hubFetch(`/telephony/${activeTab.value}`, {
method: 'POST',
body: JSON.stringify(body),
})
Notify.create({ type: 'positive', message: 'Créé avec succès', timeout: 2000 })
}
dialogOpen.value = false
await Promise.all([loadTab(), loadOverview()])
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
}
saving.value = false
}
function confirmDelete (row) {
deletingRow.value = row
deleteDialogOpen.value = true
}
async function doDelete () {
if (!deletingRow.value) return
deleting.value = true
try {
await hubFetch(`/telephony/${activeTab.value}/${deletingRow.value.ref}`, { method: 'DELETE' })
Notify.create({ type: 'positive', message: 'Supprimé', timeout: 2000 })
deleteDialogOpen.value = false
await Promise.all([loadTab(), loadOverview()])
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
}
deleting.value = false
}
// ── Lifecycle ──
watch(activeTab, () => loadTab())
onMounted(async () => {
await Promise.all([loadOverview(), loadTab()])
})
</script>
<style scoped>
.kpi-card {
cursor: pointer;
transition: all 0.15s;
border: 2px solid transparent;
}
.kpi-card:hover {
border-color: #c7d2fe;
}
.kpi-active {
border-color: #6366f1 !important;
background: #eef2ff;
}
</style>