refactor(targo-hub): add types.js, migrate acceptance+payments, drop apps/field

- lib/types.js: single source of truth for Dispatch Job status + priority enums.
  Eliminates hard-coded 'In Progress'/'in_progress'/'Completed'/'done' checks
  scattered across tech-mobile, acceptance, dispatch. Includes CLIENT_TYPES_JS
  snippet for embedding in SSR <script> blocks (no require() needed).

- lib/tech-mobile.js: applies types.js predicates (isInProgress, isTerminal,
  isDone, isUrgent) both server-side and client-side via ${CLIENT_TYPES_JS}
  template injection. Single aliasing point for future status renames.

- lib/acceptance.js: migrated 7 erpFetch + 2 erpRequest sites to erp.js wrapper.
  Removed duplicate "Lien expiré" HTML (now ui.pageExpired()). Dispatch Job
  creation uses types.JOB_STATUS + types.JOB_PRIORITY.

- lib/payments.js: migrated 15 erpFetch + 9 erpRequest sites to erp.js wrapper.
  Live Stripe flows preserved exactly — frappe.client.submit calls kept as
  erp.raw passthroughs (fetch-full-doc-then-submit pattern intact). Includes
  refund → Return PE → Credit Note lifecycle, PPA cron, idempotency guard.

- apps/field/ deleted: transitional Quasar PWA fully retired in favor of
  SSR tech-mobile at /t/{jwt}. Saves 14k lines of JS, PWA icons, and
  infra config. Docs already marked it "retiring".

Smoke-tested on prod:
  /payments/balance/:customer (200, proper shape)
  /payments/methods/:customer (200, Stripe cards live-fetched)
  /dispatch/calendar/:tech.ics (200, VCALENDAR)
  /t/{jwt} (55KB render, no errors)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-22 23:18:25 -04:00
parent 01bb99857f
commit 9fda9eb0b0
45 changed files with 217 additions and 14352 deletions

View File

@ -1,48 +0,0 @@
#!/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/)..."
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

View File

@ -1,18 +0,0 @@
<!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>
<!-- quasar:entry-point -->
</body>
</html>

View File

@ -1,29 +0,0 @@
# 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

@ -1,52 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# ERPNext API proxy token injected server-side (never in JS bundle)
location /api/ {
proxy_pass https://erp.gigafibre.ca;
proxy_ssl_verify off;
proxy_set_header Host erp.gigafibre.ca;
proxy_set_header Authorization "token b273a666c86d2d0:06120709db5e414";
proxy_set_header X-Authentik-Email $http_x_authentik_email;
proxy_set_header X-Authentik-Username $http_x_authentik_username;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
# NOTE: Ollama Vision proxy removed 2026-04-22 all invoice OCR and
# barcode/equipment scans now go directly to targo-hub (Gemini 2.5 Flash).
# See docs/features/vision-ocr.md.
# Targo Hub API proxy vision, devices, etc.
location /hub/ {
resolver 127.0.0.11 valid=10s;
set $hub_upstream http://targo-hub:3300;
proxy_pass $hub_upstream/;
proxy_set_header Host $host;
proxy_set_header X-Authentik-Email $http_x_authentik_email;
proxy_set_header X-Authentik-Username $http_x_authentik_username;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
client_max_body_size 20m;
}
# SPA fallback
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";
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,30 +0,0 @@
{
"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/cli": "^3.0.0",
"@quasar/extras": "^1.16.12",
"idb-keyval": "^6.2.1",
"pinia": "^2.1.7",
"quasar": "^2.16.10",
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"workbox-build": "^7.0.0"
},
"devDependencies": {
"@quasar/app-vite": "^1.10.0",
"eslint": "^8.57.0"
},
"engines": {
"node": "^18 || ^20",
"npm": ">= 6.13.4"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -1,81 +0,0 @@
/* 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: {
notify: { position: 'top', timeout: 2500 },
},
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

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

View File

@ -1,17 +0,0 @@
{
"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

@ -1,10 +0,0 @@
/* eslint-disable */
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
import "quasar/dist/types/feature-flag";
declare module "quasar/dist/types/feature-flag" {
interface QuasarFeatureFlags {
pwa: true;
}
}

View File

@ -1,11 +0,0 @@
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) },
})

View File

@ -1,11 +0,0 @@
<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

@ -1,40 +0,0 @@
import { BASE_URL } from 'src/config/erpnext'
// Token is optional — in production, nginx injects it server-side.
// Only needed for local dev (VITE_ERP_TOKEN in .env).
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || ''
export function authFetch (url, opts = {}) {
if (SERVICE_TOKEN) {
opts.headers = {
...opts.headers,
Authorization: 'token ' + SERVICE_TOKEN,
}
} else {
opts.headers = { ...opts.headers }
}
opts.redirect = 'manual'
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 headers = SERVICE_TOKEN ? { Authorization: 'token ' + SERVICE_TOKEN } : {}
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', { headers })
if (res.ok) {
const data = await res.json()
return data.message || 'authenticated'
}
} catch {}
return 'authenticated'
}
export async function logout () {
window.location.href = 'https://id.gigafibre.ca/if/flow/default-invalidation-flow/'
}

View File

@ -1,58 +0,0 @@
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

@ -1,74 +0,0 @@
/**
* OCR / Vision client (field app).
*
* All calls go through targo-hub, which runs Gemini 2.5 Flash. We used to
* hit a local Ollama (llama3.2-vision) for invoice OCR, but that required
* a GPU on the serving VM ops doesn't have one, so we centralized every
* vision model behind the hub.
*
* NOTE: apps/field is being folded into apps/ops under /j (see
* docs/architecture/overview.md §"Legacy Retirement Plan"). During the transition
* we keep this file in sync with apps/ops/src/api/ocr.js so no surprises
* when code moves over.
*/
const HUB_URL = 'https://msg.gigafibre.ca'
const VISION_BARCODES = `${HUB_URL}/vision/barcodes`
const VISION_INVOICE = `${HUB_URL}/vision/invoice`
function stripDataUri (base64Image) {
return String(base64Image || '').replace(/^data:image\/[^;]+;base64,/, '')
}
/**
* Send a photo to Gemini (via hub) for bill/invoice OCR.
* @param {string} base64Image base64 or data URI
* @returns {Promise<object>} Parsed invoice (see targo-hub/lib/vision.js INVOICE_SCHEMA)
*/
export async function ocrBill (base64Image) {
const res = await fetch(VISION_INVOICE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: stripDataUri(base64Image) }),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error('Invoice OCR failed: ' + (text || res.status))
}
return res.json()
}
/**
* Send a photo to Gemini (via hub) for barcode / serial extraction.
* @param {string} base64Image base64 or data URI
* @returns {Promise<{ barcodes: string[] }>}
*/
export async function scanBarcodes (base64Image) {
const res = await fetch(VISION_BARCODES, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: base64Image }),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error('Vision scan failed: ' + (text || res.status))
}
const data = await res.json()
return { barcodes: data.barcodes || [] }
}
/**
* Vision service health probe. Pings the hub's /health endpoint.
* Kept under the legacy name `checkOllamaStatus` for backward compat with
* any caller still referencing it ops uses the same name.
*/
export async function checkOllamaStatus () {
try {
const res = await fetch(`${HUB_URL}/health`, { method: 'GET' })
if (!res.ok) return { online: false, error: 'HTTP ' + res.status }
return { online: true, models: ['gemini-2.5-flash'], hasVision: true }
} catch (e) {
return { online: false, error: e.message }
}
}

View File

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

View File

@ -1,172 +0,0 @@
import { ref, watch } from 'vue'
import { scanBarcodes } from 'src/api/ocr'
import { useOfflineStore } from 'src/stores/offline'
const SCAN_TIMEOUT_MS = 8000
/**
* Barcode scanner using device camera photo capture + Gemini Vision AI.
*
* Strategy: Use <input type="file" capture="environment"> which triggers
* the native camera app this gives proper autofocus, tap-to-focus,
* and high-res photos. Then send to Gemini Vision for barcode extraction.
*
* Resilience: if Gemini doesn't answer within SCAN_TIMEOUT_MS (weak LTE),
* the photo is queued in IndexedDB via the offline store and retried when
* the signal comes back. The tech gets a "scan en attente" indicator and
* can keep working; late results are delivered via onNewCode().
*
* @param {object} options
* @param {(code: string) => void} [options.onNewCode] called for each
* newly detected code, whether the scan was synchronous or delivered
* later from the offline queue. Typically used to trigger lookup + notify.
*/
export function useScanner (options = {}) {
const onNewCode = options.onNewCode || (() => {})
const barcodes = ref([]) // Array of { value, region } — max 3
const scanning = ref(false) // true while Gemini is processing
const error = ref(null)
const lastPhoto = ref(null) // data URI of last captured photo (thumbnail)
const photos = ref([]) // all captured photo thumbnails
const offline = useOfflineStore()
// Pick up any scans that completed while the page was unmounted (e.g. tech
// queued a photo, locked phone, walked out of the basement, signal returns).
for (const result of offline.scanResults) {
mergeCodes(result.barcodes || [], 'queued')
offline.consumeScanResult(result.id)
}
// Watch for sync completions during the lifetime of this scanner.
// Vue auto-disposes the watcher when the host component unmounts.
watch(
() => offline.scanResults.length,
() => {
for (const result of [...offline.scanResults]) {
mergeCodes(result.barcodes || [], 'queued')
offline.consumeScanResult(result.id)
}
}
)
function addCode (code, region) {
if (barcodes.value.length >= 3) return false
if (barcodes.value.find(b => b.value === code)) return false
barcodes.value.push({ value: code, region })
onNewCode(code)
return true
}
function mergeCodes (codes, region) {
const added = []
for (const code of codes) {
if (addCode(code, region)) added.push(code)
}
return added
}
/**
* Process a photo file from camera input.
* Resizes for AI, keeps thumbnail, sends to Gemini with an 8s timeout.
* On timeout/failure, the photo is queued for background retry.
*/
async function processPhoto (file) {
if (!file) return []
error.value = null
scanning.value = true
let aiImage = null
const photoIdx = photos.value.length
let found = []
try {
// Create thumbnail for display (small)
const thumbUrl = await resizeImage(file, 400)
lastPhoto.value = thumbUrl
photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [], queued: false })
// Create optimized image for AI — keep high res for text readability
aiImage = await resizeImage(file, 1600, 0.92)
const result = await scanBarcodesWithTimeout(aiImage, SCAN_TIMEOUT_MS)
found = mergeCodes(result.barcodes || [], 'photo')
photos.value[photoIdx].codes = found
if (found.length === 0) {
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
}
} catch (e) {
if (aiImage && isRetryable(e)) {
await offline.enqueueVisionScan({ image: aiImage })
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
} else {
error.value = e.message || 'Erreur'
}
} finally {
scanning.value = false
}
return found
}
async function scanBarcodesWithTimeout (image, ms) {
return await Promise.race([
scanBarcodes(image),
new Promise((_, reject) => setTimeout(
() => reject(new Error('ScanTimeout')),
ms,
)),
])
}
function isRetryable (e) {
const msg = (e?.message || '').toLowerCase()
return msg.includes('scantimeout')
|| msg.includes('failed to fetch')
|| msg.includes('networkerror')
|| msg.includes('load failed')
|| e?.name === 'TypeError' // fetch throws TypeError on network error
}
/**
* Resize an image file to a max dimension, return as base64 data URI.
*/
function resizeImage (file, maxDim, quality = 0.85) {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
let { width, height } = img
if (width > maxDim || height > maxDim) {
const ratio = Math.min(maxDim / width, maxDim / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
canvas.getContext('2d').drawImage(img, 0, 0, width, height)
resolve(canvas.toDataURL('image/jpeg', quality))
}
img.onerror = reject
img.src = URL.createObjectURL(file)
})
}
function removeBarcode (value) {
barcodes.value = barcodes.value.filter(b => b.value !== value)
}
function clearBarcodes () {
barcodes.value = []
error.value = null
lastPhoto.value = null
photos.value = []
}
return {
barcodes, scanning, error, lastPhoto, photos,
processPhoto, removeBarcode, clearBarcodes,
}
}

View File

