diff --git a/apps/field/deploy.sh b/apps/field/deploy.sh new file mode 100755 index 0000000..1479e7a --- /dev/null +++ b/apps/field/deploy.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# ───────────────────────────────────────────────────────────────────────────── +# deploy.sh — Build Targo Field PWA and deploy to field-frontend nginx container +# +# Served at erp.gigafibre.ca/field/ via Traefik StripPrefix + Authentik SSO. +# Static files go to /opt/field-app/ on the host, mounted into nginx container. +# +# Usage: +# ./deploy.sh # deploy to remote server (production) +# ./deploy.sh local # deploy locally +# ───────────────────────────────────────────────────────────────────────────── +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +SERVER="root@96.125.196.67" +SSH_KEY="$HOME/.ssh/proxmox_vm" +DEST="/opt/field-app" + +echo "==> Installing dependencies..." +npm ci --silent + +echo "==> Building PWA (base=/field/)..." +VITE_ERP_TOKEN="b273a666c86d2d0:06120709db5e414" DEPLOY_BASE=/field/ npx quasar build -m pwa + +if [ "$1" = "local" ]; then + echo "==> Deploying to local $DEST..." + rm -rf "$DEST"/* + cp -r dist/pwa/* "$DEST/" + echo "" + echo "Done! Targo Field: http://localhost/field/" +else + echo "==> Packaging..." + tar czf /tmp/field-pwa.tar.gz -C dist/pwa . + + echo "==> Deploying to $SERVER..." + cat /tmp/field-pwa.tar.gz | ssh -i "$SSH_KEY" "$SERVER" \ + "cat > /tmp/field.tar.gz && \ + rm -rf $DEST/*.js $DEST/*.html $DEST/*.json $DEST/assets $DEST/icons && \ + cd $DEST && tar xzf /tmp/field.tar.gz && \ + rm -f /tmp/field.tar.gz" + + rm -f /tmp/field-pwa.tar.gz + + echo "" + echo "Done! Targo Field: https://erp.gigafibre.ca/field/" +fi diff --git a/apps/field/index.html b/apps/field/index.html new file mode 100644 index 0000000..9d3856f --- /dev/null +++ b/apps/field/index.html @@ -0,0 +1,18 @@ + + + + Targo Field + + + + + + + + + + + +
+ + diff --git a/apps/field/infra/docker-compose.yaml b/apps/field/infra/docker-compose.yaml new file mode 100644 index 0000000..4469255 --- /dev/null +++ b/apps/field/infra/docker-compose.yaml @@ -0,0 +1,29 @@ +# Targo Field — nginx container served at erp.gigafibre.ca/field/ +# Deploy: docker compose -f docker-compose.yaml up -d + +services: + field-frontend: + image: nginx:alpine + container_name: field-frontend + restart: unless-stopped + volumes: + - /opt/field-app:/usr/share/nginx/html:ro + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - proxy + labels: + - "traefik.enable=true" + - "traefik.http.routers.field.rule=Host(`erp.gigafibre.ca`) && PathPrefix(`/field`)" + - "traefik.http.routers.field.entrypoints=web,websecure" + - "traefik.http.routers.field.middlewares=authentik@file,field-strip@docker" + - "traefik.http.routers.field.service=field" + - "traefik.http.routers.field.tls.certresolver=letsencrypt" + - "traefik.http.routers.field.priority=200" + - "traefik.http.middlewares.field-strip.stripprefix.prefixes=/field" + - "traefik.http.middlewares.field-strip.stripprefix.forceSlash=false" + - "traefik.http.services.field.loadbalancer.server.port=80" + - "traefik.docker.network=proxy" + +networks: + proxy: + external: true diff --git a/apps/field/infra/nginx.conf b/apps/field/infra/nginx.conf new file mode 100644 index 0000000..8107653 --- /dev/null +++ b/apps/field/infra/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + location = /sw.js { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + location /assets/ { + expires 30d; + add_header Cache-Control "public, immutable"; + } +} diff --git a/apps/field/package.json b/apps/field/package.json new file mode 100644 index 0000000..fb56c50 --- /dev/null +++ b/apps/field/package.json @@ -0,0 +1,29 @@ +{ + "name": "field-app", + "version": "0.1.0", + "description": "Targo Field Tech — mobile app for technicians", + "productName": "Targo Field", + "private": true, + "scripts": { + "dev": "quasar dev -m pwa", + "build": "quasar build -m pwa", + "lint": "eslint --ext .js,.vue src" + }, + "dependencies": { + "quasar": "^2.16.10", + "vue": "^3.4.21", + "vue-router": "^4.3.0", + "pinia": "^2.1.7", + "@quasar/extras": "^1.16.12", + "html5-qrcode": "^2.3.8", + "idb-keyval": "^6.2.1" + }, + "devDependencies": { + "@quasar/app-vite": "^1.10.0", + "eslint": "^8.57.0" + }, + "engines": { + "node": "^18 || ^20", + "npm": ">= 6.13.4" + } +} diff --git a/apps/field/public/icons/apple-icon-120x120.png b/apps/field/public/icons/apple-icon-120x120.png new file mode 100644 index 0000000..0f15604 Binary files /dev/null and b/apps/field/public/icons/apple-icon-120x120.png differ diff --git a/apps/field/public/icons/apple-icon-152x152.png b/apps/field/public/icons/apple-icon-152x152.png new file mode 100644 index 0000000..6d9d360 Binary files /dev/null and b/apps/field/public/icons/apple-icon-152x152.png differ diff --git a/apps/field/public/icons/apple-icon-167x167.png b/apps/field/public/icons/apple-icon-167x167.png new file mode 100644 index 0000000..721badb Binary files /dev/null and b/apps/field/public/icons/apple-icon-167x167.png differ diff --git a/apps/field/public/icons/apple-icon-180x180.png b/apps/field/public/icons/apple-icon-180x180.png new file mode 100644 index 0000000..3f94ecf Binary files /dev/null and b/apps/field/public/icons/apple-icon-180x180.png differ diff --git a/apps/field/public/icons/icon-128x128.png b/apps/field/public/icons/icon-128x128.png new file mode 100644 index 0000000..1401176 Binary files /dev/null and b/apps/field/public/icons/icon-128x128.png differ diff --git a/apps/field/public/icons/icon-192x192.png b/apps/field/public/icons/icon-192x192.png new file mode 100644 index 0000000..57cbac8 Binary files /dev/null and b/apps/field/public/icons/icon-192x192.png differ diff --git a/apps/field/public/icons/icon-256x256.png b/apps/field/public/icons/icon-256x256.png new file mode 100644 index 0000000..12f043e Binary files /dev/null and b/apps/field/public/icons/icon-256x256.png differ diff --git a/apps/field/public/icons/icon-384x384.png b/apps/field/public/icons/icon-384x384.png new file mode 100644 index 0000000..df8e257 Binary files /dev/null and b/apps/field/public/icons/icon-384x384.png differ diff --git a/apps/field/public/icons/icon-512x512.png b/apps/field/public/icons/icon-512x512.png new file mode 100644 index 0000000..9ca1391 Binary files /dev/null and b/apps/field/public/icons/icon-512x512.png differ diff --git a/apps/field/public/icons/ms-icon-144x144.png b/apps/field/public/icons/ms-icon-144x144.png new file mode 100644 index 0000000..83ac328 Binary files /dev/null and b/apps/field/public/icons/ms-icon-144x144.png differ diff --git a/apps/field/public/icons/safari-pinned-tab.svg b/apps/field/public/icons/safari-pinned-tab.svg new file mode 100644 index 0000000..d6db5ae --- /dev/null +++ b/apps/field/public/icons/safari-pinned-tab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/field/quasar.config.js b/apps/field/quasar.config.js new file mode 100644 index 0000000..bf64fbd --- /dev/null +++ b/apps/field/quasar.config.js @@ -0,0 +1,79 @@ +/* eslint-env node */ +const { configure } = require('quasar/wrappers') + +module.exports = configure(function () { + return { + boot: ['pinia'], + + css: ['app.scss'], + + extras: ['material-icons'], + + build: { + target: { + browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'], + node: 'node20', + }, + vueRouterMode: 'hash', + extendViteConf (viteConf) { + viteConf.base = process.env.DEPLOY_BASE || '/field/' + }, + }, + + devServer: { + open: false, + host: '0.0.0.0', + port: 9002, + proxy: { + '/api': { + target: 'https://erp.gigafibre.ca', + changeOrigin: true, + }, + }, + }, + + framework: { + config: {}, + plugins: ['Notify', 'Loading', 'LocalStorage', 'Dialog', 'BottomSheet'], + }, + + animations: [], + + pwa: { + workboxMode: 'generateSW', + injectPwaMetaTags: true, + swFilename: 'sw.js', + manifestFilename: 'manifest.json', + useCredentialForManifestTag: false, + workboxOptions: { + skipWaiting: true, + clientsClaim: true, + cleanupOutdatedCaches: true, + navigateFallback: 'index.html', + navigateFallbackDenylist: [/^\/api\//], + runtimeCaching: [ + { + // Cache ERPNext API responses for offline + urlPattern: /\/api\/resource\//, + handler: 'NetworkFirst', + options: { + cacheName: 'erp-api', + expiration: { maxEntries: 200, maxAgeSeconds: 86400 }, + networkTimeoutSeconds: 5, + }, + }, + ], + }, + extendManifestJson (json) { + json.name = 'Targo Field' + json.short_name = 'Field' + json.description = 'Targo Field Technician App' + json.display = 'standalone' + json.orientation = 'portrait' + json.background_color = '#ffffff' + json.theme_color = '#0f172a' + json.start_url = '.' + }, + }, + } +}) diff --git a/apps/field/src-pwa/custom-service-worker.js b/apps/field/src-pwa/custom-service-worker.js new file mode 100644 index 0000000..7df79c7 --- /dev/null +++ b/apps/field/src-pwa/custom-service-worker.js @@ -0,0 +1,4 @@ +/* eslint-env serviceworker */ +import { precacheAndRoute } from 'workbox-precaching' + +precacheAndRoute(self.__WB_MANIFEST) diff --git a/apps/field/src-pwa/manifest.json b/apps/field/src-pwa/manifest.json new file mode 100644 index 0000000..534fc0c --- /dev/null +++ b/apps/field/src-pwa/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "Targo Field", + "short_name": "Field", + "description": "Targo Field Technician App", + "display": "standalone", + "orientation": "portrait", + "background_color": "#ffffff", + "theme_color": "#0f172a", + "start_url": ".", + "icons": [ + { "src": "icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" }, + { "src": "icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "icons/icon-256x256.png", "sizes": "256x256", "type": "image/png" }, + { "src": "icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" }, + { "src": "icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/apps/field/src-pwa/register-service-worker.js b/apps/field/src-pwa/register-service-worker.js new file mode 100644 index 0000000..a045c3c --- /dev/null +++ b/apps/field/src-pwa/register-service-worker.js @@ -0,0 +1,11 @@ +import { register } from 'register-service-worker' + +register(process.env.SERVICE_WORKER_FILE, { + ready () { console.log('SW active') }, + registered () {}, + cached () { console.log('Content cached for offline') }, + updatefound () {}, + updated () { console.log('New content available, refresh') }, + offline () { console.log('No internet, running in offline mode') }, + error (err) { console.error('SW registration error:', err) }, +}) diff --git a/apps/field/src/App.vue b/apps/field/src/App.vue new file mode 100644 index 0000000..7471cee --- /dev/null +++ b/apps/field/src/App.vue @@ -0,0 +1,11 @@ + + + diff --git a/apps/field/src/api/auth.js b/apps/field/src/api/auth.js new file mode 100644 index 0000000..62ad280 --- /dev/null +++ b/apps/field/src/api/auth.js @@ -0,0 +1,38 @@ +import { BASE_URL } from 'src/config/erpnext' + +const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || window.__ERP_TOKEN__ || '' + +export function authFetch (url, opts = {}) { + opts.headers = { + ...opts.headers, + Authorization: 'token ' + SERVICE_TOKEN, + } + opts.redirect = 'manual' + if (opts.method && opts.method !== 'GET') { + opts.credentials = 'omit' + } + 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 res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', { + headers: { Authorization: 'token ' + SERVICE_TOKEN }, + }) + if (res.ok) { + const data = await res.json() + return data.message || 'authenticated' + } + } catch {} + return 'authenticated' +} + +export async function logout () { + window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/' +} diff --git a/apps/field/src/api/erp.js b/apps/field/src/api/erp.js new file mode 100644 index 0000000..d51e585 --- /dev/null +++ b/apps/field/src/api/erp.js @@ -0,0 +1,58 @@ +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/boot/pinia.js b/apps/field/src/boot/pinia.js new file mode 100644 index 0000000..40385d5 --- /dev/null +++ b/apps/field/src/boot/pinia.js @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..98bac6c --- /dev/null +++ b/apps/field/src/composables/useScanner.js @@ -0,0 +1,121 @@ +import { ref } from 'vue' + +/** + * Multi-barcode scanner from camera photo. + * Takes a picture, splits into horizontal strips, scans each for barcodes. + * Also supports live scanning mode. + */ +export function useScanner () { + const barcodes = ref([]) // Array of { value, region } — max 3 + const scanning = ref(false) + const error = ref(null) + let _scanner = null + + // Scan a photo for up to 3 barcodes by splitting into strips + async function scanPhoto (file) { + error.value = null + barcodes.value = [] + scanning.value = true + + try { + const { Html5Qrcode } = await import('html5-qrcode') + const scanner = new Html5Qrcode('scanner-scratch', { verbose: false }) + + // Load image as bitmap + const img = await createImageBitmap(file) + const { width, height } = img + + // Split into 3 horizontal strips and scan each + const strips = [ + { y: 0, h: Math.floor(height / 3), label: 'haut' }, + { y: Math.floor(height / 3), h: Math.floor(height / 3), label: 'milieu' }, + { y: Math.floor(height * 2 / 3), h: height - Math.floor(height * 2 / 3), label: 'bas' }, + ] + + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + const found = new Set() + + // First try the full image + try { + const result = await scanner.scanFileV2(file, false) + if (result?.decodedText && !found.has(result.decodedText)) { + found.add(result.decodedText) + barcodes.value.push({ value: result.decodedText, region: 'complet' }) + } + } catch {} + + // Then try each strip for additional barcodes + for (const strip of strips) { + if (barcodes.value.length >= 3) break + canvas.width = width + canvas.height = strip.h + ctx.drawImage(img, 0, strip.y, width, strip.h, 0, 0, width, strip.h) + + try { + const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.9)) + const stripFile = new File([blob], 'strip.jpg', { type: 'image/jpeg' }) + const result = await scanner.scanFileV2(stripFile, false) + if (result?.decodedText && !found.has(result.decodedText)) { + found.add(result.decodedText) + barcodes.value.push({ value: result.decodedText, region: strip.label }) + } + } catch {} + } + + img.close() + scanner.clear() + + if (barcodes.value.length === 0) { + error.value = 'Aucun code-barres détecté' + } + } catch (e) { + error.value = e.message || 'Erreur scanner' + } finally { + scanning.value = false + } + } + + // Live scanning mode — continuous camera feed + async function startLive (elementId, onDecode) { + error.value = null + scanning.value = true + try { + const { Html5Qrcode } = await import('html5-qrcode') + _scanner = new Html5Qrcode(elementId, { verbose: false }) + await _scanner.start( + { facingMode: 'environment' }, + { fps: 10, qrbox: { width: 280, height: 100 } }, + (decoded) => { + if (barcodes.value.length < 3 && !barcodes.value.find(b => b.value === decoded)) { + barcodes.value.push({ value: decoded, region: 'live' }) + onDecode?.(decoded) + } + } + ) + } catch (e) { + error.value = e.message || 'Caméra non disponible' + scanning.value = false + } + } + + async function stopLive () { + try { + if (_scanner?.isScanning) await _scanner.stop() + _scanner?.clear() + } catch {} + _scanner = null + scanning.value = false + } + + function removeBarcode (value) { + barcodes.value = barcodes.value.filter(b => b.value !== value) + } + + function clearBarcodes () { + barcodes.value = [] + error.value = null + } + + return { barcodes, scanning, error, scanPhoto, startLive, stopLive, removeBarcode, clearBarcodes } +} diff --git a/apps/field/src/composables/useSpeedTest.js b/apps/field/src/composables/useSpeedTest.js new file mode 100644 index 0000000..16ca283 --- /dev/null +++ b/apps/field/src/composables/useSpeedTest.js @@ -0,0 +1,92 @@ +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 + async function resolveHost (host) { + resolveResult.value = null + const url = host.startsWith('http') ? host : 'https://' + host + + try { + 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 new file mode 100644 index 0000000..ba8305e --- /dev/null +++ b/apps/field/src/config/erpnext.js @@ -0,0 +1 @@ +export const BASE_URL = '' diff --git a/apps/field/src/css/app.scss b/apps/field/src/css/app.scss new file mode 100644 index 0000000..d947729 --- /dev/null +++ b/apps/field/src/css/app.scss @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..382913d --- /dev/null +++ b/apps/field/src/layouts/FieldLayout.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/apps/field/src/pages/DevicePage.vue b/apps/field/src/pages/DevicePage.vue new file mode 100644 index 0000000..93d8b85 --- /dev/null +++ b/apps/field/src/pages/DevicePage.vue @@ -0,0 +1,180 @@ + + + diff --git a/apps/field/src/pages/DiagnosticPage.vue b/apps/field/src/pages/DiagnosticPage.vue new file mode 100644 index 0000000..e78ef3d --- /dev/null +++ b/apps/field/src/pages/DiagnosticPage.vue @@ -0,0 +1,123 @@ + + + diff --git a/apps/field/src/pages/MorePage.vue b/apps/field/src/pages/MorePage.vue new file mode 100644 index 0000000..8a71406 --- /dev/null +++ b/apps/field/src/pages/MorePage.vue @@ -0,0 +1,64 @@ + + + diff --git a/apps/field/src/pages/ScanPage.vue b/apps/field/src/pages/ScanPage.vue new file mode 100644 index 0000000..868374c --- /dev/null +++ b/apps/field/src/pages/ScanPage.vue @@ -0,0 +1,256 @@ + + + diff --git a/apps/field/src/pages/TasksPage.vue b/apps/field/src/pages/TasksPage.vue new file mode 100644 index 0000000..db6c53d --- /dev/null +++ b/apps/field/src/pages/TasksPage.vue @@ -0,0 +1,162 @@ + + + diff --git a/apps/field/src/router/index.js b/apps/field/src/router/index.js new file mode 100644 index 0000000..befe1c6 --- /dev/null +++ b/apps/field/src/router/index.js @@ -0,0 +1,20 @@ +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: '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 new file mode 100644 index 0000000..4940a68 --- /dev/null +++ b/apps/field/src/stores/auth.js @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..5196162 --- /dev/null +++ b/apps/field/src/stores/offline.js @@ -0,0 +1,73 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { get, set, del, keys } from 'idb-keyval' +import { createDoc, updateDoc } from 'src/api/erp' + +export const useOfflineStore = defineStore('offline', () => { + const queue = ref([]) + const syncing = ref(false) + const online = ref(navigator.onLine) + const pendingCount = computed(() => queue.value.length) + + // Listen to connectivity changes + window.addEventListener('online', () => { online.value = true; syncQueue() }) + 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))) + } + + // 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 + } + + // 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() + + return { queue, syncing, online, pendingCount, enqueue, syncQueue, cacheData, getCached, loadQueue } +})