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>
This commit is contained in:
louispaulb 2026-03-30 23:00:44 -04:00
parent 13dcd4bf77
commit 11cd38f93c
37 changed files with 1546 additions and 0 deletions

48
apps/field/deploy.sh Executable file
View 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
View 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>

View 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

View 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
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.5 KiB

View 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 = '.'
},
},
}
})

View File

@ -0,0 +1,4 @@
/* eslint-env serviceworker */
import { precacheAndRoute } from 'workbox-precaching'
precacheAndRoute(self.__WB_MANIFEST)

View 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" }
]
}

View 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
View 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>

View 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
View 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 || []
}

View File

@ -0,0 +1,6 @@
import { boot } from 'quasar/wrappers'
import { createPinia } from 'pinia'
export default boot(({ app }) => {
app.use(createPinia())
})

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

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

View File

@ -0,0 +1 @@
export const BASE_URL = ''

View 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;
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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"> &middot; {{ 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 || '' }} &middot; {{ 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>

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

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

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