@ -1,102 +0,0 @@
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
//
// Why two fetches: browser fetch() includes DNS + TCP + TLS + HTTP on top
// of real RTT. First call pays the cold-connection tax (easily 200400ms
// on mobile LTE when the radio is idle); second call on the now-warm
// connection reports something close to actual RTT. Before this change
// techs saw "cloudflare.com a répondu en 350ms" on what was actually a
// 5ms link and opened false-positive tickets.
async function resolveHost (host) {
resolveResult.value = null
const url = host.startsWith('http') ? host : 'https://' + host
try {
// Warm-up — result discarded. Pays DNS + TCP + TLS + LTE wake-up.
await fetch(url, { mode: 'no-cors', cache: 'no-store' })
// Steady-state measurement on the warm connection.
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

@ -1,4 +0,0 @@
// Route API calls through field-frontend nginx which injects the ERP token.
// Without this, POST/PUT/DELETE fail with 403 (CSRF) because they go directly
// to ERPNext via Traefik without the API token header.
export const BASE_URL = '/field'

View File

@ -1,17 +0,0 @@
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

@ -1,78 +0,0 @@
<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-toolbar>
</q-header>
<q-page-container>
<router-view />
</q-page-container>
<!-- Bottom section: offline banner + tabs -->
<q-footer class="bg-white text-dark" bordered>
<!-- Offline banner above tabs -->
<transition name="slide-down">
<div v-if="!offline.online" class="offline-banner">
<q-icon name="wifi_off" size="16px" class="q-mr-xs" />
Hors ligne
</div>
</transition>
<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;
}
}
.offline-banner {
display: flex;
align-items: center;
justify-content: center;
background: #c62828;
color: white;
font-size: 13px;
font-weight: 500;
padding: 4px 0;
}
.slide-down-enter-active,
.slide-down-leave-active {
transition: max-height 0.3s ease, opacity 0.3s ease;
overflow: hidden;
}
.slide-down-enter-from,
.slide-down-leave-to {
max-height: 0;
opacity: 0;
}
.slide-down-enter-to,
.slide-down-leave-from {
max-height: 30px;
opacity: 1;
}
</style>

View File

@ -1,180 +0,0 @@
<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

