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:
louispaulb 2026-04-02 13:59:59 -04:00
parent 413e15b16c
commit 4693bcf60c
34 changed files with 4264 additions and 50 deletions

View File

@ -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 || []
}

View 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 }
}

View 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 }
}

View File

@ -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);
}
}
}

View File

@ -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' },
] ]

View File

@ -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>

View 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>

View File

@ -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()

View 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' }}
&middot; {{ 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>

View 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 }} &middot; 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>

View File

@ -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()

View File

@ -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') },
], ],
}, },

View File

@ -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": {

View File

@ -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"

View File

@ -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

View File

@ -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
} }
} }

View File

@ -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 -->

View 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>

View File

@ -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 {

View 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,
}
}

View File

@ -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' },
] ]

View File

@ -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()

View File

@ -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: ActifActive, 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>

View File

@ -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 = {}

View 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>

View File

@ -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') },
], ],
}, },

View 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";

View File

@ -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

View File

@ -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-")

View 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!")

View 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!")

View 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

View 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

File diff suppressed because it is too large Load Diff