Accumulated work on the dispatch/legacy-writeback branch: - Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard, PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs; hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js. - Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js, sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental migration scripts; LegacySyncPage, SupplierInvoices + negative-billing / terminated-active reports. - Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit, olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers, legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets, ClientDetail updates. - docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
319 lines
12 KiB
JavaScript
319 lines
12 KiB
JavaScript
import { ref } from 'vue'
|
|
import { listDocs, getDoc } from 'src/api/erp'
|
|
import { authFetch } from 'src/api/auth'
|
|
import { BASE_URL } from 'src/config/erpnext'
|
|
import { HUB_URL as _HUB_URL } from 'src/config/hub'
|
|
|
|
export function useClientData (deps) {
|
|
const { equipment, modalOpen, ticketsExpanded, invoicesExpanded, paymentsExpanded, invalidateAll, fetchStatus, fetchOltStatus } = deps
|
|
|
|
const loading = ref(true)
|
|
const customer = ref(null)
|
|
const contact = ref(null)
|
|
const locations = ref([])
|
|
const subscriptions = ref([])
|
|
const tickets = ref([])
|
|
const invoices = ref([])
|
|
const payments = ref([])
|
|
const voipLines = ref([])
|
|
const paymentMethods = ref([])
|
|
const arrangements = ref([])
|
|
const quotations = ref([])
|
|
const serviceContracts = ref([])
|
|
const comments = ref([])
|
|
const accountBalance = ref(null)
|
|
|
|
const loadingMoreTickets = ref(false)
|
|
const loadingMoreInvoices = ref(false)
|
|
const loadingMorePayments = ref(false)
|
|
|
|
function resetState () {
|
|
loading.value = true
|
|
customer.value = null
|
|
locations.value = []
|
|
subscriptions.value = []
|
|
equipment.value = []
|
|
tickets.value = []
|
|
invoices.value = []
|
|
payments.value = []
|
|
voipLines.value = []
|
|
paymentMethods.value = []
|
|
arrangements.value = []
|
|
quotations.value = []
|
|
serviceContracts.value = []
|
|
comments.value = []
|
|
contact.value = null
|
|
modalOpen.value = false
|
|
ticketsExpanded.value = false
|
|
invoicesExpanded.value = false
|
|
paymentsExpanded.value = false
|
|
}
|
|
|
|
function loadLocations (custFilter) {
|
|
return listDocs('Service Location', {
|
|
filters: custFilter,
|
|
fields: ['name', 'location_name', 'status', 'address_line', 'city', 'postal_code',
|
|
'connection_type', 'olt_port', 'network_id', 'contact_name', 'contact_phone',
|
|
'longitude', 'latitude'],
|
|
limit: 100, orderBy: 'status asc, address_line asc',
|
|
})
|
|
}
|
|
|
|
async function loadSubscriptions (custFilter) {
|
|
// Service Subscription is the canonical subscription doctype: contracts,
|
|
// chain activation, prorated billing and the "Add service" dialog all
|
|
// converge on it. Stock ERPNext Subscription rows are legacy-migration
|
|
// artefacts (~39k paired at import time) — we no longer create them and
|
|
// we no longer surface them here.
|
|
const subs = await listDocs('Service Subscription', {
|
|
filters: { customer: custFilter.customer },
|
|
fields: ['name', 'plan_name', 'service_category', 'monthly_price', 'billing_cycle',
|
|
'service_location', 'status', 'start_date', 'end_date', 'cancellation_date',
|
|
'contract_duration', 'product_sku', 'speed_down', 'speed_up',
|
|
'radius_user', 'device', 'display_order', 'legacy_service_id'],
|
|
limit: 100,
|
|
// display_order first (dispatcher-controlled), then by start date so
|
|
// newer subs float up when display_order hasn't been set yet (= 0).
|
|
orderBy: 'display_order asc, start_date desc, creation desc',
|
|
})
|
|
|
|
// Map Service Subscription → UI row shape (matches what useSubscriptionGroups
|
|
// / useSubscriptionActions / the template consume: `actual_price`, `custom_description`,
|
|
// `billing_frequency`, English status strings, `cancel_at_period_end`).
|
|
const toUiStatus = s => {
|
|
if (s === 'Actif') return 'Active'
|
|
if (s === 'Annulé') return 'Cancelled'
|
|
if (s === 'Suspendu') return 'Suspended'
|
|
if (s === 'En attente') return 'Pending'
|
|
return s || 'Active'
|
|
}
|
|
|
|
return subs.map(doc => ({
|
|
name: doc.name,
|
|
subscription: doc.name,
|
|
plan_name: doc.plan_name || '',
|
|
item_code: doc.product_sku || '',
|
|
item_name: doc.plan_name || '',
|
|
item_group: doc.service_category || '',
|
|
custom_description: doc.plan_name || '',
|
|
actual_price: Number(doc.monthly_price || 0),
|
|
service_location: doc.service_location || '',
|
|
billing_frequency: doc.billing_cycle === 'Annuel' ? 'A' : 'M',
|
|
status: toUiStatus(doc.status),
|
|
start_date: doc.start_date,
|
|
end_date: doc.end_date,
|
|
cancel_at_period_end: doc.end_date ? 1 : 0,
|
|
cancelation_date: doc.cancellation_date,
|
|
current_invoice_start: null,
|
|
current_invoice_end: null,
|
|
radius_user: doc.radius_user || '',
|
|
radius_pwd: '',
|
|
device: doc.device || '',
|
|
display_order: Number(doc.display_order || 0),
|
|
legacy_service_id: Number(doc.legacy_service_id || 0), // = service.id de F (0 = ajouté dans ERPNext, pas dans F)
|
|
qty: 1,
|
|
}))
|
|
}
|
|
|
|
function loadEquipment (custFilter) {
|
|
return listDocs('Service Equipment', {
|
|
filters: custFilter,
|
|
fields: ['name', 'equipment_type', 'brand', 'model', 'serial_number', 'mac_address',
|
|
'ip_address', 'status', 'service_location',
|
|
'olt_name', 'olt_ip', 'olt_frame', 'olt_slot', 'olt_port', 'olt_ontid'],
|
|
limit: 200, orderBy: 'equipment_type asc',
|
|
})
|
|
}
|
|
|
|
function loadTickets (custFilter) {
|
|
return listDocs('Issue', {
|
|
filters: custFilter,
|
|
fields: ['name', 'subject', 'status', 'priority', 'opening_date', 'service_location', 'legacy_ticket_id', 'is_important', 'assigned_staff', 'opened_by_staff', 'issue_type'],
|
|
limit: 10, orderBy: 'is_important desc, opening_date desc',
|
|
})
|
|
}
|
|
|
|
function loadInvoices (custFilter) {
|
|
return listDocs('Sales Invoice', {
|
|
filters: custFilter,
|
|
fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount', 'status', 'is_return', 'return_against', 'creation'],
|
|
limit: 5, orderBy: 'posting_date desc, name desc',
|
|
})
|
|
}
|
|
|
|
function loadPayments (id) {
|
|
return listDocs('Payment Entry', {
|
|
filters: { party_type: 'Customer', party: id },
|
|
fields: ['name', 'posting_date', 'creation', 'paid_amount', 'mode_of_payment', 'reference_no'],
|
|
limit: 5, orderBy: 'creation desc',
|
|
})
|
|
}
|
|
|
|
function loadVoipLines (custFilter) {
|
|
return listDocs('VoIP Line', {
|
|
filters: custFilter,
|
|
fields: ['name', 'did', 'status', 'service_location', 'sip_user', 'sip_host',
|
|
'e911_civic_number', 'e911_street_name', 'e911_municipality', 'e911_synced',
|
|
'ata_model', 'ata_mac'],
|
|
limit: 50, orderBy: 'status asc, did asc',
|
|
}).catch(() => [])
|
|
}
|
|
|
|
function loadPaymentMethods (custId) {
|
|
// Fetch via targo-hub which enriches with live Stripe card data
|
|
return fetch(`${_HUB_URL}/payments/methods/${encodeURIComponent(custId)}`, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}).then(r => r.json()).then(d => d.methods || []).catch(() => [])
|
|
}
|
|
|
|
function loadArrangements (custFilter) {
|
|
return listDocs('Payment Arrangement', {
|
|
filters: custFilter,
|
|
fields: ['name', 'status', 'total_amount', 'payment_method', 'date_agreed', 'date_due', 'date_cutoff', 'staff', 'note'],
|
|
limit: 50, orderBy: 'date_agreed desc',
|
|
}).catch(() => [])
|
|
}
|
|
|
|
function loadQuotations (id) {
|
|
// Both legacy (custom_legacy_soumission_id > 0) and new wizard-created
|
|
// Quotations are returned. Filtering only on party_name surfaces both.
|
|
return listDocs('Quotation', {
|
|
filters: { party_name: id },
|
|
fields: ['name', 'transaction_date', 'grand_total', 'status', 'custom_legacy_soumission_id', 'custom_po_number'],
|
|
limit: 50, orderBy: 'transaction_date desc',
|
|
}).catch(() => [])
|
|
}
|
|
|
|
function loadServiceContracts (id) {
|
|
// Service Contracts are the "offre de service" artifact — they carry
|
|
// monthly_rate, duration, and benefits (net promotions). Shown alongside
|
|
// Soumissions so the rep can retrieve the recap after publish.
|
|
// `service_subscription` is the Link field ERPNext uses for referential
|
|
// integrity; we need it client-side so delete-sub can detect a live
|
|
// contract and surface termination fees before breaking the link.
|
|
return listDocs('Service Contract', {
|
|
filters: { customer: id },
|
|
fields: ['name', 'contract_type', 'status', 'start_date', 'end_date',
|
|
'duration_months', 'monthly_rate', 'total_benefit_value',
|
|
'quotation', 'acceptance_method', 'signed_at', 'service_subscription'],
|
|
limit: 50, orderBy: 'creation desc',
|
|
}).catch(() => [])
|
|
}
|
|
|
|
function loadComments (id) {
|
|
return listDocs('Comment', {
|
|
filters: { reference_doctype: 'Customer', reference_name: id, comment_type: 'Comment' },
|
|
fields: ['name', 'content', 'comment_by', 'creation'],
|
|
limit: 50, orderBy: 'creation desc',
|
|
}).catch(() => [])
|
|
}
|
|
|
|
function loadBalance (id) {
|
|
return authFetch(BASE_URL + '/api/method/customer_balance?customer=' + encodeURIComponent(id)).catch(() => null)
|
|
}
|
|
|
|
async function loadCustomer (id) {
|
|
resetState()
|
|
try {
|
|
const custFilter = { customer: id }
|
|
const [cust, locs, subs, equip, tix, invs, pays, voip, pmethods, arrgs, quots, contracts, memos, balRes] = await Promise.all([
|
|
getDoc('Customer', id),
|
|
loadLocations(custFilter),
|
|
loadSubscriptions(custFilter),
|
|
loadEquipment(custFilter),
|
|
loadTickets(custFilter),
|
|
loadInvoices(custFilter),
|
|
loadPayments(id),
|
|
loadVoipLines(custFilter),
|
|
loadPaymentMethods(id),
|
|
loadArrangements(custFilter),
|
|
loadQuotations(id),
|
|
loadServiceContracts(id),
|
|
loadComments(id),
|
|
loadBalance(id),
|
|
])
|
|
|
|
for (const f of ['is_commercial', 'is_bad_payer', 'exclude_fees', 'ppa_enabled']) { cust[f] = !!cust[f] }
|
|
customer.value = cust
|
|
locations.value = locs
|
|
subscriptions.value = subs
|
|
invalidateAll()
|
|
equipment.value = equip
|
|
if (equip.length) {
|
|
fetchStatus(equip).catch(() => {})
|
|
for (const eq of equip) {
|
|
if (eq.serial_number) fetchOltStatus(eq.serial_number).catch(() => {})
|
|
}
|
|
}
|
|
tickets.value = tix.sort((a, b) => (b.is_important || 0) - (a.is_important || 0) || (b.opening_date || '').localeCompare(a.opening_date || ''))
|
|
invoices.value = invs
|
|
payments.value = pays
|
|
voipLines.value = voip
|
|
paymentMethods.value = pmethods
|
|
arrangements.value = arrgs
|
|
quotations.value = quots
|
|
serviceContracts.value = contracts
|
|
contact.value = null
|
|
comments.value = memos
|
|
|
|
if (balRes?.ok) {
|
|
try { accountBalance.value = (await balRes.json()).message } catch {}
|
|
}
|
|
} catch {
|
|
customer.value = null
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadAllTickets () {
|
|
if (ticketsExpanded.value || !customer.value) return
|
|
loadingMoreTickets.value = true
|
|
try {
|
|
tickets.value = await listDocs('Issue', {
|
|
filters: { customer: customer.value.name },
|
|
fields: ['name', 'subject', 'status', 'priority', 'opening_date', 'service_location', 'legacy_ticket_id', 'is_important', 'assigned_staff', 'opened_by_staff', 'issue_type'],
|
|
limit: 500, orderBy: 'is_important desc, opening_date desc',
|
|
})
|
|
ticketsExpanded.value = true
|
|
} catch {}
|
|
loadingMoreTickets.value = 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', 'creation', 'paid_amount', 'mode_of_payment', 'reference_no'],
|
|
limit: 200, orderBy: 'creation desc',
|
|
})
|
|
paymentsExpanded.value = true
|
|
} catch {}
|
|
loadingMorePayments.value = false
|
|
}
|
|
|
|
return {
|
|
loading, customer, contact, locations, subscriptions, tickets,
|
|
invoices, payments, voipLines, paymentMethods, arrangements, quotations,
|
|
serviceContracts, comments, accountBalance,
|
|
loadingMoreTickets, loadingMoreInvoices, loadingMorePayments,
|
|
loadCustomer, loadAllTickets, loadAllInvoices, loadAllPayments,
|
|
}
|
|
}
|