feat: add field tech app — barcode scanner, tasks, diagnostics, offline
Mobile-first Quasar PWA for field technicians at erp.gigafibre.ca/field/: - Multi-barcode scanner (photo + live + manual) with device lookup - Tasks page: today's Dispatch Jobs + assigned tickets - Diagnostic: speed test, HTTP resolve, batch service check - Device detail with customer linking - Offline support: IndexedDB queue, API cache, auto-sync - Standalone nginx container with Traefik StripPrefix + Authentik SSO Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
48
apps/field/deploy.sh
Executable file
|
|
@ -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
|
||||
18
apps/field/index.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Targo Field</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="description" content="Targo Field Technician App">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="msapplication-tap-highlight" content="no">
|
||||
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, viewport-fit=cover">
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="icons/icon-128x128.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="icons/icon-128x128.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/icon-128x128.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="icons/icon-128x128.png">
|
||||
</head>
|
||||
<body>
|
||||
<div id="q-app"></div>
|
||||
</body>
|
||||
</html>
|
||||
29
apps/field/infra/docker-compose.yaml
Normal file
|
|
@ -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
|
||||
22
apps/field/infra/nginx.conf
Normal file
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
29
apps/field/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
apps/field/public/icons/apple-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/field/public/icons/apple-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/field/public/icons/apple-icon-167x167.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/field/public/icons/apple-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/field/public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/field/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
apps/field/public/icons/icon-256x256.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/field/public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
apps/field/public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/field/public/icons/ms-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
apps/field/public/icons/safari-pinned-tab.svg
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
79
apps/field/quasar.config.js
Normal file
|
|
@ -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 = '.'
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
4
apps/field/src-pwa/custom-service-worker.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/* eslint-env serviceworker */
|
||||
import { precacheAndRoute } from 'workbox-precaching'
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
17
apps/field/src-pwa/manifest.json
Normal file
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
11
apps/field/src-pwa/register-service-worker.js
Normal file
|
|
@ -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) },
|
||||
})
|
||||
11
apps/field/src/App.vue
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from 'src/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
onMounted(() => auth.checkSession())
|
||||
</script>
|
||||
38
apps/field/src/api/auth.js
Normal file
|
|
@ -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/'
|
||||
}
|
||||
58
apps/field/src/api/erp.js
Normal file
|
|
@ -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 || []
|
||||
}
|
||||
6
apps/field/src/boot/pinia.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { boot } from 'quasar/wrappers'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export default boot(({ app }) => {
|
||||
app.use(createPinia())
|
||||
})
|
||||
121
apps/field/src/composables/useScanner.js
Normal file
|
|
@ -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 }
|
||||
}
|
||||
92
apps/field/src/composables/useSpeedTest.js
Normal file
|
|
@ -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 }
|
||||
}
|
||||
1
apps/field/src/config/erpnext.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const BASE_URL = ''
|
||||
17
apps/field/src/css/app.scss
Normal file
|
|
@ -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;
|
||||
}
|
||||
45
apps/field/src/layouts/FieldLayout.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<q-layout view="hHh lpR fFf">
|
||||
<!-- Top bar -->
|
||||
<q-header class="bg-dark">
|
||||
<q-toolbar class="q-py-xs">
|
||||
<q-toolbar-title class="text-subtitle1 text-weight-bold">
|
||||
Targo Field
|
||||
</q-toolbar-title>
|
||||
<q-badge v-if="offline.pendingCount > 0" color="orange" :label="offline.pendingCount + ' en attente'" class="q-mr-sm" />
|
||||
<q-badge v-if="!offline.online" color="red" label="Hors ligne" />
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
|
||||
<q-page-container>
|
||||
<router-view />
|
||||
</q-page-container>
|
||||
|
||||
<!-- Bottom tab bar -->
|
||||
<q-footer class="bg-white text-dark" bordered>
|
||||
<q-tabs v-model="tab" dense no-caps active-color="primary" indicator-color="primary" class="field-tabs">
|
||||
<q-route-tab name="tasks" icon="assignment" label="Tâches" to="/" exact />
|
||||
<q-route-tab name="scan" icon="qr_code_scanner" label="Scanner" to="/scan" />
|
||||
<q-route-tab name="speed" icon="speed" label="Diagnostic" to="/diagnostic" />
|
||||
<q-route-tab name="more" icon="more_horiz" label="Plus" to="/more" />
|
||||
</q-tabs>
|
||||
</q-footer>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useOfflineStore } from 'src/stores/offline'
|
||||
|
||||
const tab = ref('tasks')
|
||||
const offline = useOfflineStore()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.field-tabs {
|
||||
:deep(.q-tab) {
|
||||
min-height: 56px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
180
apps/field/src/pages/DevicePage.vue
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<template>
|
||||
<q-page padding>
|
||||
<q-btn flat icon="arrow_back" label="Retour" @click="$router.back()" class="q-mb-sm" />
|
||||
|
||||
<q-spinner v-if="loading" size="lg" class="block q-mx-auto q-mt-xl" />
|
||||
|
||||
<template v-if="device">
|
||||
<q-card class="q-mb-md">
|
||||
<q-card-section>
|
||||
<div class="row items-center q-mb-sm">
|
||||
<q-badge :color="statusColor" class="q-mr-sm" />
|
||||
<div class="text-h6">{{ device.equipment_type || 'Équipement' }}</div>
|
||||
</div>
|
||||
<q-list dense>
|
||||
<q-item>
|
||||
<q-item-section avatar><q-icon name="tag" /></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label caption>Numéro de série</q-item-label>
|
||||
<q-item-label style="font-family: monospace">{{ device.serial_number }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="device.mac_address">
|
||||
<q-item-section avatar><q-icon name="router" /></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label caption>MAC</q-item-label>
|
||||
<q-item-label style="font-family: monospace">{{ device.mac_address }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="device.brand || device.model">
|
||||
<q-item-section avatar><q-icon name="devices" /></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label caption>Marque / Modèle</q-item-label>
|
||||
<q-item-label>{{ device.brand }} {{ device.model }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="device.ip_address">
|
||||
<q-item-section avatar><q-icon name="language" /></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label caption>IP</q-item-label>
|
||||
<q-item-label style="font-family: monospace">{{ device.ip_address }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="device.firmware_version">
|
||||
<q-item-section avatar><q-icon name="system_update" /></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label caption>Firmware</q-item-label>
|
||||
<q-item-label>{{ device.firmware_version }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Customer link -->
|
||||
<q-card class="q-mb-md">
|
||||
<q-card-section>
|
||||
<div class="text-subtitle1 q-mb-sm">Compte client</div>
|
||||
<div v-if="device.customer" class="text-body1">
|
||||
<q-icon name="person" class="q-mr-xs" />
|
||||
{{ device.customer_name || device.customer }}
|
||||
</div>
|
||||
<div v-else class="text-grey">Aucun client associé</div>
|
||||
|
||||
<q-input v-model="customerSearch" label="Rechercher un client" outlined dense class="q-mt-sm"
|
||||
@update:model-value="searchCustomer" debounce="400">
|
||||
<template v-slot:append>
|
||||
<q-icon name="search" />
|
||||
</template>
|
||||
</q-input>
|
||||
<q-list v-if="customerResults.length > 0" dense bordered class="q-mt-xs" style="max-height: 200px; overflow: auto">
|
||||
<q-item v-for="c in customerResults" :key="c.name" clickable @click="linkToCustomer(c)">
|
||||
<q-item-section>
|
||||
<q-item-label>{{ c.customer_name }}</q-item-label>
|
||||
<q-item-label caption>{{ c.name }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- OLT info if available -->
|
||||
<q-card v-if="device.custom_olt_name" class="q-mb-md">
|
||||
<q-card-section>
|
||||
<div class="text-subtitle1 q-mb-sm">Info OLT</div>
|
||||
<q-list dense>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>OLT</q-item-label>
|
||||
<q-item-label>{{ device.custom_olt_name }} ({{ device.custom_olt_ip }})</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="device.custom_olt_frame !== undefined">
|
||||
<q-item-section>
|
||||
<q-item-label caption>Port</q-item-label>
|
||||
<q-item-label>{{ device.custom_olt_frame }}/{{ device.custom_olt_slot }}/{{ device.custom_olt_port }} ONT {{ device.custom_olt_ont_id }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<div v-if="!loading && !device" class="text-center text-grey q-mt-xl">
|
||||
Équipement non trouvé
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { listDocs, getDoc, updateDoc, searchDocs } from 'src/api/erp'
|
||||
import { Notify } from 'quasar'
|
||||
|
||||
const props = defineProps({ serial: String })
|
||||
|
||||
const loading = ref(true)
|
||||
const device = ref(null)
|
||||
const customerSearch = ref('')
|
||||
const customerResults = ref([])
|
||||
|
||||
const statusColor = computed(() => {
|
||||
const s = device.value?.status
|
||||
if (s === 'Actif') return 'green'
|
||||
if (s === 'Défectueux' || s === 'Perdu') return 'red'
|
||||
return 'grey'
|
||||
})
|
||||
|
||||
async function loadDevice () {
|
||||
loading.value = true
|
||||
try {
|
||||
// Search by serial_number
|
||||
const results = await listDocs('Service Equipment', {
|
||||
filters: { serial_number: props.serial },
|
||||
fields: ['name'],
|
||||
limit: 1,
|
||||
})
|
||||
if (results.length > 0) {
|
||||
device.value = await getDoc('Service Equipment', results[0].name)
|
||||
} else {
|
||||
// Try barcode field
|
||||
const byBarcode = await listDocs('Service Equipment', {
|
||||
filters: { barcode: props.serial },
|
||||
fields: ['name'],
|
||||
limit: 1,
|
||||
})
|
||||
if (byBarcode.length > 0) {
|
||||
device.value = await getDoc('Service Equipment', byBarcode[0].name)
|
||||
}
|
||||
}
|
||||
} catch {} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function searchCustomer (text) {
|
||||
if (!text || text.length < 2) { customerResults.value = []; return }
|
||||
try {
|
||||
customerResults.value = await listDocs('Customer', {
|
||||
filters: { customer_name: ['like', '%' + text + '%'] },
|
||||
fields: ['name', 'customer_name'],
|
||||
limit: 10,
|
||||
})
|
||||
} catch { customerResults.value = [] }
|
||||
}
|
||||
|
||||
async function linkToCustomer (customer) {
|
||||
try {
|
||||
await updateDoc('Service Equipment', device.value.name, { customer: customer.name })
|
||||
device.value.customer = customer.name
|
||||
device.value.customer_name = customer.customer_name
|
||||
customerResults.value = []
|
||||
customerSearch.value = ''
|
||||
Notify.create({ type: 'positive', message: 'Lié à ' + customer.customer_name })
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDevice)
|
||||
</script>
|
||||
123
apps/field/src/pages/DiagnosticPage.vue
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<template>
|
||||
<q-page padding>
|
||||
<div class="text-h6 q-mb-md">Diagnostic réseau</div>
|
||||
|
||||
<!-- Speed test -->
|
||||
<q-card class="q-mb-md">
|
||||
<q-card-section>
|
||||
<div class="text-subtitle1 q-mb-sm">Test de vitesse</div>
|
||||
<div class="row q-gutter-md q-mb-sm">
|
||||
<div class="col text-center">
|
||||
<div class="text-h4 text-primary">{{ speedTest.downloadSpeed.value ?? '—' }}</div>
|
||||
<div class="text-caption">Mbps (API)</div>
|
||||
</div>
|
||||
<div class="col text-center">
|
||||
<div class="text-h4 text-secondary">{{ speedTest.latency.value ?? '—' }}</div>
|
||||
<div class="text-caption">ms latence</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn color="primary" icon="speed" label="Lancer le test" :loading="speedTest.running.value"
|
||||
@click="speedTest.runSpeedTest()" class="full-width" />
|
||||
<div v-if="speedTest.error.value" class="text-negative text-caption q-mt-xs">{{ speedTest.error.value }}</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- HTTP Resolve -->
|
||||
<q-card class="q-mb-md">
|
||||
<q-card-section>
|
||||
<div class="text-subtitle1 q-mb-sm">Résolution HTTP</div>
|
||||
<q-input v-model="resolveHost" label="Hostname (ex: google.ca)" outlined dense class="q-mb-sm"
|
||||
@keyup.enter="doResolve">
|
||||
<template v-slot:append>
|
||||
<q-btn flat dense icon="dns" @click="doResolve" />
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<!-- Quick hosts -->
|
||||
<div class="row q-gutter-xs q-mb-sm">
|
||||
<q-chip v-for="h in quickHosts" :key="h" dense clickable @click="resolveHost = h; doResolve()">{{ h }}</q-chip>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div v-if="resolveResults.length > 0">
|
||||
<q-list dense separator>
|
||||
<q-item v-for="r in resolveResults" :key="r.host">
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="r.status === 'ok' ? 'check_circle' : 'error'" :color="r.status === 'ok' ? 'positive' : 'negative'" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ r.host }}</q-item-label>
|
||||
<q-item-label caption>
|
||||
{{ r.status === 'ok' ? r.time + 'ms' : r.error }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Batch check -->
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-subtitle1 q-mb-sm">Check complet</div>
|
||||
<q-btn color="secondary" icon="fact_check" label="Tester tous les services" :loading="batchRunning"
|
||||
@click="runBatchCheck" class="full-width" />
|
||||
<div v-if="batchResults.length > 0" class="q-mt-sm">
|
||||
<q-list dense separator>
|
||||
<q-item v-for="r in batchResults" :key="r.host">
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="r.status === 'ok' ? 'check_circle' : 'error'" :color="r.status === 'ok' ? 'positive' : 'negative'" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ r.host }}</q-item-label>
|
||||
<q-item-label caption>{{ r.status === 'ok' ? r.time + 'ms' : r.error }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useSpeedTest } from 'src/composables/useSpeedTest'
|
||||
|
||||
const speedTest = useSpeedTest()
|
||||
const resolveHost = ref('')
|
||||
const resolveResults = ref([])
|
||||
const batchRunning = ref(false)
|
||||
const batchResults = ref([])
|
||||
|
||||
const quickHosts = ['google.ca', 'erp.gigafibre.ca', 'cloudflare.com', '8.8.8.8']
|
||||
const batchHosts = [
|
||||
'erp.gigafibre.ca',
|
||||
'dispatch.gigafibre.ca',
|
||||
'auth.targo.ca',
|
||||
'id.gigafibre.ca',
|
||||
'oss.gigafibre.ca',
|
||||
'n8n.gigafibre.ca',
|
||||
'www.gigafibre.ca',
|
||||
'google.ca',
|
||||
]
|
||||
|
||||
async function doResolve () {
|
||||
if (!resolveHost.value.trim()) return
|
||||
await speedTest.resolveHost(resolveHost.value.trim())
|
||||
if (speedTest.resolveResult.value) {
|
||||
// Prepend to results, avoid duplicates
|
||||
resolveResults.value = [
|
||||
speedTest.resolveResult.value,
|
||||
...resolveResults.value.filter(r => r.host !== speedTest.resolveResult.value.host),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
async function runBatchCheck () {
|
||||
batchRunning.value = true
|
||||
batchResults.value = await speedTest.checkHosts(batchHosts)
|
||||
batchRunning.value = false
|
||||
}
|
||||
</script>
|
||||
64
apps/field/src/pages/MorePage.vue
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<template>
|
||||
<q-page padding>
|
||||
<div class="text-h6 q-mb-md">Plus</div>
|
||||
|
||||
<q-list>
|
||||
<!-- Offline queue -->
|
||||
<q-item>
|
||||
<q-item-section avatar><q-icon name="cloud_sync" /></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>File d'attente hors ligne</q-item-label>
|
||||
<q-item-label caption>{{ offline.pendingCount }} action(s) en attente</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn flat dense icon="sync" :loading="offline.syncing" @click="offline.syncQueue()"
|
||||
:disable="!offline.online || offline.pendingCount === 0" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-separator />
|
||||
|
||||
<!-- Connectivity -->
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="offline.online ? 'wifi' : 'wifi_off'" :color="offline.online ? 'positive' : 'negative'" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ offline.online ? 'En ligne' : 'Hors ligne' }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-separator />
|
||||
|
||||
<!-- User -->
|
||||
<q-item>
|
||||
<q-item-section avatar><q-icon name="person" /></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ auth.user || '—' }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-separator />
|
||||
|
||||
<!-- Logout -->
|
||||
<q-item clickable @click="auth.doLogout()">
|
||||
<q-item-section avatar><q-icon name="logout" color="negative" /></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-negative">Déconnexion</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<div class="text-caption text-grey text-center q-mt-xl">
|
||||
Targo Field v0.1.0
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useOfflineStore } from 'src/stores/offline'
|
||||
import { useAuthStore } from 'src/stores/auth'
|
||||
|
||||
const offline = useOfflineStore()
|
||||
const auth = useAuthStore()
|
||||
</script>
|
||||
256
apps/field/src/pages/ScanPage.vue
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
<template>
|
||||
<q-page padding>
|
||||
<div class="text-h6 q-mb-md">Scanner</div>
|
||||
|
||||
<!-- Mode toggle -->
|
||||
<q-tabs v-model="mode" dense no-caps active-color="primary" class="q-mb-md">
|
||||
<q-tab name="photo" icon="photo_camera" label="Photo" />
|
||||
<q-tab name="live" icon="videocam" label="Live" />
|
||||
<q-tab name="manual" icon="keyboard" label="Manuel" />
|
||||
</q-tabs>
|
||||
|
||||
<!-- Photo mode: take picture, scan for up to 3 barcodes -->
|
||||
<div v-if="mode === 'photo'" class="text-center">
|
||||
<q-btn color="primary" icon="photo_camera" label="Prendre une photo" size="lg" @click="triggerCamera" :loading="scanner.scanning.value" />
|
||||
<input ref="cameraInput" type="file" accept="image/*" capture="environment" class="hidden" @change="onPhoto" />
|
||||
<div v-if="photoPreview" class="q-mt-md">
|
||||
<img :src="photoPreview" style="max-width: 100%; max-height: 300px; border-radius: 8px" />
|
||||
</div>
|
||||
<div v-if="scanner.error.value" class="text-negative q-mt-sm">{{ scanner.error.value }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Live mode -->
|
||||
<div v-if="mode === 'live'">
|
||||
<div id="live-reader" style="width: 100%; max-width: 400px; margin: 0 auto" />
|
||||
<div class="text-center q-mt-sm">
|
||||
<q-btn v-if="!scanner.scanning.value" color="primary" label="Démarrer" @click="startLive" />
|
||||
<q-btn v-else color="negative" label="Arrêter" @click="scanner.stopLive()" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual entry -->
|
||||
<div v-if="mode === 'manual'">
|
||||
<q-input v-model="manualCode" label="Code-barres / SN / MAC" outlined dense class="q-mb-sm"
|
||||
@keyup.enter="addManual">
|
||||
<template v-slot:append>
|
||||
<q-btn flat dense icon="add" @click="addManual" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<!-- Hidden element for scanner scratch space -->
|
||||
<div id="scanner-scratch" style="display:none" />
|
||||
|
||||
<!-- Scanned barcodes -->
|
||||
<div v-if="scanner.barcodes.value.length > 0" class="q-mt-lg">
|
||||
<div class="text-subtitle2 q-mb-sm">Codes détectés ({{ scanner.barcodes.value.length }}/3)</div>
|
||||
<q-card v-for="bc in scanner.barcodes.value" :key="bc.value" class="q-mb-sm">
|
||||
<q-card-section class="q-py-sm row items-center no-wrap">
|
||||
<q-icon name="qr_code" class="q-mr-sm" color="primary" />
|
||||
<div class="col">
|
||||
<div class="text-subtitle2" style="font-family: monospace">{{ bc.value }}</div>
|
||||
<div class="text-caption text-grey">{{ bc.region }}</div>
|
||||
</div>
|
||||
<q-btn flat dense icon="search" @click="lookupDevice(bc.value)" :loading="lookingUp === bc.value" />
|
||||
<q-btn flat dense icon="close" color="negative" @click="scanner.removeBarcode(bc.value)" />
|
||||
</q-card-section>
|
||||
|
||||
<!-- Lookup result -->
|
||||
<q-card-section v-if="lookupResults[bc.value]" class="q-pt-none">
|
||||
<div v-if="lookupResults[bc.value].found">
|
||||
<q-badge color="green" label="Trouvé" class="q-mb-xs" />
|
||||
<div class="text-caption">
|
||||
{{ lookupResults[bc.value].equipment.equipment_type }} —
|
||||
{{ lookupResults[bc.value].equipment.brand }} {{ lookupResults[bc.value].equipment.model }}
|
||||
</div>
|
||||
<div class="text-caption">
|
||||
Client: {{ lookupResults[bc.value].equipment.customer_name || lookupResults[bc.value].equipment.customer || 'Aucun' }}
|
||||
</div>
|
||||
<q-btn flat dense size="sm" label="Voir détails" icon="open_in_new" class="q-mt-xs"
|
||||
@click="$router.push({ name: 'device', params: { serial: bc.value } })" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-badge color="orange" label="Non trouvé" class="q-mb-xs" />
|
||||
<q-btn flat dense size="sm" color="primary" label="Créer équipement" icon="add"
|
||||
@click="openCreateDialog(bc.value)" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<!-- Link all scanned devices to account -->
|
||||
<div v-if="scanner.barcodes.value.length > 0 && jobContext" class="q-mt-md">
|
||||
<q-btn color="primary" icon="link" :label="'Lier au client ' + (jobContext.customer || '')"
|
||||
@click="linkAllToAccount" :loading="linking" class="full-width" />
|
||||
</div>
|
||||
|
||||
<!-- Create equipment dialog -->
|
||||
<q-dialog v-model="createDialog">
|
||||
<q-card style="min-width: 320px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Nouvel équipement</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-input v-model="newEquip.serial_number" label="Numéro de série" outlined dense readonly class="q-mb-sm" />
|
||||
<q-select v-model="newEquip.equipment_type" :options="eqTypes" label="Type" outlined dense class="q-mb-sm" />
|
||||
<q-input v-model="newEquip.brand" label="Marque" outlined dense class="q-mb-sm" />
|
||||
<q-input v-model="newEquip.model" label="Modèle" outlined dense class="q-mb-sm" />
|
||||
<q-input v-model="newEquip.mac_address" label="MAC (optionnel)" outlined dense class="q-mb-sm" />
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Annuler" v-close-popup />
|
||||
<q-btn color="primary" label="Créer" @click="createEquipment" :loading="creating" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useScanner } from 'src/composables/useScanner'
|
||||
import { listDocs, createDoc, updateDoc } from 'src/api/erp'
|
||||
import { useOfflineStore } from 'src/stores/offline'
|
||||
import { Notify } from 'quasar'
|
||||
|
||||
const route = useRoute()
|
||||
const scanner = useScanner()
|
||||
const offline = useOfflineStore()
|
||||
|
||||
const mode = ref('photo')
|
||||
const cameraInput = ref(null)
|
||||
const photoPreview = ref(null)
|
||||
const manualCode = ref('')
|
||||
const lookingUp = ref(null)
|
||||
const lookupResults = ref({})
|
||||
const linking = ref(false)
|
||||
const createDialog = ref(false)
|
||||
const creating = ref(false)
|
||||
|
||||
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Décodeur TV', 'VoIP', 'Amplificateur', 'Splitter', 'Câble', 'Autre']
|
||||
|
||||
// Job context from query params (when coming from TasksPage)
|
||||
const jobContext = ref(route.query.job ? { job: route.query.job, customer: route.query.customer } : null)
|
||||
|
||||
const newEquip = ref({ serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' })
|
||||
|
||||
function triggerCamera () {
|
||||
cameraInput.value?.click()
|
||||
}
|
||||
|
||||
async function onPhoto (e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
photoPreview.value = URL.createObjectURL(file)
|
||||
await scanner.scanPhoto(file)
|
||||
// Auto-lookup found barcodes
|
||||
for (const bc of scanner.barcodes.value) {
|
||||
lookupDevice(bc.value)
|
||||
}
|
||||
}
|
||||
|
||||
function addManual () {
|
||||
const code = manualCode.value.trim()
|
||||
if (!code) return
|
||||
if (scanner.barcodes.value.length >= 3) {
|
||||
Notify.create({ type: 'warning', message: 'Maximum 3 codes' })
|
||||
return
|
||||
}
|
||||
if (!scanner.barcodes.value.find(b => b.value === code)) {
|
||||
scanner.barcodes.value.push({ value: code, region: 'manuel' })
|
||||
lookupDevice(code)
|
||||
}
|
||||
manualCode.value = ''
|
||||
}
|
||||
|
||||
async function startLive () {
|
||||
await scanner.startLive('live-reader', (decoded) => {
|
||||
Notify.create({ type: 'positive', message: 'Scanné: ' + decoded, timeout: 1500 })
|
||||
lookupDevice(decoded)
|
||||
})
|
||||
}
|
||||
|
||||
async function lookupDevice (serial) {
|
||||
lookingUp.value = serial
|
||||
try {
|
||||
const results = await listDocs('Service Equipment', {
|
||||
filters: { serial_number: serial },
|
||||
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'customer', 'customer_name',
|
||||
'service_location', 'status', 'mac_address'],
|
||||
limit: 1,
|
||||
})
|
||||
if (results.length > 0) {
|
||||
lookupResults.value[serial] = { found: true, equipment: results[0] }
|
||||
} else {
|
||||
// Also try barcode field
|
||||
const byBarcode = await listDocs('Service Equipment', {
|
||||
filters: { barcode: serial },
|
||||
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'customer', 'customer_name',
|
||||
'service_location', 'status', 'mac_address'],
|
||||
limit: 1,
|
||||
})
|
||||
if (byBarcode.length > 0) {
|
||||
lookupResults.value[serial] = { found: true, equipment: byBarcode[0] }
|
||||
} else {
|
||||
lookupResults.value[serial] = { found: false }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
lookupResults.value[serial] = { found: false }
|
||||
} finally {
|
||||
lookingUp.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog (serial) {
|
||||
newEquip.value = { serial_number: serial, equipment_type: 'ONT', brand: '', model: '', mac_address: '' }
|
||||
createDialog.value = true
|
||||
}
|
||||
|
||||
async function createEquipment () {
|
||||
creating.value = true
|
||||
const data = {
|
||||
...newEquip.value,
|
||||
status: 'Actif',
|
||||
customer: jobContext.value?.customer || '',
|
||||
}
|
||||
try {
|
||||
if (offline.online) {
|
||||
const doc = await createDoc('Service Equipment', data)
|
||||
lookupResults.value[data.serial_number] = { found: true, equipment: doc }
|
||||
Notify.create({ type: 'positive', message: 'Équipement créé: ' + doc.name })
|
||||
} else {
|
||||
await offline.enqueue({ type: 'create', doctype: 'Service Equipment', data })
|
||||
Notify.create({ type: 'info', message: 'Sauvegardé hors ligne' })
|
||||
}
|
||||
createDialog.value = false
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function linkAllToAccount () {
|
||||
if (!jobContext.value?.customer) return
|
||||
linking.value = true
|
||||
let linked = 0
|
||||
for (const bc of scanner.barcodes.value) {
|
||||
const result = lookupResults.value[bc.value]
|
||||
if (result?.found && result.equipment.name) {
|
||||
try {
|
||||
await updateDoc('Service Equipment', result.equipment.name, { customer: jobContext.value.customer })
|
||||
result.equipment.customer = jobContext.value.customer
|
||||
linked++
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
linking.value = false
|
||||
Notify.create({ type: 'positive', message: linked + ' équipement(s) lié(s)' })
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
scanner.stopLive()
|
||||
})
|
||||
</script>
|
||||
162
apps/field/src/pages/TasksPage.vue
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<template>
|
||||
<q-page padding>
|
||||
<!-- Date header -->
|
||||
<div class="row items-center q-mb-md">
|
||||
<div class="text-h6">{{ todayLabel }}</div>
|
||||
<q-space />
|
||||
<q-btn flat dense icon="refresh" :loading="loading" @click="loadTasks" />
|
||||
</div>
|
||||
|
||||
<!-- Filter tabs -->
|
||||
<q-tabs v-model="filter" dense no-caps active-color="primary" class="q-mb-md">
|
||||
<q-tab name="jobs" :label="'Jobs (' + jobs.length + ')'" />
|
||||
<q-tab name="tickets" :label="'Tickets (' + tickets.length + ')'" />
|
||||
</q-tabs>
|
||||
|
||||
<!-- Jobs list -->
|
||||
<template v-if="filter === 'jobs'">
|
||||
<q-card v-for="job in jobs" :key="job.name" class="q-mb-sm" @click="expandJob(job)">
|
||||
<q-card-section class="q-py-sm">
|
||||
<div class="row items-center no-wrap">
|
||||
<q-badge :color="statusColor(job.status)" class="q-mr-sm" />
|
||||
<div class="col">
|
||||
<div class="text-subtitle2 ellipsis">{{ job.subject || job.name }}</div>
|
||||
<div class="text-caption text-grey">
|
||||
{{ job.customer_name || job.customer || '' }}
|
||||
<span v-if="job.scheduled_time"> · {{ job.scheduled_time?.slice(0, 5) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<q-icon name="chevron_right" color="grey" />
|
||||
</div>
|
||||
|
||||
<!-- Expanded detail -->
|
||||
<q-slide-transition>
|
||||
<div v-if="expanded === job.name" class="q-mt-sm">
|
||||
<div v-if="job.description" class="text-body2 q-mb-xs" v-html="job.description" />
|
||||
<div v-if="job.service_location_name" class="text-caption">
|
||||
<q-icon name="place" size="xs" /> {{ job.service_location_name }}
|
||||
</div>
|
||||
<div class="row q-mt-sm q-gutter-sm">
|
||||
<q-btn size="sm" color="primary" label="Commencer" icon="play_arrow"
|
||||
v-if="job.status === 'Scheduled'" @click.stop="updateJobStatus(job, 'In Progress')" />
|
||||
<q-btn size="sm" color="positive" label="Terminer" icon="check"
|
||||
v-if="job.status === 'In Progress'" @click.stop="updateJobStatus(job, 'Completed')" />
|
||||
<q-btn size="sm" flat label="Scanner" icon="qr_code_scanner"
|
||||
@click.stop="$router.push({ name: 'scan', query: { job: job.name, customer: job.customer } })" />
|
||||
</div>
|
||||
</div>
|
||||
</q-slide-transition>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<div v-if="!loading && jobs.length === 0" class="text-center text-grey q-mt-xl">
|
||||
Aucun job aujourd'hui
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tickets list -->
|
||||
<template v-if="filter === 'tickets'">
|
||||
<q-card v-for="t in tickets" :key="t.name" class="q-mb-sm">
|
||||
<q-card-section class="q-py-sm">
|
||||
<div class="row items-center no-wrap">
|
||||
<q-badge :color="t.status === 'Open' ? 'orange' : t.status === 'Closed' ? 'grey' : 'blue'" class="q-mr-sm" />
|
||||
<div class="col">
|
||||
<div class="text-subtitle2 ellipsis">{{ t.subject }}</div>
|
||||
<div class="text-caption text-grey">
|
||||
{{ t.customer_name || '' }} · {{ formatDate(t.creation) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<div v-if="!loading && tickets.length === 0" class="text-center text-grey q-mt-xl">
|
||||
Aucun ticket assigné
|
||||
</div>
|
||||
</template>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { listDocs, updateDoc } from 'src/api/erp'
|
||||
import { useOfflineStore } from 'src/stores/offline'
|
||||
import { Notify } from 'quasar'
|
||||
|
||||
const loading = ref(false)
|
||||
const filter = ref('jobs')
|
||||
const jobs = ref([])
|
||||
const tickets = ref([])
|
||||
const expanded = ref(null)
|
||||
const offline = useOfflineStore()
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const todayLabel = computed(() =>
|
||||
new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
)
|
||||
|
||||
function formatDate (d) {
|
||||
if (!d) return ''
|
||||
return new Date(d).toLocaleDateString('fr-CA', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function statusColor (s) {
|
||||
const map = { Scheduled: 'blue', 'In Progress': 'orange', Completed: 'green', Cancelled: 'grey' }
|
||||
return map[s] || 'grey'
|
||||
}
|
||||
|
||||
function expandJob (job) {
|
||||
expanded.value = expanded.value === job.name ? null : job.name
|
||||
}
|
||||
|
||||
async function loadTasks () {
|
||||
loading.value = true
|
||||
try {
|
||||
const [j, t] = await Promise.all([
|
||||
listDocs('Dispatch Job', {
|
||||
filters: { scheduled_date: today },
|
||||
fields: ['name', 'subject', 'status', 'customer', 'customer_name', 'service_location',
|
||||
'service_location_name', 'scheduled_time', 'description', 'job_type'],
|
||||
limit: 50,
|
||||
orderBy: 'scheduled_time asc',
|
||||
}),
|
||||
listDocs('Issue', {
|
||||
filters: { status: ['in', ['Open', 'Replied']] },
|
||||
fields: ['name', 'subject', 'status', 'customer', 'customer_name', 'creation', 'priority'],
|
||||
limit: 30,
|
||||
orderBy: 'creation desc',
|
||||
}),
|
||||
])
|
||||
jobs.value = j
|
||||
tickets.value = t
|
||||
// Cache for offline
|
||||
offline.cacheData('tasks-jobs', j)
|
||||
offline.cacheData('tasks-tickets', t)
|
||||
} catch {
|
||||
// Try cached data
|
||||
const cj = await offline.getCached('tasks-jobs')
|
||||
const ct = await offline.getCached('tasks-tickets')
|
||||
if (cj) jobs.value = cj
|
||||
if (ct) tickets.value = ct
|
||||
Notify.create({ type: 'warning', message: 'Mode hors ligne — données en cache' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateJobStatus (job, status) {
|
||||
try {
|
||||
if (offline.online) {
|
||||
await updateDoc('Dispatch Job', job.name, { status })
|
||||
job.status = status
|
||||
Notify.create({ type: 'positive', message: status === 'Completed' ? 'Job terminé' : 'Job démarré' })
|
||||
} else {
|
||||
await offline.enqueue({ type: 'update', doctype: 'Dispatch Job', name: job.name, data: { status } })
|
||||
job.status = status
|
||||
Notify.create({ type: 'info', message: 'Sauvegardé hors ligne' })
|
||||
}
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadTasks)
|
||||
</script>
|
||||
20
apps/field/src/router/index.js
Normal file
|
|
@ -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,
|
||||
})
|
||||
21
apps/field/src/stores/auth.js
Normal file
|
|
@ -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 }
|
||||
})
|
||||
73
apps/field/src/stores/offline.js
Normal file
|
|
@ -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 }
|
||||
})
|
||||