- 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>
360 lines
14 KiB
Vue
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>
|