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