@ -1,126 +0,0 @@
<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([])
// 1.1.1.1/cdn-cgi/trace instead of bare cloudflare.com the bare host
// redirects 301 www.cloudflare.com, doubling the handshake cost. The
// trace endpoint is 88 bytes, no redirect, canonical "internet is up" probe.
const quickHosts = ['google.ca', 'erp.gigafibre.ca', '1.1.1.1/cdn-cgi/trace', '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

@ -1,526 +0,0 @@
<template>
<q-page class="job-detail-page">
<!-- Top bar -->
<div class="job-topbar">
<q-btn flat dense icon="arrow_back" @click="$router.back()" color="white" />
<div class="col text-center">
<div class="text-subtitle2">{{ job?.subject || job?.name || 'Job' }}</div>
<div class="text-caption text-grey-4">{{ job?.name }}</div>
</div>
<q-btn flat dense icon="more_vert" color="white">
<q-menu>
<q-list dense>
<q-item clickable v-close-popup @click="openInErp">
<q-item-section avatar><q-icon name="open_in_new" size="xs" /></q-item-section>
<q-item-section>Ouvrir dans ERPNext</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
<q-spinner v-if="loading" size="lg" color="primary" class="block q-mx-auto q-mt-xl" />
<template v-if="job && !loading">
<!-- Status + actions hero -->
<div class="job-status-hero" :class="'status-' + (job.status || '').toLowerCase().replace(/\s/g, '-')">
<q-badge :color="statusColor" :label="statusLabel" class="text-body2 q-px-md q-py-xs" />
<div class="row q-mt-md q-gutter-sm justify-center">
<q-btn v-if="job.status === 'Scheduled'" unelevated color="blue" icon="directions_car"
label="En route" @click="updateStatus('In Progress')" :loading="saving" class="action-btn" />
<q-btn v-if="job.status === 'In Progress'" unelevated color="positive" icon="check_circle"
label="Terminer" @click="updateStatus('Completed')" :loading="saving" class="action-btn" />
<q-btn v-if="job.status === 'Completed'" flat color="grey" icon="replay"
label="Rouvrir" @click="updateStatus('In Progress')" :loading="saving" class="action-btn" />
</div>
</div>
<!-- Scrollable content -->
<div class="job-content q-pa-md">
<!-- Info card -->
<q-card flat bordered class="q-mb-md">
<q-card-section class="q-pb-none">
<div class="text-overline text-grey-6">INFORMATIONS</div>
</q-card-section>
<q-card-section>
<q-input v-model="job.subject" label="Sujet" outlined dense class="q-mb-sm"
@blur="saveField('subject', job.subject)" />
<div class="row q-gutter-sm q-mb-sm">
<q-select v-model="job.job_type" :options="jobTypes" label="Type" outlined dense
class="col" emit-value map-options @update:model-value="saveField('job_type', $event)" />
<q-select v-model="job.priority" :options="priorities" label="Priorité" outlined dense
class="col" emit-value map-options @update:model-value="saveField('priority', $event)" />
</div>
<div class="row q-gutter-sm q-mb-sm">
<q-input v-model="job.scheduled_time" label="Heure" type="time" outlined dense class="col"
@blur="saveField('scheduled_time', job.scheduled_time)" />
<q-input v-model="displayDuration" label="Durée (h)" type="number" step="0.5" min="0.5" max="12"
outlined dense class="col"
@blur="saveField('duration_h', parseFloat(displayDuration) || 1)" />
</div>
<q-input v-model="job.description" label="Notes / Description" type="textarea" outlined dense
autogrow rows="2" class="q-mb-sm"
@blur="saveField('description', job.description)" />
</q-card-section>
</q-card>
<!-- Location card -->
<q-card flat bordered class="q-mb-md" v-if="job.service_location_name || job.customer_name">
<q-card-section class="q-pb-none">
<div class="text-overline text-grey-6">CLIENT & ADRESSE</div>
</q-card-section>
<q-card-section>
<div v-if="job.customer_name" class="row items-center q-mb-xs">
<q-icon name="person" color="grey" class="q-mr-sm" />
<span class="text-body2">{{ job.customer_name }}</span>
</div>
<div v-if="locationAddress" class="row items-center q-mb-sm">
<q-icon name="place" color="grey" class="q-mr-sm" />
<span class="text-body2">{{ locationAddress }}</span>
<q-space />
<q-btn flat dense round icon="navigation" color="primary" @click="openGps" title="Naviguer" />
</div>
<div v-if="locationDetail?.contact_name" class="text-caption text-grey">
Contact: {{ locationDetail.contact_name }}
<span v-if="locationDetail.contact_phone"> {{ locationDetail.contact_phone }}</span>
</div>
</q-card-section>
</q-card>
<!-- Equipment section -->
<q-card flat bordered class="q-mb-md">
<q-card-section class="q-pb-none row items-center">
<div class="text-overline text-grey-6 col">ÉQUIPEMENTS ({{ equipment.length }})</div>
<q-btn flat dense size="sm" icon="add" color="primary" label="Ajouter" @click="addEquipmentMenu = true" />
</q-card-section>
<q-card-section v-if="loadingEquip" class="text-center">
<q-spinner size="sm" />
</q-card-section>
<q-list v-else-if="equipment.length" separator>
<q-item v-for="eq in equipment" :key="eq.name" clickable
@click="$router.push({ name: 'device', params: { serial: eq.serial_number } })">
<q-item-section avatar>
<q-icon :name="eqIcon(eq.equipment_type)" :color="eqStatusColor(eq.status)" />
</q-item-section>
<q-item-section>
<q-item-label>{{ eq.equipment_type }} {{ eq.brand }} {{ eq.model }}</q-item-label>
<q-item-label caption class="mono">{{ eq.serial_number }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-badge :color="eqStatusColor(eq.status)" :label="eq.status || '—'" />
</q-item-section>
</q-item>
</q-list>
<q-card-section v-else class="text-center text-grey text-caption">
Aucun équipement lié
</q-card-section>
</q-card>
<!-- Add equipment menu -->
<q-dialog v-model="addEquipmentMenu" position="bottom">
<q-card style="width: 100%; max-width: 400px">
<q-card-section class="text-h6">Ajouter un équipement</q-card-section>
<q-list>
<q-item clickable v-close-popup @click="goToScanner">
<q-item-section avatar><q-icon name="qr_code_scanner" color="primary" /></q-item-section>
<q-item-section>
<q-item-label>Scanner un code-barres / QR</q-item-label>
<q-item-label caption>Utiliser la caméra pour détecter un SN ou MAC</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="searchEquipDialog = true">
<q-item-section avatar><q-icon name="search" color="orange" /></q-item-section>
<q-item-section>
<q-item-label>Rechercher un équipement existant</q-item-label>
<q-item-label caption>Par numéro de série ou MAC</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="createEquipDialog = true">
<q-item-section avatar><q-icon name="add_circle" color="green" /></q-item-section>
<q-item-section>
<q-item-label>Créer un nouvel équipement</q-item-label>
<q-item-label caption>Saisir manuellement les informations</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card>
</q-dialog>
<!-- Search existing equipment dialog -->
<q-dialog v-model="searchEquipDialog">
<q-card style="min-width: 340px">
<q-card-section class="text-h6">Rechercher un équipement</q-card-section>
<q-card-section>
<q-input v-model="eqSearchText" label="Numéro de série ou MAC" outlined dense autofocus
@keyup.enter="searchEquipment" debounce="400" @update:model-value="searchEquipment">
<template v-slot:append><q-icon name="search" /></template>
</q-input>
<q-list v-if="eqSearchResults.length" bordered separator class="q-mt-sm" style="max-height: 250px; overflow-y: auto">
<q-item v-for="eq in eqSearchResults" :key="eq.name" clickable @click="linkEquipToJob(eq)">
<q-item-section>
<q-item-label>{{ eq.equipment_type }} {{ eq.brand }} {{ eq.model }}</q-item-label>
<q-item-label caption class="mono">SN: {{ eq.serial_number }}</q-item-label>
<q-item-label caption v-if="eq.customer_name">Client: {{ eq.customer_name }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="link" color="primary" />
</q-item-section>
</q-item>
</q-list>
<div v-if="eqSearchText && !eqSearchResults.length && !eqSearching" class="text-caption text-grey q-mt-sm text-center">
Aucun résultat
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Fermer" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Create new equipment dialog -->
<q-dialog v-model="createEquipDialog">
<q-card style="min-width: 340px">
<q-card-section class="text-h6">Nouvel équipement</q-card-section>
<q-card-section>
<q-input v-model="newEquip.serial_number" label="Numéro de série" outlined dense 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 & Lier" :loading="creatingEquip" @click="createAndLinkEquip" />
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getDoc, updateDoc, listDocs, createDoc } from 'src/api/erp'
import { useOfflineStore } from 'src/stores/offline'
import { Notify } from 'quasar'
import { BASE_URL } from 'src/config/erpnext'
const route = useRoute()
const router = useRouter()
const offline = useOfflineStore()
const jobName = computed(() => route.params.name)
const loading = ref(true)
const saving = ref(false)
const job = ref(null)
const locationDetail = ref(null)
const equipment = ref([])
const loadingEquip = ref(false)
// Add equipment
const addEquipmentMenu = ref(false)
const searchEquipDialog = ref(false)
const createEquipDialog = ref(false)
const eqSearchText = ref('')
const eqSearchResults = ref([])
const eqSearching = ref(false)
const creatingEquip = ref(false)
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Décodeur TV', 'VoIP', 'Amplificateur', 'Splitter', 'Câble', 'Autre']
const newEquip = ref({ serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' })
const displayDuration = computed({
get: () => job.value?.duration_h || 1,
set: v => { if (job.value) job.value.duration_h = v },
})
const jobTypes = [
{ label: 'Installation', value: 'Installation' },
{ label: 'Réparation', value: 'Repair' },
{ label: 'Maintenance', value: 'Maintenance' },
{ label: 'Inspection', value: 'Inspection' },
{ label: 'Livraison', value: 'Delivery' },
{ label: 'Autre', value: 'Other' },
]
const priorities = [
{ label: 'Basse', value: 'low' },
{ label: 'Moyenne', value: 'medium' },
{ label: 'Haute', value: 'high' },
{ label: 'Urgente', value: 'urgent' },
]
const statusColor = computed(() => {
const s = job.value?.status
if (s === 'Scheduled') return 'blue'
if (s === 'In Progress') return 'orange'
if (s === 'Completed') return 'green'
if (s === 'Cancelled') return 'grey'
return 'grey'
})
const statusLabel = computed(() => {
const map = { Scheduled: 'Planifié', 'In Progress': 'En cours', Completed: 'Terminé', Cancelled: 'Annulé' }
return map[job.value?.status] || job.value?.status || ''
})
const locationAddress = computed(() => {
const loc = locationDetail.value
if (!loc) return job.value?.service_location_name || ''
return [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ')
})
function eqIcon (type) {
const map = { ONT: 'settings_input_hdmi', Modem: 'router', Routeur: 'wifi', 'Décodeur TV': 'tv', VoIP: 'phone' }
return map[type] || 'memory'
}
function eqStatusColor (s) {
if (s === 'Actif') return 'green'
if (s === 'Défectueux' || s === 'Perdu') return 'red'
return 'grey'
}
// --- Load job + related data ---
async function loadJob () {
loading.value = true
try {
job.value = await getDoc('Dispatch Job', jobName.value)
// Load location details
if (job.value.service_location) {
getDoc('Service Location', job.value.service_location)
.then(loc => { locationDetail.value = loc })
.catch(() => {})
}
// Load equipment at this location
loadEquipment()
} catch (e) {
// Try cache
const cached = await offline.getCached('job-' + jobName.value)
if (cached) {
job.value = cached
} else {
Notify.create({ type: 'negative', message: 'Erreur chargement job' })
}
} finally {
loading.value = false
}
}
async function loadEquipment () {
if (!job.value?.service_location && !job.value?.customer) return
loadingEquip.value = true
try {
const filters = job.value.service_location
? { service_location: job.value.service_location }
: { customer: job.value.customer }
equipment.value = await listDocs('Service Equipment', {
filters,
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'mac_address', 'status', 'customer_name'],
limit: 50,
})
} catch { equipment.value = [] }
finally { loadingEquip.value = false }
}
// --- Save field ---
async function saveField (field, value) {
if (!job.value?.name) return
try {
if (offline.online) {
await updateDoc('Dispatch Job', job.value.name, { [field]: value })
} else {
await offline.enqueue({ type: 'update', doctype: 'Dispatch Job', name: job.value.name, data: { [field]: value } })
}
// Cache for offline
offline.cacheData('job-' + job.value.name, { ...job.value })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur sauvegarde: ' + e.message })
}
}
// --- Status update ---
async function updateStatus (status) {
saving.value = true
try {
if (offline.online) {
await updateDoc('Dispatch Job', job.value.name, { status })
} else {
await offline.enqueue({ type: 'update', doctype: 'Dispatch Job', name: job.value.name, data: { status } })
}
job.value.status = status
const msgs = { 'In Progress': 'En route !', Completed: 'Job terminé', Scheduled: 'Job réouvert' }
Notify.create({ type: 'positive', message: msgs[status] || status, icon: status === 'Completed' ? 'check_circle' : 'directions_car' })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
saving.value = false
}
}
// --- GPS navigation ---
function openGps () {
const loc = locationDetail.value
if (!loc) return
const addr = [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ')
// Try Google Maps first (most common on Android), fallback to Apple Maps
const encoded = encodeURIComponent(addr)
if (loc.latitude && loc.longitude) {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${loc.latitude},${loc.longitude}`, '_blank')
} else {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encoded}`, '_blank')
}
}
function openInErp () {
window.open(`${BASE_URL}/app/dispatch-job/${job.value.name}`, '_blank')
}
// --- Equipment search ---
async function searchEquipment () {
const text = eqSearchText.value?.trim()
if (!text || text.length < 2) { eqSearchResults.value = []; return }
eqSearching.value = true
try {
// Search by serial
let results = await listDocs('Service Equipment', {
filters: { serial_number: ['like', `%${text}%`] },
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'mac_address', 'customer_name', 'status'],
limit: 10,
})
if (!results.length) {
// Search by MAC
results = await listDocs('Service Equipment', {
filters: { mac_address: ['like', `%${text}%`] },
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'mac_address', 'customer_name', 'status'],
limit: 10,
})
}
eqSearchResults.value = results
} catch { eqSearchResults.value = [] }
finally { eqSearching.value = false }
}
async function linkEquipToJob (eq) {
try {
const updates = {}
if (job.value.customer) updates.customer = job.value.customer
if (job.value.service_location) updates.service_location = job.value.service_location
await updateDoc('Service Equipment', eq.name, updates)
equipment.value.push(eq)
searchEquipDialog.value = false
eqSearchText.value = ''
eqSearchResults.value = []
Notify.create({ type: 'positive', message: `${eq.equipment_type} lié au job`, icon: 'link' })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
}
}
async function createAndLinkEquip () {
if (!newEquip.value.serial_number?.trim()) {
Notify.create({ type: 'warning', message: 'Numéro de série requis' })
return
}
creatingEquip.value = true
try {
const data = {
...newEquip.value,
status: 'Actif',
customer: job.value.customer || '',
service_location: job.value.service_location || '',
}
if (offline.online) {
const doc = await createDoc('Service Equipment', data)
equipment.value.push(doc)
Notify.create({ type: 'positive', message: 'Équipement créé et lié', icon: 'check_circle' })
} else {
await offline.enqueue({ type: 'create', doctype: 'Service Equipment', data })
Notify.create({ type: 'info', message: 'Sauvegardé hors ligne' })
}
createEquipDialog.value = false
newEquip.value = { serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' }
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
creatingEquip.value = false
}
}
function goToScanner () {
router.push({
name: 'scan',
query: {
job: job.value.name,
customer: job.value.customer,
customer_name: job.value.customer_name,
location: job.value.service_location,
location_name: job.value.service_location_name,
},
})
}
onMounted(loadJob)
</script>
<style lang="scss" scoped>
.job-detail-page {
padding: 0 !important;
display: flex;
flex-direction: column;
min-height: 100%;
}
.job-topbar {
display: flex;
align-items: center;
padding: 8px 8px 8px 4px;
background: var(--q-primary, #1976d2);
color: white;
position: sticky;
top: 0;
z-index: 10;
}
.job-status-hero {
text-align: center;
padding: 20px 16px;
background: #f5f7fa;
border-bottom: 1px solid rgba(0,0,0,0.06);
&.status-in-progress { background: #fff8e1; }
&.status-completed { background: #e8f5e9; }
&.status-cancelled { background: #fafafa; }
}
.action-btn {
min-width: 140px;
font-weight: 600;
}
.job-content {
flex: 1;
overflow-y: auto;
padding-bottom: 80px !important; // clear bottom nav
}
.mono {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.85em;
}
.text-overline {
font-size: 0.68rem;
letter-spacing: 0.08em;
}
</style>

View File

@ -1,64 +0,0 @@
<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

@ -1,535 +0,0 @@
<template>
<q-page padding class="scan-page">
<!-- Job context banner -->
<q-card v-if="jobContext" flat bordered class="q-mb-md bg-blue-1">
<q-card-section class="q-py-sm row items-center no-wrap">
<q-icon name="work" color="primary" class="q-mr-sm" />
<div class="col">
<div class="text-subtitle2">{{ jobContext.customer_name || jobContext.customer }}</div>
<div class="text-caption text-grey" v-if="jobContext.location_name">
<q-icon name="place" size="xs" /> {{ jobContext.location_name }}
</div>
</div>
<q-btn flat dense size="sm" icon="close" @click="jobContext = null" />
</q-card-section>
<q-card-section class="q-pt-none q-pb-sm text-caption text-blue-grey">
Les équipements scannés seront automatiquement liés à ce client et cette adresse.
</q-card-section>
</q-card>
<!-- Camera capture button -->
<div class="text-center">
<q-btn
color="primary" icon="photo_camera" label="Scanner"
size="lg" rounded unelevated
@click="takePhoto"
:loading="scanner.scanning.value"
class="q-px-xl"
/>
<input ref="cameraInput" type="file" accept="image/*" capture="environment" class="hidden" @change="onPhoto" />
</div>
<!-- Pending scan indicator (signal faible) -->
<div v-if="offline.pendingVisionCount > 0" class="text-center q-mt-sm">
<q-chip icon="wifi_tethering_off" color="orange" text-color="white" dense clickable @click="offline.syncVisionQueue()">
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }} en attente · toucher pour réessayer
</q-chip>
</div>
<!-- Last captured photo (thumbnail) -->
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
<div v-if="scanner.scanning.value" class="preview-overlay">
<q-spinner-dots size="32px" color="white" />
<div class="text-white text-caption q-mt-xs">Analyse Gemini...</div>
</div>
</div>
<!-- Error / status -->
<div v-if="scanner.error.value" class="text-caption text-center q-mt-sm text-negative">
{{ scanner.error.value }}
</div>
<!-- Manual entry -->
<q-input v-model="manualCode" label="Saisie manuelle SN / MAC" outlined dense class="q-mt-md"
@keyup.enter="addManual">
<template v-slot:append>
<q-btn flat dense icon="add" @click="addManual" :disable="!manualCode.trim()" />
</template>
</q-input>
<!-- Scanned barcodes -->
<div v-if="scanner.barcodes.value.length > 0" class="q-mt-md">
<div class="text-subtitle2 q-mb-xs">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 mono">{{ bc.value }}</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>
<div v-if="!lookupResults[bc.value].equipment.service_location && !jobContext" class="q-mt-xs">
<q-btn flat dense size="sm" color="orange" label="Lier à un service" icon="link"
@click="openLinkDialog(bc.value, lookupResults[bc.value].equipment)" />
</div>
<div v-else class="text-caption text-green q-mt-xs">
<q-icon name="check_circle" size="xs" class="q-mr-xs" />
{{ lookupResults[bc.value].equipment.service_location }}
</div>
<q-btn flat dense size="sm" label="Détails" icon="open_in_new" class="q-mt-xs"
@click="$router.push({ name: 'device', params: { serial: lookupResults[bc.value].equipment.serial_number || 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>
<!-- Photo history (small thumbnails) -->
<div v-if="scanner.photos.value.length > 1" class="q-mt-md">
<div class="text-caption text-grey q-mb-xs">Photos capturées</div>
<div class="row q-gutter-xs">
<div v-for="(p, i) in scanner.photos.value" :key="i" class="photo-thumb" @click="viewPhoto(p)">
<img :src="p.url" />
<q-badge v-if="p.codes.length" color="green" floating :label="p.codes.length" />
</div>
</div>
</div>
<!-- Link all to account (manual, when no job context) -->
<div v-if="scanner.barcodes.value.length > 0 && !jobContext && hasUnlinked" class="q-mt-sm">
<q-btn color="orange" icon="link" label="Lier les équipements à un service"
@click="openLinkDialogForAll" outline class="full-width" />
</div>
<!-- Full photo viewer -->
<q-dialog v-model="showFullPhoto" maximized>
<q-card class="bg-black column">
<q-card-section class="col-auto row items-center">
<div class="text-white text-subtitle2 col">Photo</div>
<q-btn flat round icon="close" color="white" v-close-popup />
</q-card-section>
<q-card-section class="col column items-center justify-center">
<img :src="fullPhotoUrl" style="max-width:100%; max-height:80vh; object-fit:contain" />
</q-card-section>
</q-card>
</q-dialog>
<!-- 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>
<!-- Link device to service dialog -->
<q-dialog v-model="linkDialog">
<q-card style="min-width: 340px">
<q-card-section>
<div class="text-h6">Lier à un service</div>
<div class="text-caption text-grey mono">{{ linkTarget?.serial_number }}</div>
</q-card-section>
<q-card-section>
<q-input v-model="linkSearch" label="Rechercher client" outlined dense class="q-mb-sm"
@update:model-value="searchCustomers" debounce="400">
<template v-slot:append><q-icon name="search" /></template>
</q-input>
<q-list v-if="customerResults.length" bordered separator class="q-mb-sm" style="max-height: 150px; overflow-y: auto">
<q-item v-for="c in customerResults" :key="c.name" clickable @click="selectCustomer(c)">
<q-item-section>
<q-item-label>{{ c.customer_name || c.name }}</q-item-label>
<q-item-label caption>{{ c.name }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<div v-if="selectedCustomer">
<div class="text-subtitle2 q-mb-xs">{{ selectedCustomer.customer_name || selectedCustomer.name }}</div>
<div v-if="loadingLocations" class="text-center q-py-sm"><q-spinner size="sm" /></div>
<q-list v-else-if="serviceLocations.length" bordered separator>
<q-item v-for="loc in serviceLocations" :key="loc.name" clickable
:class="{ 'bg-blue-1': selectedLocation?.name === loc.name }"
@click="selectedLocation = loc">
<q-item-section>
<q-item-label>{{ loc.location_name || loc.name }}</q-item-label>
<q-item-label caption>{{ loc.address_line }} {{ loc.city }}</q-item-label>
</q-item-section>
<q-item-section side v-if="selectedLocation?.name === loc.name">
<q-icon name="check_circle" color="primary" />
</q-item-section>
</q-item>
</q-list>
<div v-else class="text-caption text-grey">Aucune adresse de service</div>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn color="primary" label="Lier" :disable="!selectedCustomer || !selectedLocation"
@click="linkDeviceToService" :loading="linkingSingle" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, computed } 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 offline = useOfflineStore()
const scanner = useScanner({
onNewCode: (code) => {
Notify.create({ type: 'positive', message: code, timeout: 2500, icon: 'qr_code', color: 'deep-purple' })
lookupDevice(code)
},
})
const cameraInput = ref(null)
const manualCode = ref('')
const lookingUp = ref(null)
const lookupResults = ref({})
const createDialog = ref(false)
const creating = ref(false)
// Photo viewer
const showFullPhoto = ref(false)
const fullPhotoUrl = ref('')
// Link dialog
const linkDialog = ref(false)
const linkTarget = ref(null)
const linkTargetBarcode = ref('')
const linkSearch = ref('')
const customerResults = ref([])
const selectedCustomer = ref(null)
const serviceLocations = ref([])
const selectedLocation = ref(null)
const loadingLocations = ref(false)
const linkingSingle = ref(false)
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Décodeur TV', 'VoIP', 'Amplificateur', 'Splitter', 'Câble', 'Autre']
const jobContext = ref(route.query.job ? {
job: route.query.job,
customer: route.query.customer,
customer_name: route.query.customer_name,
location: route.query.location,
location_name: route.query.location_name,
} : null)
const newEquip = ref({ serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' })
const hasUnlinked = computed(() =>
scanner.barcodes.value.some(bc => {
const r = lookupResults.value[bc.value]
return r?.found && !r.equipment.service_location
})
)
// --- Camera ---
function takePhoto () {
// Reset the input so same file triggers change
if (cameraInput.value) cameraInput.value.value = ''
cameraInput.value?.click()
}
async function onPhoto (e) {
const file = e.target.files?.[0]
if (!file) return
// Codes are delivered through the onNewCode callback registered on the
// scanner fires both for sync scans and for queued scans that complete
// later when the signal comes back.
await scanner.processPhoto(file)
}
function viewPhoto (photo) {
fullPhotoUrl.value = photo.url
showFullPhoto.value = true
}
// --- Manual entry ---
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 = ''
}
// --- Device lookup ---
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] }
return
}
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] }
return
}
const normalized = serial.replace(/[:\-\.]/g, '').toUpperCase()
if (normalized.length === 12 && /^[A-F0-9]+$/.test(normalized)) {
const byMac = await listDocs('Service Equipment', {
filters: { mac_address: ['like', `%${normalized.slice(-6)}%`] },
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'customer', 'customer_name',
'service_location', 'status', 'mac_address'],
limit: 1,
})
if (byMac.length > 0) {
lookupResults.value[serial] = { found: true, equipment: byMac[0] }
return
}
}
lookupResults.value[serial] = { found: false }
} catch {
lookupResults.value[serial] = { found: false }
} finally {
lookingUp.value = null
}
// Auto-link to job context if device found but not yet linked
const result = lookupResults.value[serial]
if (result?.found && jobContext.value?.customer && !result.equipment.service_location) {
await autoLinkToJob(serial, result.equipment)
}
}
// --- Auto-link device to job context ---
async function autoLinkToJob (serial, equipment) {
if (!jobContext.value?.customer) return
const updates = { customer: jobContext.value.customer }
if (jobContext.value.location) updates.service_location = jobContext.value.location
try {
await updateDoc('Service Equipment', equipment.name, updates)
equipment.customer = jobContext.value.customer
equipment.customer_name = jobContext.value.customer_name
if (jobContext.value.location) equipment.service_location = jobContext.value.location
// Update lookupResults
if (lookupResults.value[serial]) {
lookupResults.value[serial].equipment = { ...equipment }
}
Notify.create({
type: 'positive',
message: 'Lié à ' + (jobContext.value.customer_name || jobContext.value.customer),
caption: jobContext.value.location_name || undefined,
icon: 'link',
})
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur liaison: ' + e.message })
}
}
// --- Create equipment ---
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 || '',
service_location: jobContext.value?.location || '',
}
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éé' })
} 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
}
}
// --- Link dialog for unlinked devices (no job context) ---
function openLinkDialogForAll () {
// Find first unlinked device
for (const bc of scanner.barcodes.value) {
const r = lookupResults.value[bc.value]
if (r?.found && !r.equipment.service_location) {
openLinkDialog(bc.value, r.equipment)
return
}
}
}
// --- Link device to service ---
function openLinkDialog (barcode, equipment) {
linkTarget.value = equipment
linkTargetBarcode.value = barcode
linkSearch.value = ''
customerResults.value = []
selectedCustomer.value = null
serviceLocations.value = []
selectedLocation.value = null
if (equipment.customer) {
selectedCustomer.value = { name: equipment.customer, customer_name: equipment.customer_name }
loadServiceLocations(equipment.customer)
}
linkDialog.value = true
}
async function searchCustomers (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 selectCustomer (customer) {
selectedCustomer.value = customer
customerResults.value = []
linkSearch.value = ''
selectedLocation.value = null
await loadServiceLocations(customer.name)
}
async function loadServiceLocations (customerId) {
loadingLocations.value = true
try {
serviceLocations.value = await listDocs('Service Location', {
filters: { customer: customerId },
fields: ['name', 'location_name', 'address_line', 'city', 'connection_type'],
limit: 50,
})
} catch { serviceLocations.value = [] }
finally { loadingLocations.value = false }
}
async function linkDeviceToService () {
if (!linkTarget.value || !selectedCustomer.value || !selectedLocation.value) return
linkingSingle.value = true
try {
await updateDoc('Service Equipment', linkTarget.value.name, {
customer: selectedCustomer.value.name,
service_location: selectedLocation.value.name,
})
linkTarget.value.customer = selectedCustomer.value.name
linkTarget.value.customer_name = selectedCustomer.value.customer_name
linkTarget.value.service_location = selectedLocation.value.name
if (lookupResults.value[linkTargetBarcode.value]) {
lookupResults.value[linkTargetBarcode.value].equipment = { ...linkTarget.value }
}
Notify.create({ type: 'positive', message: 'Lié à ' + (selectedLocation.value.location_name || selectedLocation.value.name) })
linkDialog.value = false
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
linkingSingle.value = false
}
}
</script>
<style lang="scss" scoped>
.scan-page {
padding-bottom: 16px !important;
}
.photo-preview {
position: relative;
text-align: center;
}
.preview-img {
max-width: 100%;
max-height: 250px;
border-radius: 12px;
cursor: pointer;
}
.preview-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.5);
border-radius: 12px;
}
.photo-thumb {
position: relative;
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
</style>

View File

@ -1,521 +0,0 @@
<template>
<q-page class="tasks-page">
<!-- Tech header -->
<div class="tech-header">
<div class="tech-header-row">
<div class="tech-header-left">
<div class="tech-date">{{ todayLabel }}</div>
<div class="tech-name">{{ techName }}</div>
</div>
<div class="tech-header-right">
<q-badge :color="offline.online ? 'green' : 'grey'" :label="offline.online ? 'En ligne' : 'Hors ligne'" class="tech-status-badge" />
<q-btn flat dense round icon="swap_horiz" color="white" size="sm" @click="loadTasks" :loading="loading" />
<q-avatar size="36px" color="indigo-8" text-color="white" class="tech-avatar">{{ initials }}</q-avatar>
</div>
</div>
<!-- Stats cards -->
<div class="stats-row">
<div class="stat-card" @click="filter = 'all'">
<div class="stat-value">{{ jobs.length }}</div>
<div class="stat-label">Total</div>
</div>
<div class="stat-card" @click="filter = 'todo'">
<div class="stat-value">{{ todoCount }}</div>
<div class="stat-label">A faire</div>
</div>
<div class="stat-card stat-done" @click="filter = 'done'">
<div class="stat-value">{{ doneCount }}</div>
<div class="stat-label">Faits</div>
</div>
</div>
</div>
<!-- Job list -->
<div class="jobs-list q-pa-md">
<!-- Upcoming section -->
<div v-if="upcomingJobs.length" class="section-label">A VENIR ({{ upcomingJobs.length }})</div>
<div v-for="(job, idx) in upcomingJobs" :key="job.name" class="job-card" :class="{ 'job-card-urgent': job.priority === 'urgent' || job.priority === 'high' }" @click="openSheet(job)">
<div class="job-card-header">
<div class="job-card-left">
<span class="job-order">{{ idx + 1 }}</span>
<span class="job-id">{{ job.name }}</span>
<span v-if="job.priority === 'urgent' || job.priority === 'high'" class="job-priority-dot" />
</div>
<div class="job-time">{{ fmtTime(job.scheduled_time) }}</div>
</div>
<div class="job-card-title">{{ job.subject || 'Sans titre' }}</div>
<div v-if="job.service_location_name" class="job-card-location">
<q-icon name="place" size="14px" color="grey-6" /> {{ job.service_location_name }}
</div>
<div class="job-card-badges">
<span v-if="job.duration_h" class="job-badge">
<q-icon name="schedule" size="12px" /> {{ job.duration_h }}h
</span>
</div>
</div>
<!-- In progress section -->
<div v-if="inProgressJobs.length" class="section-label q-mt-md">EN COURS ({{ inProgressJobs.length }})</div>
<div v-for="job in inProgressJobs" :key="job.name" class="job-card job-card-progress" @click="openSheet(job)">
<div class="job-card-header">
<div class="job-card-left">
<q-spinner-dots size="14px" color="orange" class="q-mr-xs" />
<span class="job-id">{{ job.name }}</span>
</div>
<div class="job-time">{{ fmtTime(job.scheduled_time) }}</div>
</div>
<div class="job-card-title">{{ job.subject || 'Sans titre' }}</div>
<div v-if="job.service_location_name" class="job-card-location">
<q-icon name="place" size="14px" color="grey-6" /> {{ job.service_location_name }}
</div>
</div>
<!-- Completed section -->
<div v-if="completedJobs.length && (filter === 'all' || filter === 'done')" class="section-label q-mt-md">TERMINÉS ({{ completedJobs.length }})</div>
<div v-for="job in (filter === 'all' || filter === 'done' ? completedJobs : [])" :key="job.name" class="job-card job-card-done" @click="openSheet(job)">
<div class="job-card-header">
<div class="job-card-left">
<q-icon name="check_circle" size="16px" color="green" class="q-mr-xs" />
<span class="job-id">{{ job.name }}</span>
</div>
<div class="job-time text-grey">{{ fmtTime(job.scheduled_time) }}</div>
</div>
<div class="job-card-title text-grey">{{ job.subject || 'Sans titre' }}</div>
</div>
<div v-if="!loading && jobs.length === 0" class="text-center text-grey q-mt-xl q-pa-lg">
<q-icon name="event_available" size="48px" color="grey-4" class="q-mb-md" /><br>
Aucun job aujourd'hui
</div>
</div>
<!-- Job detail bottom sheet -->
<q-dialog v-model="sheetOpen" position="bottom" seamless>
<q-card class="bottom-sheet" v-if="sheetJob">
<div class="sheet-handle" />
<q-card-section class="q-pb-sm">
<div class="row items-start no-wrap">
<div class="col">
<div class="row items-center q-gutter-xs q-mb-xs">
<span class="sheet-job-id">{{ sheetJob.name }}</span>
<span v-if="sheetJob.priority === 'urgent' || sheetJob.priority === 'high'" class="job-priority-dot" />
<q-badge v-if="sheetJob.priority === 'urgent'" color="red" label="Urgent" />
<q-badge v-else-if="sheetJob.priority === 'high'" color="orange" label="Haute" />
</div>
<div class="sheet-title">{{ sheetJob.subject || 'Sans titre' }}</div>
</div>
<q-btn flat dense round icon="close" @click="sheetOpen = false" />
</div>
</q-card-section>
<!-- Address -->
<q-card-section v-if="sheetJob.service_location_name" class="q-py-sm">
<div class="sheet-info-row">
<q-icon name="place" size="20px" color="red-5" class="q-mr-sm" />
<div class="col">
<div class="sheet-info-label">Adresse</div>
<div class="sheet-info-value">{{ sheetJob.service_location_name }}</div>
</div>
<q-btn flat dense color="primary" label="Carte" icon="navigation" @click="openGps(sheetJob)" />
</div>
</q-card-section>
<!-- Duration + travel -->
<q-card-section class="q-py-sm">
<div class="row q-gutter-md">
<div v-if="sheetJob.duration_h" class="sheet-info-row col">
<q-icon name="schedule" size="20px" color="grey-6" class="q-mr-sm" />
<div>
<div class="sheet-info-label">Durée estimée</div>
<div class="sheet-info-value">{{ sheetJob.duration_h }}h</div>
</div>
</div>
<div v-if="sheetJob.customer_name" class="sheet-info-row col">
<q-icon name="person" size="20px" color="grey-6" class="q-mr-sm" />
<div>
<div class="sheet-info-label">Client</div>
<div class="sheet-info-value">{{ sheetJob.customer_name }}</div>
</div>
</div>
</div>
</q-card-section>
<!-- Description -->
<q-card-section v-if="sheetJob.description" class="q-py-sm">
<div class="sheet-info-label q-mb-xs">Notes</div>
<div class="text-body2" v-html="sheetJob.description" />
</q-card-section>
<!-- Action buttons -->
<q-card-section class="q-pt-sm">
<div class="row q-gutter-sm">
<q-btn v-if="sheetJob.status === 'Scheduled'" unelevated color="indigo" icon="directions_car"
label="En route" class="col action-btn" @click="doStatus(sheetJob, 'In Progress')" :loading="saving" />
<q-btn v-if="sheetJob.status === 'In Progress'" unelevated color="positive" icon="check_circle"
label="Terminer" class="col action-btn" @click="doStatus(sheetJob, 'Completed')" :loading="saving" />
<q-btn v-if="sheetJob.status === 'Completed'" unelevated color="blue-grey" icon="replay"
label="Rouvrir" class="col action-btn" @click="doStatus(sheetJob, 'In Progress')" :loading="saving" />
</div>
<div class="row q-gutter-sm q-mt-xs">
<q-btn outline color="primary" icon="qr_code_scanner" label="Scanner" class="col"
@click="goScan(sheetJob)" />
<q-btn outline color="grey-8" icon="open_in_full" label="Détails" class="col"
@click="goDetail(sheetJob)" />
</div>
</q-card-section>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { listDocs, updateDoc } from 'src/api/erp'
import { useOfflineStore } from 'src/stores/offline'
import { useAuthStore } from 'src/stores/auth'
import { Notify } from 'quasar'
const router = useRouter()
const loading = ref(false)
const filter = ref('all')
const jobs = ref([])
const offline = useOfflineStore()
const auth = useAuthStore()
const saving = ref(false)
// Bottom sheet
const sheetOpen = ref(false)
const sheetJob = ref(null)
const today = new Date().toISOString().slice(0, 10)
const todayLabel = computed(() =>
new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' })
)
const techName = computed(() => {
const u = auth.user || ''
// Authentik gives full name or email parse for display
if (u.includes('@')) return u.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
if (u === 'authenticated') return 'Technicien'
return u
})
const initials = computed(() => {
const parts = techName.value.split(' ')
return parts.length >= 2 ? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() : techName.value.slice(0, 2).toUpperCase()
})
const todoCount = computed(() => jobs.value.filter(j => j.status === 'Scheduled' || j.status === 'In Progress').length)
const doneCount = computed(() => jobs.value.filter(j => j.status === 'Completed').length)
const upcomingJobs = computed(() => {
const filtered = jobs.value.filter(j => j.status === 'Scheduled')
if (filter.value === 'done') return []
return filtered.sort((a, b) => (a.scheduled_time || '').localeCompare(b.scheduled_time || ''))
})
const inProgressJobs = computed(() => {
if (filter.value === 'done') return []
return jobs.value.filter(j => j.status === 'In Progress')
})
const completedJobs = computed(() => jobs.value.filter(j => j.status === 'Completed'))
function fmtTime (t) {
if (!t) return ''
const [h, m] = t.split(':')
return `${h}h${m}`
}
function openSheet (job) {
sheetJob.value = job
sheetOpen.value = true
}
async function loadTasks () {
loading.value = true
try {
const j = await listDocs('Dispatch Job', {
filters: { scheduled_date: today },
fields: ['name', 'subject', 'status', 'customer', 'customer_name', 'service_location',
'service_location_name', 'scheduled_time', 'description', 'job_type', 'duration_h', 'priority'],
limit: 50,
orderBy: 'scheduled_time asc',
})
jobs.value = j
offline.cacheData('tasks-jobs', j)
} catch {
const cached = await offline.getCached('tasks-jobs')
if (cached) jobs.value = cached
Notify.create({ type: 'warning', message: 'Mode hors ligne — données en cache' })
} finally {
loading.value = false
}
}
async function doStatus (job, status) {
saving.value = true
try {
if (offline.online) {
await updateDoc('Dispatch Job', job.name, { status })
} else {
await offline.enqueue({ type: 'update', doctype: 'Dispatch Job', name: job.name, data: { status } })
}
job.status = status
const msgs = { 'In Progress': 'En route !', Completed: 'Job terminé ✓' }
Notify.create({ type: 'positive', message: msgs[status] || status })
if (status === 'Completed') sheetOpen.value = false
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
saving.value = false
}
}
function openGps (job) {
const loc = job.service_location_name || ''
if (job.latitude && job.longitude) {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${job.latitude},${job.longitude}`, '_blank')
} else {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(loc)}`, '_blank')
}
}
function goScan (job) {
sheetOpen.value = false
router.push({ name: 'scan', query: {
job: job.name, customer: job.customer, customer_name: job.customer_name,
location: job.service_location, location_name: job.service_location_name,
}})
}
function goDetail (job) {
sheetOpen.value = false
router.push({ name: 'job-detail', params: { name: job.name } })
}
onMounted(loadTasks)
</script>
<style lang="scss" scoped>
.tasks-page {
padding: 0 !important;
background: #eef1f5;
min-height: 100vh;
}
/* ── Tech header ── */
.tech-header {
background: linear-gradient(135deg, #3f3d7a 0%, #5c59a8 100%);
color: white;
padding: 16px 16px 0;
border-radius: 0 0 20px 20px;
}
.tech-header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.tech-date {
font-size: 0.78rem;
opacity: 0.7;
text-transform: capitalize;
}
.tech-name {
font-size: 1.25rem;
font-weight: 700;
}
.tech-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.tech-status-badge {
font-size: 0.65rem;
padding: 3px 8px;
border-radius: 10px;
}
.tech-avatar {
font-weight: 700;
font-size: 0.8rem;
}
/* ── Stats ── */
.stats-row {
display: flex;
gap: 10px;
margin: 0 -4px;
transform: translateY(24px);
}
.stat-card {
flex: 1;
background: #4a4880;
border-radius: 12px;
padding: 12px 8px;
text-align: center;
cursor: pointer;
transition: background 0.15s;
&:active { background: #5a589a; }
}
.stat-card.stat-done .stat-value { color: #ff6b6b; }
.stat-value {
font-size: 1.5rem;
font-weight: 800;
line-height: 1.2;
}
.stat-label {
font-size: 0.7rem;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* ── Jobs list ── */
.jobs-list {
padding-top: 36px !important;
}
.section-label {
font-size: 0.72rem;
font-weight: 700;
color: #8b8fa3;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
/* ── Job card ── */
.job-card {
background: white;
border-radius: 14px;
padding: 14px 16px;
margin-bottom: 10px;
cursor: pointer;
border-left: 4px solid #5c59a8;
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
transition: transform 0.1s;
&:active { transform: scale(0.98); }
}
.job-card-urgent { border-left-color: #ef4444; }
.job-card-progress {
border-left-color: #f59e0b;
background: #fffbeb;
}
.job-card-done {
border-left-color: #22c55e;
opacity: 0.7;
}
.job-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.job-card-left {
display: flex;
align-items: center;
gap: 6px;
}
.job-order {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: #5c59a8;
color: white;
font-size: 0.7rem;
font-weight: 700;
}
.job-id {
font-size: 0.78rem;
font-weight: 600;
color: #5c59a8;
}
.job-priority-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ef4444;
display: inline-block;
}
.job-time {
font-size: 0.82rem;
font-weight: 600;
color: #374151;
}
.job-card-title {
font-size: 0.95rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 4px;
}
.job-card-location {
font-size: 0.78rem;
color: #6b7280;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 2px;
}
.job-card-badges {
display: flex;
gap: 8px;
}
.job-badge {
display: inline-flex;
align-items: center;
gap: 3px;
background: #f3f4f6;
border-radius: 8px;
padding: 2px 8px;
font-size: 0.72rem;
font-weight: 600;
color: #6b7280;
}
/* ── Bottom sheet ── */
.bottom-sheet {
border-radius: 20px 20px 0 0;
max-height: 70vh;
width: 100%;
}
.sheet-handle {
width: 36px;
height: 4px;
border-radius: 2px;
background: #d1d5db;
margin: 10px auto 4px;
}
.sheet-job-id {
font-size: 0.82rem;
font-weight: 700;
color: #5c59a8;
}
.sheet-title {
font-size: 1.15rem;
font-weight: 700;
color: #1f2937;
}
.sheet-info-row {
display: flex;
align-items: center;
}
.sheet-info-label {
font-size: 0.72rem;
color: #9ca3af;
}
.sheet-info-value {
font-size: 0.95rem;
font-weight: 600;
color: #1f2937;
}
.action-btn {
font-weight: 700;
border-radius: 12px;
min-height: 48px;
font-size: 0.9rem;
}
</style>

View File

@ -1,21 +0,0 @@
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: 'job/:name', name: 'job-detail', component: () => import('src/pages/JobDetailPage.vue'), props: true },
{ path: 'device/:serial', name: 'device', component: () => import('src/pages/DevicePage.vue'), props: true },
],
},
]
export default createRouter({
history: createWebHashHistory(),
routes,
})

View File

@ -1,21 +0,0 @@
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

@ -1,174 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { get, set, del, keys } from 'idb-keyval'
import { createDoc, updateDoc } from 'src/api/erp'
import { scanBarcodes } from 'src/api/ocr'
export const useOfflineStore = defineStore('offline', () => {
const queue = ref([])
const syncing = ref(false)
const online = ref(navigator.onLine)
const pendingCount = computed(() => queue.value.length)
// Vision scan queue — photos whose Gemini call timed out / failed,
// waiting to be retried when the signal is back.
const visionQueue = ref([]) // { id, image (base64), ts, status }
const scanResults = ref([]) // completed scans not yet consumed by a page
// { id, barcodes: string[], ts }
const pendingVisionCount = computed(() => visionQueue.value.length)
let retryTimer = null
let visionSyncing = false
// Listen to connectivity changes
window.addEventListener('online', () => {
online.value = true
syncQueue()
syncVisionQueue()
})
window.addEventListener('offline', () => { online.value = false })
async function loadQueue () {
try {
const stored = await get('offline-queue')
queue.value = stored || []
} catch { queue.value = [] }
}
async function saveQueue () {
await set('offline-queue', JSON.parse(JSON.stringify(queue.value)))
}
async function loadVisionQueue () {
try {
visionQueue.value = (await get('vision-queue')) || []
scanResults.value = (await get('vision-results')) || []
} catch {
visionQueue.value = []
scanResults.value = []
}
if (visionQueue.value.length) scheduleVisionRetry(5000)
}
async function saveVisionQueue () {
await set('vision-queue', JSON.parse(JSON.stringify(visionQueue.value)))
}
async function saveScanResults () {
await set('vision-results', JSON.parse(JSON.stringify(scanResults.value)))
}
// Enqueue a mutation to be synced later
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
}
// Enqueue a photo whose Gemini scan couldn't complete (timeout / offline).
// Returns the queued entry so the caller can display a pending indicator.
async function enqueueVisionScan ({ image }) {
const entry = {
id: Date.now() + '-' + Math.random().toString(36).slice(2, 8),
image,
ts: Date.now(),
status: 'queued',
}
visionQueue.value.push(entry)
await saveVisionQueue()
scheduleVisionRetry(5000)
return entry
}
// Retry each queued photo. Success → move to scanResults, fail → stay queued
// with a bumped retry schedule. navigator.onLine can lie in weak-signal
// zones, so we drive retries off the queue itself, not off the online flag.
async function syncVisionQueue () {
if (visionSyncing) return
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
if (visionQueue.value.length === 0) return
visionSyncing = true
const remaining = []
try {
for (const entry of [...visionQueue.value]) {
try {
entry.status = 'syncing'
const result = await scanBarcodes(entry.image)
scanResults.value.push({
id: entry.id,
barcodes: result.barcodes || [],
ts: Date.now(),
})
} catch {
entry.status = 'queued'
remaining.push(entry)
}
}
visionQueue.value = remaining
await Promise.all([saveVisionQueue(), saveScanResults()])
if (remaining.length) scheduleVisionRetry(30000)
} finally {
visionSyncing = false
}
}
function scheduleVisionRetry (delay) {
if (retryTimer) return
retryTimer = setTimeout(() => {
retryTimer = null
syncVisionQueue()
}, delay)
}
// Consumer (ScanPage) calls this after merging a result into the UI so the
// same serial doesn't reappear next time the page mounts.
async function consumeScanResult (id) {
scanResults.value = scanResults.value.filter(r => r.id !== id)
await saveScanResults()
}
// 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()
loadVisionQueue()
return {
queue, syncing, online, pendingCount, enqueue, syncQueue,
visionQueue, scanResults, pendingVisionCount,
enqueueVisionScan, syncVisionQueue, consumeScanResult,
cacheData, getCached, loadQueue,
}
})

