gigafibre-fsm/apps/client/src/api/portal.js
louispaulb 607ea54b5c refactor: reduce token count, DRY code, consolidate docs
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>
2026-04-13 08:39:58 -04:00

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