From 9fda9eb0b030af951082bc76a8fd9623990f4aaa Mon Sep 17 00:00:00 2001 From: louispaulb Date: Wed, 22 Apr 2026 23:18:25 -0400 Subject: [PATCH] refactor(targo-hub): add types.js, migrate acceptance+payments, drop apps/field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/types.js: single source of truth for Dispatch Job status + priority enums. Eliminates hard-coded 'In Progress'/'in_progress'/'Completed'/'done' checks scattered across tech-mobile, acceptance, dispatch. Includes CLIENT_TYPES_JS snippet for embedding in SSR diff --git a/apps/field/src/api/auth.js b/apps/field/src/api/auth.js deleted file mode 100644 index 7346252..0000000 --- a/apps/field/src/api/auth.js +++ /dev/null @@ -1,40 +0,0 @@ -import { BASE_URL } from 'src/config/erpnext' - -// Token is optional — in production, nginx injects it server-side. -// Only needed for local dev (VITE_ERP_TOKEN in .env). -const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || '' - -export function authFetch (url, opts = {}) { - if (SERVICE_TOKEN) { - opts.headers = { - ...opts.headers, - Authorization: 'token ' + SERVICE_TOKEN, - } - } else { - opts.headers = { ...opts.headers } - } - opts.redirect = 'manual' - return fetch(url, opts).then(res => { - if (res.type === 'opaqueredirect' || res.status === 302 || res.status === 401) { - window.location.reload() - return new Response('{}', { status: 401 }) - } - return res - }) -} - -export async function getLoggedUser () { - try { - const headers = SERVICE_TOKEN ? { Authorization: 'token ' + SERVICE_TOKEN } : {} - const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', { headers }) - if (res.ok) { - const data = await res.json() - return data.message || 'authenticated' - } - } catch {} - return 'authenticated' -} - -export async function logout () { - window.location.href = 'https://id.gigafibre.ca/if/flow/default-invalidation-flow/' -} diff --git a/apps/field/src/api/erp.js b/apps/field/src/api/erp.js deleted file mode 100644 index d51e585..0000000 --- a/apps/field/src/api/erp.js +++ /dev/null @@ -1,58 +0,0 @@ -import { BASE_URL } from 'src/config/erpnext' -import { authFetch } from './auth' - -export async function listDocs (doctype, { filters = {}, fields = ['name'], limit = 20, offset = 0, orderBy = 'creation desc' } = {}) { - const params = new URLSearchParams({ - fields: JSON.stringify(fields), - filters: JSON.stringify(filters), - limit_page_length: limit, - limit_start: offset, - order_by: orderBy, - }) - const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '?' + params) - if (!res.ok) throw new Error('API error: ' + res.status) - const data = await res.json() - return data.data || [] -} - -export async function getDoc (doctype, name) { - const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name)) - if (!res.ok) throw new Error('Not found: ' + name) - const data = await res.json() - return data.data -} - -export async function createDoc (doctype, data) { - const res = await authFetch(BASE_URL + '/api/resource/' + doctype, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - if (!res.ok) throw new Error('Create failed: ' + res.status) - const json = await res.json() - return json.data -} - -export async function updateDoc (doctype, name, data) { - const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - if (!res.ok) throw new Error('Update failed: ' + res.status) - const json = await res.json() - return json.data -} - -export async function searchDocs (doctype, text, { filters = {}, fields = ['name'], limit = 20 } = {}) { - const params = new URLSearchParams({ - doctype, - txt: text, - filters: JSON.stringify(filters), - limit_page_length: limit, - }) - const res = await authFetch(BASE_URL + '/api/method/frappe.client.get_list?' + params) - if (!res.ok) return [] - const data = await res.json() - return data.message || [] -} diff --git a/apps/field/src/api/ocr.js b/apps/field/src/api/ocr.js deleted file mode 100644 index 0e9734a..0000000 --- a/apps/field/src/api/ocr.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * OCR / Vision client (field app). - * - * All calls go through targo-hub, which runs Gemini 2.5 Flash. We used to - * hit a local Ollama (llama3.2-vision) for invoice OCR, but that required - * a GPU on the serving VM — ops doesn't have one, so we centralized every - * vision model behind the hub. - * - * NOTE: apps/field is being folded into apps/ops under /j (see - * docs/architecture/overview.md §"Legacy Retirement Plan"). During the transition - * we keep this file in sync with apps/ops/src/api/ocr.js so no surprises - * when code moves over. - */ - -const HUB_URL = 'https://msg.gigafibre.ca' - -const VISION_BARCODES = `${HUB_URL}/vision/barcodes` -const VISION_INVOICE = `${HUB_URL}/vision/invoice` - -function stripDataUri (base64Image) { - return String(base64Image || '').replace(/^data:image\/[^;]+;base64,/, '') -} - -/** - * Send a photo to Gemini (via hub) for bill/invoice OCR. - * @param {string} base64Image — base64 or data URI - * @returns {Promise} Parsed invoice (see targo-hub/lib/vision.js INVOICE_SCHEMA) - */ -export async function ocrBill (base64Image) { - const res = await fetch(VISION_INVOICE, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ image: stripDataUri(base64Image) }), - }) - if (!res.ok) { - const text = await res.text().catch(() => '') - throw new Error('Invoice OCR failed: ' + (text || res.status)) - } - return res.json() -} - -/** - * Send a photo to Gemini (via hub) for barcode / serial extraction. - * @param {string} base64Image — base64 or data URI - * @returns {Promise<{ barcodes: string[] }>} - */ -export async function scanBarcodes (base64Image) { - const res = await fetch(VISION_BARCODES, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ image: base64Image }), - }) - if (!res.ok) { - const text = await res.text().catch(() => '') - throw new Error('Vision scan failed: ' + (text || res.status)) - } - const data = await res.json() - return { barcodes: data.barcodes || [] } -} - -/** - * Vision service health probe. Pings the hub's /health endpoint. - * Kept under the legacy name `checkOllamaStatus` for backward compat with - * any caller still referencing it — ops uses the same name. - */ -export async function checkOllamaStatus () { - try { - const res = await fetch(`${HUB_URL}/health`, { method: 'GET' }) - if (!res.ok) return { online: false, error: 'HTTP ' + res.status } - return { online: true, models: ['gemini-2.5-flash'], hasVision: true } - } catch (e) { - return { online: false, error: e.message } - } -} diff --git a/apps/field/src/boot/pinia.js b/apps/field/src/boot/pinia.js deleted file mode 100644 index 40385d5..0000000 --- a/apps/field/src/boot/pinia.js +++ /dev/null @@ -1,6 +0,0 @@ -import { boot } from 'quasar/wrappers' -import { createPinia } from 'pinia' - -export default boot(({ app }) => { - app.use(createPinia()) -}) diff --git a/apps/field/src/composables/useScanner.js b/apps/field/src/composables/useScanner.js deleted file mode 100644 index d2c1ab8..0000000 --- a/apps/field/src/composables/useScanner.js +++ /dev/null @@ -1,172 +0,0 @@ -import { ref, watch } from 'vue' -import { scanBarcodes } from 'src/api/ocr' -import { useOfflineStore } from 'src/stores/offline' - -const SCAN_TIMEOUT_MS = 8000 - -/** - * Barcode scanner using device camera photo capture + Gemini Vision AI. - * - * Strategy: Use which triggers - * the native camera app — this gives proper autofocus, tap-to-focus, - * and high-res photos. Then send to Gemini Vision for barcode extraction. - * - * Resilience: if Gemini doesn't answer within SCAN_TIMEOUT_MS (weak LTE), - * the photo is queued in IndexedDB via the offline store and retried when - * the signal comes back. The tech gets a "scan en attente" indicator and - * can keep working; late results are delivered via onNewCode(). - * - * @param {object} options - * @param {(code: string) => void} [options.onNewCode] — called for each - * newly detected code, whether the scan was synchronous or delivered - * later from the offline queue. Typically used to trigger lookup + notify. - */ -export function useScanner (options = {}) { - const onNewCode = options.onNewCode || (() => {}) - const barcodes = ref([]) // Array of { value, region } — max 3 - const scanning = ref(false) // true while Gemini is processing - const error = ref(null) - const lastPhoto = ref(null) // data URI of last captured photo (thumbnail) - const photos = ref([]) // all captured photo thumbnails - - const offline = useOfflineStore() - - // Pick up any scans that completed while the page was unmounted (e.g. tech - // queued a photo, locked phone, walked out of the basement, signal returns). - for (const result of offline.scanResults) { - mergeCodes(result.barcodes || [], 'queued') - offline.consumeScanResult(result.id) - } - - // Watch for sync completions during the lifetime of this scanner. - // Vue auto-disposes the watcher when the host component unmounts. - watch( - () => offline.scanResults.length, - () => { - for (const result of [...offline.scanResults]) { - mergeCodes(result.barcodes || [], 'queued') - offline.consumeScanResult(result.id) - } - } - ) - - function addCode (code, region) { - if (barcodes.value.length >= 3) return false - if (barcodes.value.find(b => b.value === code)) return false - barcodes.value.push({ value: code, region }) - onNewCode(code) - return true - } - - function mergeCodes (codes, region) { - const added = [] - for (const code of codes) { - if (addCode(code, region)) added.push(code) - } - return added - } - - /** - * Process a photo file from camera input. - * Resizes for AI, keeps thumbnail, sends to Gemini with an 8s timeout. - * On timeout/failure, the photo is queued for background retry. - */ - async function processPhoto (file) { - if (!file) return [] - error.value = null - scanning.value = true - - let aiImage = null - const photoIdx = photos.value.length - let found = [] - - try { - // Create thumbnail for display (small) - const thumbUrl = await resizeImage(file, 400) - lastPhoto.value = thumbUrl - photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [], queued: false }) - - // Create optimized image for AI — keep high res for text readability - aiImage = await resizeImage(file, 1600, 0.92) - - const result = await scanBarcodesWithTimeout(aiImage, SCAN_TIMEOUT_MS) - found = mergeCodes(result.barcodes || [], 'photo') - photos.value[photoIdx].codes = found - - if (found.length === 0) { - error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point' - } - } catch (e) { - if (aiImage && isRetryable(e)) { - await offline.enqueueVisionScan({ image: aiImage }) - if (photos.value[photoIdx]) photos.value[photoIdx].queued = true - error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.' - } else { - error.value = e.message || 'Erreur' - } - } finally { - scanning.value = false - } - - return found - } - - async function scanBarcodesWithTimeout (image, ms) { - return await Promise.race([ - scanBarcodes(image), - new Promise((_, reject) => setTimeout( - () => reject(new Error('ScanTimeout')), - ms, - )), - ]) - } - - function isRetryable (e) { - const msg = (e?.message || '').toLowerCase() - return msg.includes('scantimeout') - || msg.includes('failed to fetch') - || msg.includes('networkerror') - || msg.includes('load failed') - || e?.name === 'TypeError' // fetch throws TypeError on network error - } - - /** - * Resize an image file to a max dimension, return as base64 data URI. - */ - function resizeImage (file, maxDim, quality = 0.85) { - return new Promise((resolve, reject) => { - const img = new Image() - img.onload = () => { - let { width, height } = img - if (width > maxDim || height > maxDim) { - const ratio = Math.min(maxDim / width, maxDim / height) - width = Math.round(width * ratio) - height = Math.round(height * ratio) - } - const canvas = document.createElement('canvas') - canvas.width = width - canvas.height = height - canvas.getContext('2d').drawImage(img, 0, 0, width, height) - resolve(canvas.toDataURL('image/jpeg', quality)) - } - img.onerror = reject - img.src = URL.createObjectURL(file) - }) - } - - function removeBarcode (value) { - barcodes.value = barcodes.value.filter(b => b.value !== value) - } - - function clearBarcodes () { - barcodes.value = [] - error.value = null - lastPhoto.value = null - photos.value = [] - } - - return { - barcodes, scanning, error, lastPhoto, photos, - processPhoto, removeBarcode, clearBarcodes, - } -} diff --git a/apps/field/src/composables/useSpeedTest.js b/apps/field/src/composables/useSpeedTest.js deleted file mode 100644 index 2269485..0000000 --- a/apps/field/src/composables/useSpeedTest.js +++ /dev/null @@ -1,102 +0,0 @@ -import { ref } from 'vue' - -/** - * HTTP-based speed test + DNS/HTTP resolve check. - * Downloads a test payload to measure throughput. - * Resolves a hostname to check DNS + HTTP connectivity. - */ -export function useSpeedTest () { - const running = ref(false) - const downloadSpeed = ref(null) // Mbps - const latency = ref(null) // ms - const resolveResult = ref(null) // { host, status, time, ip?, error? } - const error = ref(null) - - // Download speed test — fetches a known URL and measures throughput - async function runSpeedTest (testUrl) { - running.value = true - downloadSpeed.value = null - latency.value = null - error.value = null - - // Default: use ERPNext API as a simple latency test, or a configurable URL - const url = testUrl || '/api/method/frappe.client.get_count?doctype=User' - - try { - // Latency: time a small request - const t0 = performance.now() - const pingRes = await fetch(url, { cache: 'no-store' }) - const t1 = performance.now() - latency.value = Math.round(t1 - t0) - - if (!pingRes.ok) { - error.value = 'HTTP ' + pingRes.status - return - } - - // Download: fetch a larger payload and measure - // Use a cache-busted URL with random param - const dlUrl = (testUrl || '/api/method/frappe.client.get_list') + - '?doctype=Sales+Invoice&limit_page_length=100&fields=["name","grand_total","posting_date","customer_name"]&_t=' + Date.now() - const dlStart = performance.now() - const dlRes = await fetch(dlUrl, { cache: 'no-store' }) - const blob = await dlRes.blob() - const dlEnd = performance.now() - - const bytes = blob.size - const seconds = (dlEnd - dlStart) / 1000 - const mbps = ((bytes * 8) / (seconds * 1_000_000)) - downloadSpeed.value = Math.round(mbps * 100) / 100 - } catch (e) { - error.value = e.message || 'Test échoué' - } finally { - running.value = false - } - } - - // HTTP resolve — check if a host is reachable via HTTP - // - // Why two fetches: browser fetch() includes DNS + TCP + TLS + HTTP on top - // of real RTT. First call pays the cold-connection tax (easily 200–400ms - // on mobile LTE when the radio is idle); second call on the now-warm - // connection reports something close to actual RTT. Before this change - // techs saw "cloudflare.com a répondu en 350ms" on what was actually a - // 5ms link and opened false-positive tickets. - async function resolveHost (host) { - resolveResult.value = null - const url = host.startsWith('http') ? host : 'https://' + host - - try { - // Warm-up — result discarded. Pays DNS + TCP + TLS + LTE wake-up. - await fetch(url, { mode: 'no-cors', cache: 'no-store' }) - // Steady-state measurement on the warm connection. - const t0 = performance.now() - const res = await fetch(url, { mode: 'no-cors', cache: 'no-store' }) - const t1 = performance.now() - resolveResult.value = { - host, - status: 'ok', - time: Math.round(t1 - t0), - httpStatus: res.status || 'opaque', - } - } catch (e) { - resolveResult.value = { - host, - status: 'error', - error: e.message || 'Non joignable', - } - } - } - - // Quick connectivity check for multiple hosts - async function checkHosts (hosts) { - const results = [] - for (const h of hosts) { - await resolveHost(h) - results.push({ ...resolveResult.value }) - } - return results - } - - return { running, downloadSpeed, latency, resolveResult, error, runSpeedTest, resolveHost, checkHosts } -} diff --git a/apps/field/src/config/erpnext.js b/apps/field/src/config/erpnext.js deleted file mode 100644 index 8f19327..0000000 --- a/apps/field/src/config/erpnext.js +++ /dev/null @@ -1,4 +0,0 @@ -// Route API calls through field-frontend nginx which injects the ERP token. -// Without this, POST/PUT/DELETE fail with 403 (CSRF) because they go directly -// to ERPNext via Traefik without the API token header. -export const BASE_URL = '/field' diff --git a/apps/field/src/css/app.scss b/apps/field/src/css/app.scss deleted file mode 100644 index d947729..0000000 --- a/apps/field/src/css/app.scss +++ /dev/null @@ -1,17 +0,0 @@ -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - -webkit-font-smoothing: antialiased; - -webkit-tap-highlight-color: transparent; - // Prevent overscroll on iOS - overscroll-behavior: none; -} - -// Compact cards for mobile -.q-card { - border-radius: 12px; -} - -// Monospace for serial numbers, IPs -.mono { - font-family: 'SF Mono', 'Fira Code', monospace; -} diff --git a/apps/field/src/layouts/FieldLayout.vue b/apps/field/src/layouts/FieldLayout.vue deleted file mode 100644 index 7346c74..0000000 --- a/apps/field/src/layouts/FieldLayout.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - - - diff --git a/apps/field/src/pages/DevicePage.vue b/apps/field/src/pages/DevicePage.vue deleted file mode 100644 index 93d8b85..0000000 --- a/apps/field/src/pages/DevicePage.vue +++ /dev/null @@ -1,180 +0,0 @@ - - - diff --git a/apps/field/src/pages/DiagnosticPage.vue b/apps/field/src/pages/DiagnosticPage.vue deleted file mode 100644 index b2a00c5..0000000 --- a/apps/field/src/pages/DiagnosticPage.vue +++ /dev/null @@ -1,126 +0,0 @@ - - - diff --git a/apps/field/src/pages/JobDetailPage.vue b/apps/field/src/pages/JobDetailPage.vue deleted file mode 100644 index ac8187c..0000000 --- a/apps/field/src/pages/JobDetailPage.vue +++ /dev/null @@ -1,526 +0,0 @@ - - - - - diff --git a/apps/field/src/pages/MorePage.vue b/apps/field/src/pages/MorePage.vue deleted file mode 100644 index 8a71406..0000000 --- a/apps/field/src/pages/MorePage.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - diff --git a/apps/field/src/pages/ScanPage.vue b/apps/field/src/pages/ScanPage.vue deleted file mode 100644 index 107ba65..0000000 --- a/apps/field/src/pages/ScanPage.vue +++ /dev/null @@ -1,535 +0,0 @@ - - - - - diff --git a/apps/field/src/pages/TasksPage.vue b/apps/field/src/pages/TasksPage.vue deleted file mode 100644 index 0591f21..0000000 --- a/apps/field/src/pages/TasksPage.vue +++ /dev/null @@ -1,521 +0,0 @@ - - - - - diff --git a/apps/field/src/router/index.js b/apps/field/src/router/index.js deleted file mode 100644 index 5130bc2..0000000 --- a/apps/field/src/router/index.js +++ /dev/null @@ -1,21 +0,0 @@ -import { createRouter, createWebHashHistory } from 'vue-router' - -const routes = [ - { - path: '/', - component: () => import('src/layouts/FieldLayout.vue'), - children: [ - { path: '', name: 'tasks', component: () => import('src/pages/TasksPage.vue') }, - { path: 'scan', name: 'scan', component: () => import('src/pages/ScanPage.vue') }, - { path: 'diagnostic', name: 'diagnostic', component: () => import('src/pages/DiagnosticPage.vue') }, - { path: 'more', name: 'more', component: () => import('src/pages/MorePage.vue') }, - { path: 'job/:name', name: 'job-detail', component: () => import('src/pages/JobDetailPage.vue'), props: true }, - { path: 'device/:serial', name: 'device', component: () => import('src/pages/DevicePage.vue'), props: true }, - ], - }, -] - -export default createRouter({ - history: createWebHashHistory(), - routes, -}) diff --git a/apps/field/src/stores/auth.js b/apps/field/src/stores/auth.js deleted file mode 100644 index 4940a68..0000000 --- a/apps/field/src/stores/auth.js +++ /dev/null @@ -1,21 +0,0 @@ -import { defineStore } from 'pinia' -import { ref } from 'vue' -import { getLoggedUser, logout } from 'src/api/auth' - -export const useAuthStore = defineStore('auth', () => { - const user = ref(null) - const loading = ref(true) - - async function checkSession () { - loading.value = true - try { - user.value = await getLoggedUser() - } catch { - user.value = 'authenticated' - } finally { - loading.value = false - } - } - - return { user, loading, checkSession, doLogout: logout } -}) diff --git a/apps/field/src/stores/offline.js b/apps/field/src/stores/offline.js deleted file mode 100644 index 337d1db..0000000 --- a/apps/field/src/stores/offline.js +++ /dev/null @@ -1,174 +0,0 @@ -import { defineStore } from 'pinia' -import { ref, computed } from 'vue' -import { get, set, del, keys } from 'idb-keyval' -import { createDoc, updateDoc } from 'src/api/erp' -import { scanBarcodes } from 'src/api/ocr' - -export const useOfflineStore = defineStore('offline', () => { - const queue = ref([]) - const syncing = ref(false) - const online = ref(navigator.onLine) - const pendingCount = computed(() => queue.value.length) - - // Vision scan queue — photos whose Gemini call timed out / failed, - // waiting to be retried when the signal is back. - const visionQueue = ref([]) // { id, image (base64), ts, status } - const scanResults = ref([]) // completed scans not yet consumed by a page - // { id, barcodes: string[], ts } - const pendingVisionCount = computed(() => visionQueue.value.length) - let retryTimer = null - let visionSyncing = false - - // Listen to connectivity changes - window.addEventListener('online', () => { - online.value = true - syncQueue() - syncVisionQueue() - }) - window.addEventListener('offline', () => { online.value = false }) - - async function loadQueue () { - try { - const stored = await get('offline-queue') - queue.value = stored || [] - } catch { queue.value = [] } - } - - async function saveQueue () { - await set('offline-queue', JSON.parse(JSON.stringify(queue.value))) - } - - async function loadVisionQueue () { - try { - visionQueue.value = (await get('vision-queue')) || [] - scanResults.value = (await get('vision-results')) || [] - } catch { - visionQueue.value = [] - scanResults.value = [] - } - if (visionQueue.value.length) scheduleVisionRetry(5000) - } - - async function saveVisionQueue () { - await set('vision-queue', JSON.parse(JSON.stringify(visionQueue.value))) - } - - async function saveScanResults () { - await set('vision-results', JSON.parse(JSON.stringify(scanResults.value))) - } - - // Enqueue a mutation to be synced later - async function enqueue (action) { - // action = { type: 'create'|'update', doctype, name?, data, ts } - action.ts = Date.now() - action.id = action.ts + '-' + Math.random().toString(36).slice(2, 8) - queue.value.push(action) - await saveQueue() - if (online.value) syncQueue() - return action - } - - async function syncQueue () { - if (syncing.value || queue.value.length === 0) return - syncing.value = true - const failed = [] - for (const action of [...queue.value]) { - try { - if (action.type === 'create') { - await createDoc(action.doctype, action.data) - } else if (action.type === 'update') { - await updateDoc(action.doctype, action.name, action.data) - } - } catch { - failed.push(action) - } - } - queue.value = failed - await saveQueue() - syncing.value = false - } - - // Enqueue a photo whose Gemini scan couldn't complete (timeout / offline). - // Returns the queued entry so the caller can display a pending indicator. - async function enqueueVisionScan ({ image }) { - const entry = { - id: Date.now() + '-' + Math.random().toString(36).slice(2, 8), - image, - ts: Date.now(), - status: 'queued', - } - visionQueue.value.push(entry) - await saveVisionQueue() - scheduleVisionRetry(5000) - return entry - } - - // Retry each queued photo. Success → move to scanResults, fail → stay queued - // with a bumped retry schedule. navigator.onLine can lie in weak-signal - // zones, so we drive retries off the queue itself, not off the online flag. - async function syncVisionQueue () { - if (visionSyncing) return - if (retryTimer) { clearTimeout(retryTimer); retryTimer = null } - if (visionQueue.value.length === 0) return - visionSyncing = true - const remaining = [] - try { - for (const entry of [...visionQueue.value]) { - try { - entry.status = 'syncing' - const result = await scanBarcodes(entry.image) - scanResults.value.push({ - id: entry.id, - barcodes: result.barcodes || [], - ts: Date.now(), - }) - } catch { - entry.status = 'queued' - remaining.push(entry) - } - } - visionQueue.value = remaining - await Promise.all([saveVisionQueue(), saveScanResults()]) - if (remaining.length) scheduleVisionRetry(30000) - } finally { - visionSyncing = false - } - } - - function scheduleVisionRetry (delay) { - if (retryTimer) return - retryTimer = setTimeout(() => { - retryTimer = null - syncVisionQueue() - }, delay) - } - - // Consumer (ScanPage) calls this after merging a result into the UI so the - // same serial doesn't reappear next time the page mounts. - async function consumeScanResult (id) { - scanResults.value = scanResults.value.filter(r => r.id !== id) - await saveScanResults() - } - - // Cache data for offline reading - async function cacheData (key, data) { - await set('cache-' + key, { data, ts: Date.now() }) - } - - async function getCached (key) { - try { - const entry = await get('cache-' + key) - return entry?.data || null - } catch { return null } - } - - loadQueue() - loadVisionQueue() - - return { - queue, syncing, online, pendingCount, enqueue, syncQueue, - visionQueue, scanResults, pendingVisionCount, - enqueueVisionScan, syncVisionQueue, consumeScanResult, - cacheData, getCached, loadQueue, - } -}) diff --git a/services/targo-hub/lib/acceptance.js b/services/targo-hub/lib/acceptance.js index 0c0ff29..5e5a8dd 100644 --- a/services/targo-hub/lib/acceptance.js +++ b/services/targo-hub/lib/acceptance.js @@ -1,6 +1,9 @@ 'use strict' const cfg = require('./config') const { log, json, parseBody } = require('./helpers') +const erp = require('./erp') +const types = require('./types') +const ui = require('./ui') const { signJwt, verifyJwt } = require('./magic-link') // ── Acceptance Links ───────────────────────────────────────────────────────── @@ -94,39 +97,26 @@ async function checkDocuSealStatus (submissionId) { // ── ERPNext helpers ────────────────────────────────────────────────────────── async function fetchQuotation (name) { - const { erpFetch } = require('./helpers') - const res = await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`) - if (res.status !== 200) return null - return res.data.data + return erp.get('Quotation', name) } async function acceptQuotation (name, acceptanceData) { - const { erpFetch } = require('./helpers') - // Add acceptance comment with proof - await erpFetch(`/api/resource/Comment`, { - method: 'POST', - body: JSON.stringify({ - comment_type: 'Info', - reference_doctype: 'Quotation', - reference_name: name, - content: `✅ Devis accepté par le client
- Horodatage: ${new Date().toISOString()}
- Méthode: ${acceptanceData.method || 'Lien JWT'}
- Contact: ${acceptanceData.contact || 'N/A'}
- IP: ${acceptanceData.ip || 'N/A'}
- User-Agent: ${acceptanceData.userAgent || 'N/A'}
- ${acceptanceData.docusealUrl ? `Document signé: ${acceptanceData.docusealUrl}` : ''}`, - }), + await erp.create('Comment', { + comment_type: 'Info', + reference_doctype: 'Quotation', + reference_name: name, + content: `✅ Devis accepté par le client
+ Horodatage: ${new Date().toISOString()}
+ Méthode: ${acceptanceData.method || 'Lien JWT'}
+ Contact: ${acceptanceData.contact || 'N/A'}
+ IP: ${acceptanceData.ip || 'N/A'}
+ User-Agent: ${acceptanceData.userAgent || 'N/A'}
+ ${acceptanceData.docusealUrl ? `Document signé: ${acceptanceData.docusealUrl}` : ''}`, }) - // Update quotation status - try { - await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`, { - method: 'PUT', - body: JSON.stringify({ accepted_by_client: 1 }), - }) - } catch {} + // Update quotation status (best-effort — ignore failure) + await erp.update('Quotation', name, { accepted_by_client: 1 }).catch(() => {}) // ── Create deferred dispatch jobs if wizard_steps exist ── try { @@ -139,10 +129,7 @@ async function acceptQuotation (name, acceptanceData) { log(`Created ${createdJobs.length} deferred jobs for ${name} upon acceptance`) // Clear wizard_steps so they don't get created again - await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`, { - method: 'PUT', - body: JSON.stringify({ wizard_steps: '', wizard_context: '' }), - }) + await erp.update('Quotation', name, { wizard_steps: '', wizard_context: '' }) // Also create subscriptions for recurring items on the quotation await createDeferredSubscriptions(quotation, ctx) @@ -156,7 +143,6 @@ async function acceptQuotation (name, acceptanceData) { // ── Create dispatch jobs from wizard steps stored on quotation ─────────────── async function createDeferredJobs (steps, ctx, quotationName) { - const { erpFetch } = require('./helpers') const createdJobs = [] for (let i = 0; i < steps.length; i++) { @@ -180,8 +166,8 @@ async function createDeferredJobs (steps, ctx, quotationName) { subject: step.subject || 'Tâche', address: ctx.address || '', duration_h: step.duration_h || 1, - priority: step.priority || 'medium', - status: dependsOn ? 'On Hold' : 'open', + priority: step.priority || types.JOB_PRIORITY.MEDIUM, + status: dependsOn ? types.JOB_STATUS.ON_HOLD : types.JOB_STATUS.OPEN, job_type: step.job_type || 'Autre', source_issue: ctx.issue || '', customer: ctx.customer || '', @@ -209,21 +195,13 @@ async function createDeferredJobs (steps, ctx, quotationName) { scheduled_date: step.scheduled_date || ctx.scheduled_date || new Date().toISOString().slice(0, 10), } - try { - const res = await erpFetch('/api/resource/Dispatch%20Job', { - method: 'POST', - body: JSON.stringify(payload), - }) - if (res.status === 200 && res.data?.data) { - createdJobs.push(res.data.data) - log(` + Job ${res.data.data.name}: ${step.subject}`) - } else { - createdJobs.push({ name: ticketId }) - log(` ! Job creation returned ${res.status} for: ${step.subject}`) - } - } catch (e) { + const res = await erp.create('Dispatch Job', payload) + if (res.ok && res.data) { + createdJobs.push(res.data) + log(` + Job ${res.data.name}: ${step.subject}`) + } else { createdJobs.push({ name: ticketId }) - log(` ! Job creation failed for: ${step.subject} — ${e.message}`) + log(` ! Job creation failed for: ${step.subject} — ${res.error || 'unknown'}`) } } @@ -268,7 +246,6 @@ function _extractDurationMonths (item) { } async function createDeferredSubscriptions (quotation, ctx) { - const { erpFetch } = require('./helpers') const customer = ctx.customer || quotation.customer || quotation.party_name || '' const serviceLocation = ctx.service_location || '' if (!customer) return [] @@ -309,19 +286,12 @@ async function createDeferredSubscriptions (quotation, ctx) { notes: `Créé automatiquement via acceptation du devis ${quotation.name || ''}`, } - try { - const res = await erpFetch('/api/resource/Service%20Subscription', { - method: 'POST', - body: JSON.stringify(payload), - }) - if (res.status === 200 && res.data?.data) { - created.push(res.data.data.name) - log(` + Service Subscription ${res.data.data.name} (En attente) — ${item.item_name}`) - } else { - log(` ! Service Subscription creation returned ${res.status} for ${item.item_name}`) - } - } catch (e) { - log(` ! Service Subscription creation failed for ${item.item_name}: ${e.message}`) + const res = await erp.create('Service Subscription', payload) + if (res.ok && res.name) { + created.push(res.name) + log(` + Service Subscription ${res.name} (En attente) — ${item.item_name}`) + } else { + log(` ! Service Subscription creation failed for ${item.item_name}: ${res.error || 'unknown'}`) } } return created @@ -330,23 +300,17 @@ async function createDeferredSubscriptions (quotation, ctx) { // ── PDF generation via ERPNext ──────────────────────────────────────────────── async function getQuotationPdfBuffer (quotationName, printFormat) { - const { erpFetch } = require('./helpers') + // PDF comes back as raw bytes, not JSON — go around erpFetch. const format = printFormat || 'Standard' - const url = `/api/method/frappe.utils.print_format.download_pdf?doctype=Quotation&name=${encodeURIComponent(quotationName)}&format=${encodeURIComponent(format)}&no_letterhead=0` - const res = await erpFetch(url, { rawResponse: true }) - if (res.status !== 200) return null - // erpFetch returns parsed JSON by default; we need the raw buffer - // Use direct fetch instead - const directUrl = `${cfg.ERP_URL}${url}` - const pdfRes = await fetch(directUrl, { + const url = `${cfg.ERP_URL}/api/method/frappe.utils.print_format.download_pdf?doctype=Quotation&name=${encodeURIComponent(quotationName)}&format=${encodeURIComponent(format)}&no_letterhead=0` + const pdfRes = await fetch(url, { headers: { 'Authorization': `token ${cfg.ERP_TOKEN}`, 'X-Frappe-Site-Name': cfg.ERP_SITE, }, }) if (!pdfRes.ok) return null - const buf = Buffer.from(await pdfRes.arrayBuffer()) - return buf + return Buffer.from(await pdfRes.arrayBuffer()) } async function getDocPdfBuffer (doctype, name, printFormat) { @@ -504,14 +468,8 @@ async function handle (req, res, method, path) { const payload = verifyJwt(token) if (!payload || payload.type !== 'acceptance') { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) - return res.end(` - Lien expiré -
-
🔗
-