View File

@ -1,6 +1,9 @@
'use strict' 'use strict'
const cfg = require('./config') const cfg = require('./config')
const { log, json, parseBody } = require('./helpers') const { log, json, parseBody } = require('./helpers')
const erp = require('./erp')
const types = require('./types')
const ui = require('./ui')
const { signJwt, verifyJwt } = require('./magic-link') const { signJwt, verifyJwt } = require('./magic-link')
// ── Acceptance Links ───────────────────────────────────────────────────────── // ── Acceptance Links ─────────────────────────────────────────────────────────
@ -94,39 +97,26 @@ async function checkDocuSealStatus (submissionId) {
// ── ERPNext helpers ────────────────────────────────────────────────────────── // ── ERPNext helpers ──────────────────────────────────────────────────────────
async function fetchQuotation (name) { async function fetchQuotation (name) {
const { erpFetch } = require('./helpers') return erp.get('Quotation', name)
const res = await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`)
if (res.status !== 200) return null
return res.data.data
} }
async function acceptQuotation (name, acceptanceData) { async function acceptQuotation (name, acceptanceData) {
const { erpFetch } = require('./helpers')
// Add acceptance comment with proof // Add acceptance comment with proof
await erpFetch(`/api/resource/Comment`, { await erp.create('Comment', {
method: 'POST', comment_type: 'Info',
body: JSON.stringify({ reference_doctype: 'Quotation',
comment_type: 'Info', reference_name: name,
reference_doctype: 'Quotation', content: `<b>✅ Devis accepté par le client</b><br>
reference_name: name, <b>Horodatage:</b> ${new Date().toISOString()}<br>
content: `<b>✅ Devis accepté par le client</b><br> <b>Méthode:</b> ${acceptanceData.method || 'Lien JWT'}<br>
<b>Horodatage:</b> ${new Date().toISOString()}<br> <b>Contact:</b> ${acceptanceData.contact || 'N/A'}<br>
<b>Méthode:</b> ${acceptanceData.method || 'Lien JWT'}<br> <b>IP:</b> ${acceptanceData.ip || 'N/A'}<br>
<b>Contact:</b> ${acceptanceData.contact || 'N/A'}<br> <b>User-Agent:</b> ${acceptanceData.userAgent || 'N/A'}<br>
<b>IP:</b> ${acceptanceData.ip || 'N/A'}<br> ${acceptanceData.docusealUrl ? `<b>Document signé:</b> <a href="${acceptanceData.docusealUrl}">${acceptanceData.docusealUrl}</a>` : ''}`,
<b>User-Agent:</b> ${acceptanceData.userAgent || 'N/A'}<br>
${acceptanceData.docusealUrl ? `<b>Document signé:</b> <a href="${acceptanceData.docusealUrl}">${acceptanceData.docusealUrl}</a>` : ''}`,
}),
}) })
// Update quotation status // Update quotation status (best-effort — ignore failure)
try { await erp.update('Quotation', name, { accepted_by_client: 1 }).catch(() => {})
await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`, {
method: 'PUT',
body: JSON.stringify({ accepted_by_client: 1 }),
})
} catch {}
// ── Create deferred dispatch jobs if wizard_steps exist ── // ── Create deferred dispatch jobs if wizard_steps exist ──
try { try {
@ -139,10 +129,7 @@ async function acceptQuotation (name, acceptanceData) {
log(`Created ${createdJobs.length} deferred jobs for ${name} upon acceptance`) log(`Created ${createdJobs.length} deferred jobs for ${name} upon acceptance`)
// Clear wizard_steps so they don't get created again // Clear wizard_steps so they don't get created again
await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`, { await erp.update('Quotation', name, { wizard_steps: '', wizard_context: '' })
method: 'PUT',
body: JSON.stringify({ wizard_steps: '', wizard_context: '' }),
})
// Also create subscriptions for recurring items on the quotation // Also create subscriptions for recurring items on the quotation
await createDeferredSubscriptions(quotation, ctx) await createDeferredSubscriptions(quotation, ctx)
@ -156,7 +143,6 @@ async function acceptQuotation (name, acceptanceData) {
// ── Create dispatch jobs from wizard steps stored on quotation ─────────────── // ── Create dispatch jobs from wizard steps stored on quotation ───────────────
async function createDeferredJobs (steps, ctx, quotationName) { async function createDeferredJobs (steps, ctx, quotationName) {
const { erpFetch } = require('./helpers')
const createdJobs = [] const createdJobs = []
for (let i = 0; i < steps.length; i++) { for (let i = 0; i < steps.length; i++) {
@ -180,8 +166,8 @@ async function createDeferredJobs (steps, ctx, quotationName) {
subject: step.subject || 'Tâche', subject: step.subject || 'Tâche',
address: ctx.address || '', address: ctx.address || '',
duration_h: step.duration_h || 1, duration_h: step.duration_h || 1,
priority: step.priority || 'medium', priority: step.priority || types.JOB_PRIORITY.MEDIUM,
status: dependsOn ? 'On Hold' : 'open', status: dependsOn ? types.JOB_STATUS.ON_HOLD : types.JOB_STATUS.OPEN,
job_type: step.job_type || 'Autre', job_type: step.job_type || 'Autre',
source_issue: ctx.issue || '', source_issue: ctx.issue || '',
customer: ctx.customer || '', customer: ctx.customer || '',
@ -209,21 +195,13 @@ async function createDeferredJobs (steps, ctx, quotationName) {
scheduled_date: step.scheduled_date || ctx.scheduled_date || new Date().toISOString().slice(0, 10), scheduled_date: step.scheduled_date || ctx.scheduled_date || new Date().toISOString().slice(0, 10),
} }
try { const res = await erp.create('Dispatch Job', payload)
const res = await erpFetch('/api/resource/Dispatch%20Job', { if (res.ok && res.data) {
method: 'POST', createdJobs.push(res.data)
body: JSON.stringify(payload), log(` + Job ${res.data.name}: ${step.subject}`)
}) } else {
if (res.status === 200 && res.data?.data) {
createdJobs.push(res.data.data)
log(` + Job ${res.data.data.name}: ${step.subject}`)
} else {
createdJobs.push({ name: ticketId })
log(` ! Job creation returned ${res.status} for: ${step.subject}`)
}
} catch (e) {
createdJobs.push({ name: ticketId }) createdJobs.push({ name: ticketId })
log(` ! Job creation failed for: ${step.subject}${e.message}`) log(` ! Job creation failed for: ${step.subject}${res.error || 'unknown'}`)
} }
} }
@ -268,7 +246,6 @@ function _extractDurationMonths (item) {
} }
async function createDeferredSubscriptions (quotation, ctx) { async function createDeferredSubscriptions (quotation, ctx) {
const { erpFetch } = require('./helpers')
const customer = ctx.customer || quotation.customer || quotation.party_name || '' const customer = ctx.customer || quotation.customer || quotation.party_name || ''
const serviceLocation = ctx.service_location || '' const serviceLocation = ctx.service_location || ''
if (!customer) return [] if (!customer) return []
@ -309,19 +286,12 @@ async function createDeferredSubscriptions (quotation, ctx) {
notes: `Créé automatiquement via acceptation du devis ${quotation.name || ''}`, notes: `Créé automatiquement via acceptation du devis ${quotation.name || ''}`,
} }
try { const res = await erp.create('Service Subscription', payload)
const res = await erpFetch('/api/resource/Service%20Subscription', { if (res.ok && res.name) {
method: 'POST', created.push(res.name)
body: JSON.stringify(payload), log(` + Service Subscription ${res.name} (En attente) — ${item.item_name}`)
}) } else {
if (res.status === 200 && res.data?.data) { log(` ! Service Subscription creation failed for ${item.item_name}: ${res.error || 'unknown'}`)
created.push(res.data.data.name)
log(` + Service Subscription ${res.data.data.name} (En attente) — ${item.item_name}`)
} else {
log(` ! Service Subscription creation returned ${res.status} for ${item.item_name}`)
}
} catch (e) {
log(` ! Service Subscription creation failed for ${item.item_name}: ${e.message}`)
} }
} }
return created return created
@ -330,23 +300,17 @@ async function createDeferredSubscriptions (quotation, ctx) {
// ── PDF generation via ERPNext ──────────────────────────────────────────────── // ── PDF generation via ERPNext ────────────────────────────────────────────────
async function getQuotationPdfBuffer (quotationName, printFormat) { async function getQuotationPdfBuffer (quotationName, printFormat) {
const { erpFetch } = require('./helpers') // PDF comes back as raw bytes, not JSON — go around erpFetch.
const format = printFormat || 'Standard' const format = printFormat || 'Standard'
const url = `/api/method/frappe.utils.print_format.download_pdf?doctype=Quotation&name=${encodeURIComponent(quotationName)}&format=${encodeURIComponent(format)}&no_letterhead=0` const url = `${cfg.ERP_URL}/api/method/frappe.utils.print_format.download_pdf?doctype=Quotation&name=${encodeURIComponent(quotationName)}&format=${encodeURIComponent(format)}&no_letterhead=0`
const res = await erpFetch(url, { rawResponse: true }) const pdfRes = await fetch(url, {
if (res.status !== 200) return null
// erpFetch returns parsed JSON by default; we need the raw buffer
// Use direct fetch instead
const directUrl = `${cfg.ERP_URL}${url}`
const pdfRes = await fetch(directUrl, {
headers: { headers: {
'Authorization': `token ${cfg.ERP_TOKEN}`, 'Authorization': `token ${cfg.ERP_TOKEN}`,
'X-Frappe-Site-Name': cfg.ERP_SITE, 'X-Frappe-Site-Name': cfg.ERP_SITE,
}, },
}) })
if (!pdfRes.ok) return null if (!pdfRes.ok) return null
const buf = Buffer.from(await pdfRes.arrayBuffer()) return Buffer.from(await pdfRes.arrayBuffer())
return buf
} }
async function getDocPdfBuffer (doctype, name, printFormat) { async function getDocPdfBuffer (doctype, name, printFormat) {
@ -504,14 +468,8 @@ async function handle (req, res, method, path) {
const payload = verifyJwt(token) const payload = verifyJwt(token)
if (!payload || payload.type !== 'acceptance') { if (!payload || payload.type !== 'acceptance') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) res.writeHead(200, ui.htmlHeaders())
return res.end(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> return res.end(ui.pageExpired())
<title>Lien expiré</title></head><body style="font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#f1f5f9">
<div style="text-align:center;background:white;padding:2rem;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,0.08)">
<div style="font-size:3rem;margin-bottom:1rem">🔗</div>
<h2 style="color:#1e293b">Lien expiré</h2>
<p style="color:#64748b;margin-top:0.5rem">Ce lien d'acceptation a expiré ou est invalide.<br>Contactez-nous pour recevoir un nouveau lien.</p>
</div></body></html>`)
} }
try { try {
@ -591,15 +549,8 @@ async function handle (req, res, method, path) {
result.submission_id = dsResult.submissionId result.submission_id = dsResult.submissionId
// Persist signing URL on the Quotation so the print-format QR code is populated // Persist signing URL on the Quotation so the print-format QR code is populated
try { const up = await erp.update('Quotation', quotation, { custom_docuseal_signing_url: dsResult.signUrl })
const { erpFetch } = require('./helpers') if (!up.ok) log('Failed to save DocuSeal signing URL to Quotation:', up.error)
await erpFetch(`/api/resource/Quotation/${encodeURIComponent(quotation)}`, {
method: 'PUT',
body: JSON.stringify({ custom_docuseal_signing_url: dsResult.signUrl }),
})
} catch (e) {
log('Failed to save DocuSeal signing URL to Quotation:', e.message)
}
} else { } else {
// Fallback to JWT if DocuSeal fails // Fallback to JWT if DocuSeal fails
result.method = 'jwt' result.method = 'jwt'

View File

@ -31,7 +31,8 @@
const https = require('https') const https = require('https')
const crypto = require('crypto') const crypto = require('crypto')
const cfg = require('./config') const cfg = require('./config')
const { log, json, parseBody, erpFetch, erpRequest } = require('./helpers') const { log, json, parseBody } = require('./helpers')
const erp = require('./erp')
const sse = require('./sse') const sse = require('./sse')
// Stripe config from environment // Stripe config from environment
@ -135,57 +136,55 @@ function verifyWebhookSignature (rawBody, sigHeader) {
// ──────────────────────────────────────────── // ────────────────────────────────────────────
async function getCustomerBalance (customerId) { async function getCustomerBalance (customerId) {
// Sum outstanding amounts from unpaid invoices // Sum outstanding amounts from unpaid invoices
const filters = encodeURIComponent(JSON.stringify([ const invoices = await erp.list('Sales Invoice', {
['customer', '=', customerId], filters: [
['outstanding_amount', '>', 0], ['customer', '=', customerId],
['docstatus', '=', 1], ['outstanding_amount', '>', 0],
])) ['docstatus', '=', 1],
const fields = encodeURIComponent(JSON.stringify(['name', 'posting_date', 'grand_total', 'outstanding_amount'])) ],
const res = await erpFetch(`/api/resource/Sales%20Invoice?filters=${filters}&fields=${fields}&order_by=posting_date asc&limit_page_length=100`) fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount'],
if (res.status !== 200) return { balance: 0, invoices: [] } orderBy: 'posting_date asc',
const invoices = res.data?.data || [] limit: 100,
})
const balance = invoices.reduce((sum, inv) => sum + (inv.outstanding_amount || 0), 0) const balance = invoices.reduce((sum, inv) => sum + (inv.outstanding_amount || 0), 0)
return { balance: Math.round(balance * 100) / 100, invoices } return { balance: Math.round(balance * 100) / 100, invoices }
} }
async function getInvoiceDoc (invoiceName) { async function getInvoiceDoc (invoiceName) {
const res = await erpFetch(`/api/resource/Sales%20Invoice/${encodeURIComponent(invoiceName)}?fields=["name","customer","customer_name","posting_date","due_date","grand_total","outstanding_amount","status","docstatus","currency"]`) return erp.get('Sales Invoice', invoiceName, {
if (res.status !== 200) return null fields: ['name', 'customer', 'customer_name', 'posting_date', 'due_date', 'grand_total', 'outstanding_amount', 'status', 'docstatus', 'currency'],
return res.data?.data || null })
} }
async function getCustomerDoc (customerId) { async function getCustomerDoc (customerId) {
const res = await erpFetch(`/api/resource/Customer/${encodeURIComponent(customerId)}`) return erp.get('Customer', customerId)
if (res.status !== 200) return null
return res.data?.data || null
} }
async function getPaymentMethods (customerId) { async function getPaymentMethods (customerId) {
const filters = encodeURIComponent(JSON.stringify([['customer', '=', customerId]])) return erp.list('Payment Method', {
const fields = encodeURIComponent(JSON.stringify([ filters: [['customer', '=', customerId]],
'name', 'provider', 'is_active', 'is_auto_ppa', fields: [
'stripe_customer_id', 'stripe_ppa_enabled', 'stripe_ppa_nocc', 'name', 'provider', 'is_active', 'is_auto_ppa',
'paysafe_profile_id', 'paysafe_card_id', 'paysafe_token', 'stripe_customer_id', 'stripe_ppa_enabled', 'stripe_ppa_nocc',
'ppa_name', 'ppa_institution', 'ppa_branch', 'ppa_account', 'ppa_amount', 'ppa_buffer', 'paysafe_profile_id', 'paysafe_card_id', 'paysafe_token',
])) 'ppa_name', 'ppa_institution', 'ppa_branch', 'ppa_account', 'ppa_amount', 'ppa_buffer',
const res = await erpFetch(`/api/resource/Payment%20Method?filters=${filters}&fields=${fields}&limit_page_length=20`) ],
if (res.status !== 200) { limit: 20,
log('getPaymentMethods error:', JSON.stringify(res.data).slice(0, 300)) })
return []
}
return res.data?.data || []
} }
// Check for existing Payment Entry with same reference_no (idempotency) // Check for existing Payment Entry with same reference_no (idempotency)
async function paymentEntryExists (referenceNo) { async function paymentEntryExists (referenceNo) {
if (!referenceNo) return false if (!referenceNo) return false
const filters = encodeURIComponent(JSON.stringify([ const rows = await erp.list('Payment Entry', {
['reference_no', '=', referenceNo], filters: [
['docstatus', '!=', 2], // not cancelled ['reference_no', '=', referenceNo],
])) ['docstatus', '!=', 2], // not cancelled
const res = await erpFetch(`/api/resource/Payment%20Entry?filters=${filters}&fields=["name"]&limit_page_length=1`) ],
if (res.status !== 200) return false fields: ['name'],
return (res.data?.data || []).length > 0 limit: 1,
})
return rows.length > 0
} }
// ──────────────────────────────────────────── // ────────────────────────────────────────────
@ -232,9 +231,9 @@ async function saveStripeCustomerId (customerId, stripeId) {
const methods = await getPaymentMethods(customerId) const methods = await getPaymentMethods(customerId)
const existing = methods.find(m => m.provider === 'Stripe') const existing = methods.find(m => m.provider === 'Stripe')
if (existing) { if (existing) {
await erpRequest('PUT', `/api/resource/Payment%20Method/${existing.name}`, { stripe_customer_id: stripeId }) await erp.update('Payment Method', existing.name, { stripe_customer_id: stripeId })
} else { } else {
await erpRequest('POST', '/api/resource/Payment%20Method', { await erp.create('Payment Method', {
customer: customerId, provider: 'Stripe', stripe_customer_id: stripeId, customer: customerId, provider: 'Stripe', stripe_customer_id: stripeId,
}) })
} }
@ -620,13 +619,13 @@ async function handle (req, res, method, path, url) {
// Update ERPNext Payment Method // Update ERPNext Payment Method
if (stripePm) { if (stripePm) {
await erpRequest('PUT', `/api/resource/Payment%20Method/${stripePm.name}`, { await erp.update('Payment Method', stripePm.name, {
is_auto_ppa: enabled ? 1 : 0, is_auto_ppa: enabled ? 1 : 0,
}) })
} }
// Sync ppa_enabled flag on Customer doc // Sync ppa_enabled flag on Customer doc
await erpRequest('PUT', `/api/resource/Customer/${encodeURIComponent(customer)}`, { await erp.update('Customer', customer, {
ppa_enabled: enabled ? 1 : 0, ppa_enabled: enabled ? 1 : 0,
}) })
@ -790,9 +789,7 @@ async function handle (req, res, method, path, url) {
if (!payment_entry) return json(res, 400, { error: 'payment_entry required' }) if (!payment_entry) return json(res, 400, { error: 'payment_entry required' })
// Look up the Payment Entry in ERPNext to find the Stripe reference // Look up the Payment Entry in ERPNext to find the Stripe reference
const peRes = await erpFetch(`/api/resource/Payment%20Entry/${encodeURIComponent(payment_entry)}`) const pe = await erp.get('Payment Entry', payment_entry)
if (peRes.status !== 200) return json(res, 404, { error: 'Payment Entry not found' })
const pe = peRes.data?.data
if (!pe) return json(res, 404, { error: 'Payment Entry not found' }) if (!pe) return json(res, 404, { error: 'Payment Entry not found' })
const refNo = pe.reference_no || '' const refNo = pe.reference_no || ''
@ -856,15 +853,14 @@ async function handle (req, res, method, path, url) {
})) }))
} }
const result = await erpRequest('POST', '/api/resource/Payment%20Entry', returnPe) const result = await erp.create('Payment Entry', returnPe)
let returnName = result.data?.data?.name let returnName = result.name
// Submit the return entry (fetch full doc for modified timestamp) // Submit the return entry (fetch full doc for modified timestamp)
if (returnName) { if (returnName) {
const fullReturnRes = await erpFetch(`/api/resource/Payment%20Entry/${returnName}`) const fullReturnDoc = await erp.get('Payment Entry', returnName)
const fullReturnDoc = fullReturnRes.data?.data
if (fullReturnDoc) { if (fullReturnDoc) {
await erpFetch('/api/method/frappe.client.submit', { await erp.raw('/api/method/frappe.client.submit', {
method: 'POST', method: 'POST',
body: JSON.stringify({ doc: fullReturnDoc }), body: JSON.stringify({ doc: fullReturnDoc }),
}) })
@ -877,8 +873,7 @@ async function handle (req, res, method, path, url) {
for (const ref of pe.references) { for (const ref of pe.references) {
if (ref.reference_doctype === 'Sales Invoice') { if (ref.reference_doctype === 'Sales Invoice') {
try { try {
const invRes = await erpFetch(`/api/resource/Sales%20Invoice/${encodeURIComponent(ref.reference_name)}`) const origInv = await erp.get('Sales Invoice', ref.reference_name)
const origInv = invRes.data?.data
if (origInv && origInv.docstatus === 1) { if (origInv && origInv.docstatus === 1) {
const creditNote = { const creditNote = {
doctype: 'Sales Invoice', doctype: 'Sales Invoice',
@ -903,13 +898,12 @@ async function handle (req, res, method, path, url) {
rate: tax.rate, rate: tax.rate,
})), })),
} }
const cnResult = await erpRequest('POST', '/api/resource/Sales%20Invoice', creditNote) const cnResult = await erp.create('Sales Invoice', creditNote)
const cnName = cnResult.data?.data?.name const cnName = cnResult.name
if (cnName) { if (cnName) {
const fullCnRes = await erpFetch(`/api/resource/Sales%20Invoice/${cnName}`) const fullCnDoc = await erp.get('Sales Invoice', cnName)
const fullCnDoc = fullCnRes.data?.data
if (fullCnDoc) { if (fullCnDoc) {
await erpFetch('/api/method/frappe.client.submit', { await erp.raw('/api/method/frappe.client.submit', {
method: 'POST', method: 'POST',
body: JSON.stringify({ doc: fullCnDoc }), body: JSON.stringify({ doc: fullCnDoc }),
}) })
@ -1218,17 +1212,16 @@ async function recordPayment (customerId, amount, reference, modeOfPayment, targ
} }
try { try {
const result = await erpRequest('POST', '/api/resource/Payment%20Entry', pe) const result = await erp.create('Payment Entry', pe)
if (result.status === 200 && result.data?.data?.name) { if (result.ok && result.name) {
// Submit the payment entry // Submit the payment entry
const peName = result.data.data.name const peName = result.name
// Fetch full doc for frappe.client.submit (PostgreSQL compatibility) // Fetch full doc for frappe.client.submit (PostgreSQL compatibility)
const fullPeRes = await erpFetch(`/api/resource/Payment%20Entry/${peName}`) const fullPeDoc = await erp.get('Payment Entry', peName)
const fullPeDoc = fullPeRes.data?.data
if (fullPeDoc) { if (fullPeDoc) {
fullPeDoc.docstatus = 1 fullPeDoc.docstatus = 1
await erpFetch('/api/method/frappe.client.submit', { await erp.raw('/api/method/frappe.client.submit', {
method: 'POST', method: 'POST',
body: JSON.stringify({ doc: fullPeDoc }), body: JSON.stringify({ doc: fullPeDoc }),
}) })
@ -1243,7 +1236,7 @@ async function recordPayment (customerId, amount, reference, modeOfPayment, targ
if (inv) { if (inv) {
const newOutstanding = Math.max(0, Math.round((inv.outstanding_amount - ref.allocated_amount) * 100) / 100) const newOutstanding = Math.max(0, Math.round((inv.outstanding_amount - ref.allocated_amount) * 100) / 100)
const newStatus = newOutstanding <= 0 ? 'Paid' : (newOutstanding < inv.grand_total ? 'Partly Paid' : 'Unpaid') const newStatus = newOutstanding <= 0 ? 'Paid' : (newOutstanding < inv.grand_total ? 'Partly Paid' : 'Unpaid')
await erpRequest('PUT', `/api/resource/Sales%20Invoice/${encodeURIComponent(ref.reference_name)}`, { await erp.update('Sales Invoice', ref.reference_name, {
outstanding_amount: newOutstanding, outstanding_amount: newOutstanding,
status: newStatus, status: newStatus,
}) })
@ -1253,7 +1246,7 @@ async function recordPayment (customerId, amount, reference, modeOfPayment, targ
return peName return peName
} else { } else {
log(`Failed to create Payment Entry: ${JSON.stringify(result.data).slice(0, 500)}`) log(`Failed to create Payment Entry: ${result.error || 'unknown error'}`)
} }
} catch (e) { } catch (e) {
log(`Payment Entry creation error: ${e.message}`) log(`Payment Entry creation error: ${e.message}`)
@ -1277,14 +1270,15 @@ async function runPPACron () {
try { try {
// Find all Payment Methods with is_auto_ppa=1 and provider=Stripe // Find all Payment Methods with is_auto_ppa=1 and provider=Stripe
const filters = encodeURIComponent(JSON.stringify([ const ppaMethods = await erp.list('Payment Method', {
['is_auto_ppa', '=', 1], filters: [
['provider', '=', 'Stripe'], ['is_auto_ppa', '=', 1],
['stripe_customer_id', '!=', ''], ['provider', '=', 'Stripe'],
])) ['stripe_customer_id', '!=', ''],
const fields = encodeURIComponent(JSON.stringify(['name', 'customer', 'stripe_customer_id'])) ],
const res = await erpFetch(`/api/resource/Payment%20Method?filters=${filters}&fields=${fields}&limit_page_length=1000`) fields: ['name', 'customer', 'stripe_customer_id'],
const ppaMethods = res.data?.data || [] limit: 1000,
})
log(`PPA Cron: ${ppaMethods.length} customers with auto-pay enabled`) log(`PPA Cron: ${ppaMethods.length} customers with auto-pay enabled`)

View File

@ -2,6 +2,7 @@
const cfg = require('./config') const cfg = require('./config')
const { log, json } = require('./helpers') const { log, json } = require('./helpers')
const erp = require('./erp') const erp = require('./erp')
const types = require('./types')
const { verifyJwt } = require('./magic-link') const { verifyJwt } = require('./magic-link')
const { extractField } = require('./vision') const { extractField } = require('./vision')
const ui = require('./ui') const ui = require('./ui')
@ -380,9 +381,9 @@ async function handleEquipList (req, res, path) {
// ═════════════════════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════════════════════
function jobCard (j, today) { function jobCard (j, today) {
const urgent = j.priority === 'urgent' || j.priority === 'high' const urgent = types.isUrgent(j.priority)
const done = j.status === 'Completed' || j.status === 'Cancelled' const done = types.isTerminal(j.status)
const inProg = j.status === 'In Progress' || j.status === 'in_progress' const inProg = types.isInProgress(j.status)
const border = urgent ? 'var(--danger)' : done ? 'var(--text-dim)' : inProg ? 'var(--warning)' : 'var(--brand)' const border = urgent ? 'var(--danger)' : done ? 'var(--text-dim)' : inProg ? 'var(--warning)' : 'var(--brand)'
const overdue = !done && j.scheduled_date && j.scheduled_date < today const overdue = !done && j.scheduled_date && j.scheduled_date < today
const dlbl = ui.dateLabelFr(j.scheduled_date, today) const dlbl = ui.dateLabelFr(j.scheduled_date, today)
@ -411,12 +412,12 @@ function renderPage ({ tech, jobs, token, today }) {
const techName = tech.full_name || tech.name const techName = tech.full_name || tech.name
// Partition for the home view — rest is client-side filtering // Partition for the home view — rest is client-side filtering
const inProgress = jobs.filter(j => j.status === 'In Progress' || j.status === 'in_progress') const inProgress = jobs.filter(j => types.isInProgress(j.status))
const pending = jobs.filter(j => !['Completed', 'Cancelled', 'In Progress', 'in_progress'].includes(j.status)) const pending = jobs.filter(j => !types.isTerminal(j.status) && !types.isInProgress(j.status))
const overdue = pending.filter(j => j.scheduled_date && j.scheduled_date < today) const overdue = pending.filter(j => j.scheduled_date && j.scheduled_date < today)
const todayJobs = pending.filter(j => j.scheduled_date === today) const todayJobs = pending.filter(j => j.scheduled_date === today)
const upcoming = pending.filter(j => j.scheduled_date && j.scheduled_date > today).slice(0, 20) const upcoming = pending.filter(j => j.scheduled_date && j.scheduled_date > today).slice(0, 20)
const history = jobs.filter(j => j.status === 'Completed' || j.status === 'Cancelled') const history = jobs.filter(j => types.isTerminal(j.status))
const nodate = pending.filter(j => !j.scheduled_date) const nodate = pending.filter(j => !j.scheduled_date)
const activeCount = inProgress.length + overdue.length + todayJobs.length + nodate.length const activeCount = inProgress.length + overdue.length + todayJobs.length + nodate.length
@ -540,8 +541,8 @@ function renderHome ({ techName, inProgress, overdue, todayJobs, nodate, upcomin
function renderHist ({ history, overdue, today }) { function renderHist ({ history, overdue, today }) {
const all = [...overdue, ...history] const all = [...overdue, ...history]
const doneCount = history.filter(j => j.status === 'Completed').length const doneCount = history.filter(j => types.isDone(j.status)).length
const cancCount = history.filter(j => j.status === 'Cancelled').length const cancCount = history.filter(j => types.isCancelled(j.status)).length
return `<div class="view" id="view-hist"> return `<div class="view" id="view-hist">
<div class="hdr"> <div class="hdr">
@ -643,6 +644,8 @@ function renderEquipOverlay () {
// ═════════════════════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════════════════════
const CLIENT_SCRIPT = ` const CLIENT_SCRIPT = `
${types.CLIENT_TYPES_JS}
// Current detail-view job, customer, location (set by openDetail) // Current detail-view job, customer, location (set by openDetail)
var CJ='',CC='',CL='',CMODEL='',CTYPE=''; var CJ='',CC='',CL='',CMODEL='',CTYPE='';
// Equipment overlay scanner (separate from field-scan) // Equipment overlay scanner (separate from field-scan)
@ -703,9 +706,9 @@ function applyHistFilter(){
var j=JOBS[jidEl.textContent]; if(!j){c.style.display='none';continue} var j=JOBS[jidEl.textContent]; if(!j){c.style.display='none';continue}
var okQ = !q || txt.indexOf(q)>=0; var okQ = !q || txt.indexOf(q)>=0;
var okF = true; var okF = true;
if(f==='done') okF = j.status==='Completed'; if(f==='done') okF = isDone(j.status);
else if(f==='cancelled') okF = j.status==='Cancelled'; else if(f==='cancelled') okF = j.status==='Cancelled';
else if(f==='overdue') okF = j.status!=='Completed' && j.status!=='Cancelled' && j.scheduled_date && j.scheduled_date<TODAY; else if(f==='overdue') okF = !isTerminal(j.status) && j.scheduled_date && j.scheduled_date<TODAY;
c.style.display = (okQ&&okF) ? 'block' : 'none'; c.style.display = (okQ&&okF) ? 'block' : 'none';
} }
} }
@ -723,14 +726,14 @@ function openDetail(name){
} }
function renderDetail(j){ function renderDetail(j){
var done = j.status==='Completed' || j.status==='Cancelled'; var done = isTerminal(j.status);
var canStart = ['Scheduled','assigned','open'].indexOf(j.status) >= 0; var canStart = ['Scheduled','assigned','open'].indexOf(j.status) >= 0;
var canFinish= j.status==='In Progress' || j.status==='in_progress'; var canFinish= isInProgress(j.status);
var sMeta = {Scheduled:['Planifié','#818cf8'], assigned:['Assigné','#818cf8'], open:['Ouvert','#818cf8'], var sMeta = {Scheduled:['Planifié','#818cf8'], assigned:['Assigné','#818cf8'], open:['Ouvert','#818cf8'],
'In Progress':['En cours','#f59e0b'], in_progress:['En cours','#f59e0b'], 'In Progress':['En cours','#f59e0b'], in_progress:['En cours','#f59e0b'],
Completed:['Terminé','#22c55e'], Cancelled:['Annulé','#94a3b8']}; Completed:['Terminé','#22c55e'], done:['Terminé','#22c55e'], Cancelled:['Annulé','#94a3b8']};
var sm = sMeta[j.status] || [j.status||'—','#94a3b8']; var sm = sMeta[j.status] || [j.status||'—','#94a3b8'];
var urgent = j.priority==='urgent' || j.priority==='high'; var urgent = isUrgent(j.priority);
var addr = j.address || j.service_location_name || ''; var addr = j.address || j.service_location_name || '';
var gps = j.service_location_name ? 'https://www.google.com/maps/dir/?api=1&destination='+encodeURIComponent(j.service_location_name) : ''; var gps = j.service_location_name ? 'https://www.google.com/maps/dir/?api=1&destination='+encodeURIComponent(j.service_location_name) : '';

View File

@ -0,0 +1,95 @@
'use strict'
// ─────────────────────────────────────────────────────────────────────────────
// Shared enums + predicates for Dispatch Job and related doctypes.
//
// Background: the v16 Dispatch Job doctype evolved and ended up with both
// snake_case and Title Case variants in its status Select field:
// open, assigned, in_progress, In Progress, On Hold, Scheduled,
// Completed, Cancelled, done
//
// Code all over the codebase had ad-hoc checks like:
// if (j.status === 'In Progress' || j.status === 'in_progress')
// if (!['Completed', 'Cancelled', 'In Progress', 'in_progress'].includes(…))
//
// This module collects them in one place so:
// 1. Any future status rename (or cleanup) touches one file.
// 2. Client-side JS embedded in tech-mobile can import the same spellings.
// 3. New callers have a semantic helper instead of memorizing aliases.
// ─────────────────────────────────────────────────────────────────────────────
// ── Canonical Dispatch Job statuses ──────────────────────────────────────────
// Grouped by logical phase. We keep every spelling ERPNext accepts so filters
// catch legacy rows too.
const JOB_STATUS = {
// New, not yet assigned
OPEN: 'open',
// Assigned to a tech but not yet scheduled/started
ASSIGNED: 'assigned',
// Date/time set, tech hasn't started
SCHEDULED: 'Scheduled',
// Blocked by a dependency (parent chain, parts, …)
ON_HOLD: 'On Hold',
// Tech started (we emit "In Progress" on new starts; "in_progress" is legacy)
IN_PROGRESS: 'In Progress',
IN_PROGRESS_LEGACY: 'in_progress',
// Finished (we emit "Completed"; "done" is legacy)
COMPLETED: 'Completed',
COMPLETED_LEGACY: 'done',
// Aborted
CANCELLED: 'Cancelled',
}
// Logical groupings — use these in filters so legacy spellings don't slip through.
const JOB_IN_PROGRESS_STATUSES = [JOB_STATUS.IN_PROGRESS, JOB_STATUS.IN_PROGRESS_LEGACY]
const JOB_DONE_STATUSES = [JOB_STATUS.COMPLETED, JOB_STATUS.COMPLETED_LEGACY]
const JOB_TERMINAL_STATUSES = [...JOB_DONE_STATUSES, JOB_STATUS.CANCELLED]
const JOB_PENDING_STATUSES = [JOB_STATUS.OPEN, JOB_STATUS.ASSIGNED, JOB_STATUS.SCHEDULED, JOB_STATUS.ON_HOLD]
const JOB_ACTIVE_STATUSES = [...JOB_PENDING_STATUSES, ...JOB_IN_PROGRESS_STATUSES]
// ── Predicates ───────────────────────────────────────────────────────────────
// Use these in branching logic; use the arrays above in list filters.
const isInProgress = s => JOB_IN_PROGRESS_STATUSES.includes(s)
const isDone = s => JOB_DONE_STATUSES.includes(s)
const isCancelled = s => s === JOB_STATUS.CANCELLED
const isTerminal = s => JOB_TERMINAL_STATUSES.includes(s)
const isPending = s => JOB_PENDING_STATUSES.includes(s)
// ── Priority ─────────────────────────────────────────────────────────────────
// Doctype Select options: low | medium | high.
// 'urgent' shows up in older LLM prompts and client filters — treated as high.
const JOB_PRIORITY = {
LOW: 'low',
MEDIUM: 'medium',
HIGH: 'high',
URGENT: 'urgent', // alias — code treats as "high or above"
}
const URGENT_PRIORITIES = [JOB_PRIORITY.HIGH, JOB_PRIORITY.URGENT]
const isUrgent = p => URGENT_PRIORITIES.includes(p)
// ── Client-side snippet ──────────────────────────────────────────────────────
// For embedding in template-literal <script> blocks that can't `require()`.
// The primitives are intentionally minimal so the script stays small.
const CLIENT_TYPES_JS = `
var JOB_DONE_STATUSES = ${JSON.stringify(JOB_DONE_STATUSES)};
var JOB_INPROG_STATUSES = ${JSON.stringify(JOB_IN_PROGRESS_STATUSES)};
var JOB_TERMINAL = ${JSON.stringify(JOB_TERMINAL_STATUSES)};
var URGENT_PRIORITIES = ${JSON.stringify(URGENT_PRIORITIES)};
function isDone(s) { return JOB_DONE_STATUSES.indexOf(s) !== -1; }
function isInProgress(s) { return JOB_INPROG_STATUSES.indexOf(s) !== -1; }
function isTerminal(s) { return JOB_TERMINAL.indexOf(s) !== -1; }
function isUrgent(p) { return URGENT_PRIORITIES.indexOf(p) !== -1; }
`.trim()
module.exports = {
JOB_STATUS,
JOB_PRIORITY,
JOB_IN_PROGRESS_STATUSES,
JOB_DONE_STATUSES,
JOB_TERMINAL_STATUSES,
JOB_PENDING_STATUSES,
JOB_ACTIVE_STATUSES,
URGENT_PRIORITIES,
isInProgress, isDone, isCancelled, isTerminal, isPending, isUrgent,
CLIENT_TYPES_JS,
}