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 }
|
||||||
|
})
|
||||||