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>
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
11147
apps/field/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
|
@ -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 = '.'
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
/* eslint-env serviceworker */
|
||||
import { precacheAndRoute } from 'workbox-precaching'
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
10
apps/field/src-pwa/pwa-flag.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) },
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
@ -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/'
|
||||
}
|
||||
|
|
@ -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 || []
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { boot } from 'quasar/wrappers'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export default boot(({ app }) => {
|
||||
app.use(createPinia())
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 200–400ms
|
||||
// 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 }
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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 }
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
'use strict'
|
||||
const cfg = require('./config')
|
||||
const { log, json, parseBody } = require('./helpers')
|
||||
const erp = require('./erp')
|
||||
const types = require('./types')
|
||||
const ui = require('./ui')
|
||||
const { signJwt, verifyJwt } = require('./magic-link')
|
||||
|
||||
// ── Acceptance Links ─────────────────────────────────────────────────────────
|
||||
|
|
@ -94,39 +97,26 @@ async function checkDocuSealStatus (submissionId) {
|
|||
// ── ERPNext helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchQuotation (name) {
|
||||
const { erpFetch } = require('./helpers')
|
||||
const res = await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`)
|
||||
if (res.status !== 200) return null
|
||||
return res.data.data
|
||||
return erp.get('Quotation', name)
|
||||
}
|
||||
|
||||
async function acceptQuotation (name, acceptanceData) {
|
||||
const { erpFetch } = require('./helpers')
|
||||
|
||||
// Add acceptance comment with proof
|
||||
await erpFetch(`/api/resource/Comment`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
comment_type: 'Info',
|
||||
reference_doctype: 'Quotation',
|
||||
reference_name: name,
|
||||
content: `<b>✅ Devis accepté par le client</b><br>
|
||||
<b>Horodatage:</b> ${new Date().toISOString()}<br>
|
||||
<b>Méthode:</b> ${acceptanceData.method || 'Lien JWT'}<br>
|
||||
<b>Contact:</b> ${acceptanceData.contact || 'N/A'}<br>
|
||||
<b>IP:</b> ${acceptanceData.ip || 'N/A'}<br>
|
||||
<b>User-Agent:</b> ${acceptanceData.userAgent || 'N/A'}<br>
|
||||
${acceptanceData.docusealUrl ? `<b>Document signé:</b> <a href="${acceptanceData.docusealUrl}">${acceptanceData.docusealUrl}</a>` : ''}`,
|
||||
}),
|
||||
await erp.create('Comment', {
|
||||
comment_type: 'Info',
|
||||
reference_doctype: 'Quotation',
|
||||
reference_name: name,
|
||||
content: `<b>✅ Devis accepté par le client</b><br>
|
||||
<b>Horodatage:</b> ${new Date().toISOString()}<br>
|
||||
<b>Méthode:</b> ${acceptanceData.method || 'Lien JWT'}<br>
|
||||
<b>Contact:</b> ${acceptanceData.contact || 'N/A'}<br>
|
||||
<b>IP:</b> ${acceptanceData.ip || 'N/A'}<br>
|
||||
<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
|
||||
try {
|
||||
await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ accepted_by_client: 1 }),
|
||||
})
|
||||
} catch {}
|
||||
// Update quotation status (best-effort — ignore failure)
|
||||
await erp.update('Quotation', name, { accepted_by_client: 1 }).catch(() => {})
|
||||
|
||||
// ── Create deferred dispatch jobs if wizard_steps exist ──
|
||||
try {
|
||||
|
|
@ -139,10 +129,7 @@ async function acceptQuotation (name, acceptanceData) {
|
|||
log(`Created ${createdJobs.length} deferred jobs for ${name} upon acceptance`)
|
||||
|
||||
// Clear wizard_steps so they don't get created again
|
||||
await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ wizard_steps: '', wizard_context: '' }),
|
||||
})
|
||||
await erp.update('Quotation', name, { wizard_steps: '', wizard_context: '' })
|
||||
|
||||
// Also create subscriptions for recurring items on the quotation
|
||||
await createDeferredSubscriptions(quotation, ctx)
|
||||
|
|
@ -156,7 +143,6 @@ async function acceptQuotation (name, acceptanceData) {
|
|||
// ── Create dispatch jobs from wizard steps stored on quotation ───────────────
|
||||
|
||||
async function createDeferredJobs (steps, ctx, quotationName) {
|
||||
const { erpFetch } = require('./helpers')
|
||||
const createdJobs = []
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
|
|
@ -180,8 +166,8 @@ async function createDeferredJobs (steps, ctx, quotationName) {
|
|||
subject: step.subject || 'Tâche',
|
||||
address: ctx.address || '',
|
||||
duration_h: step.duration_h || 1,
|
||||
priority: step.priority || 'medium',
|
||||
status: dependsOn ? 'On Hold' : 'open',
|
||||
priority: step.priority || types.JOB_PRIORITY.MEDIUM,
|
||||
status: dependsOn ? types.JOB_STATUS.ON_HOLD : types.JOB_STATUS.OPEN,
|
||||
job_type: step.job_type || 'Autre',
|
||||
source_issue: ctx.issue || '',
|
||||
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),
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await erpFetch('/api/resource/Dispatch%20Job', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
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) {
|
||||
const res = await erp.create('Dispatch Job', payload)
|
||||
if (res.ok && res.data) {
|
||||
createdJobs.push(res.data)
|
||||
log(` + Job ${res.data.name}: ${step.subject}`)
|
||||
} else {
|
||||
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) {
|
||||
const { erpFetch } = require('./helpers')
|
||||
const customer = ctx.customer || quotation.customer || quotation.party_name || ''
|
||||
const serviceLocation = ctx.service_location || ''
|
||||
if (!customer) return []
|
||||
|
|
@ -309,19 +286,12 @@ async function createDeferredSubscriptions (quotation, ctx) {
|
|||
notes: `Créé automatiquement via acceptation du devis ${quotation.name || ''}`,
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await erpFetch('/api/resource/Service%20Subscription', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (res.status === 200 && res.data?.data) {
|
||||
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}`)
|
||||
const res = await erp.create('Service Subscription', payload)
|
||||
if (res.ok && res.name) {
|
||||
created.push(res.name)
|
||||
log(` + Service Subscription ${res.name} (En attente) — ${item.item_name}`)
|
||||
} else {
|
||||
log(` ! Service Subscription creation failed for ${item.item_name}: ${res.error || 'unknown'}`)
|
||||
}
|
||||
}
|
||||
return created
|
||||
|
|
@ -330,23 +300,17 @@ async function createDeferredSubscriptions (quotation, ctx) {
|
|||
// ── PDF generation via ERPNext ────────────────────────────────────────────────
|
||||
|
||||
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 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 })
|
||||
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, {
|
||||
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 pdfRes = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `token ${cfg.ERP_TOKEN}`,
|
||||
'X-Frappe-Site-Name': cfg.ERP_SITE,
|
||||
},
|
||||
})
|
||||
if (!pdfRes.ok) return null
|
||||
const buf = Buffer.from(await pdfRes.arrayBuffer())
|
||||
return buf
|
||||
return Buffer.from(await pdfRes.arrayBuffer())
|
||||
}
|
||||
|
||||
async function getDocPdfBuffer (doctype, name, printFormat) {
|
||||
|
|
@ -504,14 +468,8 @@ async function handle (req, res, method, path) {
|
|||
const payload = verifyJwt(token)
|
||||
|
||||
if (!payload || payload.type !== 'acceptance') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||
return res.end(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<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>`)
|
||||
res.writeHead(200, ui.htmlHeaders())
|
||||
return res.end(ui.pageExpired())
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -591,15 +549,8 @@ async function handle (req, res, method, path) {
|
|||
result.submission_id = dsResult.submissionId
|
||||
|
||||
// Persist signing URL on the Quotation so the print-format QR code is populated
|
||||
try {
|
||||
const { erpFetch } = require('./helpers')
|
||||
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)
|
||||
}
|
||||
const up = await erp.update('Quotation', quotation, { custom_docuseal_signing_url: dsResult.signUrl })
|
||||
if (!up.ok) log('Failed to save DocuSeal signing URL to Quotation:', up.error)
|
||||
} else {
|
||||
// Fallback to JWT if DocuSeal fails
|
||||
result.method = 'jwt'
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@
|
|||
const https = require('https')
|
||||
const crypto = require('crypto')
|
||||
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')
|
||||
|
||||
// Stripe config from environment
|
||||
|
|
@ -135,57 +136,55 @@ function verifyWebhookSignature (rawBody, sigHeader) {
|
|||
// ────────────────────────────────────────────
|
||||
async function getCustomerBalance (customerId) {
|
||||
// Sum outstanding amounts from unpaid invoices
|
||||
const filters = encodeURIComponent(JSON.stringify([
|
||||
['customer', '=', customerId],
|
||||
['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`)
|
||||
if (res.status !== 200) return { balance: 0, invoices: [] }
|
||||
const invoices = res.data?.data || []
|
||||
const invoices = await erp.list('Sales Invoice', {
|
||||
filters: [
|
||||
['customer', '=', customerId],
|
||||
['outstanding_amount', '>', 0],
|
||||
['docstatus', '=', 1],
|
||||
],
|
||||
fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount'],
|
||||
orderBy: 'posting_date asc',
|
||||
limit: 100,
|
||||
})
|
||||
const balance = invoices.reduce((sum, inv) => sum + (inv.outstanding_amount || 0), 0)
|
||||
return { balance: Math.round(balance * 100) / 100, invoices }
|
||||
}
|
||||
|
||||
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"]`)
|
||||
if (res.status !== 200) return null
|
||||
return res.data?.data || null
|
||||
return erp.get('Sales Invoice', invoiceName, {
|
||||
fields: ['name', 'customer', 'customer_name', 'posting_date', 'due_date', 'grand_total', 'outstanding_amount', 'status', 'docstatus', 'currency'],
|
||||
})
|
||||
}
|
||||
|
||||
async function getCustomerDoc (customerId) {
|
||||
const res = await erpFetch(`/api/resource/Customer/${encodeURIComponent(customerId)}`)
|
||||
if (res.status !== 200) return null
|
||||
return res.data?.data || null
|
||||
return erp.get('Customer', customerId)
|
||||
}
|
||||
|
||||
async function getPaymentMethods (customerId) {
|
||||
const filters = encodeURIComponent(JSON.stringify([['customer', '=', customerId]]))
|
||||
const fields = encodeURIComponent(JSON.stringify([
|
||||
'name', 'provider', 'is_active', 'is_auto_ppa',
|
||||
'stripe_customer_id', 'stripe_ppa_enabled', 'stripe_ppa_nocc',
|
||||
'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) {
|
||||
log('getPaymentMethods error:', JSON.stringify(res.data).slice(0, 300))
|
||||
return []
|
||||
}
|
||||
return res.data?.data || []
|
||||
return erp.list('Payment Method', {
|
||||
filters: [['customer', '=', customerId]],
|
||||
fields: [
|
||||
'name', 'provider', 'is_active', 'is_auto_ppa',
|
||||
'stripe_customer_id', 'stripe_ppa_enabled', 'stripe_ppa_nocc',
|
||||
'paysafe_profile_id', 'paysafe_card_id', 'paysafe_token',
|
||||
'ppa_name', 'ppa_institution', 'ppa_branch', 'ppa_account', 'ppa_amount', 'ppa_buffer',
|
||||
],
|
||||
limit: 20,
|
||||
})
|
||||
}
|
||||
|
||||
// Check for existing Payment Entry with same reference_no (idempotency)
|
||||
async function paymentEntryExists (referenceNo) {
|
||||
if (!referenceNo) return false
|
||||
const filters = encodeURIComponent(JSON.stringify([
|
||||
['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
|
||||
return (res.data?.data || []).length > 0
|
||||
const rows = await erp.list('Payment Entry', {
|
||||
filters: [
|
||||
['reference_no', '=', referenceNo],
|
||||
['docstatus', '!=', 2], // not cancelled
|
||||
],
|
||||
fields: ['name'],
|
||||
limit: 1,
|
||||
})
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
|
|
@ -232,9 +231,9 @@ async function saveStripeCustomerId (customerId, stripeId) {
|
|||
const methods = await getPaymentMethods(customerId)
|
||||
const existing = methods.find(m => m.provider === 'Stripe')
|
||||
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 {
|
||||
await erpRequest('POST', '/api/resource/Payment%20Method', {
|
||||
await erp.create('Payment Method', {
|
||||
customer: customerId, provider: 'Stripe', stripe_customer_id: stripeId,
|
||||
})
|
||||
}
|
||||
|
|
@ -620,13 +619,13 @@ async function handle (req, res, method, path, url) {
|
|||
|
||||
// Update ERPNext Payment Method
|
||||
if (stripePm) {
|
||||
await erpRequest('PUT', `/api/resource/Payment%20Method/${stripePm.name}`, {
|
||||
await erp.update('Payment Method', stripePm.name, {
|
||||
is_auto_ppa: enabled ? 1 : 0,
|
||||
})
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
|
||||
|
|
@ -790,9 +789,7 @@ async function handle (req, res, method, path, url) {
|
|||
if (!payment_entry) return json(res, 400, { error: 'payment_entry required' })
|
||||
|
||||
// Look up the Payment Entry in ERPNext to find the Stripe reference
|
||||
const peRes = await erpFetch(`/api/resource/Payment%20Entry/${encodeURIComponent(payment_entry)}`)
|
||||
if (peRes.status !== 200) return json(res, 404, { error: 'Payment Entry not found' })
|
||||
const pe = peRes.data?.data
|
||||
const pe = await erp.get('Payment Entry', payment_entry)
|
||||
if (!pe) return json(res, 404, { error: 'Payment Entry not found' })
|
||||
|
||||
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)
|
||||
let returnName = result.data?.data?.name
|
||||
const result = await erp.create('Payment Entry', returnPe)
|
||||
let returnName = result.name
|
||||
|
||||
// Submit the return entry (fetch full doc for modified timestamp)
|
||||
if (returnName) {
|
||||
const fullReturnRes = await erpFetch(`/api/resource/Payment%20Entry/${returnName}`)
|
||||
const fullReturnDoc = fullReturnRes.data?.data
|
||||
const fullReturnDoc = await erp.get('Payment Entry', returnName)
|
||||
if (fullReturnDoc) {
|
||||
await erpFetch('/api/method/frappe.client.submit', {
|
||||
await erp.raw('/api/method/frappe.client.submit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ doc: fullReturnDoc }),
|
||||
})
|
||||
|
|
@ -877,8 +873,7 @@ async function handle (req, res, method, path, url) {
|
|||
for (const ref of pe.references) {
|
||||
if (ref.reference_doctype === 'Sales Invoice') {
|
||||
try {
|
||||
const invRes = await erpFetch(`/api/resource/Sales%20Invoice/${encodeURIComponent(ref.reference_name)}`)
|
||||
const origInv = invRes.data?.data
|
||||
const origInv = await erp.get('Sales Invoice', ref.reference_name)
|
||||
if (origInv && origInv.docstatus === 1) {
|
||||
const creditNote = {
|
||||
doctype: 'Sales Invoice',
|
||||
|
|
@ -903,13 +898,12 @@ async function handle (req, res, method, path, url) {
|
|||
rate: tax.rate,
|
||||
})),
|
||||
}
|
||||
const cnResult = await erpRequest('POST', '/api/resource/Sales%20Invoice', creditNote)
|
||||
const cnName = cnResult.data?.data?.name
|
||||
const cnResult = await erp.create('Sales Invoice', creditNote)
|
||||
const cnName = cnResult.name
|
||||
if (cnName) {
|
||||
const fullCnRes = await erpFetch(`/api/resource/Sales%20Invoice/${cnName}`)
|
||||
const fullCnDoc = fullCnRes.data?.data
|
||||
const fullCnDoc = await erp.get('Sales Invoice', cnName)
|
||||
if (fullCnDoc) {
|
||||
await erpFetch('/api/method/frappe.client.submit', {
|
||||
await erp.raw('/api/method/frappe.client.submit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ doc: fullCnDoc }),
|
||||
})
|
||||
|
|
@ -1218,17 +1212,16 @@ async function recordPayment (customerId, amount, reference, modeOfPayment, targ
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await erpRequest('POST', '/api/resource/Payment%20Entry', pe)
|
||||
if (result.status === 200 && result.data?.data?.name) {
|
||||
const result = await erp.create('Payment Entry', pe)
|
||||
if (result.ok && result.name) {
|
||||
// Submit the payment entry
|
||||
const peName = result.data.data.name
|
||||
const peName = result.name
|
||||
|
||||
// Fetch full doc for frappe.client.submit (PostgreSQL compatibility)
|
||||
const fullPeRes = await erpFetch(`/api/resource/Payment%20Entry/${peName}`)
|
||||
const fullPeDoc = fullPeRes.data?.data
|
||||
const fullPeDoc = await erp.get('Payment Entry', peName)
|
||||
if (fullPeDoc) {
|
||||
fullPeDoc.docstatus = 1
|
||||
await erpFetch('/api/method/frappe.client.submit', {
|
||||
await erp.raw('/api/method/frappe.client.submit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ doc: fullPeDoc }),
|
||||
})
|
||||
|
|
@ -1243,7 +1236,7 @@ async function recordPayment (customerId, amount, reference, modeOfPayment, targ
|
|||
if (inv) {
|
||||
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')
|
||||
await erpRequest('PUT', `/api/resource/Sales%20Invoice/${encodeURIComponent(ref.reference_name)}`, {
|
||||
await erp.update('Sales Invoice', ref.reference_name, {
|
||||
outstanding_amount: newOutstanding,
|
||||
status: newStatus,
|
||||
})
|
||||
|
|
@ -1253,7 +1246,7 @@ async function recordPayment (customerId, amount, reference, modeOfPayment, targ
|
|||
|
||||
return peName
|
||||
} 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) {
|
||||
log(`Payment Entry creation error: ${e.message}`)
|
||||
|
|
@ -1277,14 +1270,15 @@ async function runPPACron () {
|
|||
|
||||
try {
|
||||
// Find all Payment Methods with is_auto_ppa=1 and provider=Stripe
|
||||
const filters = encodeURIComponent(JSON.stringify([
|
||||
['is_auto_ppa', '=', 1],
|
||||
['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`)
|
||||
const ppaMethods = res.data?.data || []
|
||||
const ppaMethods = await erp.list('Payment Method', {
|
||||
filters: [
|
||||
['is_auto_ppa', '=', 1],
|
||||
['provider', '=', 'Stripe'],
|
||||
['stripe_customer_id', '!=', ''],
|
||||
],
|
||||
fields: ['name', 'customer', 'stripe_customer_id'],
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
log(`PPA Cron: ${ppaMethods.length} customers with auto-pay enabled`)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
const cfg = require('./config')
|
||||
const { log, json } = require('./helpers')
|
||||
const erp = require('./erp')
|
||||
const types = require('./types')
|
||||
const { verifyJwt } = require('./magic-link')
|
||||
const { extractField } = require('./vision')
|
||||
const ui = require('./ui')
|
||||
|
|
@ -380,9 +381,9 @@ async function handleEquipList (req, res, path) {
|
|||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function jobCard (j, today) {
|
||||
const urgent = j.priority === 'urgent' || j.priority === 'high'
|
||||
const done = j.status === 'Completed' || j.status === 'Cancelled'
|
||||
const inProg = j.status === 'In Progress' || j.status === 'in_progress'
|
||||
const urgent = types.isUrgent(j.priority)
|
||||
const done = types.isTerminal(j.status)
|
||||
const inProg = types.isInProgress(j.status)
|
||||
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 dlbl = ui.dateLabelFr(j.scheduled_date, today)
|
||||
|
|
@ -411,12 +412,12 @@ function renderPage ({ tech, jobs, token, today }) {
|
|||
const techName = tech.full_name || tech.name
|
||||
|
||||
// Partition for the home view — rest is client-side filtering
|
||||
const inProgress = jobs.filter(j => j.status === 'In Progress' || j.status === 'in_progress')
|
||||
const pending = jobs.filter(j => !['Completed', 'Cancelled', 'In Progress', 'in_progress'].includes(j.status))
|
||||
const inProgress = jobs.filter(j => types.isInProgress(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 todayJobs = pending.filter(j => j.scheduled_date === today)
|
||||
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 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 }) {
|
||||
const all = [...overdue, ...history]
|
||||
const doneCount = history.filter(j => j.status === 'Completed').length
|
||||
const cancCount = history.filter(j => j.status === 'Cancelled').length
|
||||
const doneCount = history.filter(j => types.isDone(j.status)).length
|
||||
const cancCount = history.filter(j => types.isCancelled(j.status)).length
|
||||
|
||||
return `<div class="view" id="view-hist">
|
||||
<div class="hdr">
|
||||
|
|
@ -643,6 +644,8 @@ function renderEquipOverlay () {
|
|||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const CLIENT_SCRIPT = `
|
||||
${types.CLIENT_TYPES_JS}
|
||||
|
||||
// Current detail-view job, customer, location (set by openDetail)
|
||||
var CJ='',CC='',CL='',CMODEL='',CTYPE='';
|
||||
// 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 okQ = !q || txt.indexOf(q)>=0;
|
||||
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==='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';
|
||||
}
|
||||
}
|
||||
|
|
@ -723,14 +726,14 @@ function openDetail(name){
|
|||
}
|
||||
|
||||
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 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'],
|
||||
'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 urgent = j.priority==='urgent' || j.priority==='high';
|
||||
var urgent = isUrgent(j.priority);
|
||||
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) : '';
|
||||
|
||||
|
|
|
|||
95
services/targo-hub/lib/types.js
Normal 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,
|
||||
}
|
||||