All docs moved with git mv so --follow preserves history. Flattens the single-folder layout into goal-oriented folders and adds a README.md index at every level. - docs/README.md — new landing page with "I want to…" intent table - docs/architecture/ — overview, data-model, app-design - docs/features/ — billing-payments, cpe-management, vision-ocr, flow-editor - docs/reference/ — erpnext-item-diff, legacy-wizard/ - docs/archive/ — HANDOFF-2026-04-18, MIGRATION, status-snapshots/ - docs/assets/ — pptx sources, build scripts (fixed hardcoded path) - roadmap.md gains a "Modules in production" section with clickable URLs for every ops/tech/portal route and admin surface - Phase 4 (Customer Portal) flipped to "Largely Shipped" based on audit of services/targo-hub/lib/payments.js (16 endpoints, webhook, PPA cron, Klarna BNPL all live) - Archive files get an "ARCHIVED" banner so stale links inside them don't mislead readers Code comments + nginx configs rewritten to use new doc paths. Root README.md documentation table replaced with intent-oriented index. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
237 lines
8.9 KiB
JavaScript
237 lines
8.9 KiB
JavaScript
/**
|
|
* Offline store — mutation queue + vision (Gemini) retry queue.
|
|
*
|
|
* This store is the backbone of the tech `/j` (mobile) workflow: techs work
|
|
* in basements, elevators, and under couches where LTE drops for seconds to
|
|
* minutes. We can't afford to lose a scan or a "job completed" tap, so both
|
|
* mutations AND vision photos are persisted to IndexedDB and retried in the
|
|
* background when connectivity returns.
|
|
*
|
|
* Two queues, different retry strategies:
|
|
*
|
|
* ┌─ queue (ERPNext mutations) ──────────────────────────────────────┐
|
|
* │ { type: 'create'|'update', doctype, name?, data, ts, id } │
|
|
* │ flush on `online` event → replay createDoc/updateDoc. │
|
|
* │ Failed items stay queued until next online flip. │
|
|
* └──────────────────────────────────────────────────────────────────┘
|
|
*
|
|
* ┌─ visionQueue (Gemini photo OCR) ─────────────────────────────────┐
|
|
* │ { id, image (base64), ts, status } │
|
|
* │ Retries are time-driven (scheduleVisionRetry), not connectivity│
|
|
* │ -driven, because `navigator.onLine` lies in weak-signal zones │
|
|
* │ (reports true on a captive 2-bar LTE that can't actually │
|
|
* │ reach msg.gigafibre.ca). First retry at 5s, backoff to 30s. │
|
|
* │ │
|
|
* │ Successful scans land in `scanResults` and the `useScanner` │
|
|
* │ composable merges them back into the UI via a watcher. │
|
|
* └──────────────────────────────────────────────────────────────────┘
|
|
*
|
|
* IndexedDB keys (idb-keyval, no schema):
|
|
* - `offline-queue` → mutation queue
|
|
* - `vision-queue` → pending photos
|
|
* - `vision-results` → completed scans waiting for the UI to consume
|
|
* - `cache-{key}` → generic read cache (used for read-through patterns)
|
|
*
|
|
* Ported from apps/field/src/stores/offline.js as part of the field→ops
|
|
* unification (see docs/architecture/overview.md §"Legacy Retirement Plan").
|
|
*/
|
|
|
|
import { defineStore } from 'pinia'
|
|
import { ref, computed } from 'vue'
|
|
import { get, set } from 'idb-keyval'
|
|
import { createDoc, updateDoc } from 'src/api/erp'
|
|
import { scanBarcodes } from 'src/api/ocr'
|
|
|
|
export const useOfflineStore = defineStore('offline', () => {
|
|
// ─── Mutation queue ──────────────────────────────────────────────
|
|
const queue = ref([])
|
|
const syncing = ref(false)
|
|
const online = ref(navigator.onLine)
|
|
const pendingCount = computed(() => queue.value.length)
|
|
|
|
// ─── Vision queue ────────────────────────────────────────────────
|
|
const visionQueue = ref([]) // { id, image (base64), ts, status }
|
|
const scanResults = ref([]) // { id, barcodes: string[], ts }
|
|
const pendingVisionCount = computed(() => visionQueue.value.length)
|
|
let retryTimer = null
|
|
let visionSyncing = false
|
|
|
|
// Listen to connectivity changes. We kick off BOTH queues on `online`
|
|
// because a reconnect is the cheapest signal we have that things might
|
|
// work now — worst case the retries fail again and we stay queued.
|
|
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 () {
|
|
// Pinia refs aren't structured-clonable directly (proxies); JSON
|
|
// round-trip is the simplest way to get a plain copy for IndexedDB.
|
|
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 we're restoring a non-empty queue (app was closed with pending
|
|
// scans), give the network 5s to settle before the first retry.
|
|
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.
|
|
* @param {{ type: 'create'|'update', doctype: string, name?: string, data: object }} action
|
|
*/
|
|
async function enqueue (action) {
|
|
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).
|
|
* Called by useScanner when scanBarcodes exceeds SCAN_TIMEOUT_MS or throws
|
|
* a network error. Returns the queued entry so the caller can display a
|
|
* "scan en attente" chip in the UI.
|
|
*
|
|
* @param {{ image: string }} opts — base64 (data URI) of the optimized image
|
|
*/
|
|
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. We drive retries off the queue
|
|
* itself, not off `online`, because navigator.onLine can report true
|
|
* even on weak LTE that can't reach the hub.
|
|
*/
|
|
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 / TechScanPage) calls this after merging a result
|
|
* into the UI so the same serial doesn't reappear next time the page
|
|
* mounts from persisted state.
|
|
*/
|
|
async function consumeScanResult (id) {
|
|
scanResults.value = scanResults.value.filter(r => r.id !== id)
|
|
await saveScanResults()
|
|
}
|
|
|
|
// ─── Generic read cache (used by list pages for offline browse) ──
|
|
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 }
|
|
}
|
|
|
|
// Kick off initial loads (fire-and-forget — refs start empty and fill
|
|
// in once IndexedDB resolves, which is fine for the UI).
|
|
loadQueue()
|
|
loadVisionQueue()
|
|
|
|
return {
|
|
// mutation queue
|
|
queue, syncing, online, pendingCount, enqueue, syncQueue,
|
|
// vision queue
|
|
visionQueue, scanResults, pendingVisionCount,
|
|
enqueueVisionScan, syncVisionQueue, consumeScanResult,
|
|
// read cache
|
|
cacheData, getCached, loadQueue,
|
|
}
|
|
})
|