Backend services: - targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas, extract dispatch scoring weights, trim section dividers across 9 files - modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(), consolidate DM query factory, fix duplicate username fill bug, trim headers (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%) Frontend: - useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into 6 focused helpers (processOnlineStatus, processWanIPs, processRadios, processMeshNodes, processClients, checkRadioIssues) - EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments Documentation (17 → 13 files, -1,400 lines): - New consolidated README.md (architecture, services, dependencies, auth) - Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md - Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md - Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md - Update ROADMAP.md with current phase status - Delete CONTEXT.md (absorbed into README) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
310 lines
11 KiB
JavaScript
310 lines
11 KiB
JavaScript
import { authFetch } from './auth'
|
|
import { BASE_URL } from 'src/config/erpnext'
|
|
|
|
async function apiGet (path) {
|
|
const res = await authFetch(BASE_URL + path)
|
|
if (!res.ok) throw new Error(`API ${res.status}: ${path}`)
|
|
const ct = (res.headers.get('content-type') || '')
|
|
if (!ct.includes('application/json')) throw new Error('Not JSON response for: ' + path)
|
|
const data = await res.json()
|
|
if (data.exc) throw new Error(data.exc)
|
|
return data
|
|
}
|
|
|
|
/**
|
|
* Get current portal user info from Authentik headers.
|
|
* Returns { email, customer_id, customer_name }
|
|
*/
|
|
export async function getPortalUser () {
|
|
const data = await apiGet('/api/method/client_portal_get_user_info')
|
|
return data.message
|
|
}
|
|
|
|
/**
|
|
* Fetch paginated Sales Invoices for a customer.
|
|
*/
|
|
export async function fetchInvoices (customer, { page = 1, pageSize = 20 } = {}) {
|
|
const start = (page - 1) * pageSize
|
|
const filters = JSON.stringify([
|
|
['customer', '=', customer],
|
|
['docstatus', '=', 1],
|
|
])
|
|
const fields = JSON.stringify([
|
|
'name', 'posting_date', 'due_date', 'grand_total',
|
|
'outstanding_amount', 'status', 'currency',
|
|
])
|
|
const path = `/api/resource/Sales Invoice?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&order_by=posting_date desc&limit_page_length=${pageSize}&limit_start=${start}`
|
|
const data = await apiGet(path)
|
|
return data.data || []
|
|
}
|
|
|
|
/**
|
|
* Count total invoices for pagination.
|
|
*/
|
|
export async function countInvoices (customer) {
|
|
const filters = JSON.stringify([
|
|
['customer', '=', customer],
|
|
['docstatus', '=', 1],
|
|
])
|
|
const data = await apiGet(`/api/method/frappe.client.get_count?doctype=Sales Invoice&filters=${encodeURIComponent(filters)}`)
|
|
return data.message || 0
|
|
}
|
|
|
|
/**
|
|
* Download invoice PDF.
|
|
*/
|
|
export async function fetchInvoicePDF (invoiceName, format = 'Facture TARGO') {
|
|
const url = `${BASE_URL}/api/method/frappe.utils.print_format.download_pdf?doctype=Sales%20Invoice&name=${encodeURIComponent(invoiceName)}&format=${encodeURIComponent(format)}`
|
|
const res = await authFetch(url)
|
|
if (!res.ok) throw new Error('PDF download failed')
|
|
return res.blob()
|
|
}
|
|
|
|
/**
|
|
* Fetch customer's Issues/Tickets.
|
|
*/
|
|
export async function fetchTickets (customer) {
|
|
const filters = JSON.stringify([['customer', '=', customer]])
|
|
const fields = JSON.stringify([
|
|
'name', 'subject', 'status', 'priority', 'creation',
|
|
'issue_type', 'sla_resolution_date',
|
|
])
|
|
const path = `/api/resource/Issue?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&order_by=creation desc&limit_page_length=50`
|
|
const data = await apiGet(path)
|
|
return data.data || []
|
|
}
|
|
|
|
/**
|
|
* Create a new support ticket.
|
|
*/
|
|
export async function createTicket (customer, subject, description) {
|
|
const res = await authFetch(BASE_URL + '/api/resource/Issue', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
customer,
|
|
subject,
|
|
description,
|
|
issue_type: 'Support',
|
|
priority: 'Medium',
|
|
}),
|
|
})
|
|
if (!res.ok) throw new Error('Failed to create ticket')
|
|
const data = await res.json()
|
|
return data.data
|
|
}
|
|
|
|
/**
|
|
* Fetch customer profile with addresses.
|
|
*/
|
|
export async function fetchProfile (customer) {
|
|
const data = await apiGet(`/api/resource/Customer/${encodeURIComponent(customer)}`)
|
|
return data.data
|
|
}
|
|
|
|
/**
|
|
* Fetch addresses linked to customer.
|
|
*/
|
|
export async function fetchAddresses (customer) {
|
|
const filters = JSON.stringify([
|
|
['Dynamic Link', 'link_doctype', '=', 'Customer'],
|
|
['Dynamic Link', 'link_name', '=', customer],
|
|
])
|
|
const fields = JSON.stringify([
|
|
'name', 'address_title', 'address_line1', 'address_line2',
|
|
'city', 'state', 'pincode', 'country', 'is_primary_address',
|
|
])
|
|
const path = `/api/resource/Address?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}`
|
|
const data = await apiGet(path)
|
|
return data.data || []
|
|
}
|
|
|
|
/**
|
|
* Fetch Service Locations with their active Service Subscriptions.
|
|
* Returns locations grouped with subscriptions and monthly totals.
|
|
*/
|
|
export async function fetchServiceLocations (customer) {
|
|
// Fetch locations
|
|
const locFields = JSON.stringify([
|
|
'name', 'location_name', 'address_line', 'city', 'postal_code',
|
|
'province', 'status', 'connection_type',
|
|
])
|
|
const locFilters = JSON.stringify([['customer', '=', customer]])
|
|
const locPath = `/api/resource/Service Location?filters=${encodeURIComponent(locFilters)}&fields=${encodeURIComponent(locFields)}&order_by=creation asc&limit_page_length=50`
|
|
const locData = await apiGet(locPath)
|
|
const locations = locData.data || []
|
|
|
|
// Fetch subscriptions
|
|
const subFields = JSON.stringify([
|
|
'name', 'service_location', 'status', 'service_category',
|
|
'plan_name', 'monthly_price', 'speed_down', 'speed_up',
|
|
'billing_cycle', 'start_date', 'end_date', 'promo_end',
|
|
])
|
|
const subFilters = JSON.stringify([
|
|
['customer', '=', customer],
|
|
['status', '=', 'Actif'],
|
|
])
|
|
const subPath = `/api/resource/Service Subscription?filters=${encodeURIComponent(subFilters)}&fields=${encodeURIComponent(subFields)}&order_by=service_category asc, monthly_price desc&limit_page_length=200`
|
|
const subData = await apiGet(subPath)
|
|
const subscriptions = subData.data || []
|
|
|
|
// Group subscriptions by location
|
|
const subsByLoc = {}
|
|
for (const sub of subscriptions) {
|
|
const loc = sub.service_location || '_unassigned'
|
|
if (!subsByLoc[loc]) subsByLoc[loc] = []
|
|
subsByLoc[loc].push(sub)
|
|
}
|
|
|
|
// Attach subscriptions to locations and compute totals
|
|
for (const loc of locations) {
|
|
loc.subscriptions = subsByLoc[loc.name] || []
|
|
loc.monthly_total = loc.subscriptions.reduce((sum, s) => sum + (s.monthly_price || 0), 0)
|
|
}
|
|
|
|
// Grand total across all locations
|
|
const grandTotal = locations.reduce((sum, loc) => sum + loc.monthly_total, 0)
|
|
|
|
return { locations, grandTotal, subscriptionCount: subscriptions.length }
|
|
}
|
|
|
|
/**
|
|
* 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 || []
|
|
}
|