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>
This commit is contained in:
parent
413e15b16c
commit
4693bcf60c
|
|
@ -116,3 +116,143 @@ export async function fetchAddresses (customer) {
|
||||||
const data = await apiGet(path)
|
const data = await apiGet(path)
|
||||||
return data.data || []
|
return data.data || []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single Sales Invoice with line items.
|
||||||
|
*/
|
||||||
|
export async function fetchInvoice (invoiceName) {
|
||||||
|
const data = await apiGet(`/api/resource/Sales Invoice/${encodeURIComponent(invoiceName)}`)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch invoice HTML print preview (Jinja rendered).
|
||||||
|
*/
|
||||||
|
export async function fetchInvoiceHTML (invoiceName, format = 'Facture TARGO') {
|
||||||
|
const url = `${BASE_URL}/api/method/frappe.www.printview.get_html_and_style?doc=Sales Invoice&name=${encodeURIComponent(invoiceName)}&print_format=${encodeURIComponent(format)}&no_letterhead=0`
|
||||||
|
const res = await authFetch(url)
|
||||||
|
if (!res.ok) throw new Error('Print preview failed')
|
||||||
|
const data = await res.json()
|
||||||
|
return data.message || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single Issue with full details.
|
||||||
|
*/
|
||||||
|
export async function fetchTicket (ticketName) {
|
||||||
|
const data = await apiGet(`/api/resource/Issue/${encodeURIComponent(ticketName)}`)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Communications linked to a reference (Issue or Customer).
|
||||||
|
*/
|
||||||
|
export async function fetchCommunications (refDoctype, refName, limit = 100) {
|
||||||
|
const filters = JSON.stringify([
|
||||||
|
['reference_doctype', '=', refDoctype],
|
||||||
|
['reference_name', '=', refName],
|
||||||
|
])
|
||||||
|
const fields = JSON.stringify([
|
||||||
|
'name', 'communication_medium', 'communication_type',
|
||||||
|
'sent_or_received', 'sender', 'sender_full_name', 'phone_no',
|
||||||
|
'subject', 'content', 'creation', 'status',
|
||||||
|
'reference_doctype', 'reference_name',
|
||||||
|
])
|
||||||
|
const path = `/api/resource/Communication?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&order_by=creation asc&limit_page_length=${limit}`
|
||||||
|
const data = await apiGet(path)
|
||||||
|
return data.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all Communications for a customer (across all references + direct).
|
||||||
|
* Returns Communications where reference is Customer or Issue belonging to customer.
|
||||||
|
*/
|
||||||
|
export async function fetchAllCommunications (customer, limit = 200) {
|
||||||
|
// Direct communications on customer
|
||||||
|
const directFilters = JSON.stringify([
|
||||||
|
['reference_doctype', '=', 'Customer'],
|
||||||
|
['reference_name', '=', customer],
|
||||||
|
])
|
||||||
|
const fields = JSON.stringify([
|
||||||
|
'name', 'communication_medium', 'communication_type',
|
||||||
|
'sent_or_received', 'sender', 'sender_full_name', 'phone_no',
|
||||||
|
'subject', 'content', 'creation', 'status',
|
||||||
|
'reference_doctype', 'reference_name',
|
||||||
|
])
|
||||||
|
const directPath = `/api/resource/Communication?filters=${encodeURIComponent(directFilters)}&fields=${encodeURIComponent(fields)}&order_by=creation desc&limit_page_length=${limit}`
|
||||||
|
const directData = await apiGet(directPath)
|
||||||
|
|
||||||
|
// Communications on customer's Issues
|
||||||
|
const issueFilters = JSON.stringify([
|
||||||
|
['reference_doctype', '=', 'Issue'],
|
||||||
|
['reference_name', 'in', []], // will be filled after ticket fetch
|
||||||
|
])
|
||||||
|
// First get the customer's ticket names
|
||||||
|
const ticketFilters = JSON.stringify([['customer', '=', customer]])
|
||||||
|
const ticketFields = JSON.stringify(['name'])
|
||||||
|
const ticketPath = `/api/resource/Issue?filters=${encodeURIComponent(ticketFilters)}&fields=${encodeURIComponent(ticketFields)}&limit_page_length=200`
|
||||||
|
const ticketData = await apiGet(ticketPath)
|
||||||
|
const ticketNames = (ticketData.data || []).map(t => t.name)
|
||||||
|
|
||||||
|
let issueComs = []
|
||||||
|
if (ticketNames.length) {
|
||||||
|
const issueComsFilters = JSON.stringify([
|
||||||
|
['reference_doctype', '=', 'Issue'],
|
||||||
|
['reference_name', 'in', ticketNames],
|
||||||
|
])
|
||||||
|
const issueComsPath = `/api/resource/Communication?filters=${encodeURIComponent(issueComsFilters)}&fields=${encodeURIComponent(fields)}&order_by=creation desc&limit_page_length=${limit}`
|
||||||
|
const issueComsData = await apiGet(issueComsPath)
|
||||||
|
issueComs = issueComsData.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge, deduplicate, sort desc
|
||||||
|
const all = [...(directData.data || []), ...issueComs]
|
||||||
|
const seen = new Set()
|
||||||
|
const unique = all.filter(c => {
|
||||||
|
if (seen.has(c.name)) return false
|
||||||
|
seen.add(c.name)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
unique.sort((a, b) => new Date(b.creation) - new Date(a.creation))
|
||||||
|
return unique
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post a reply on a ticket (creates a Communication).
|
||||||
|
*/
|
||||||
|
export async function replyToTicket (ticketName, message) {
|
||||||
|
const res = await authFetch(BASE_URL + '/api/resource/Communication', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
communication_type: 'Communication',
|
||||||
|
communication_medium: 'Other',
|
||||||
|
sent_or_received: 'Sent',
|
||||||
|
sender: 'portal@gigafibre.ca',
|
||||||
|
content: message,
|
||||||
|
reference_doctype: 'Issue',
|
||||||
|
reference_name: ticketName,
|
||||||
|
status: 'Linked',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Failed to send reply')
|
||||||
|
const data = await res.json()
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Comments linked to a document (visible to portal users).
|
||||||
|
*/
|
||||||
|
export async function fetchComments (refDoctype, refName) {
|
||||||
|
const filters = JSON.stringify([
|
||||||
|
['reference_doctype', '=', refDoctype],
|
||||||
|
['reference_name', '=', refName],
|
||||||
|
['comment_type', '=', 'Comment'],
|
||||||
|
])
|
||||||
|
const fields = JSON.stringify([
|
||||||
|
'name', 'comment_by', 'content', 'creation',
|
||||||
|
])
|
||||||
|
const path = `/api/resource/Comment?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&order_by=creation asc&limit_page_length=100`
|
||||||
|
const data = await apiGet(path)
|
||||||
|
return data.data || []
|
||||||
|
}
|
||||||
|
|
|
||||||
62
apps/client/src/composables/usePolling.js
Normal file
62
apps/client/src/composables/usePolling.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart incremental polling composable.
|
||||||
|
* Calls `fetchFn()` at `interval` ms. Compares result count/IDs
|
||||||
|
* with previous to detect new items and trigger `onNew` callback.
|
||||||
|
*
|
||||||
|
* @param {Function} fetchFn - async function that returns array of items
|
||||||
|
* @param {Object} opts
|
||||||
|
* @param {number} opts.interval - poll interval in ms (default 10000)
|
||||||
|
* @param {Function} opts.getId - function to get unique ID from item (default: item => item.name)
|
||||||
|
* @param {Function} opts.onNew - callback when new items detected, receives array of new items
|
||||||
|
*/
|
||||||
|
export function usePolling (fetchFn, opts = {}) {
|
||||||
|
const interval = opts.interval || 10000
|
||||||
|
const getId = opts.getId || (item => item.name)
|
||||||
|
const onNew = opts.onNew || (() => {})
|
||||||
|
|
||||||
|
const knownIds = ref(new Set())
|
||||||
|
let timer = null
|
||||||
|
let running = false
|
||||||
|
|
||||||
|
function seedKnownIds (items) {
|
||||||
|
knownIds.value = new Set(items.map(getId))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll () {
|
||||||
|
if (running) return
|
||||||
|
running = true
|
||||||
|
try {
|
||||||
|
const items = await fetchFn()
|
||||||
|
const newItems = items.filter(item => !knownIds.value.has(getId(item)))
|
||||||
|
if (newItems.length) {
|
||||||
|
for (const item of items) {
|
||||||
|
knownIds.value.add(getId(item))
|
||||||
|
}
|
||||||
|
onNew(newItems, items)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fail on poll
|
||||||
|
} finally {
|
||||||
|
running = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start () {
|
||||||
|
stop()
|
||||||
|
timer = setInterval(poll, interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop () {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-cleanup on component unmount
|
||||||
|
onUnmounted(stop)
|
||||||
|
|
||||||
|
return { start, stop, poll, seedKnownIds }
|
||||||
|
}
|
||||||
65
apps/client/src/composables/useSSE.js
Normal file
65
apps/client/src/composables/useSSE.js
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const HUB_URL = 'https://msg.gigafibre.ca'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE composable for real-time Communication events from targo-hub.
|
||||||
|
*/
|
||||||
|
export function useSSE (opts = {}) {
|
||||||
|
const connected = ref(false)
|
||||||
|
let es = null
|
||||||
|
let reconnectTimer = null
|
||||||
|
let reconnectDelay = 1000
|
||||||
|
|
||||||
|
function connect (topics) {
|
||||||
|
disconnect()
|
||||||
|
if (!topics || !topics.length) return
|
||||||
|
|
||||||
|
const url = `${HUB_URL}/sse?topics=${encodeURIComponent(topics.join(','))}`
|
||||||
|
es = new EventSource(url)
|
||||||
|
|
||||||
|
es.onopen = () => {
|
||||||
|
connected.value = true
|
||||||
|
reconnectDelay = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
es.addEventListener('message', (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data)
|
||||||
|
if (opts.onMessage) opts.onMessage(data)
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
|
||||||
|
es.addEventListener('sms-incoming', (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data)
|
||||||
|
if (opts.onSmsIncoming) opts.onSmsIncoming(data)
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
connected.value = false
|
||||||
|
if (es && es.readyState === EventSource.CLOSED) {
|
||||||
|
scheduleReconnect(topics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect (topics) {
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 2, 30000)
|
||||||
|
connect(topics)
|
||||||
|
}, reconnectDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect () {
|
||||||
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null }
|
||||||
|
if (es) { es.close(); es = null }
|
||||||
|
connected.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(disconnect)
|
||||||
|
|
||||||
|
return { connect, disconnect, connected }
|
||||||
|
}
|
||||||
|
|
@ -63,3 +63,14 @@ body {
|
||||||
color: var(--gf-text);
|
color: var(--gf-text);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clickable table rows
|
||||||
|
.clickable-table {
|
||||||
|
.q-table tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
&:hover {
|
||||||
|
background: rgba(14, 165, 233, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ const navLinks = [
|
||||||
{ to: '/', icon: 'dashboard', label: 'Tableau de bord' },
|
{ to: '/', icon: 'dashboard', label: 'Tableau de bord' },
|
||||||
{ to: '/invoices', icon: 'receipt_long', label: 'Factures' },
|
{ to: '/invoices', icon: 'receipt_long', label: 'Factures' },
|
||||||
{ to: '/tickets', icon: 'support_agent', label: 'Support' },
|
{ to: '/tickets', icon: 'support_agent', label: 'Support' },
|
||||||
|
{ to: '/messages', icon: 'chat', label: 'Messages' },
|
||||||
{ to: '/me', icon: 'person', label: 'Mon compte' },
|
{ to: '/me', icon: 'person', label: 'Mon compte' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
<div v-if="recentInvoices.length" class="q-mt-lg">
|
<div v-if="recentInvoices.length" class="q-mt-lg">
|
||||||
<div class="text-subtitle1 text-weight-medium q-mb-sm">Dernières factures</div>
|
<div class="text-subtitle1 text-weight-medium q-mb-sm">Dernières factures</div>
|
||||||
<q-list bordered separator class="rounded-borders bg-white">
|
<q-list bordered separator class="rounded-borders bg-white">
|
||||||
<q-item v-for="inv in recentInvoices" :key="inv.name" clickable @click="$router.push('/invoices')">
|
<q-item v-for="inv in recentInvoices" :key="inv.name" clickable @click="$router.push('/invoices/' + inv.name)">
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>{{ inv.name }}</q-item-label>
|
<q-item-label>{{ inv.name }}</q-item-label>
|
||||||
<q-item-label caption>{{ formatDate(inv.posting_date) }}</q-item-label>
|
<q-item-label caption>{{ formatDate(inv.posting_date) }}</q-item-label>
|
||||||
|
|
|
||||||
180
apps/client/src/pages/InvoiceDetailPage.vue
Normal file
180
apps/client/src/pages/InvoiceDetailPage.vue
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center q-mb-md">
|
||||||
|
<q-btn flat round icon="arrow_back" @click="$router.push('/invoices')" />
|
||||||
|
<div class="page-title q-mb-none q-ml-sm">{{ invoiceName }}</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn outline color="primary" icon="picture_as_pdf" label="Télécharger PDF"
|
||||||
|
:loading="downloadingPDF" @click="downloadPDF" no-caps class="q-mr-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary bar -->
|
||||||
|
<div v-if="invoice" class="row q-col-gutter-sm q-mb-md">
|
||||||
|
<div class="col-6 col-sm-3">
|
||||||
|
<div class="portal-card text-center">
|
||||||
|
<div class="text-caption text-grey-7">Total</div>
|
||||||
|
<div class="text-h6">{{ formatMoney(invoice.grand_total) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-sm-3">
|
||||||
|
<div class="portal-card text-center">
|
||||||
|
<div class="text-caption text-grey-7">Solde</div>
|
||||||
|
<div class="text-h6" :class="invoice.outstanding_amount > 0 ? 'text-negative' : 'text-positive'">
|
||||||
|
{{ formatMoney(invoice.outstanding_amount) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-sm-3">
|
||||||
|
<div class="portal-card text-center">
|
||||||
|
<div class="text-caption text-grey-7">Date</div>
|
||||||
|
<div class="text-body1">{{ formatDate(invoice.posting_date) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-sm-3">
|
||||||
|
<div class="portal-card text-center">
|
||||||
|
<div class="text-caption text-grey-7">Statut</div>
|
||||||
|
<q-badge :color="statusColor(invoice.status)" :label="statusLabel(invoice.status)" class="q-mt-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Line items table -->
|
||||||
|
<div v-if="invoice && invoice.items" class="q-mb-md">
|
||||||
|
<div class="text-subtitle1 text-weight-medium q-mb-sm">Détails</div>
|
||||||
|
<q-table
|
||||||
|
:rows="invoice.items"
|
||||||
|
:columns="itemColumns"
|
||||||
|
row-key="name"
|
||||||
|
flat bordered
|
||||||
|
class="bg-white"
|
||||||
|
hide-pagination
|
||||||
|
:pagination="{ rowsPerPage: 0 }"
|
||||||
|
>
|
||||||
|
<template #body-cell-rate="props">
|
||||||
|
<q-td :props="props" class="text-right">{{ formatMoney(props.value) }}</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-amount="props">
|
||||||
|
<q-td :props="props" class="text-right text-weight-medium">{{ formatMoney(props.value) }}</q-td>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
|
||||||
|
<!-- Totals -->
|
||||||
|
<div class="bg-white q-pa-md rounded-borders" style="border: 1px solid #e0e0e0; border-top: none;">
|
||||||
|
<div class="row justify-end q-gutter-sm">
|
||||||
|
<div v-if="invoice.total_taxes_and_charges" class="text-right">
|
||||||
|
<span class="text-grey-7">Taxes: </span>
|
||||||
|
<span class="text-weight-medium">{{ formatMoney(invoice.total_taxes_and_charges) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-right q-ml-lg">
|
||||||
|
<span class="text-grey-7">Grand total: </span>
|
||||||
|
<span class="text-h6 text-weight-bold">{{ formatMoney(invoice.grand_total) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jinja rendered preview -->
|
||||||
|
<div v-if="printHTML" class="q-mb-md">
|
||||||
|
<div class="text-subtitle1 text-weight-medium q-mb-sm">Aperçu de la facture</div>
|
||||||
|
<div class="bg-white q-pa-md rounded-borders invoice-preview" style="border: 1px solid #e0e0e0;">
|
||||||
|
<div v-html="printHTML" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex flex-center q-pa-xl">
|
||||||
|
<q-spinner-dots size="48px" color="primary" />
|
||||||
|
</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useCustomerStore } from 'src/stores/customer'
|
||||||
|
import { fetchInvoice, fetchInvoiceHTML, fetchInvoicePDF } from 'src/api/portal'
|
||||||
|
import { useFormatters } from 'src/composables/useFormatters'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useCustomerStore()
|
||||||
|
const { formatDate, formatMoney } = useFormatters()
|
||||||
|
|
||||||
|
const invoiceName = route.params.name
|
||||||
|
const invoice = ref(null)
|
||||||
|
const printHTML = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
const downloadingPDF = ref(false)
|
||||||
|
|
||||||
|
const itemColumns = [
|
||||||
|
{ name: 'item_name', label: 'Description', field: 'item_name', align: 'left' },
|
||||||
|
{ name: 'qty', label: 'Qté', field: 'qty', align: 'center' },
|
||||||
|
{ name: 'rate', label: 'Prix unitaire', field: 'rate', align: 'right' },
|
||||||
|
{ name: 'amount', label: 'Montant', field: 'amount', align: 'right' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function statusColor (s) {
|
||||||
|
if (s === 'Paid') return 'positive'
|
||||||
|
if (s === 'Overdue') return 'negative'
|
||||||
|
if (s === 'Unpaid') return 'warning'
|
||||||
|
return 'grey'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel (s) {
|
||||||
|
const map = { Paid: 'Payée', Unpaid: 'Impayée', Overdue: 'En retard', 'Partly Paid': 'Partielle' }
|
||||||
|
return map[s] || s
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadPDF () {
|
||||||
|
downloadingPDF.value = true
|
||||||
|
try {
|
||||||
|
const blob = await fetchInvoicePDF(invoiceName)
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${invoiceName}.pdf`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} finally {
|
||||||
|
downloadingPDF.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!store.customerId) return
|
||||||
|
try {
|
||||||
|
const [inv, html] = await Promise.allSettled([
|
||||||
|
fetchInvoice(invoiceName),
|
||||||
|
fetchInvoiceHTML(invoiceName),
|
||||||
|
])
|
||||||
|
if (inv.status === 'fulfilled') {
|
||||||
|
// Security: verify this invoice belongs to the logged-in customer
|
||||||
|
if (inv.value.customer !== store.customerId) {
|
||||||
|
router.replace('/invoices')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
invoice.value = inv.value
|
||||||
|
}
|
||||||
|
if (html.status === 'fulfilled' && html.value.html) {
|
||||||
|
printHTML.value = html.value.html
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.invoice-preview {
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.invoice-preview :deep(table) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.invoice-preview :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -9,8 +9,9 @@
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:pagination="pagination"
|
:pagination="pagination"
|
||||||
@request="onRequest"
|
@request="onRequest"
|
||||||
|
@row-click="(evt, row) => router.push('/invoices/' + row.name)"
|
||||||
flat bordered
|
flat bordered
|
||||||
class="bg-white"
|
class="bg-white clickable-table"
|
||||||
no-data-label="Aucune facture"
|
no-data-label="Aucune facture"
|
||||||
>
|
>
|
||||||
<template #body-cell-posting_date="props">
|
<template #body-cell-posting_date="props">
|
||||||
|
|
@ -47,10 +48,12 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useCustomerStore } from 'src/stores/customer'
|
import { useCustomerStore } from 'src/stores/customer'
|
||||||
import { fetchInvoices, countInvoices, fetchInvoicePDF } from 'src/api/portal'
|
import { fetchInvoices, countInvoices, fetchInvoicePDF } from 'src/api/portal'
|
||||||
import { useFormatters } from 'src/composables/useFormatters'
|
import { useFormatters } from 'src/composables/useFormatters'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const store = useCustomerStore()
|
const store = useCustomerStore()
|
||||||
const { formatShortDate, formatMoney } = useFormatters()
|
const { formatShortDate, formatMoney } = useFormatters()
|
||||||
|
|
||||||
|
|
|
||||||
228
apps/client/src/pages/MessagesPage.vue
Normal file
228
apps/client/src/pages/MessagesPage.vue
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<div class="page-title">Messages</div>
|
||||||
|
|
||||||
|
<!-- Filter tabs -->
|
||||||
|
<q-tabs v-model="activeTab" dense align="left" class="q-mb-md bg-white rounded-borders"
|
||||||
|
active-color="primary" indicator-color="primary" narrow-indicator>
|
||||||
|
<q-tab name="all" label="Tous" no-caps />
|
||||||
|
<q-tab name="SMS" label="SMS" no-caps icon="sms" />
|
||||||
|
<q-tab name="Email" label="Courriel" no-caps icon="email" />
|
||||||
|
<q-tab name="Phone" label="Appels" no-caps icon="phone" />
|
||||||
|
</q-tabs>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex flex-center q-pa-xl">
|
||||||
|
<q-spinner-dots size="48px" color="primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-else-if="!filteredMessages.length" class="text-center text-grey-6 q-pa-xl">
|
||||||
|
<q-icon name="chat_bubble_outline" size="64px" class="q-mb-md" />
|
||||||
|
<div class="text-h6">Aucun message</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grouped messages -->
|
||||||
|
<div v-else>
|
||||||
|
<div v-for="group in groupedMessages" :key="group.key" class="q-mb-lg">
|
||||||
|
<!-- Group header -->
|
||||||
|
<div class="flex items-center q-mb-sm">
|
||||||
|
<q-icon :name="groupIcon(group)" size="20px" color="grey-7" class="q-mr-sm" />
|
||||||
|
<div class="text-subtitle2 text-weight-medium">
|
||||||
|
{{ group.label }}
|
||||||
|
</div>
|
||||||
|
<q-badge v-if="group.ticketStatus" :color="ticketStatusColor(group.ticketStatus)"
|
||||||
|
:label="group.ticketStatus" class="q-ml-sm" />
|
||||||
|
<q-space />
|
||||||
|
<span class="text-caption text-grey-6">{{ group.dateRange }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages in group -->
|
||||||
|
<q-list bordered separator class="rounded-borders bg-white">
|
||||||
|
<q-item v-for="msg in group.messages" :key="msg.name" class="q-pa-sm">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon :name="mediumIcon(msg.communication_medium)"
|
||||||
|
:color="msg.sent_or_received === 'Sent' ? 'primary' : 'grey-7'" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label class="flex items-center">
|
||||||
|
<q-icon v-if="msg.sent_or_received === 'Sent'" name="arrow_upward" size="14px" color="primary" class="q-mr-xs" />
|
||||||
|
<q-icon v-else name="arrow_downward" size="14px" color="positive" class="q-mr-xs" />
|
||||||
|
<span class="text-caption text-grey-7">
|
||||||
|
{{ msg.sent_or_received === 'Sent' ? 'Envoyé' : 'Reçu' }}
|
||||||
|
· {{ formatDateTime(msg.creation) }}
|
||||||
|
</span>
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label class="q-mt-xs message-content" v-html="truncateHTML(msg.content, 200)" />
|
||||||
|
<q-item-label v-if="msg.phone_no" caption>
|
||||||
|
<q-icon name="phone" size="12px" /> {{ msg.phone_no }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side v-if="msg.reference_doctype === 'Issue'">
|
||||||
|
<q-btn flat dense size="sm" color="primary" no-caps
|
||||||
|
:label="msg.reference_name"
|
||||||
|
@click="$router.push('/tickets/' + msg.reference_name)" />
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useQuasar } from 'quasar'
|
||||||
|
import { useCustomerStore } from 'src/stores/customer'
|
||||||
|
import { fetchAllCommunications } from 'src/api/portal'
|
||||||
|
import { useFormatters } from 'src/composables/useFormatters'
|
||||||
|
import { useSSE } from 'src/composables/useSSE'
|
||||||
|
|
||||||
|
const $q = useQuasar()
|
||||||
|
const store = useCustomerStore()
|
||||||
|
const { formatDate } = useFormatters()
|
||||||
|
|
||||||
|
const messages = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const activeTab = ref('all')
|
||||||
|
|
||||||
|
// SSE for real-time updates
|
||||||
|
const { connect: sseConnect } = useSSE({
|
||||||
|
onMessage: async (data) => {
|
||||||
|
if (data.customer === store.customerId) {
|
||||||
|
// Reload full list on any new message
|
||||||
|
messages.value = await fetchAllCommunications(store.customerId)
|
||||||
|
if (data.direction === 'in') {
|
||||||
|
$q.notify({
|
||||||
|
type: 'info',
|
||||||
|
icon: 'chat',
|
||||||
|
message: `Nouveau message de ${data.customer_name || data.phone || 'Client'}`,
|
||||||
|
timeout: 3000,
|
||||||
|
position: 'top-right',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function mediumIcon (m) {
|
||||||
|
if (m === 'SMS') return 'sms'
|
||||||
|
if (m === 'Phone') return 'phone'
|
||||||
|
if (m === 'Email') return 'email'
|
||||||
|
return 'chat_bubble_outline'
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupIcon (group) {
|
||||||
|
if (group.type === 'ticket') return 'confirmation_number'
|
||||||
|
if (group.medium === 'SMS') return 'sms'
|
||||||
|
if (group.medium === 'Email') return 'email'
|
||||||
|
if (group.medium === 'Phone') return 'phone'
|
||||||
|
return 'chat_bubble_outline'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketStatusColor (s) {
|
||||||
|
if (s === 'Open') return 'primary'
|
||||||
|
if (s === 'Closed' || s === 'Resolved') return 'positive'
|
||||||
|
return 'grey'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime (d) {
|
||||||
|
if (!d) return ''
|
||||||
|
return new Date(d).toLocaleString('fr-CA', {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateHTML (html, maxLen) {
|
||||||
|
if (!html) return ''
|
||||||
|
// Strip HTML tags for length check, but return original if short enough
|
||||||
|
const text = html.replace(/<[^>]+>/g, '')
|
||||||
|
if (text.length <= maxLen) return html
|
||||||
|
return text.substring(0, maxLen) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredMessages = computed(() => {
|
||||||
|
if (activeTab.value === 'all') return messages.value
|
||||||
|
return messages.value.filter(m => m.communication_medium === activeTab.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupedMessages = computed(() => {
|
||||||
|
const groups = new Map()
|
||||||
|
|
||||||
|
for (const msg of filteredMessages.value) {
|
||||||
|
let key, label, type, medium
|
||||||
|
if (msg.reference_doctype === 'Issue' && msg.reference_name) {
|
||||||
|
// Group by ticket
|
||||||
|
key = 'ticket:' + msg.reference_name
|
||||||
|
label = msg.reference_name + (msg.subject ? ' - ' + msg.subject : '')
|
||||||
|
type = 'ticket'
|
||||||
|
} else if (msg.communication_medium === 'SMS' && msg.phone_no) {
|
||||||
|
// Group SMS by phone number + date
|
||||||
|
const d = new Date(msg.creation).toISOString().slice(0, 10)
|
||||||
|
key = 'sms:' + msg.phone_no + ':' + d
|
||||||
|
label = 'SMS - ' + msg.phone_no
|
||||||
|
type = 'sms'
|
||||||
|
medium = 'SMS'
|
||||||
|
} else if (msg.communication_medium === 'Email' && msg.subject) {
|
||||||
|
// Group email by subject (strip Re: / Fwd: prefixes)
|
||||||
|
const cleanSubject = (msg.subject || '').replace(/^(Re|Fwd|TR|RE):\s*/gi, '').trim()
|
||||||
|
key = 'email:' + cleanSubject
|
||||||
|
label = msg.subject
|
||||||
|
type = 'email'
|
||||||
|
medium = 'Email'
|
||||||
|
} else {
|
||||||
|
// Group by date
|
||||||
|
const d = new Date(msg.creation).toISOString().slice(0, 10)
|
||||||
|
key = 'other:' + d
|
||||||
|
label = formatDate(msg.creation)
|
||||||
|
type = 'other'
|
||||||
|
medium = msg.communication_medium
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groups.has(key)) {
|
||||||
|
groups.set(key, { key, label, type, medium, messages: [], ticketStatus: null })
|
||||||
|
}
|
||||||
|
groups.get(key).messages.push(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort groups by latest message, compute dateRange
|
||||||
|
const result = Array.from(groups.values())
|
||||||
|
for (const g of result) {
|
||||||
|
g.messages.sort((a, b) => new Date(a.creation) - new Date(b.creation))
|
||||||
|
const first = g.messages[0].creation
|
||||||
|
const last = g.messages[g.messages.length - 1].creation
|
||||||
|
if (first === last) {
|
||||||
|
g.dateRange = formatDateTime(first)
|
||||||
|
} else {
|
||||||
|
g.dateRange = formatDateTime(first) + ' - ' + formatDateTime(last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort groups: most recent message first
|
||||||
|
result.sort((a, b) => {
|
||||||
|
const aLast = new Date(a.messages[a.messages.length - 1].creation)
|
||||||
|
const bLast = new Date(b.messages[b.messages.length - 1].creation)
|
||||||
|
return bLast - aLast
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!store.customerId) return
|
||||||
|
try {
|
||||||
|
messages.value = await fetchAllCommunications(store.customerId)
|
||||||
|
sseConnect(['customer:' + store.customerId])
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-content {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.message-content :deep(p) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
304
apps/client/src/pages/TicketDetailPage.vue
Normal file
304
apps/client/src/pages/TicketDetailPage.vue
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center q-mb-md">
|
||||||
|
<q-btn flat round icon="arrow_back" @click="$router.push('/tickets')" />
|
||||||
|
<div class="q-ml-sm">
|
||||||
|
<div class="page-title q-mb-none">{{ ticket?.subject || ticketName }}</div>
|
||||||
|
<div v-if="ticket" class="text-caption text-grey-7">
|
||||||
|
{{ ticketName }} · Créé le {{ formatDate(ticket.creation) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-space />
|
||||||
|
<q-badge v-if="ticket" :color="statusColor(ticket.status)" :label="statusLabel(ticket.status)" class="text-body2 q-pa-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ticket info -->
|
||||||
|
<div v-if="ticket" class="row q-col-gutter-md q-mb-md">
|
||||||
|
<div class="col-12 col-md-8">
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="portal-card">
|
||||||
|
<div class="text-subtitle2 text-weight-medium q-mb-sm">Description</div>
|
||||||
|
<div class="ticket-description" v-html="ticket.description || '<em>Aucune description</em>'" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conversation thread -->
|
||||||
|
<div class="portal-card q-mt-md">
|
||||||
|
<div class="text-subtitle2 text-weight-medium q-mb-md">Conversation</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="!thread.length && !loading" class="text-grey-6 text-center q-pa-lg">
|
||||||
|
Aucun message pour le moment
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Thread entries -->
|
||||||
|
<div v-for="(group, gi) in groupedThread" :key="gi" class="q-mb-md">
|
||||||
|
<div class="text-center q-mb-sm">
|
||||||
|
<q-chip size="sm" color="grey-3" text-color="grey-7" dense>
|
||||||
|
{{ group.date }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="entry in group.items" :key="entry.name" class="thread-entry q-mb-sm"
|
||||||
|
:class="entry.sent_or_received === 'Sent' && entry.sender === 'portal@gigafibre.ca' ? 'thread-mine' : 'thread-theirs'">
|
||||||
|
<div class="thread-bubble">
|
||||||
|
<div class="flex items-center q-mb-xs">
|
||||||
|
<q-icon :name="mediumIcon(entry.communication_medium)" size="16px" class="q-mr-xs" />
|
||||||
|
<span class="text-caption text-weight-medium">
|
||||||
|
{{ entry.sender_full_name || entry.sender || 'Système' }}
|
||||||
|
</span>
|
||||||
|
<q-space />
|
||||||
|
<span class="text-caption text-grey-6">{{ formatTime(entry.creation) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="thread-content" v-html="entry.content" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reply box -->
|
||||||
|
<div class="q-mt-md">
|
||||||
|
<q-input v-model="replyText" outlined placeholder="Répondre..." type="textarea" rows="3"
|
||||||
|
:disable="sending" autogrow />
|
||||||
|
<div class="flex justify-end q-mt-sm">
|
||||||
|
<q-btn color="primary" label="Envoyer" icon="send" no-caps
|
||||||
|
:loading="sending" :disable="!replyText.trim()"
|
||||||
|
@click="sendReply" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="portal-card">
|
||||||
|
<div class="text-subtitle2 text-weight-medium q-mb-sm">Détails</div>
|
||||||
|
<q-list dense>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label caption>Statut</q-item-label>
|
||||||
|
<q-item-label>
|
||||||
|
<q-badge :color="statusColor(ticket.status)" :label="statusLabel(ticket.status)" />
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label caption>Priorité</q-item-label>
|
||||||
|
<q-item-label>
|
||||||
|
<q-badge :color="priorityColor(ticket.priority)" :label="ticket.priority || 'Medium'" outline />
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item v-if="ticket.issue_type">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label caption>Type</q-item-label>
|
||||||
|
<q-item-label>{{ ticket.issue_type }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label caption>Créé le</q-item-label>
|
||||||
|
<q-item-label>{{ formatDate(ticket.creation) }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item v-if="ticket.resolution_date">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label caption>Résolu le</q-item-label>
|
||||||
|
<q-item-label>{{ formatDate(ticket.resolution_date) }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex flex-center q-pa-xl">
|
||||||
|
<q-spinner-dots size="48px" color="primary" />
|
||||||
|
</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useQuasar } from 'quasar'
|
||||||
|
import { useCustomerStore } from 'src/stores/customer'
|
||||||
|
import { fetchTicket, fetchCommunications, fetchComments, replyToTicket } from 'src/api/portal'
|
||||||
|
import { useFormatters } from 'src/composables/useFormatters'
|
||||||
|
import { useSSE } from 'src/composables/useSSE'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const $q = useQuasar()
|
||||||
|
const store = useCustomerStore()
|
||||||
|
const { formatDate } = useFormatters()
|
||||||
|
|
||||||
|
const ticketName = route.params.name
|
||||||
|
const ticket = ref(null)
|
||||||
|
const thread = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const replyText = ref('')
|
||||||
|
const sending = ref(false)
|
||||||
|
|
||||||
|
// SSE for real-time updates
|
||||||
|
const { connect: sseConnect } = useSSE({
|
||||||
|
onMessage: async (data) => {
|
||||||
|
if (data.customer === store.customerId) {
|
||||||
|
await loadThread()
|
||||||
|
if (data.direction === 'in') {
|
||||||
|
$q.notify({
|
||||||
|
type: 'info',
|
||||||
|
icon: 'chat',
|
||||||
|
message: `Nouvelle réponse sur ${ticketName}`,
|
||||||
|
timeout: 3000,
|
||||||
|
position: 'top-right',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function statusColor (s) {
|
||||||
|
if (s === 'Open') return 'primary'
|
||||||
|
if (s === 'Closed' || s === 'Resolved') return 'positive'
|
||||||
|
if (s === 'Replied') return 'info'
|
||||||
|
return 'grey'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel (s) {
|
||||||
|
const map = { Open: 'Ouvert', Closed: 'Fermé', Resolved: 'Résolu', Replied: 'Répondu' }
|
||||||
|
return map[s] || s
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityColor (p) {
|
||||||
|
if (p === 'High' || p === 'Urgent') return 'negative'
|
||||||
|
if (p === 'Medium') return 'warning'
|
||||||
|
return 'grey'
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediumIcon (m) {
|
||||||
|
if (m === 'SMS') return 'sms'
|
||||||
|
if (m === 'Phone') return 'phone'
|
||||||
|
if (m === 'Email') return 'email'
|
||||||
|
return 'chat_bubble_outline'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime (d) {
|
||||||
|
if (!d) return ''
|
||||||
|
return new Date(d).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedThread = computed(() => {
|
||||||
|
const groups = []
|
||||||
|
let currentDate = null
|
||||||
|
let currentGroup = null
|
||||||
|
for (const entry of thread.value) {
|
||||||
|
const d = new Date(entry.creation).toLocaleDateString('fr-CA', {
|
||||||
|
year: 'numeric', month: 'long', day: 'numeric',
|
||||||
|
})
|
||||||
|
if (d !== currentDate) {
|
||||||
|
currentDate = d
|
||||||
|
currentGroup = { date: d, items: [] }
|
||||||
|
groups.push(currentGroup)
|
||||||
|
}
|
||||||
|
currentGroup.items.push(entry)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadThread () {
|
||||||
|
const [coms, comments] = await Promise.all([
|
||||||
|
fetchCommunications('Issue', ticketName),
|
||||||
|
fetchComments('Issue', ticketName),
|
||||||
|
])
|
||||||
|
// Merge communications and comments into unified thread sorted by creation
|
||||||
|
const merged = [
|
||||||
|
...coms.map(c => ({ ...c, _type: 'communication' })),
|
||||||
|
...comments.map(c => ({
|
||||||
|
name: c.name,
|
||||||
|
communication_medium: 'Other',
|
||||||
|
sent_or_received: 'Received',
|
||||||
|
sender: c.comment_by,
|
||||||
|
sender_full_name: c.comment_by,
|
||||||
|
content: c.content,
|
||||||
|
creation: c.creation,
|
||||||
|
_type: 'comment',
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
merged.sort((a, b) => new Date(a.creation) - new Date(b.creation))
|
||||||
|
thread.value = merged
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendReply () {
|
||||||
|
if (!replyText.value.trim()) return
|
||||||
|
sending.value = true
|
||||||
|
try {
|
||||||
|
await replyToTicket(ticketName, replyText.value.trim())
|
||||||
|
replyText.value = ''
|
||||||
|
$q.notify({ type: 'positive', message: 'Message envoyé' })
|
||||||
|
await loadThread()
|
||||||
|
} catch (e) {
|
||||||
|
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!store.customerId) return
|
||||||
|
try {
|
||||||
|
const t = await fetchTicket(ticketName)
|
||||||
|
// Security: verify ticket belongs to customer
|
||||||
|
if (t.customer !== store.customerId) {
|
||||||
|
router.replace('/tickets')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ticket.value = t
|
||||||
|
await loadThread()
|
||||||
|
// Connect SSE for real-time updates on this customer
|
||||||
|
sseConnect(['customer:' + store.customerId])
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ticket-description {
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.ticket-description :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.thread-entry {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.thread-mine {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.thread-theirs {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.thread-bubble {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.thread-mine .thread-bubble {
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.thread-theirs .thread-bubble {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
.thread-content {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.thread-content :deep(p) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -10,8 +10,9 @@
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
row-key="name"
|
row-key="name"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
@row-click="(evt, row) => router.push('/tickets/' + row.name)"
|
||||||
flat bordered
|
flat bordered
|
||||||
class="bg-white"
|
class="bg-white clickable-table"
|
||||||
no-data-label="Aucun ticket"
|
no-data-label="Aucun ticket"
|
||||||
:pagination="{ rowsPerPage: 50 }"
|
:pagination="{ rowsPerPage: 50 }"
|
||||||
>
|
>
|
||||||
|
|
@ -58,11 +59,13 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useQuasar } from 'quasar'
|
import { useQuasar } from 'quasar'
|
||||||
import { useCustomerStore } from 'src/stores/customer'
|
import { useCustomerStore } from 'src/stores/customer'
|
||||||
import { fetchTickets, createTicket } from 'src/api/portal'
|
import { fetchTickets, createTicket } from 'src/api/portal'
|
||||||
import { useFormatters } from 'src/composables/useFormatters'
|
import { useFormatters } from 'src/composables/useFormatters'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
const store = useCustomerStore()
|
const store = useCustomerStore()
|
||||||
const { formatShortDate } = useFormatters()
|
const { formatShortDate } = useFormatters()
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ const routes = [
|
||||||
children: [
|
children: [
|
||||||
{ path: '', name: 'dashboard', component: () => import('pages/DashboardPage.vue') },
|
{ path: '', name: 'dashboard', component: () => import('pages/DashboardPage.vue') },
|
||||||
{ path: 'invoices', name: 'invoices', component: () => import('pages/InvoicesPage.vue') },
|
{ path: 'invoices', name: 'invoices', component: () => import('pages/InvoicesPage.vue') },
|
||||||
|
{ path: 'invoices/:name', name: 'invoice-detail', component: () => import('pages/InvoiceDetailPage.vue') },
|
||||||
{ path: 'tickets', name: 'tickets', component: () => import('pages/TicketsPage.vue') },
|
{ path: 'tickets', name: 'tickets', component: () => import('pages/TicketsPage.vue') },
|
||||||
|
{ path: 'tickets/:name', name: 'ticket-detail', component: () => import('pages/TicketDetailPage.vue') },
|
||||||
|
{ path: 'messages', name: 'messages', component: () => import('pages/MessagesPage.vue') },
|
||||||
{ path: 'me', name: 'account', component: () => import('pages/AccountPage.vue') },
|
{ path: 'me', name: 'account', component: () => import('pages/AccountPage.vue') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
62
apps/ops/package-lock.json
generated
62
apps/ops/package-lock.json
generated
|
|
@ -9,9 +9,11 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@quasar/extras": "^1.16.12",
|
"@quasar/extras": "^1.16.12",
|
||||||
|
"@twilio/voice-sdk": "^2.18.1",
|
||||||
"lucide-vue-next": "^1.0.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"quasar": "^2.16.10",
|
"quasar": "^2.16.10",
|
||||||
|
"sip.js": "^0.21.2",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.3.0",
|
||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0"
|
||||||
|
|
@ -2627,6 +2629,28 @@
|
||||||
"sourcemap-codec": "^1.4.8"
|
"sourcemap-codec": "^1.4.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@twilio/voice-errors": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@twilio/voice-errors/-/voice-errors-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-9TvniWpzU0iy6SYFAcDP+HG+/mNz2yAHSs7+m0DZk86lE+LoTB6J/ZONTPuxXrXWi4tso/DulSHuA0w7nIQtGg==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@twilio/voice-sdk": {
|
||||||
|
"version": "2.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@twilio/voice-sdk/-/voice-sdk-2.18.1.tgz",
|
||||||
|
"integrity": "sha512-4IvFgQOWrnluJrtJ79s1B4CiLbXLG7DKAXJW/uzgEsG9zQP30avs4d98LmtpOFr9ozd1kdKtjhKoqD+RbsY+MA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@twilio/voice-errors": "1.7.0",
|
||||||
|
"@types/events": "^3.0.3",
|
||||||
|
"events": "3.3.0",
|
||||||
|
"loglevel": "1.9.2",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
|
|
@ -2684,6 +2708,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/events": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/express": {
|
"node_modules/@types/express": {
|
||||||
"version": "4.17.25",
|
"version": "4.17.25",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
|
||||||
|
|
@ -5132,6 +5162,15 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/events": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.22.1",
|
"version": "4.22.1",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
|
|
@ -6897,6 +6936,19 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/loglevel": {
|
||||||
|
"version": "1.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||||
|
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lower-case": {
|
"node_modules/lower-case": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
||||||
|
|
@ -8511,6 +8563,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/sip.js": {
|
||||||
|
"version": "0.21.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/sip.js/-/sip.js-0.21.2.tgz",
|
||||||
|
"integrity": "sha512-tSqTcIgrOd2IhP/rd70JablvAp+fSfLSxO4hGNY6LkWRY1SKygTO7OtJEV/BQb8oIxtMRx0LE7nUF2MaqGbFzA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/slice-ansi": {
|
"node_modules/slice-ansi": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
|
||||||
|
|
@ -8983,7 +9044,6 @@
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@quasar/extras": "^1.16.12",
|
"@quasar/extras": "^1.16.12",
|
||||||
|
"@twilio/voice-sdk": "^2.18.1",
|
||||||
"lucide-vue-next": "^1.0.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"quasar": "^2.16.10",
|
"quasar": "^2.16.10",
|
||||||
|
"sip.js": "^0.21.2",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.3.0",
|
||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*
|
*
|
||||||
* @param {string} phone - Phone number (e.g. +15145551234)
|
* @param {string} phone - Phone number (e.g. +15145551234)
|
||||||
* @param {string} message - SMS body
|
* @param {string} message - SMS body
|
||||||
* @param {string} customer - Customer ID (e.g. LPB4 or C10000000000001) — logged as Communication in ERPNext
|
* @param {string} customer - Customer ID (e.g. C-LPB4 or C-10000000034941) — logged as Communication in ERPNext
|
||||||
* @param {object} [opts] - Extra options
|
* @param {object} [opts] - Extra options
|
||||||
* @param {string} [opts.reference_doctype] - Link to specific doctype (default: Customer)
|
* @param {string} [opts.reference_doctype] - Link to specific doctype (default: Customer)
|
||||||
* @param {string} [opts.reference_name] - Link to specific record
|
* @param {string} [opts.reference_name] - Link to specific record
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,12 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- WebRTC Phone Modal -->
|
||||||
|
<PhoneModal v-model="phoneModalOpen" :initial-number="callTo || smsTo || customerPhone"
|
||||||
|
:customer-name="props.customerName" :customer-erp-name="props.customerName"
|
||||||
|
:provider="phoneProvider"
|
||||||
|
@call-ended="onCallEnded" />
|
||||||
|
|
||||||
<!-- Canned Responses Manager -->
|
<!-- Canned Responses Manager -->
|
||||||
<q-dialog v-model="cannedModal">
|
<q-dialog v-model="cannedModal">
|
||||||
<q-card style="min-width:460px;max-width:600px">
|
<q-card style="min-width:460px;max-width:600px">
|
||||||
|
|
@ -152,6 +158,7 @@ import { Notify } from 'quasar'
|
||||||
import { listDocs, createDoc, updateDoc, deleteDoc } from 'src/api/erp'
|
import { listDocs, createDoc, updateDoc, deleteDoc } from 'src/api/erp'
|
||||||
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
|
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
|
||||||
import ComposeBar from './ComposeBar.vue'
|
import ComposeBar from './ComposeBar.vue'
|
||||||
|
import PhoneModal from './PhoneModal.vue'
|
||||||
|
|
||||||
const TAB_OPTIONS = [
|
const TAB_OPTIONS = [
|
||||||
{ label: 'Tout', value: 'all', icon: 'forum' },
|
{ label: 'Tout', value: 'all', icon: 'forum' },
|
||||||
|
|
@ -195,6 +202,10 @@ const deleteDialog = ref(false)
|
||||||
const deleteTarget = ref(null)
|
const deleteTarget = ref(null)
|
||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
// Phone modal
|
||||||
|
const phoneModalOpen = ref(false)
|
||||||
|
const phoneProvider = ref('twilio') // 'twilio' or 'sip'
|
||||||
|
|
||||||
// Canned responses
|
// Canned responses
|
||||||
const cannedModal = ref(false)
|
const cannedModal = ref(false)
|
||||||
const cannedResponses = ref(loadCannedResponses())
|
const cannedResponses = ref(loadCannedResponses())
|
||||||
|
|
@ -418,7 +429,10 @@ async function send () {
|
||||||
else if (composeChannel.value === 'email') await sendEmail()
|
else if (composeChannel.value === 'email') await sendEmail()
|
||||||
composeText.value = ''
|
composeText.value = ''
|
||||||
emailSubject.value = ''
|
emailSubject.value = ''
|
||||||
setTimeout(loadCommunications, 1000)
|
// Reload immediately — the hub logs the Communication synchronously before returning
|
||||||
|
await loadCommunications()
|
||||||
|
// Safety: reload again after a short delay in case of propagation delay
|
||||||
|
setTimeout(loadCommunications, 2000)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -460,31 +474,37 @@ async function sendEmail () {
|
||||||
Notify.create({ type: 'positive', message: 'Email envoyé', timeout: 2000 })
|
Notify.create({ type: 'positive', message: 'Email envoyé', timeout: 2000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initiateCall () {
|
async function initiateCall (provider = 'twilio') {
|
||||||
const phone = callTo.value || smsTo.value || props.customerPhone
|
const phone = callTo.value || smsTo.value || props.customerPhone
|
||||||
if (!phone) { Notify.create({ type: 'warning', message: 'Aucun numéro', timeout: 3000 }); return }
|
if (!phone) { Notify.create({ type: 'warning', message: 'Aucun numéro', timeout: 3000 }); return }
|
||||||
calling.value = true
|
phoneProvider.value = provider
|
||||||
|
phoneModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCallEnded (callInfo) {
|
||||||
|
// Auto-log the call as a Communication in ERPNext
|
||||||
|
const phone = callInfo.remote || callTo.value || smsTo.value || props.customerPhone
|
||||||
|
const durationMin = Math.floor(callInfo.duration / 60)
|
||||||
|
const durationSec = callInfo.duration % 60
|
||||||
|
const durationStr = `${durationMin}m${durationSec.toString().padStart(2, '0')}s`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
window.open('tel:' + phone, '_self')
|
|
||||||
await createDoc('Communication', {
|
await createDoc('Communication', {
|
||||||
subject: 'Appel \u2192 ' + phone,
|
subject: (callInfo.direction === 'in' ? 'Appel de ' : 'Appel vers ') + phone,
|
||||||
communication_type: 'Communication',
|
communication_type: 'Communication',
|
||||||
communication_medium: 'Phone',
|
communication_medium: 'Phone',
|
||||||
sent_or_received: 'Sent',
|
sent_or_received: callInfo.direction === 'in' ? 'Received' : 'Sent',
|
||||||
status: 'Open',
|
status: 'Linked',
|
||||||
phone_no: phone,
|
phone_no: phone,
|
||||||
sender: 'sms@gigafibre.ca',
|
sender: 'sms@gigafibre.ca',
|
||||||
sender_full_name: 'Targo Ops',
|
sender_full_name: callInfo.direction === 'in' ? (callInfo.remoteName || phone) : 'Targo Ops',
|
||||||
content: 'Appel initié vers ' + phone + ' via 3CX',
|
content: `Appel ${callInfo.direction === 'in' ? 'recu de' : 'vers'} ${phone} — Duree: ${durationStr}`,
|
||||||
reference_doctype: 'Customer',
|
reference_doctype: 'Customer',
|
||||||
reference_name: props.customerName,
|
reference_name: props.customerName,
|
||||||
})
|
})
|
||||||
Notify.create({ type: 'positive', message: 'Appel lancé via 3CX', timeout: 3000 })
|
await loadCommunications()
|
||||||
setTimeout(loadCommunications, 1000)
|
} catch (e) {
|
||||||
} catch {
|
console.error('[ChatterPanel] call log error:', e)
|
||||||
Notify.create({ type: 'info', message: 'Appel lancé', timeout: 3000 })
|
|
||||||
} finally {
|
|
||||||
calling.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,16 @@
|
||||||
<q-btn flat dense round size="xs" icon="bolt" color="amber-8" @click="$emit('manage-canned')" title="Réponses rapides">
|
<q-btn flat dense round size="xs" icon="bolt" color="amber-8" @click="$emit('manage-canned')" title="Réponses rapides">
|
||||||
<q-tooltip>Gérer les réponses rapides</q-tooltip>
|
<q-tooltip>Gérer les réponses rapides</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn v-if="customerPhone" flat dense round size="sm" icon="phone" color="green-7"
|
<div v-if="customerPhone" class="call-btn-group">
|
||||||
@click="$emit('call')" :loading="calling">
|
<q-btn flat dense round size="sm" icon="phone" color="green-7"
|
||||||
<q-tooltip>Appeler {{ customerPhone }}</q-tooltip>
|
@click="$emit('call', 'twilio')" :loading="calling">
|
||||||
</q-btn>
|
<q-tooltip>Appeler via Twilio (WebRTC)</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat dense round size="sm" icon="sip" color="indigo-6"
|
||||||
|
@click="$emit('call', 'sip')" :loading="calling">
|
||||||
|
<q-tooltip>Appeler via SIP (Fonoster)</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Canned response dropdown -->
|
<!-- Canned response dropdown -->
|
||||||
|
|
|
||||||
696
apps/ops/src/components/customer/PhoneModal.vue
Normal file
696
apps/ops/src/components/customer/PhoneModal.vue
Normal file
|
|
@ -0,0 +1,696 @@
|
||||||
|
<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>
|
||||||
|
|
@ -131,8 +131,10 @@ async function sendReply () {
|
||||||
await sendTestSms(phone, text, props.customerName)
|
await sendTestSms(phone, text, props.customerName)
|
||||||
reply.value = ''
|
reply.value = ''
|
||||||
Notify.create({ type: 'positive', message: 'SMS envoye', timeout: 2000 })
|
Notify.create({ type: 'positive', message: 'SMS envoye', timeout: 2000 })
|
||||||
// Reload after a short delay to let n8n log the Communication
|
// Reload immediately — hub logs Communication before returning
|
||||||
setTimeout(() => loadMessages(), 1500)
|
await loadMessages()
|
||||||
|
// Safety: reload again in case of propagation delay
|
||||||
|
setTimeout(() => loadMessages(), 2000)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
424
apps/ops/src/composables/usePhone.js
Normal file
424
apps/ops/src/composables/usePhone.js
Normal file
|
|
@ -0,0 +1,424 @@
|
||||||
|
/**
|
||||||
|
* usePhone — WebRTC softphone via SIP.js + 3CX PBX.
|
||||||
|
*
|
||||||
|
* Registers as a SIP UA on the 3CX SBC (WebSocket),
|
||||||
|
* can make outbound calls and receive inbound calls.
|
||||||
|
*
|
||||||
|
* 3CX SIP credentials come from the xAPI: MyUser.AuthID / AuthPassword
|
||||||
|
* WSS endpoint is the 3CX SBC: wss://<sbc-fqdn>:443/ws or wss://<fqdn>:5001
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const phone = usePhone()
|
||||||
|
* await phone.register()
|
||||||
|
* await phone.call('+15145551234')
|
||||||
|
* phone.hangup()
|
||||||
|
*/
|
||||||
|
import { ref, computed, onUnmounted } from 'vue'
|
||||||
|
import { UserAgent, Registerer, Inviter, SessionState } from 'sip.js'
|
||||||
|
|
||||||
|
// ── 3CX Config (loaded from localStorage, set via Settings page) ──
|
||||||
|
const STORAGE_KEY = 'ops-phone-config'
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
pbxUrl: 'https://targopbx.3cx.ca',
|
||||||
|
wssUrl: 'wss://targopbx.3cx.ca/wss', // SBC WebSocket — adjust once SBC is enabled
|
||||||
|
sipDomain: 'targopbx.3cx.ca',
|
||||||
|
// Per-user SIP creds (from 3CX MyUser API)
|
||||||
|
extension: '',
|
||||||
|
authId: '',
|
||||||
|
authPassword: '',
|
||||||
|
displayName: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPhoneConfig () {
|
||||||
|
try {
|
||||||
|
return { ...defaults, ...JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') }
|
||||||
|
} catch {
|
||||||
|
return { ...defaults }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePhoneConfig (cfg) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate to 3CX API and fetch SIP credentials for the user.
|
||||||
|
* Returns { extension, authId, authPassword, displayName } or throws.
|
||||||
|
*/
|
||||||
|
export async function fetch3cxCredentials (username, password) {
|
||||||
|
const cfg = getPhoneConfig()
|
||||||
|
// Step 1: Login to get Bearer token
|
||||||
|
const loginRes = await fetch(cfg.pbxUrl + '/webclient/api/Login/GetAccessToken', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ Username: username, Password: password }),
|
||||||
|
})
|
||||||
|
const loginData = await loginRes.json()
|
||||||
|
if (loginData.Status !== 'AuthSuccess') {
|
||||||
|
throw new Error('3CX login failed: ' + (loginData.Status || 'Unknown'))
|
||||||
|
}
|
||||||
|
const token = loginData.Token.access_token
|
||||||
|
|
||||||
|
// Step 2: Get SIP credentials from MyUser
|
||||||
|
const userRes = await fetch(cfg.pbxUrl + '/xapi/v1/MyUser', {
|
||||||
|
headers: { Authorization: 'Bearer ' + token },
|
||||||
|
})
|
||||||
|
if (!userRes.ok) throw new Error('Failed to fetch 3CX user: ' + userRes.status)
|
||||||
|
const user = await userRes.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
extension: user.Number,
|
||||||
|
authId: user.AuthID,
|
||||||
|
authPassword: user.AuthPassword,
|
||||||
|
displayName: (user.FirstName + ' ' + user.LastName).trim(),
|
||||||
|
token, // keep for API calls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Singleton state (shared across components) ──
|
||||||
|
const registered = ref(false)
|
||||||
|
const registering = ref(false)
|
||||||
|
const callState = ref('idle') // idle | calling | ringing | active | ended
|
||||||
|
const callDuration = ref(0)
|
||||||
|
const callDirection = ref('') // out | in
|
||||||
|
const callRemote = ref('') // remote party number
|
||||||
|
const callRemoteName = ref('')
|
||||||
|
const callStartTime = ref(null)
|
||||||
|
const error = ref('')
|
||||||
|
const incomingCall = ref(null) // pending incoming session
|
||||||
|
|
||||||
|
let ua = null
|
||||||
|
let registerer = null
|
||||||
|
let currentSession = null
|
||||||
|
let durationTimer = null
|
||||||
|
let audioElement = null
|
||||||
|
|
||||||
|
function getAudioElement () {
|
||||||
|
if (!audioElement) {
|
||||||
|
audioElement = document.createElement('audio')
|
||||||
|
audioElement.autoplay = true
|
||||||
|
document.body.appendChild(audioElement)
|
||||||
|
}
|
||||||
|
return audioElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDurationTimer () {
|
||||||
|
callStartTime.value = Date.now()
|
||||||
|
callDuration.value = 0
|
||||||
|
durationTimer = setInterval(() => {
|
||||||
|
if (callStartTime.value) {
|
||||||
|
callDuration.value = Math.floor((Date.now() - callStartTime.value) / 1000)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDurationTimer () {
|
||||||
|
if (durationTimer) {
|
||||||
|
clearInterval(durationTimer)
|
||||||
|
durationTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCallState () {
|
||||||
|
callState.value = 'idle'
|
||||||
|
callDuration.value = 0
|
||||||
|
callDirection.value = ''
|
||||||
|
callRemote.value = ''
|
||||||
|
callRemoteName.value = ''
|
||||||
|
callStartTime.value = null
|
||||||
|
incomingCall.value = null
|
||||||
|
currentSession = null
|
||||||
|
stopDurationTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachSessionListeners (session) {
|
||||||
|
currentSession = session
|
||||||
|
|
||||||
|
session.stateChange.addListener((state) => {
|
||||||
|
switch (state) {
|
||||||
|
case SessionState.Establishing:
|
||||||
|
callState.value = 'ringing'
|
||||||
|
break
|
||||||
|
case SessionState.Established:
|
||||||
|
callState.value = 'active'
|
||||||
|
startDurationTimer()
|
||||||
|
// Attach remote audio
|
||||||
|
setupRemoteMedia(session)
|
||||||
|
break
|
||||||
|
case SessionState.Terminated:
|
||||||
|
callState.value = 'ended'
|
||||||
|
stopDurationTimer()
|
||||||
|
cleanupMedia()
|
||||||
|
// Auto-reset after 2s
|
||||||
|
setTimeout(resetCallState, 2000)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupRemoteMedia (session) {
|
||||||
|
const audio = getAudioElement()
|
||||||
|
const pc = session.sessionDescriptionHandler?.peerConnection
|
||||||
|
if (pc) {
|
||||||
|
pc.getReceivers().forEach((receiver) => {
|
||||||
|
if (receiver.track && receiver.track.kind === 'audio') {
|
||||||
|
const stream = new MediaStream([receiver.track])
|
||||||
|
audio.srcObject = stream
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupMedia () {
|
||||||
|
if (audioElement) {
|
||||||
|
audioElement.srcObject = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePhone () {
|
||||||
|
/**
|
||||||
|
* Register with 3CX SBC via WebSocket SIP.
|
||||||
|
*/
|
||||||
|
async function register () {
|
||||||
|
if (registered.value || registering.value) return
|
||||||
|
registering.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
const cfg = getPhoneConfig()
|
||||||
|
if (!cfg.extension || !cfg.authId || !cfg.authPassword) {
|
||||||
|
error.value = 'Phone not configured. Go to Settings → Phone.'
|
||||||
|
registering.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uri = UserAgent.makeURI(`sip:${cfg.extension}@${cfg.sipDomain}`)
|
||||||
|
if (!uri) throw new Error('Invalid SIP URI')
|
||||||
|
|
||||||
|
ua = new UserAgent({
|
||||||
|
uri,
|
||||||
|
transportOptions: {
|
||||||
|
server: cfg.wssUrl,
|
||||||
|
},
|
||||||
|
authorizationUsername: cfg.authId,
|
||||||
|
authorizationPassword: cfg.authPassword,
|
||||||
|
displayName: cfg.displayName || cfg.extension,
|
||||||
|
// Log level: warn in prod
|
||||||
|
logLevel: 'warn',
|
||||||
|
// Handle incoming calls
|
||||||
|
delegate: {
|
||||||
|
onInvite: (invitation) => {
|
||||||
|
callDirection.value = 'in'
|
||||||
|
callRemote.value = invitation.remoteIdentity?.uri?.user || 'Unknown'
|
||||||
|
callRemoteName.value = invitation.remoteIdentity?.displayName || ''
|
||||||
|
callState.value = 'ringing'
|
||||||
|
incomingCall.value = invitation
|
||||||
|
attachSessionListeners(invitation)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await ua.start()
|
||||||
|
|
||||||
|
registerer = new Registerer(ua)
|
||||||
|
await registerer.register()
|
||||||
|
registered.value = true
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Registration failed: ' + e.message
|
||||||
|
console.error('[usePhone] register error:', e)
|
||||||
|
} finally {
|
||||||
|
registering.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister and disconnect.
|
||||||
|
*/
|
||||||
|
async function unregister () {
|
||||||
|
try {
|
||||||
|
if (currentSession) {
|
||||||
|
try { currentSession.bye?.() || currentSession.cancel?.() } catch {}
|
||||||
|
}
|
||||||
|
if (registerer) await registerer.unregister().catch(() => {})
|
||||||
|
if (ua) await ua.stop().catch(() => {})
|
||||||
|
} catch {}
|
||||||
|
registered.value = false
|
||||||
|
registering.value = false
|
||||||
|
resetCallState()
|
||||||
|
ua = null
|
||||||
|
registerer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make outbound call.
|
||||||
|
*/
|
||||||
|
async function call (number) {
|
||||||
|
if (!ua || !registered.value) {
|
||||||
|
error.value = 'Phone not registered'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (callState.value !== 'idle') {
|
||||||
|
error.value = 'Already in a call'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
error.value = ''
|
||||||
|
const cfg = getPhoneConfig()
|
||||||
|
const target = UserAgent.makeURI(`sip:${number}@${cfg.sipDomain}`)
|
||||||
|
if (!target) {
|
||||||
|
error.value = 'Invalid number'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
callDirection.value = 'out'
|
||||||
|
callRemote.value = number
|
||||||
|
callState.value = 'calling'
|
||||||
|
|
||||||
|
const inviter = new Inviter(ua, target, {
|
||||||
|
sessionDescriptionHandlerOptions: {
|
||||||
|
constraints: { audio: true, video: false },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
attachSessionListeners(inviter)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await inviter.invite()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Call failed: ' + e.message
|
||||||
|
resetCallState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer incoming call.
|
||||||
|
*/
|
||||||
|
async function answer () {
|
||||||
|
if (!incomingCall.value) return
|
||||||
|
try {
|
||||||
|
await incomingCall.value.accept({
|
||||||
|
sessionDescriptionHandlerOptions: {
|
||||||
|
constraints: { audio: true, video: false },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Answer failed: ' + e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject incoming call.
|
||||||
|
*/
|
||||||
|
function reject () {
|
||||||
|
if (incomingCall.value) {
|
||||||
|
try { incomingCall.value.reject() } catch {}
|
||||||
|
resetCallState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hang up active call.
|
||||||
|
*/
|
||||||
|
function hangup () {
|
||||||
|
if (!currentSession) return
|
||||||
|
try {
|
||||||
|
if (callState.value === 'active') {
|
||||||
|
currentSession.bye()
|
||||||
|
} else {
|
||||||
|
// Ringing or calling — cancel
|
||||||
|
currentSession.cancel?.() || currentSession.reject?.()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
resetCallState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle mute.
|
||||||
|
*/
|
||||||
|
const muted = ref(false)
|
||||||
|
function toggleMute () {
|
||||||
|
if (!currentSession) return
|
||||||
|
const pc = currentSession.sessionDescriptionHandler?.peerConnection
|
||||||
|
if (pc) {
|
||||||
|
pc.getSenders().forEach((sender) => {
|
||||||
|
if (sender.track && sender.track.kind === 'audio') {
|
||||||
|
sender.track.enabled = !sender.track.enabled
|
||||||
|
muted.value = !sender.track.enabled
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle hold (re-INVITE with sendonly/recvonly).
|
||||||
|
*/
|
||||||
|
const held = ref(false)
|
||||||
|
async function toggleHold () {
|
||||||
|
// Simplified hold — mute + hold state
|
||||||
|
// Full SIP hold requires re-INVITE, complex with SIP.js
|
||||||
|
held.value = !held.value
|
||||||
|
toggleMute() // mute audio as basic hold
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send DTMF tone.
|
||||||
|
*/
|
||||||
|
function sendDtmf (tone) {
|
||||||
|
if (!currentSession) return
|
||||||
|
try {
|
||||||
|
currentSession.sessionDescriptionHandler?.sendDtmf(tone)
|
||||||
|
} catch {
|
||||||
|
// Fallback: INFO method
|
||||||
|
try {
|
||||||
|
currentSession.info({
|
||||||
|
requestOptions: {
|
||||||
|
body: {
|
||||||
|
contentDisposition: 'render',
|
||||||
|
contentType: 'application/dtmf-relay',
|
||||||
|
content: `Signal=${tone}\r\nDuration=160`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedDuration = computed(() => {
|
||||||
|
const m = Math.floor(callDuration.value / 60)
|
||||||
|
const s = callDuration.value % 60
|
||||||
|
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// Don't unregister on unmount — keep registration alive across page navigations
|
||||||
|
// Only cleanup when explicitly called
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
registered,
|
||||||
|
registering,
|
||||||
|
callState,
|
||||||
|
callDuration,
|
||||||
|
formattedDuration,
|
||||||
|
callDirection,
|
||||||
|
callRemote,
|
||||||
|
callRemoteName,
|
||||||
|
incomingCall,
|
||||||
|
error,
|
||||||
|
muted,
|
||||||
|
held,
|
||||||
|
// Actions
|
||||||
|
register,
|
||||||
|
unregister,
|
||||||
|
call,
|
||||||
|
answer,
|
||||||
|
reject,
|
||||||
|
hangup,
|
||||||
|
toggleMute,
|
||||||
|
toggleHold,
|
||||||
|
sendDtmf,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,8 @@ export const navItems = [
|
||||||
{ path: '/tickets', icon: 'Ticket', label: 'Tickets' },
|
{ path: '/tickets', icon: 'Ticket', label: 'Tickets' },
|
||||||
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe' },
|
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe' },
|
||||||
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports' },
|
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports' },
|
||||||
{ path: '/ocr', icon: 'ScanText', label: 'OCR Factures' },
|
{ path: '/ocr', icon: 'ScanText', label: 'OCR Factures' },
|
||||||
|
{ path: '/telephony', icon: 'Phone', label: 'Téléphonie' },
|
||||||
{ path: '/settings', icon: 'Settings', label: 'Paramètres' },
|
{ path: '/settings', icon: 'Settings', label: 'Paramètres' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,10 +107,10 @@ import { listDocs } from 'src/api/erp'
|
||||||
import { navItems } from 'src/config/nav'
|
import { navItems } from 'src/config/nav'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
|
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
|
||||||
ScanText, Settings, LogOut, PanelLeftOpen, PanelLeftClose,
|
ScanText, Phone, Settings, LogOut, PanelLeftOpen, PanelLeftClose,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, ScanText, Settings, LogOut, PanelLeftOpen, PanelLeftClose }
|
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, ScanText, Phone, Settings, LogOut, PanelLeftOpen, PanelLeftClose }
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
|
||||||
|
|
@ -335,7 +335,7 @@
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="section-title" style="font-size:1rem;width:100%">
|
<div class="section-title" style="font-size:1rem;width:100%">
|
||||||
<q-icon name="receipt_long" size="20px" class="q-mr-xs" />
|
<q-icon name="receipt_long" size="20px" class="q-mr-xs" />
|
||||||
Factures ({{ invoices.length }})
|
Factures ({{ invoices.length }}{{ !invoicesExpanded ? '+' : '' }})
|
||||||
<span v-if="totalOutstanding > 0" class="text-caption text-red q-ml-sm">
|
<span v-if="totalOutstanding > 0" class="text-caption text-red q-ml-sm">
|
||||||
Solde: {{ formatMoney(totalOutstanding) }}
|
Solde: {{ formatMoney(totalOutstanding) }}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -375,6 +375,10 @@
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
</q-table>
|
</q-table>
|
||||||
|
<div v-if="!invoicesExpanded && invoices.length >= 5" class="text-center q-pa-xs">
|
||||||
|
<q-btn flat dense no-caps color="indigo-6" :loading="loadingMoreInvoices"
|
||||||
|
label="Voir toutes les factures" icon="expand_more" @click="loadAllInvoices" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
||||||
|
|
@ -383,7 +387,7 @@
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="section-title" style="font-size:1rem;width:100%">
|
<div class="section-title" style="font-size:1rem;width:100%">
|
||||||
<q-icon name="payments" size="20px" class="q-mr-xs" />
|
<q-icon name="payments" size="20px" class="q-mr-xs" />
|
||||||
Paiements ({{ payments.length }})
|
Paiements ({{ payments.length }}{{ !paymentsExpanded ? '+' : '' }})
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="!payments.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">
|
<div v-if="!payments.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">
|
||||||
|
|
@ -400,6 +404,10 @@
|
||||||
<q-td :props="props" class="text-right">{{ formatMoney(props.row.paid_amount) }}</q-td>
|
<q-td :props="props" class="text-right">{{ formatMoney(props.row.paid_amount) }}</q-td>
|
||||||
</template>
|
</template>
|
||||||
</q-table>
|
</q-table>
|
||||||
|
<div v-if="!paymentsExpanded && payments.length >= 5" class="text-center q-pa-xs">
|
||||||
|
<q-btn flat dense no-caps color="indigo-6" :loading="loadingMorePayments"
|
||||||
|
label="Voir tous les paiements" icon="expand_more" @click="loadAllPayments" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
||||||
|
|
@ -583,16 +591,15 @@ async function loadCustomer (id) {
|
||||||
comments.value = []
|
comments.value = []
|
||||||
contact.value = null
|
contact.value = null
|
||||||
modalOpen.value = false
|
modalOpen.value = false
|
||||||
|
invoicesExpanded.value = false
|
||||||
|
paymentsExpanded.value = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cust = await getDoc('Customer', id)
|
|
||||||
for (const f of ['is_commercial', 'is_bad_payer', 'exclude_fees', 'ppa_enabled']) { cust[f] = !!cust[f] }
|
|
||||||
customer.value = cust
|
|
||||||
|
|
||||||
const custFilter = { customer: id }
|
const custFilter = { customer: id }
|
||||||
const partyFilter = { party_type: 'Customer', party: id }
|
|
||||||
|
|
||||||
const [locs, subs, equip, tix, invs, pays, ctc, memos] = await Promise.all([
|
// All queries in ONE parallel batch (including customer doc) — no waterfall
|
||||||
|
const [cust, locs, subs, equip, tix, invs, pays, memos, balRes] = await Promise.all([
|
||||||
|
getDoc('Customer', id),
|
||||||
listDocs('Service Location', {
|
listDocs('Service Location', {
|
||||||
filters: custFilter,
|
filters: custFilter,
|
||||||
fields: ['name', 'location_name', 'status', 'address_line', 'city', 'postal_code',
|
fields: ['name', 'location_name', 'status', 'address_line', 'city', 'postal_code',
|
||||||
|
|
@ -607,7 +614,6 @@ async function loadCustomer (id) {
|
||||||
'speed_down', 'speed_up', 'cancellation_date', 'cancellation_reason', 'notes'],
|
'speed_down', 'speed_up', 'cancellation_date', 'cancellation_reason', 'notes'],
|
||||||
limit: 200, orderBy: 'start_date desc',
|
limit: 200, orderBy: 'start_date desc',
|
||||||
}).then(subs => subs.map(s => ({
|
}).then(subs => subs.map(s => ({
|
||||||
// Normalize to match template field names (formerly from Subscription doctype)
|
|
||||||
...s,
|
...s,
|
||||||
actual_price: s.monthly_price,
|
actual_price: s.monthly_price,
|
||||||
custom_description: s.plan_name,
|
custom_description: s.plan_name,
|
||||||
|
|
@ -617,7 +623,6 @@ async function loadCustomer (id) {
|
||||||
billing_frequency: s.billing_cycle === 'Annuel' ? 'A' : 'M',
|
billing_frequency: s.billing_cycle === 'Annuel' ? 'A' : 'M',
|
||||||
cancel_at_period_end: 0,
|
cancel_at_period_end: 0,
|
||||||
cancelation_date: s.cancellation_date,
|
cancelation_date: s.cancellation_date,
|
||||||
// Map status: Actif→Active, Annulé→Cancelled, etc.
|
|
||||||
status: s.status === 'Actif' ? 'Active' : s.status === 'Annulé' ? 'Cancelled' : s.status === 'Suspendu' ? 'Cancelled' : s.status === 'En attente' ? 'Active' : s.status,
|
status: s.status === 'Actif' ? 'Active' : s.status === 'Annulé' ? 'Cancelled' : s.status === 'Suspendu' ? 'Cancelled' : s.status === 'En attente' ? 'Active' : s.status,
|
||||||
}))),
|
}))),
|
||||||
listDocs('Service Equipment', {
|
listDocs('Service Equipment', {
|
||||||
|
|
@ -635,21 +640,23 @@ async function loadCustomer (id) {
|
||||||
listDocs('Sales Invoice', {
|
listDocs('Sales Invoice', {
|
||||||
filters: custFilter,
|
filters: custFilter,
|
||||||
fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount', 'status', 'is_return', 'return_against', 'creation'],
|
fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount', 'status', 'is_return', 'return_against', 'creation'],
|
||||||
limit: 50, orderBy: 'posting_date desc, name desc',
|
limit: 5, orderBy: 'posting_date desc, name desc',
|
||||||
}),
|
}),
|
||||||
listDocs('Payment Entry', {
|
listDocs('Payment Entry', {
|
||||||
filters: { party_type: 'Customer', party: id },
|
filters: { party_type: 'Customer', party: id },
|
||||||
fields: ['name', 'posting_date', 'paid_amount', 'mode_of_payment', 'reference_no'],
|
fields: ['name', 'posting_date', 'paid_amount', 'mode_of_payment', 'reference_no'],
|
||||||
limit: 50, orderBy: 'posting_date desc',
|
limit: 5, orderBy: 'posting_date desc',
|
||||||
}),
|
}),
|
||||||
listDocs('Contact', { filters: {}, fields: ['name', 'first_name', 'last_name', 'email_id', 'mobile_no', 'phone'], limit: 1 }).catch(() => []),
|
|
||||||
listDocs('Comment', {
|
listDocs('Comment', {
|
||||||
filters: { reference_doctype: 'Customer', reference_name: id, comment_type: 'Comment' },
|
filters: { reference_doctype: 'Customer', reference_name: id, comment_type: 'Comment' },
|
||||||
fields: ['name', 'content', 'comment_by', 'creation'],
|
fields: ['name', 'content', 'comment_by', 'creation'],
|
||||||
limit: 50, orderBy: 'creation desc',
|
limit: 50, orderBy: 'creation desc',
|
||||||
}).catch(() => []),
|
}).catch(() => []),
|
||||||
|
authFetch(BASE_URL + '/api/method/customer_balance?customer=' + encodeURIComponent(id)).catch(() => null),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
for (const f of ['is_commercial', 'is_bad_payer', 'exclude_fees', 'ppa_enabled']) { cust[f] = !!cust[f] }
|
||||||
|
customer.value = cust
|
||||||
locations.value = locs
|
locations.value = locs
|
||||||
subscriptions.value = subs
|
subscriptions.value = subs
|
||||||
invalidateAll()
|
invalidateAll()
|
||||||
|
|
@ -657,13 +664,12 @@ async function loadCustomer (id) {
|
||||||
tickets.value = tix.sort((a, b) => (b.is_important || 0) - (a.is_important || 0) || (b.opening_date || '').localeCompare(a.opening_date || ''))
|
tickets.value = tix.sort((a, b) => (b.is_important || 0) - (a.is_important || 0) || (b.opening_date || '').localeCompare(a.opening_date || ''))
|
||||||
invoices.value = invs
|
invoices.value = invs
|
||||||
payments.value = pays
|
payments.value = pays
|
||||||
contact.value = ctc.length ? ctc[0] : null
|
contact.value = null
|
||||||
comments.value = memos
|
comments.value = memos
|
||||||
|
|
||||||
try {
|
if (balRes && balRes.ok) {
|
||||||
const balRes = await authFetch(BASE_URL + '/api/method/customer_balance?customer=' + encodeURIComponent(id))
|
try { accountBalance.value = (await balRes.json()).message } catch {}
|
||||||
if (balRes.ok) { accountBalance.value = (await balRes.json()).message }
|
}
|
||||||
} catch {}
|
|
||||||
} catch {
|
} catch {
|
||||||
customer.value = null
|
customer.value = null
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -671,6 +677,40 @@ async function loadCustomer (id) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Lazy-load more invoices/payments on demand ──
|
||||||
|
const invoicesExpanded = ref(false)
|
||||||
|
const paymentsExpanded = ref(false)
|
||||||
|
const loadingMoreInvoices = ref(false)
|
||||||
|
const loadingMorePayments = ref(false)
|
||||||
|
|
||||||
|
async function loadAllInvoices () {
|
||||||
|
if (invoicesExpanded.value || !customer.value) return
|
||||||
|
loadingMoreInvoices.value = true
|
||||||
|
try {
|
||||||
|
invoices.value = await listDocs('Sales Invoice', {
|
||||||
|
filters: { customer: customer.value.name },
|
||||||
|
fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount', 'status', 'is_return', 'return_against', 'creation'],
|
||||||
|
limit: 200, orderBy: 'posting_date desc, name desc',
|
||||||
|
})
|
||||||
|
invoicesExpanded.value = true
|
||||||
|
} catch {}
|
||||||
|
loadingMoreInvoices.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllPayments () {
|
||||||
|
if (paymentsExpanded.value || !customer.value) return
|
||||||
|
loadingMorePayments.value = true
|
||||||
|
try {
|
||||||
|
payments.value = await listDocs('Payment Entry', {
|
||||||
|
filters: { party_type: 'Customer', party: customer.value.name },
|
||||||
|
fields: ['name', 'posting_date', 'paid_amount', 'mode_of_payment', 'reference_no'],
|
||||||
|
limit: 200, orderBy: 'posting_date desc',
|
||||||
|
})
|
||||||
|
paymentsExpanded.value = true
|
||||||
|
} catch {}
|
||||||
|
loadingMorePayments.value = false
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => props.id, (newId) => { if (newId) loadCustomer(newId) })
|
watch(() => props.id, (newId) => { if (newId) loadCustomer(newId) })
|
||||||
onMounted(() => loadCustomer(props.id))
|
onMounted(() => loadCustomer(props.id))
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,49 @@
|
||||||
</div>
|
</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 -->
|
<!-- ERPNext Links -->
|
||||||
<div class="ops-card">
|
<div class="ops-card">
|
||||||
<div class="section-title q-mb-sm">
|
<div class="section-title q-mb-sm">
|
||||||
|
|
@ -166,6 +209,7 @@ import { authFetch } from 'src/api/auth'
|
||||||
import { BASE_URL } from 'src/config/erpnext'
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
import { ERP_DESK_URL as erpDeskUrl } from 'src/config/erpnext'
|
import { ERP_DESK_URL as erpDeskUrl } from 'src/config/erpnext'
|
||||||
import { sendTestSms } from 'src/api/sms'
|
import { sendTestSms } from 'src/api/sms'
|
||||||
|
import { getPhoneConfig, savePhoneConfig, fetch3cxCredentials } from 'src/composables/usePhone'
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const settings = ref({})
|
const settings = ref({})
|
||||||
|
|
@ -173,6 +217,36 @@ const showToken = ref(false)
|
||||||
const testingSms = ref(false)
|
const testingSms = ref(false)
|
||||||
const smsTestResult = ref(null)
|
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
|
// Snapshot for change detection
|
||||||
const snapshots = {}
|
const snapshots = {}
|
||||||
|
|
||||||
|
|
|
||||||
359
apps/ops/src/pages/TelephonyPage.vue
Normal file
359
apps/ops/src/pages/TelephonyPage.vue
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
<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>
|
||||||
|
|
@ -14,6 +14,7 @@ const routes = [
|
||||||
{ path: 'rapports', component: () => import('src/pages/RapportsPage.vue') },
|
{ path: 'rapports', component: () => import('src/pages/RapportsPage.vue') },
|
||||||
{ path: 'ocr', component: () => import('src/pages/OcrPage.vue') },
|
{ path: 'ocr', component: () => import('src/pages/OcrPage.vue') },
|
||||||
{ path: 'settings', component: () => import('src/pages/SettingsPage.vue') },
|
{ path: 'settings', component: () => import('src/pages/SettingsPage.vue') },
|
||||||
|
{ path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') },
|
||||||
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
|
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
63
scripts/migration/add_performance_indexes.sql
Normal file
63
scripts/migration/add_performance_indexes.sql
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
-- ============================================================
|
||||||
|
-- Performance indexes for ERPNext PostgreSQL
|
||||||
|
-- ============================================================
|
||||||
|
-- Problem: ERPNext v16 PostgreSQL migration does not create
|
||||||
|
-- indexes on 'customer' / 'party' columns. This causes full
|
||||||
|
-- table scans on tables with 100k-1M+ rows, resulting in
|
||||||
|
-- 5+ second page loads in Ops client detail page.
|
||||||
|
--
|
||||||
|
-- Impact: Sales Invoice query dropped from 1,248ms to 28ms
|
||||||
|
-- (EXPLAIN ANALYZE: 378ms → 0.36ms, 1000x improvement)
|
||||||
|
--
|
||||||
|
-- Run: docker exec erpnext-db-1 psql -U postgres -d <dbname> -f /path/to/this.sql
|
||||||
|
-- Or run each CREATE INDEX statement individually.
|
||||||
|
--
|
||||||
|
-- All indexes use CONCURRENTLY to avoid locking production tables.
|
||||||
|
-- Run each statement in its own transaction (outside BEGIN/COMMIT).
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Customer lookup on Sales Invoice (630k+ rows)
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_si_customer
|
||||||
|
ON "tabSales Invoice" (customer);
|
||||||
|
|
||||||
|
-- Composite index for sorted invoice listing per customer
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_si_posting
|
||||||
|
ON "tabSales Invoice" (customer, posting_date DESC);
|
||||||
|
|
||||||
|
-- Party lookup on Payment Entry (344k+ rows)
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_pe_party
|
||||||
|
ON "tabPayment Entry" (party);
|
||||||
|
|
||||||
|
-- Composite index for sorted payment listing per party
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_pe_posting
|
||||||
|
ON "tabPayment Entry" (party, posting_date DESC);
|
||||||
|
|
||||||
|
-- Reference lookup on Comment (1.07M+ rows)
|
||||||
|
-- Used by: ticket comments, invoice notes, customer memos
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_comment_ref
|
||||||
|
ON "tabComment" (reference_doctype, reference_name);
|
||||||
|
|
||||||
|
-- Customer lookup on Issue (243k+ rows)
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_issue_customer
|
||||||
|
ON "tabIssue" (customer);
|
||||||
|
|
||||||
|
-- Customer lookup on Service Location (17k+ rows)
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sl_customer
|
||||||
|
ON "tabService Location" (customer);
|
||||||
|
|
||||||
|
-- Customer lookup on Service Subscription (40k+ rows)
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ss_customer
|
||||||
|
ON "tabService Subscription" (customer);
|
||||||
|
|
||||||
|
-- Customer lookup on Service Equipment (10k+ rows)
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_se_customer
|
||||||
|
ON "tabService Equipment" (customer);
|
||||||
|
|
||||||
|
-- Update planner statistics after index creation
|
||||||
|
ANALYZE "tabSales Invoice";
|
||||||
|
ANALYZE "tabPayment Entry";
|
||||||
|
ANALYZE "tabComment";
|
||||||
|
ANALYZE "tabIssue";
|
||||||
|
ANALYZE "tabService Location";
|
||||||
|
ANALYZE "tabService Subscription";
|
||||||
|
ANALYZE "tabService Equipment";
|
||||||
|
|
@ -7,6 +7,7 @@ Also adds custom fields to Service Subscription for RADIUS/legacy data.
|
||||||
Run inside erpnext-backend-1:
|
Run inside erpnext-backend-1:
|
||||||
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/import_services_and_enrich_customers.py
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/import_services_and_enrich_customers.py
|
||||||
"""
|
"""
|
||||||
|
import html
|
||||||
import frappe
|
import frappe
|
||||||
import pymysql
|
import pymysql
|
||||||
import os
|
import os
|
||||||
|
|
@ -221,7 +222,7 @@ for svc in services:
|
||||||
service_category = PROD_CAT_MAP.get(prod_cat, "Autre")
|
service_category = PROD_CAT_MAP.get(prod_cat, "Autre")
|
||||||
|
|
||||||
# Plan name
|
# Plan name
|
||||||
plan_name = prod_names.get(svc["product_id"], svc["sku"] or "Unknown")
|
plan_name = html.unescape(prod_names.get(svc["product_id"], svc["sku"] or "Unknown"))
|
||||||
|
|
||||||
# Speed (legacy stores in kbps, convert to Mbps)
|
# Speed (legacy stores in kbps, convert to Mbps)
|
||||||
speed_down = 0
|
speed_down = 0
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import sys
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
|
import html
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
os.chdir("/home/frappe/frappe-bench/sites")
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
|
@ -213,8 +214,8 @@ for i, ss in enumerate(ss_rows):
|
||||||
# Category
|
# Category
|
||||||
category = ss.get("service_category") or ""
|
category = ss.get("service_category") or ""
|
||||||
|
|
||||||
# Description: plan_name from SS
|
# Description: plan_name from SS (unescape HTML entities from legacy data)
|
||||||
description = ss.get("plan_name") or ""
|
description = html.unescape(ss.get("plan_name") or "")
|
||||||
|
|
||||||
sub_id = uid("SUB-")
|
sub_id = uid("SUB-")
|
||||||
|
|
||||||
|
|
|
||||||
254
scripts/migration/rename_customers_c_prefix.py
Normal file
254
scripts/migration/rename_customers_c_prefix.py
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Prepend C- to all customer names.
|
||||||
|
|
||||||
|
Current state: customers have raw legacy_customer_id as name
|
||||||
|
e.g. LPB4, 114796350603272, DOMIL5149490230
|
||||||
|
|
||||||
|
After: C-LPB4, C-114796350603272, C-DOMIL5149490230
|
||||||
|
|
||||||
|
New customers: C-10000000034941+ (naming_series C-.##############)
|
||||||
|
|
||||||
|
Two-phase rename to avoid PK collisions:
|
||||||
|
Phase A: old → _TMP_C-old
|
||||||
|
Phase B: _TMP_C-old → C-old
|
||||||
|
"""
|
||||||
|
import os, sys, time
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
import frappe
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print(f"Connected: {frappe.local.site}")
|
||||||
|
|
||||||
|
DRY_RUN = "--dry-run" in sys.argv
|
||||||
|
if DRY_RUN:
|
||||||
|
print("*** DRY RUN ***")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 1: Build mapping — prepend C- to every customer name
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 1: BUILD MAPPING")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
customers = frappe.db.sql("""
|
||||||
|
SELECT name FROM "tabCustomer" ORDER BY name
|
||||||
|
""", as_dict=True)
|
||||||
|
|
||||||
|
mapping = {} # old → new
|
||||||
|
new_used = set()
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for c in customers:
|
||||||
|
old = c["name"]
|
||||||
|
# Skip if already has C- prefix (idempotent)
|
||||||
|
if old.startswith("C-"):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
new = f"C-{old}"
|
||||||
|
if new in new_used:
|
||||||
|
new = f"{new}-dup{len(new_used)}"
|
||||||
|
new_used.add(new)
|
||||||
|
mapping[old] = new
|
||||||
|
|
||||||
|
print(f"Total customers: {len(customers)}")
|
||||||
|
print(f"Will rename: {len(mapping)}")
|
||||||
|
print(f"Already C- prefixed (skip): {skipped}")
|
||||||
|
|
||||||
|
# Samples
|
||||||
|
for old, new in list(mapping.items())[:15]:
|
||||||
|
print(f" {old:40s} → {new}")
|
||||||
|
|
||||||
|
if DRY_RUN:
|
||||||
|
print("\n*** DRY RUN complete ***")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if not mapping:
|
||||||
|
print("Nothing to rename!")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 2: Create temp mapping table
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 2: TEMP MAPPING TABLE")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
frappe.db.sql('DROP TABLE IF EXISTS _cust_cpre_map')
|
||||||
|
frappe.db.sql("""
|
||||||
|
CREATE TEMP TABLE _cust_cpre_map (
|
||||||
|
old_name VARCHAR(140) PRIMARY KEY,
|
||||||
|
new_name VARCHAR(140) NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
items = list(mapping.items())
|
||||||
|
for start in range(0, len(items), 1000):
|
||||||
|
batch = items[start:start + 1000]
|
||||||
|
frappe.db.sql(
|
||||||
|
"INSERT INTO _cust_cpre_map (old_name, new_name) VALUES " +
|
||||||
|
",".join(["(%s, %s)"] * len(batch)),
|
||||||
|
[v for pair in batch for v in pair]
|
||||||
|
)
|
||||||
|
frappe.db.commit()
|
||||||
|
print(f" {len(mapping)} mappings loaded")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 3: Update all FK references
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 3: UPDATE FK REFERENCES")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
fk_tables = [
|
||||||
|
("tabSales Invoice", "customer", ""),
|
||||||
|
("tabPayment Entry", "party", ""),
|
||||||
|
("tabGL Entry", "party", ""),
|
||||||
|
("tabPayment Ledger Entry", "party", ""),
|
||||||
|
("tabIssue", "customer", ""),
|
||||||
|
("tabService Location", "customer", ""),
|
||||||
|
("tabService Subscription", "customer", ""),
|
||||||
|
("tabSubscription", "party", ""),
|
||||||
|
("tabService Equipment", "customer", ""),
|
||||||
|
("tabDispatch Job", "customer", ""),
|
||||||
|
("tabDynamic Link", "link_name", "AND t.link_doctype = 'Customer'"),
|
||||||
|
("tabComment", "reference_name", "AND t.reference_doctype = 'Customer'"),
|
||||||
|
("tabCommunication", "reference_name", "AND t.reference_doctype = 'Customer'"),
|
||||||
|
("tabVersion", "docname", "AND t.ref_doctype = 'Customer'"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for table, col, extra in fk_tables:
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
frappe.db.sql(f"""
|
||||||
|
UPDATE "{table}" t SET "{col}" = m.new_name
|
||||||
|
FROM _cust_cpre_map m
|
||||||
|
WHERE t."{col}" = m.old_name {extra}
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
print(f" {table:35s} {col:20s} [{time.time()-t0:.1f}s]")
|
||||||
|
except Exception as e:
|
||||||
|
frappe.db.rollback()
|
||||||
|
err = str(e)[:80]
|
||||||
|
if "does not exist" not in err:
|
||||||
|
print(f" {table:35s} ERR: {err}")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 4: Two-phase rename customers
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 4: RENAME CUSTOMERS (two-phase)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Phase A: old → _TMP_C-old
|
||||||
|
t0 = time.time()
|
||||||
|
for start in range(0, len(items), 500):
|
||||||
|
batch = items[start:start + 500]
|
||||||
|
cases = " ".join(f"WHEN '{old}' THEN '_TMP_{new}'" for old, new in batch)
|
||||||
|
old_names = "','".join(old for old, _ in batch)
|
||||||
|
frappe.db.sql(f"""
|
||||||
|
UPDATE "tabCustomer"
|
||||||
|
SET name = CASE name {cases} END
|
||||||
|
WHERE name IN ('{old_names}')
|
||||||
|
""")
|
||||||
|
if (start + 500) % 5000 < 500:
|
||||||
|
frappe.db.commit()
|
||||||
|
print(f" A: {min(start+500, len(items))}/{len(items)}")
|
||||||
|
frappe.db.commit()
|
||||||
|
print(f" Phase A done [{time.time()-t0:.1f}s]")
|
||||||
|
|
||||||
|
# Phase B: _TMP_C-old → C-old
|
||||||
|
t0 = time.time()
|
||||||
|
for start in range(0, len(items), 500):
|
||||||
|
batch = items[start:start + 500]
|
||||||
|
cases = " ".join(f"WHEN '_TMP_{new}' THEN '{new}'" for _, new in batch)
|
||||||
|
temp_names = "','".join(f"_TMP_{new}" for _, new in batch)
|
||||||
|
frappe.db.sql(f"""
|
||||||
|
UPDATE "tabCustomer"
|
||||||
|
SET name = CASE name {cases} END
|
||||||
|
WHERE name IN ('{temp_names}')
|
||||||
|
""")
|
||||||
|
if (start + 500) % 5000 < 500:
|
||||||
|
frappe.db.commit()
|
||||||
|
print(f" B: {min(start+500, len(items))}/{len(items)}")
|
||||||
|
frappe.db.commit()
|
||||||
|
print(f" Phase B done [{time.time()-t0:.1f}s]")
|
||||||
|
|
||||||
|
frappe.db.sql('DROP TABLE IF EXISTS _cust_cpre_map')
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 5: Set naming series — C-.############## → C-10000000034941+
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 5: NAMING SERIES")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Update Customer doctype naming_series options
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabDocField"
|
||||||
|
SET options = 'C-.##############', "default" = 'C-.##############'
|
||||||
|
WHERE parent = 'Customer' AND fieldname = 'naming_series'
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" naming_series options: C-.##############")
|
||||||
|
|
||||||
|
# Remove old C series (no dash), set new C- series
|
||||||
|
# Counter 10000000034940 means next = C-10000000034941
|
||||||
|
frappe.db.sql("DELETE FROM \"tabSeries\" WHERE name = 'C'")
|
||||||
|
|
||||||
|
series = frappe.db.sql("SELECT current FROM \"tabSeries\" WHERE name = 'C-'", as_dict=True)
|
||||||
|
if series:
|
||||||
|
frappe.db.sql("UPDATE \"tabSeries\" SET current = 10000000034940 WHERE name = 'C-'")
|
||||||
|
else:
|
||||||
|
frappe.db.sql("INSERT INTO \"tabSeries\" (name, current) VALUES ('C-', 10000000034940)")
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" C- series counter: 10000000034940")
|
||||||
|
print(" Next new customer: C-10000000034941")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 6: Verify
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("VERIFICATION")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
total = frappe.db.sql('SELECT COUNT(*) FROM "tabCustomer"')[0][0]
|
||||||
|
with_c = frappe.db.sql("SELECT COUNT(*) FROM \"tabCustomer\" WHERE name LIKE 'C-%%'")[0][0]
|
||||||
|
without_c = total - with_c
|
||||||
|
print(f" Customers total: {total}")
|
||||||
|
print(f" With C- prefix: {with_c}")
|
||||||
|
print(f" Without C- prefix: {without_c} (should be 0)")
|
||||||
|
|
||||||
|
# Samples
|
||||||
|
samples = frappe.db.sql("""
|
||||||
|
SELECT name, customer_name, legacy_customer_id
|
||||||
|
FROM "tabCustomer" ORDER BY name LIMIT 15
|
||||||
|
""", as_dict=True)
|
||||||
|
for s in samples:
|
||||||
|
print(f" {s['name']:30s} {s.get('legacy_customer_id',''):20s} {s['customer_name']}")
|
||||||
|
|
||||||
|
# Spot checks
|
||||||
|
for lid, label in [(4, "LPB4"), (13814, "Vegpro")]:
|
||||||
|
c = frappe.db.sql('SELECT name, legacy_customer_id FROM "tabCustomer" WHERE legacy_account_id = %s', (lid,), as_dict=True)
|
||||||
|
if c:
|
||||||
|
print(f" {label}: {c[0]['name']} (bank ref: {c[0].get('legacy_customer_id','')})")
|
||||||
|
|
||||||
|
# FK checks
|
||||||
|
for table, col in [("tabSales Invoice", "customer"), ("tabSubscription", "party"), ("tabIssue", "customer")]:
|
||||||
|
orphans = frappe.db.sql(f"""
|
||||||
|
SELECT COUNT(*) FROM "{table}" t
|
||||||
|
WHERE t."{col}" IS NOT NULL AND t."{col}" != ''
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM "tabCustomer" c WHERE c.name = t."{col}")
|
||||||
|
""")[0][0]
|
||||||
|
print(f" {table}.{col}: {'OK ✓' if orphans == 0 else f'ORPHANS: {orphans}'}")
|
||||||
|
|
||||||
|
frappe.clear_cache()
|
||||||
|
print("\nDone!")
|
||||||
126
scripts/server_bulk_submit.py
Normal file
126
scripts/server_bulk_submit.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Server-side bulk submit script for ERPNext migration data.
|
||||||
|
Run this INSIDE the erpnext-backend container via bench console.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
1. SSH into server: ssh root@96.125.196.67
|
||||||
|
2. Enter container: docker exec -it erpnext-backend-1 bash
|
||||||
|
3. Go to bench: cd /home/frappe/frappe-bench
|
||||||
|
4. Run: bench --site all execute scripts.server_bulk_submit.run
|
||||||
|
|
||||||
|
OR copy this file and run with bench console:
|
||||||
|
bench --site erp.gigafibre.ca console
|
||||||
|
Then paste the code below.
|
||||||
|
|
||||||
|
BEFORE RUNNING: Fix the PostgreSQL GROUP BY bug first! (see fix_ple_postgres.sh)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# === Paste this into bench console ===
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
def run():
|
||||||
|
"""Main entry point for bench execute"""
|
||||||
|
site = frappe.local.site
|
||||||
|
print(f"Running on site: {site}")
|
||||||
|
|
||||||
|
# Mute all emails and notifications
|
||||||
|
frappe.flags.mute_emails = True
|
||||||
|
frappe.flags.mute_notifications = True
|
||||||
|
frappe.flags.in_import = True
|
||||||
|
|
||||||
|
# Step 1: Enable disabled items
|
||||||
|
print("\n═══ Step 1: Enable disabled Items ═══")
|
||||||
|
disabled_items = frappe.get_all("Item", filters={"disabled": 1}, pluck="name")
|
||||||
|
print(f" Found {len(disabled_items)} disabled items")
|
||||||
|
for item_name in disabled_items:
|
||||||
|
frappe.db.set_value("Item", item_name, "disabled", 0, update_modified=False)
|
||||||
|
frappe.db.commit()
|
||||||
|
print(f" Enabled all {len(disabled_items)} items")
|
||||||
|
|
||||||
|
# Step 2: Submit Sales Invoices
|
||||||
|
print("\n═══ Step 2: Submit Sales Invoices ═══")
|
||||||
|
draft_invoices = frappe.get_all(
|
||||||
|
"Sales Invoice",
|
||||||
|
filters={"docstatus": 0},
|
||||||
|
pluck="name",
|
||||||
|
order_by="posting_date asc",
|
||||||
|
limit_page_length=0
|
||||||
|
)
|
||||||
|
total_inv = len(draft_invoices)
|
||||||
|
print(f" Total draft invoices: {total_inv}")
|
||||||
|
|
||||||
|
ok, fail = 0, 0
|
||||||
|
errors = []
|
||||||
|
for i, inv_name in enumerate(draft_invoices):
|
||||||
|
try:
|
||||||
|
inv = frappe.get_doc("Sales Invoice", inv_name)
|
||||||
|
inv.set_posting_time = 1 # keep original posting_date
|
||||||
|
inv.flags.ignore_permissions = True
|
||||||
|
inv.flags.mute_emails = True
|
||||||
|
inv.flags.ignore_notifications = True
|
||||||
|
inv.submit()
|
||||||
|
|
||||||
|
ok += 1
|
||||||
|
if ok % 100 == 0:
|
||||||
|
frappe.db.commit()
|
||||||
|
print(f" Progress: {ok + fail}/{total_inv} (ok={ok}, fail={fail})")
|
||||||
|
except Exception as e:
|
||||||
|
fail += 1
|
||||||
|
frappe.db.rollback()
|
||||||
|
if len(errors) < 30:
|
||||||
|
errors.append(f"{inv_name}: {str(e)[:150]}")
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print(f" Done: submitted={ok}, failed={fail}")
|
||||||
|
if errors:
|
||||||
|
print(f" Errors (first {len(errors)}):")
|
||||||
|
for e in errors:
|
||||||
|
print(f" {e}")
|
||||||
|
|
||||||
|
# Step 3: Submit Payment Entries
|
||||||
|
print("\n═══ Step 3: Submit Payment Entries ═══")
|
||||||
|
draft_payments = frappe.get_all(
|
||||||
|
"Payment Entry",
|
||||||
|
filters={"docstatus": 0},
|
||||||
|
pluck="name",
|
||||||
|
order_by="posting_date asc",
|
||||||
|
limit_page_length=0
|
||||||
|
)
|
||||||
|
total_pe = len(draft_payments)
|
||||||
|
print(f" Total draft payments: {total_pe}")
|
||||||
|
|
||||||
|
ok, fail = 0, 0
|
||||||
|
errors = []
|
||||||
|
for i, pe_name in enumerate(draft_payments):
|
||||||
|
try:
|
||||||
|
pe = frappe.get_doc("Payment Entry", pe_name)
|
||||||
|
pe.flags.ignore_permissions = True
|
||||||
|
pe.flags.mute_emails = True
|
||||||
|
pe.flags.ignore_notifications = True
|
||||||
|
pe.submit()
|
||||||
|
|
||||||
|
ok += 1
|
||||||
|
if ok % 100 == 0:
|
||||||
|
frappe.db.commit()
|
||||||
|
print(f" Progress: {ok + fail}/{total_pe} (ok={ok}, fail={fail})")
|
||||||
|
except Exception as e:
|
||||||
|
fail += 1
|
||||||
|
frappe.db.rollback()
|
||||||
|
if len(errors) < 30:
|
||||||
|
errors.append(f"{pe_name}: {str(e)[:150]}")
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print(f" Done: submitted={ok}, failed={fail}")
|
||||||
|
if errors:
|
||||||
|
print(f" Errors (first {len(errors)}):")
|
||||||
|
for e in errors:
|
||||||
|
print(f" {e}")
|
||||||
|
|
||||||
|
# Unmute
|
||||||
|
frappe.flags.mute_emails = False
|
||||||
|
frappe.flags.mute_notifications = False
|
||||||
|
frappe.flags.in_import = False
|
||||||
|
|
||||||
|
print("\n✓ All done!")
|
||||||
33
services/targo-hub/docker-compose.yml
Normal file
33
services/targo-hub/docker-compose.yml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
services:
|
||||||
|
targo-hub:
|
||||||
|
image: node:20-alpine
|
||||||
|
container_name: targo-hub
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ./server.js:/app/server.js:ro
|
||||||
|
- ./package.json:/app/package.json:ro
|
||||||
|
command: node server.js
|
||||||
|
env_file: .env
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
- erpnext_erpnext
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=proxy"
|
||||||
|
|
||||||
|
# Main router — webhooks + send + health (no auth)
|
||||||
|
- "traefik.http.routers.targo-hub.rule=Host(`msg.gigafibre.ca`)"
|
||||||
|
- "traefik.http.routers.targo-hub.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.targo-hub.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.targo-hub.loadbalancer.server.port=3300"
|
||||||
|
|
||||||
|
# Disable response buffering for SSE
|
||||||
|
- "traefik.http.middlewares.sse-headers.headers.customresponseheaders.X-Accel-Buffering=no"
|
||||||
|
- "traefik.http.routers.targo-hub.middlewares=sse-headers"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
|
erpnext_erpnext:
|
||||||
|
external: true
|
||||||
13
services/targo-hub/package.json
Normal file
13
services/targo-hub/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"name": "targo-hub",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "SSE relay + unified message hub for Targo/Gigafibre",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"twilio": "^5.5.0",
|
||||||
|
"pg": "^8.13.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1038
services/targo-hub/server.js
Normal file
1038
services/targo-hub/server.js
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user