- 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>
304 lines
12 KiB
Vue
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>
|