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

304 lines
12 KiB
Vue

<template>
<q-page padding>
<div class="text-h5 q-mb-md">
<q-icon name="settings" class="q-mr-sm" /> Paramètres
</div>
<div v-if="loading" class="flex flex-center q-pa-xl">
<q-spinner size="40px" color="indigo-6" />
</div>
<template v-else>
<!-- SMS / Twilio -->
<div class="ops-card q-mb-md">
<div class="section-title q-mb-sm">
<q-icon name="sms" size="20px" class="q-mr-xs" /> SMS — Twilio
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-input v-model="settings.twilio_account_sid" label="Account SID" outlined dense
placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
@blur="save('twilio_account_sid')" />
</div>
<div class="col-12 col-md-6">
<q-input v-model="settings.twilio_auth_token" label="Auth Token" outlined dense
:type="showToken ? 'text' : 'password'" placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
@blur="save('twilio_auth_token')">
<template #append>
<q-icon :name="showToken ? 'visibility_off' : 'visibility'" class="cursor-pointer"
@click="showToken = !showToken" />
</template>
</q-input>
</div>
<div class="col-12 col-md-6">
<q-input v-model="settings.twilio_from_number" label="Numéro d'envoi (From)" outlined dense
placeholder="+15145551234"
@blur="save('twilio_from_number')" />
</div>
<div class="col-12 col-md-6">
<div class="row items-center q-gutter-sm">
<q-btn color="indigo-6" icon="send" label="Tester SMS" :loading="testingSms" dense unelevated
@click="testSms" :disable="!settings.twilio_account_sid || !settings.twilio_from_number" />
<q-badge v-if="smsTestResult" :color="smsTestResult.ok ? 'green' : 'red'" class="q-pa-xs">
{{ smsTestResult.message }}
</q-badge>
</div>
</div>
</div>
<div class="text-caption text-grey-6 q-mt-sm">
Les credentials Twilio permettent l'envoi de SMS depuis la fiche client.
<a href="https://console.twilio.com" target="_blank">Console Twilio →</a>
</div>
</div>
<!-- SMS Templates -->
<div class="ops-card q-mb-md">
<div class="section-title q-mb-sm">
<q-icon name="textsms" size="20px" class="q-mr-xs" /> Templates SMS
</div>
<div class="row q-col-gutter-md">
<div class="col-12">
<q-input v-model="settings.sms_enroute" label="Technicien en route" outlined dense autogrow
@blur="save('sms_enroute')" />
</div>
<div class="col-12">
<q-input v-model="settings.sms_completed" label="Service complété" outlined dense autogrow
@blur="save('sms_completed')" />
</div>
<div class="col-12">
<q-input v-model="settings.sms_assigned" label="Job assigné (technicien)" outlined dense autogrow
@blur="save('sms_assigned')" />
</div>
</div>
<div class="text-caption text-grey-6 q-mt-sm">
Variables: <code>{client_name}</code>, <code>{tech_name}</code>, <code>{eta}</code>, <code>{job_id}</code>, <code>{address}</code>, <code>{duration}</code>
</div>
</div>
<!-- Email / SMTP -->
<div class="ops-card q-mb-md">
<div class="section-title q-mb-sm">
<q-icon name="email" size="20px" class="q-mr-xs" /> Email — SMTP
</div>
<div class="text-caption text-grey-6">
La configuration SMTP (Mailjet) se fait dans ERPNext.
<a :href="erpDeskUrl + '/app/email-account'" target="_blank">Configurer →</a>
</div>
</div>
<!-- n8n / Webhooks -->
<div class="ops-card q-mb-md">
<div class="section-title q-mb-sm">
<q-icon name="webhook" size="20px" class="q-mr-xs" /> n8n — Webhooks
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-input v-model="settings.n8n_url" label="n8n URL" outlined dense
@blur="save('n8n_url')" />
</div>
<div class="col-12 col-md-6">
<q-input v-model="settings.n8n_webhook_base" label="Webhook base URL" outlined dense
@blur="save('n8n_webhook_base')" />
</div>
</div>
</div>
<!-- Stripe -->
<div class="ops-card q-mb-md">
<div class="section-title q-mb-sm">
<q-icon name="credit_card" size="20px" class="q-mr-xs" /> Stripe — Paiements
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<q-select v-model="settings.stripe_mode" label="Mode" outlined dense emit-value map-options
:options="[{ label: 'Test', value: 'test' }, { label: 'Live', value: 'live' }]"
@update:model-value="save('stripe_mode')" />
</div>
<div class="col-12 col-md-4">
<q-input v-model="settings.stripe_publishable_key" label="Publishable Key" outlined dense
@blur="save('stripe_publishable_key')" />
</div>
<div class="col-12 col-md-4">
<q-input v-model="settings.stripe_secret_key" label="Secret Key" outlined dense
type="password" @blur="save('stripe_secret_key')" />
</div>
</div>
</div>
<!-- Mapbox -->
<div class="ops-card q-mb-md">
<div class="section-title q-mb-sm">
<q-icon name="map" size="20px" class="q-mr-xs" /> Mapbox
</div>
<div class="row q-col-gutter-md">
<div class="col-12">
<q-input v-model="settings.mapbox_token" label="Mapbox Token" outlined dense
@blur="save('mapbox_token')" />
</div>
</div>
</div>
<!-- 3CX Phone / WebRTC -->
<div class="ops-card q-mb-md">
<div class="section-title q-mb-sm">
<q-icon name="phone" size="20px" class="q-mr-xs" /> 3CX — Telephone WebRTC
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-input v-model="phoneConfig.wssUrl" label="WSS URL (SBC)" outlined dense
placeholder="wss://targopbx.3cx.ca/wss" hint="WebSocket SBC endpoint"
@blur="savePhone" />
</div>
<div class="col-12 col-md-6">
<q-input v-model="phoneConfig.sipDomain" label="SIP Domain" outlined dense
placeholder="targopbx.3cx.ca"
@blur="savePhone" />
</div>
<div class="col-12">
<div class="text-caption text-grey-7 q-mb-xs">
Connectez-vous avec vos identifiants 3CX pour recuperer automatiquement vos credentials SIP.
</div>
<div class="row q-gutter-sm items-end">
<q-input v-model="pbxUsername" label="Email 3CX" outlined dense style="width:200px" />
<q-input v-model="pbxPassword" label="Mot de passe" outlined dense type="password" style="width:200px"
@keydown.enter="login3cx" />
<q-btn color="indigo-6" label="Connecter" dense unelevated :loading="loggingIn3cx" @click="login3cx" />
</div>
</div>
<div v-if="phoneConfig.extension" class="col-12">
<div class="row q-gutter-md">
<q-input v-model="phoneConfig.extension" label="Extension" outlined dense readonly style="width:100px" />
<q-input v-model="phoneConfig.authId" label="Auth ID" outlined dense readonly style="width:160px" />
<q-input :model-value="phoneConfig.authPassword ? '••••••••' : ''" label="Auth Password" outlined dense readonly style="width:160px" />
<q-input v-model="phoneConfig.displayName" label="Nom" outlined dense readonly style="width:160px" />
</div>
<q-badge color="green" class="q-mt-xs">SIP credentials OK — Extension {{ phoneConfig.extension }}</q-badge>
</div>
</div>
<div class="text-caption text-grey-6 q-mt-sm">
Le SBC doit etre active dans 3CX Admin → Settings pour les appels WebRTC.
<a href="https://targopbx.3cx.ca" target="_blank">3CX Admin →</a>
</div>
</div>
<!-- ERPNext Links -->
<div class="ops-card">
<div class="section-title q-mb-sm">
<q-icon name="launch" size="20px" class="q-mr-xs" /> Liens rapides
</div>
<div class="row q-gutter-sm">
<q-btn flat dense icon="open_in_new" label="ERPNext Desk" :href="erpDeskUrl" target="_blank" />
<q-btn flat dense icon="open_in_new" label="Dispatch Settings"
:href="erpDeskUrl + '/app/dispatch-settings'" target="_blank" />
<q-btn flat dense icon="open_in_new" label="n8n"
:href="'https://n8n.gigafibre.ca'" target="_blank" />
<q-btn flat dense icon="open_in_new" label="Authentik"
:href="'https://auth.targo.ca'" target="_blank" />
<q-btn flat dense icon="open_in_new" label="Twilio Console"
href="https://console.twilio.com" target="_blank" />
</div>
</div>
</template>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Notify } from 'quasar'
import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
import { ERP_DESK_URL as erpDeskUrl } from 'src/config/erpnext'
import { sendTestSms } from 'src/api/sms'
import { getPhoneConfig, savePhoneConfig, fetch3cxCredentials } from 'src/composables/usePhone'
const loading = ref(true)
const settings = ref({})
const showToken = ref(false)
const testingSms = ref(false)
const smsTestResult = ref(null)
// 3CX Phone config
const phoneConfig = ref(getPhoneConfig())
const pbxUsername = ref('')
const pbxPassword = ref('')
const loggingIn3cx = ref(false)
function savePhone () {
savePhoneConfig(phoneConfig.value)
Notify.create({ type: 'positive', message: 'Config telephone sauvegardee', timeout: 1500 })
}
async function login3cx () {
if (!pbxUsername.value || !pbxPassword.value) return
loggingIn3cx.value = true
try {
const creds = await fetch3cxCredentials(pbxUsername.value, pbxPassword.value)
phoneConfig.value.extension = creds.extension
phoneConfig.value.authId = creds.authId
phoneConfig.value.authPassword = creds.authPassword
phoneConfig.value.displayName = creds.displayName
savePhoneConfig(phoneConfig.value)
pbxPassword.value = ''
Notify.create({ type: 'positive', message: `Connecte — Extension ${creds.extension} (${creds.displayName})`, timeout: 3000 })
} catch (e) {
Notify.create({ type: 'negative', message: '3CX login echoue: ' + e.message, timeout: 4000 })
} finally {
loggingIn3cx.value = false
}
}
// Snapshot for change detection
const snapshots = {}
onMounted(async () => {
try {
const res = await authFetch(BASE_URL + '/api/resource/Dispatch Settings/Dispatch Settings')
if (!res.ok) throw new Error('Failed to load settings')
const json = await res.json()
settings.value = json.data
// Snapshot all fields
for (const key of Object.keys(json.data)) {
snapshots[key] = json.data[key] ?? ''
}
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur chargement paramètres: ' + e.message })
} finally {
loading.value = false
}
})
async function save (field) {
const val = settings.value[field] ?? ''
const prev = snapshots[field] ?? ''
if (val === prev) return
snapshots[field] = val
try {
const res = await authFetch(BASE_URL + '/api/resource/Dispatch Settings/Dispatch Settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: val }),
})
if (!res.ok) throw new Error('Save failed: ' + res.status)
Notify.create({ type: 'positive', message: 'Sauvegardé', timeout: 1500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
settings.value[field] = prev
snapshots[field] = prev
}
}
async function testSms () {
testingSms.value = true
smsTestResult.value = null
try {
const result = await sendTestSms('+15149490739', 'Test SMS depuis Targo Ops', '')
smsTestResult.value = { ok: true, message: result.simulated ? 'Simulé (pas envoyé)' : 'SMS envoyé ✓' }
} catch (e) {
smsTestResult.value = { ok: false, message: e.message }
} finally {
testingSms.value = false
}
}
</script>