Lien expiré

-

Ce lien d'acceptation a expiré ou est invalide.
Contactez-nous pour recevoir un nouveau lien.

-
`) + res.writeHead(200, ui.htmlHeaders()) + return res.end(ui.pageExpired()) } try { @@ -591,15 +549,8 @@ async function handle (req, res, method, path) { result.submission_id = dsResult.submissionId // Persist signing URL on the Quotation so the print-format QR code is populated - try { - const { erpFetch } = require('./helpers') - await erpFetch(`/api/resource/Quotation/${encodeURIComponent(quotation)}`, { - method: 'PUT', - body: JSON.stringify({ custom_docuseal_signing_url: dsResult.signUrl }), - }) - } catch (e) { - log('Failed to save DocuSeal signing URL to Quotation:', e.message) - } + const up = await erp.update('Quotation', quotation, { custom_docuseal_signing_url: dsResult.signUrl }) + if (!up.ok) log('Failed to save DocuSeal signing URL to Quotation:', up.error) } else { // Fallback to JWT if DocuSeal fails result.method = 'jwt' diff --git a/services/targo-hub/lib/payments.js b/services/targo-hub/lib/payments.js index 2048e84..63fb9e5 100644 --- a/services/targo-hub/lib/payments.js +++ b/services/targo-hub/lib/payments.js @@ -31,7 +31,8 @@ const https = require('https') const crypto = require('crypto') const cfg = require('./config') -const { log, json, parseBody, erpFetch, erpRequest } = require('./helpers') +const { log, json, parseBody } = require('./helpers') +const erp = require('./erp') const sse = require('./sse') // Stripe config from environment @@ -135,57 +136,55 @@ function verifyWebhookSignature (rawBody, sigHeader) { // ──────────────────────────────────────────── async function getCustomerBalance (customerId) { // Sum outstanding amounts from unpaid invoices - const filters = encodeURIComponent(JSON.stringify([ - ['customer', '=', customerId], - ['outstanding_amount', '>', 0], - ['docstatus', '=', 1], - ])) - const fields = encodeURIComponent(JSON.stringify(['name', 'posting_date', 'grand_total', 'outstanding_amount'])) - const res = await erpFetch(`/api/resource/Sales%20Invoice?filters=${filters}&fields=${fields}&order_by=posting_date asc&limit_page_length=100`) - if (res.status !== 200) return { balance: 0, invoices: [] } - const invoices = res.data?.data || [] + const invoices = await erp.list('Sales Invoice', { + filters: [ + ['customer', '=', customerId], + ['outstanding_amount', '>', 0], + ['docstatus', '=', 1], + ], + fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount'], + orderBy: 'posting_date asc', + limit: 100, + }) const balance = invoices.reduce((sum, inv) => sum + (inv.outstanding_amount || 0), 0) return { balance: Math.round(balance * 100) / 100, invoices } } async function getInvoiceDoc (invoiceName) { - const res = await erpFetch(`/api/resource/Sales%20Invoice/${encodeURIComponent(invoiceName)}?fields=["name","customer","customer_name","posting_date","due_date","grand_total","outstanding_amount","status","docstatus","currency"]`) - if (res.status !== 200) return null - return res.data?.data || null + return erp.get('Sales Invoice', invoiceName, { + fields: ['name', 'customer', 'customer_name', 'posting_date', 'due_date', 'grand_total', 'outstanding_amount', 'status', 'docstatus', 'currency'], + }) } async function getCustomerDoc (customerId) { - const res = await erpFetch(`/api/resource/Customer/${encodeURIComponent(customerId)}`) - if (res.status !== 200) return null - return res.data?.data || null + return erp.get('Customer', customerId) } async function getPaymentMethods (customerId) { - const filters = encodeURIComponent(JSON.stringify([['customer', '=', customerId]])) - const fields = encodeURIComponent(JSON.stringify([ - 'name', 'provider', 'is_active', 'is_auto_ppa', - 'stripe_customer_id', 'stripe_ppa_enabled', 'stripe_ppa_nocc', - 'paysafe_profile_id', 'paysafe_card_id', 'paysafe_token', - 'ppa_name', 'ppa_institution', 'ppa_branch', 'ppa_account', 'ppa_amount', 'ppa_buffer', - ])) - const res = await erpFetch(`/api/resource/Payment%20Method?filters=${filters}&fields=${fields}&limit_page_length=20`) - if (res.status !== 200) { - log('getPaymentMethods error:', JSON.stringify(res.data).slice(0, 300)) - return [] - } - return res.data?.data || [] + return erp.list('Payment Method', { + filters: [['customer', '=', customerId]], + fields: [ + 'name', 'provider', 'is_active', 'is_auto_ppa', + 'stripe_customer_id', 'stripe_ppa_enabled', 'stripe_ppa_nocc', + 'paysafe_profile_id', 'paysafe_card_id', 'paysafe_token', + 'ppa_name', 'ppa_institution', 'ppa_branch', 'ppa_account', 'ppa_amount', 'ppa_buffer', + ], + limit: 20, + }) } // Check for existing Payment Entry with same reference_no (idempotency) async function paymentEntryExists (referenceNo) { if (!referenceNo) return false - const filters = encodeURIComponent(JSON.stringify([ - ['reference_no', '=', referenceNo], - ['docstatus', '!=', 2], // not cancelled - ])) - const res = await erpFetch(`/api/resource/Payment%20Entry?filters=${filters}&fields=["name"]&limit_page_length=1`) - if (res.status !== 200) return false - return (res.data?.data || []).length > 0 + const rows = await erp.list('Payment Entry', { + filters: [ + ['reference_no', '=', referenceNo], + ['docstatus', '!=', 2], // not cancelled + ], + fields: ['name'], + limit: 1, + }) + return rows.length > 0 } // ──────────────────────────────────────────── @@ -232,9 +231,9 @@ async function saveStripeCustomerId (customerId, stripeId) { const methods = await getPaymentMethods(customerId) const existing = methods.find(m => m.provider === 'Stripe') if (existing) { - await erpRequest('PUT', `/api/resource/Payment%20Method/${existing.name}`, { stripe_customer_id: stripeId }) + await erp.update('Payment Method', existing.name, { stripe_customer_id: stripeId }) } else { - await erpRequest('POST', '/api/resource/Payment%20Method', { + await erp.create('Payment Method', { customer: customerId, provider: 'Stripe', stripe_customer_id: stripeId, }) } @@ -620,13 +619,13 @@ async function handle (req, res, method, path, url) { // Update ERPNext Payment Method if (stripePm) { - await erpRequest('PUT', `/api/resource/Payment%20Method/${stripePm.name}`, { + await erp.update('Payment Method', stripePm.name, { is_auto_ppa: enabled ? 1 : 0, }) } // Sync ppa_enabled flag on Customer doc - await erpRequest('PUT', `/api/resource/Customer/${encodeURIComponent(customer)}`, { + await erp.update('Customer', customer, { ppa_enabled: enabled ? 1 : 0, }) @@ -790,9 +789,7 @@ async function handle (req, res, method, path, url) { if (!payment_entry) return json(res, 400, { error: 'payment_entry required' }) // Look up the Payment Entry in ERPNext to find the Stripe reference - const peRes = await erpFetch(`/api/resource/Payment%20Entry/${encodeURIComponent(payment_entry)}`) - if (peRes.status !== 200) return json(res, 404, { error: 'Payment Entry not found' }) - const pe = peRes.data?.data + const pe = await erp.get('Payment Entry', payment_entry) if (!pe) return json(res, 404, { error: 'Payment Entry not found' }) const refNo = pe.reference_no || '' @@ -856,15 +853,14 @@ async function handle (req, res, method, path, url) { })) } - const result = await erpRequest('POST', '/api/resource/Payment%20Entry', returnPe) - let returnName = result.data?.data?.name + const result = await erp.create('Payment Entry', returnPe) + let returnName = result.name // Submit the return entry (fetch full doc for modified timestamp) if (returnName) { - const fullReturnRes = await erpFetch(`/api/resource/Payment%20Entry/${returnName}`) - const fullReturnDoc = fullReturnRes.data?.data + const fullReturnDoc = await erp.get('Payment Entry', returnName) if (fullReturnDoc) { - await erpFetch('/api/method/frappe.client.submit', { + await erp.raw('/api/method/frappe.client.submit', { method: 'POST', body: JSON.stringify({ doc: fullReturnDoc }), }) @@ -877,8 +873,7 @@ async function handle (req, res, method, path, url) { for (const ref of pe.references) { if (ref.reference_doctype === 'Sales Invoice') { try { - const invRes = await erpFetch(`/api/resource/Sales%20Invoice/${encodeURIComponent(ref.reference_name)}`) - const origInv = invRes.data?.data + const origInv = await erp.get('Sales Invoice', ref.reference_name) if (origInv && origInv.docstatus === 1) { const creditNote = { doctype: 'Sales Invoice', @@ -903,13 +898,12 @@ async function handle (req, res, method, path, url) { rate: tax.rate, })), } - const cnResult = await erpRequest('POST', '/api/resource/Sales%20Invoice', creditNote) - const cnName = cnResult.data?.data?.name + const cnResult = await erp.create('Sales Invoice', creditNote) + const cnName = cnResult.name if (cnName) { - const fullCnRes = await erpFetch(`/api/resource/Sales%20Invoice/${cnName}`) - const fullCnDoc = fullCnRes.data?.data + const fullCnDoc = await erp.get('Sales Invoice', cnName) if (fullCnDoc) { - await erpFetch('/api/method/frappe.client.submit', { + await erp.raw('/api/method/frappe.client.submit', { method: 'POST', body: JSON.stringify({ doc: fullCnDoc }), }) @@ -1218,17 +1212,16 @@ async function recordPayment (customerId, amount, reference, modeOfPayment, targ } try { - const result = await erpRequest('POST', '/api/resource/Payment%20Entry', pe) - if (result.status === 200 && result.data?.data?.name) { + const result = await erp.create('Payment Entry', pe) + if (result.ok && result.name) { // Submit the payment entry - const peName = result.data.data.name + const peName = result.name // Fetch full doc for frappe.client.submit (PostgreSQL compatibility) - const fullPeRes = await erpFetch(`/api/resource/Payment%20Entry/${peName}`) - const fullPeDoc = fullPeRes.data?.data + const fullPeDoc = await erp.get('Payment Entry', peName) if (fullPeDoc) { fullPeDoc.docstatus = 1 - await erpFetch('/api/method/frappe.client.submit', { + await erp.raw('/api/method/frappe.client.submit', { method: 'POST', body: JSON.stringify({ doc: fullPeDoc }), }) @@ -1243,7 +1236,7 @@ async function recordPayment (customerId, amount, reference, modeOfPayment, targ if (inv) { const newOutstanding = Math.max(0, Math.round((inv.outstanding_amount - ref.allocated_amount) * 100) / 100) const newStatus = newOutstanding <= 0 ? 'Paid' : (newOutstanding < inv.grand_total ? 'Partly Paid' : 'Unpaid') - await erpRequest('PUT', `/api/resource/Sales%20Invoice/${encodeURIComponent(ref.reference_name)}`, { + await erp.update('Sales Invoice', ref.reference_name, { outstanding_amount: newOutstanding, status: newStatus, }) @@ -1253,7 +1246,7 @@ async function recordPayment (customerId, amount, reference, modeOfPayment, targ return peName } else { - log(`Failed to create Payment Entry: ${JSON.stringify(result.data).slice(0, 500)}`) + log(`Failed to create Payment Entry: ${result.error || 'unknown error'}`) } } catch (e) { log(`Payment Entry creation error: ${e.message}`) @@ -1277,14 +1270,15 @@ async function runPPACron () { try { // Find all Payment Methods with is_auto_ppa=1 and provider=Stripe - const filters = encodeURIComponent(JSON.stringify([ - ['is_auto_ppa', '=', 1], - ['provider', '=', 'Stripe'], - ['stripe_customer_id', '!=', ''], - ])) - const fields = encodeURIComponent(JSON.stringify(['name', 'customer', 'stripe_customer_id'])) - const res = await erpFetch(`/api/resource/Payment%20Method?filters=${filters}&fields=${fields}&limit_page_length=1000`) - const ppaMethods = res.data?.data || [] + const ppaMethods = await erp.list('Payment Method', { + filters: [ + ['is_auto_ppa', '=', 1], + ['provider', '=', 'Stripe'], + ['stripe_customer_id', '!=', ''], + ], + fields: ['name', 'customer', 'stripe_customer_id'], + limit: 1000, + }) log(`PPA Cron: ${ppaMethods.length} customers with auto-pay enabled`) diff --git a/services/targo-hub/lib/tech-mobile.js b/services/targo-hub/lib/tech-mobile.js index 6eb00e3..4487a39 100644 --- a/services/targo-hub/lib/tech-mobile.js +++ b/services/targo-hub/lib/tech-mobile.js @@ -2,6 +2,7 @@ const cfg = require('./config') const { log, json } = require('./helpers') const erp = require('./erp') +const types = require('./types') const { verifyJwt } = require('./magic-link') const { extractField } = require('./vision') const ui = require('./ui') @@ -380,9 +381,9 @@ async function handleEquipList (req, res, path) { // ═════════════════════════════════════════════════════════════════════════════ function jobCard (j, today) { - const urgent = j.priority === 'urgent' || j.priority === 'high' - const done = j.status === 'Completed' || j.status === 'Cancelled' - const inProg = j.status === 'In Progress' || j.status === 'in_progress' + const urgent = types.isUrgent(j.priority) + const done = types.isTerminal(j.status) + const inProg = types.isInProgress(j.status) const border = urgent ? 'var(--danger)' : done ? 'var(--text-dim)' : inProg ? 'var(--warning)' : 'var(--brand)' const overdue = !done && j.scheduled_date && j.scheduled_date < today const dlbl = ui.dateLabelFr(j.scheduled_date, today) @@ -411,12 +412,12 @@ function renderPage ({ tech, jobs, token, today }) { const techName = tech.full_name || tech.name // Partition for the home view — rest is client-side filtering - const inProgress = jobs.filter(j => j.status === 'In Progress' || j.status === 'in_progress') - const pending = jobs.filter(j => !['Completed', 'Cancelled', 'In Progress', 'in_progress'].includes(j.status)) + const inProgress = jobs.filter(j => types.isInProgress(j.status)) + const pending = jobs.filter(j => !types.isTerminal(j.status) && !types.isInProgress(j.status)) const overdue = pending.filter(j => j.scheduled_date && j.scheduled_date < today) const todayJobs = pending.filter(j => j.scheduled_date === today) const upcoming = pending.filter(j => j.scheduled_date && j.scheduled_date > today).slice(0, 20) - const history = jobs.filter(j => j.status === 'Completed' || j.status === 'Cancelled') + const history = jobs.filter(j => types.isTerminal(j.status)) const nodate = pending.filter(j => !j.scheduled_date) const activeCount = inProgress.length + overdue.length + todayJobs.length + nodate.length @@ -540,8 +541,8 @@ function renderHome ({ techName, inProgress, overdue, todayJobs, nodate, upcomin function renderHist ({ history, overdue, today }) { const all = [...overdue, ...history] - const doneCount = history.filter(j => j.status === 'Completed').length - const cancCount = history.filter(j => j.status === 'Cancelled').length + const doneCount = history.filter(j => types.isDone(j.status)).length + const cancCount = history.filter(j => types.isCancelled(j.status)).length return `
@@ -643,6 +644,8 @@ function renderEquipOverlay () { // ═════════════════════════════════════════════════════════════════════════════ const CLIENT_SCRIPT = ` +${types.CLIENT_TYPES_JS} + // Current detail-view job, customer, location (set by openDetail) var CJ='',CC='',CL='',CMODEL='',CTYPE=''; // Equipment overlay scanner (separate from field-scan) @@ -703,9 +706,9 @@ function applyHistFilter(){ var j=JOBS[jidEl.textContent]; if(!j){c.style.display='none';continue} var okQ = !q || txt.indexOf(q)>=0; var okF = true; - if(f==='done') okF = j.status==='Completed'; + if(f==='done') okF = isDone(j.status); else if(f==='cancelled') okF = j.status==='Cancelled'; - else if(f==='overdue') okF = j.status!=='Completed' && j.status!=='Cancelled' && j.scheduled_date && j.scheduled_date= 0; - var canFinish= j.status==='In Progress' || j.status==='in_progress'; + var canFinish= isInProgress(j.status); var sMeta = {Scheduled:['Planifié','#818cf8'], assigned:['Assigné','#818cf8'], open:['Ouvert','#818cf8'], 'In Progress':['En cours','#f59e0b'], in_progress:['En cours','#f59e0b'], - Completed:['Terminé','#22c55e'], Cancelled:['Annulé','#94a3b8']}; + Completed:['Terminé','#22c55e'], done:['Terminé','#22c55e'], Cancelled:['Annulé','#94a3b8']}; var sm = sMeta[j.status] || [j.status||'—','#94a3b8']; - var urgent = j.priority==='urgent' || j.priority==='high'; + var urgent = isUrgent(j.priority); var addr = j.address || j.service_location_name || ''; var gps = j.service_location_name ? 'https://www.google.com/maps/dir/?api=1&destination='+encodeURIComponent(j.service_location_name) : ''; diff --git a/services/targo-hub/lib/types.js b/services/targo-hub/lib/types.js new file mode 100644 index 0000000..ed58c2e --- /dev/null +++ b/services/targo-hub/lib/types.js @@ -0,0 +1,95 @@ +'use strict' +// ───────────────────────────────────────────────────────────────────────────── +// Shared enums + predicates for Dispatch Job and related doctypes. +// +// Background: the v16 Dispatch Job doctype evolved and ended up with both +// snake_case and Title Case variants in its status Select field: +// open, assigned, in_progress, In Progress, On Hold, Scheduled, +// Completed, Cancelled, done +// +// Code all over the codebase had ad-hoc checks like: +// if (j.status === 'In Progress' || j.status === 'in_progress') +// if (!['Completed', 'Cancelled', 'In Progress', 'in_progress'].includes(…)) +// +// This module collects them in one place so: +// 1. Any future status rename (or cleanup) touches one file. +// 2. Client-side JS embedded in tech-mobile can import the same spellings. +// 3. New callers have a semantic helper instead of memorizing aliases. +// ───────────────────────────────────────────────────────────────────────────── + +// ── Canonical Dispatch Job statuses ────────────────────────────────────────── +// Grouped by logical phase. We keep every spelling ERPNext accepts so filters +// catch legacy rows too. +const JOB_STATUS = { + // New, not yet assigned + OPEN: 'open', + // Assigned to a tech but not yet scheduled/started + ASSIGNED: 'assigned', + // Date/time set, tech hasn't started + SCHEDULED: 'Scheduled', + // Blocked by a dependency (parent chain, parts, …) + ON_HOLD: 'On Hold', + // Tech started (we emit "In Progress" on new starts; "in_progress" is legacy) + IN_PROGRESS: 'In Progress', + IN_PROGRESS_LEGACY: 'in_progress', + // Finished (we emit "Completed"; "done" is legacy) + COMPLETED: 'Completed', + COMPLETED_LEGACY: 'done', + // Aborted + CANCELLED: 'Cancelled', +} + +// Logical groupings — use these in filters so legacy spellings don't slip through. +const JOB_IN_PROGRESS_STATUSES = [JOB_STATUS.IN_PROGRESS, JOB_STATUS.IN_PROGRESS_LEGACY] +const JOB_DONE_STATUSES = [JOB_STATUS.COMPLETED, JOB_STATUS.COMPLETED_LEGACY] +const JOB_TERMINAL_STATUSES = [...JOB_DONE_STATUSES, JOB_STATUS.CANCELLED] +const JOB_PENDING_STATUSES = [JOB_STATUS.OPEN, JOB_STATUS.ASSIGNED, JOB_STATUS.SCHEDULED, JOB_STATUS.ON_HOLD] +const JOB_ACTIVE_STATUSES = [...JOB_PENDING_STATUSES, ...JOB_IN_PROGRESS_STATUSES] + +// ── Predicates ─────────────────────────────────────────────────────────────── +// Use these in branching logic; use the arrays above in list filters. +const isInProgress = s => JOB_IN_PROGRESS_STATUSES.includes(s) +const isDone = s => JOB_DONE_STATUSES.includes(s) +const isCancelled = s => s === JOB_STATUS.CANCELLED +const isTerminal = s => JOB_TERMINAL_STATUSES.includes(s) +const isPending = s => JOB_PENDING_STATUSES.includes(s) + +// ── Priority ───────────────────────────────────────────────────────────────── +// Doctype Select options: low | medium | high. +// 'urgent' shows up in older LLM prompts and client filters — treated as high. +const JOB_PRIORITY = { + LOW: 'low', + MEDIUM: 'medium', + HIGH: 'high', + URGENT: 'urgent', // alias — code treats as "high or above" +} + +const URGENT_PRIORITIES = [JOB_PRIORITY.HIGH, JOB_PRIORITY.URGENT] +const isUrgent = p => URGENT_PRIORITIES.includes(p) + +// ── Client-side snippet ────────────────────────────────────────────────────── +// For embedding in template-literal