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'
|
'use strict'
|
||||||
const cfg = require('./config')
|
const cfg = require('./config')
|
||||||
const { log, json, parseBody } = require('./helpers')
|
const { log, json, parseBody } = require('./helpers')
|
||||||
|
const erp = require('./erp')
|
||||||
|
const types = require('./types')
|
||||||
|
const ui = require('./ui')
|
||||||
const { signJwt, verifyJwt } = require('./magic-link')
|
const { signJwt, verifyJwt } = require('./magic-link')
|
||||||
|
|
||||||
// ── Acceptance Links ─────────────────────────────────────────────────────────
|
// ── Acceptance Links ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -94,39 +97,26 @@ async function checkDocuSealStatus (submissionId) {
|
||||||
// ── ERPNext helpers ──────────────────────────────────────────────────────────
|
// ── ERPNext helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function fetchQuotation (name) {
|
async function fetchQuotation (name) {
|
||||||
const { erpFetch } = require('./helpers')
|
return erp.get('Quotation', name)
|
||||||
const res = await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`)
|
|
||||||
if (res.status !== 200) return null
|
|
||||||
return res.data.data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acceptQuotation (name, acceptanceData) {
|
async function acceptQuotation (name, acceptanceData) {
|
||||||
const { erpFetch } = require('./helpers')
|
|
||||||
|
|
||||||
// Add acceptance comment with proof
|
// Add acceptance comment with proof
|
||||||
await erpFetch(`/api/resource/Comment`, {
|
await erp.create('Comment', {
|
||||||
method: 'POST',
|
comment_type: 'Info',
|
||||||
body: JSON.stringify({
|
reference_doctype: 'Quotation',
|
||||||
comment_type: 'Info',
|
reference_name: name,
|
||||||
reference_doctype: 'Quotation',
|
content: `<b>✅ Devis accepté par le client</b><br>
|
||||||
reference_name: name,
|
<b>Horodatage:</b> ${new Date().toISOString()}<br>
|
||||||
content: `<b>✅ Devis accepté par le client</b><br>
|
<b>Méthode:</b> ${acceptanceData.method || 'Lien JWT'}<br>
|
||||||
<b>Horodatage:</b> ${new Date().toISOString()}<br>
|
<b>Contact:</b> ${acceptanceData.contact || 'N/A'}<br>
|
||||||
<b>Méthode:</b> ${acceptanceData.method || 'Lien JWT'}<br>
|
<b>IP:</b> ${acceptanceData.ip || 'N/A'}<br>
|
||||||
<b>Contact:</b> ${acceptanceData.contact || 'N/A'}<br>
|
<b>User-Agent:</b> ${acceptanceData.userAgent || 'N/A'}<br>
|
||||||
<b>IP:</b> ${acceptanceData.ip || 'N/A'}<br>
|
${acceptanceData.docusealUrl ? `<b>Document signé:</b> <a href="${acceptanceData.docusealUrl}">${acceptanceData.docusealUrl}</a>` : ''}`,
|
||||||
<b>User-Agent:</b> ${acceptanceData.userAgent || 'N/A'}<br>
|
|
||||||
${acceptanceData.docusealUrl ? `<b>Document signé:</b> <a href="${acceptanceData.docusealUrl}">${acceptanceData.docusealUrl}</a>` : ''}`,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update quotation status
|
// Update quotation status (best-effort — ignore failure)
|
||||||
try {
|
await erp.update('Quotation', name, { accepted_by_client: 1 }).catch(() => {})
|
||||||
await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ accepted_by_client: 1 }),
|
|
||||||
})
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// ── Create deferred dispatch jobs if wizard_steps exist ──
|
// ── Create deferred dispatch jobs if wizard_steps exist ──
|
||||||
try {
|
try {
|
||||||
|
|
@ -139,10 +129,7 @@ async function acceptQuotation (name, acceptanceData) {
|
||||||
log(`Created ${createdJobs.length} deferred jobs for ${name} upon acceptance`)
|
log(`Created ${createdJobs.length} deferred jobs for ${name} upon acceptance`)
|
||||||
|
|
||||||
// Clear wizard_steps so they don't get created again
|
// Clear wizard_steps so they don't get created again
|
||||||
await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`, {
|
await erp.update('Quotation', name, { wizard_steps: '', wizard_context: '' })
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ wizard_steps: '', wizard_context: '' }),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Also create subscriptions for recurring items on the quotation
|
// Also create subscriptions for recurring items on the quotation
|
||||||
await createDeferredSubscriptions(quotation, ctx)
|
await createDeferredSubscriptions(quotation, ctx)
|
||||||
|
|
@ -156,7 +143,6 @@ async function acceptQuotation (name, acceptanceData) {
|
||||||
// ── Create dispatch jobs from wizard steps stored on quotation ───────────────
|
// ── Create dispatch jobs from wizard steps stored on quotation ───────────────
|
||||||
|
|
||||||
async function createDeferredJobs (steps, ctx, quotationName) {
|
async function createDeferredJobs (steps, ctx, quotationName) {
|
||||||
const { erpFetch } = require('./helpers')
|
|
||||||
const createdJobs = []
|
const createdJobs = []
|
||||||
|
|
||||||
for (let i = 0; i < steps.length; i++) {
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
|
@ -180,8 +166,8 @@ async function createDeferredJobs (steps, ctx, quotationName) {
|
||||||
subject: step.subject || 'Tâche',
|
subject: step.subject || 'Tâche',
|
||||||
address: ctx.address || '',
|
address: ctx.address || '',
|
||||||
duration_h: step.duration_h || 1,
|
duration_h: step.duration_h || 1,
|
||||||
priority: step.priority || 'medium',
|
priority: step.priority || types.JOB_PRIORITY.MEDIUM,
|
||||||
status: dependsOn ? 'On Hold' : 'open',
|
status: dependsOn ? types.JOB_STATUS.ON_HOLD : types.JOB_STATUS.OPEN,
|
||||||
job_type: step.job_type || 'Autre',
|
job_type: step.job_type || 'Autre',
|
||||||
source_issue: ctx.issue || '',
|
source_issue: ctx.issue || '',
|
||||||
customer: ctx.customer || '',
|
customer: ctx.customer || '',
|
||||||
|
|
@ -209,21 +195,13 @@ async function createDeferredJobs (steps, ctx, quotationName) {
|
||||||
scheduled_date: step.scheduled_date || ctx.scheduled_date || new Date().toISOString().slice(0, 10),
|
scheduled_date: step.scheduled_date || ctx.scheduled_date || new Date().toISOString().slice(0, 10),
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const res = await erp.create('Dispatch Job', payload)
|
||||||
const res = await erpFetch('/api/resource/Dispatch%20Job', {
|
if (res.ok && res.data) {
|
||||||
method: 'POST',
|
createdJobs.push(res.data)
|
||||||
body: JSON.stringify(payload),
|
log(` + Job ${res.data.name}: ${step.subject}`)
|
||||||
})
|
} else {
|
||||||
if (res.status === 200 && res.data?.data) {
|
|
||||||
createdJobs.push(res.data.data)
|
|
||||||
log(` + Job ${res.data.data.name}: ${step.subject}`)
|
|
||||||
} else {
|
|
||||||
createdJobs.push({ name: ticketId })
|
|
||||||
log(` ! Job creation returned ${res.status} for: ${step.subject}`)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
createdJobs.push({ name: ticketId })
|
createdJobs.push({ name: ticketId })
|
||||||
log(` ! Job creation failed for: ${step.subject} — ${e.message}`)
|
log(` ! Job creation failed for: ${step.subject} — ${res.error || 'unknown'}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -268,7 +246,6 @@ function _extractDurationMonths (item) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createDeferredSubscriptions (quotation, ctx) {
|
async function createDeferredSubscriptions (quotation, ctx) {
|
||||||
const { erpFetch } = require('./helpers')
|
|
||||||
const customer = ctx.customer || quotation.customer || quotation.party_name || ''
|
const customer = ctx.customer || quotation.customer || quotation.party_name || ''
|
||||||
const serviceLocation = ctx.service_location || ''
|
const serviceLocation = ctx.service_location || ''
|
||||||
if (!customer) return []
|
if (!customer) return []
|
||||||
|
|
@ -309,19 +286,12 @@ async function createDeferredSubscriptions (quotation, ctx) {
|
||||||
notes: `Créé automatiquement via acceptation du devis ${quotation.name || ''}`,
|
notes: `Créé automatiquement via acceptation du devis ${quotation.name || ''}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const res = await erp.create('Service Subscription', payload)
|
||||||
const res = await erpFetch('/api/resource/Service%20Subscription', {
|
if (res.ok && res.name) {
|
||||||
method: 'POST',
|
created.push(res.name)
|
||||||
body: JSON.stringify(payload),
|
log(` + Service Subscription ${res.name} (En attente) — ${item.item_name}`)
|
||||||
})
|
} else {
|
||||||
if (res.status === 200 && res.data?.data) {
|
log(` ! Service Subscription creation failed for ${item.item_name}: ${res.error || 'unknown'}`)
|
||||||
created.push(res.data.data.name)
|
|
||||||
log(` + Service Subscription ${res.data.data.name} (En attente) — ${item.item_name}`)
|
|
||||||
} else {
|
|
||||||
log(` ! Service Subscription creation returned ${res.status} for ${item.item_name}`)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log(` ! Service Subscription creation failed for ${item.item_name}: ${e.message}`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return created
|
return created
|
||||||
|
|
@ -330,23 +300,17 @@ async function createDeferredSubscriptions (quotation, ctx) {
|
||||||
// ── PDF generation via ERPNext ────────────────────────────────────────────────
|
// ── PDF generation via ERPNext ────────────────────────────────────────────────
|
||||||
|
|
||||||
async function getQuotationPdfBuffer (quotationName, printFormat) {
|
async function getQuotationPdfBuffer (quotationName, printFormat) {
|
||||||
const { erpFetch } = require('./helpers')
|
// PDF comes back as raw bytes, not JSON — go around erpFetch.
|
||||||
const format = printFormat || 'Standard'
|
const format = printFormat || 'Standard'
|
||||||
const url = `/api/method/frappe.utils.print_format.download_pdf?doctype=Quotation&name=${encodeURIComponent(quotationName)}&format=${encodeURIComponent(format)}&no_letterhead=0`
|
const url = `${cfg.ERP_URL}/api/method/frappe.utils.print_format.download_pdf?doctype=Quotation&name=${encodeURIComponent(quotationName)}&format=${encodeURIComponent(format)}&no_letterhead=0`
|
||||||
const res = await erpFetch(url, { rawResponse: true })
|
const pdfRes = await fetch(url, {
|
||||||
if (res.status !== 200) return null
|
|
||||||
// erpFetch returns parsed JSON by default; we need the raw buffer
|
|
||||||
// Use direct fetch instead
|
|
||||||
const directUrl = `${cfg.ERP_URL}${url}`
|
|
||||||
const pdfRes = await fetch(directUrl, {
|
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `token ${cfg.ERP_TOKEN}`,
|
'Authorization': `token ${cfg.ERP_TOKEN}`,
|
||||||
'X-Frappe-Site-Name': cfg.ERP_SITE,
|
'X-Frappe-Site-Name': cfg.ERP_SITE,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!pdfRes.ok) return null
|
if (!pdfRes.ok) return null
|
||||||
const buf = Buffer.from(await pdfRes.arrayBuffer())
|
return Buffer.from(await pdfRes.arrayBuffer())
|
||||||
return buf
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDocPdfBuffer (doctype, name, printFormat) {
|
async function getDocPdfBuffer (doctype, name, printFormat) {
|
||||||
|
|
@ -504,14 +468,8 @@ async function handle (req, res, method, path) {
|
||||||
const payload = verifyJwt(token)
|
const payload = verifyJwt(token)
|
||||||
|
|
||||||
if (!payload || payload.type !== 'acceptance') {
|
if (!payload || payload.type !== 'acceptance') {
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
res.writeHead(200, ui.htmlHeaders())
|
||||||
return res.end(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
return res.end(ui.pageExpired())
|
||||||
<title>Lien expiré</title></head><body style="font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#f1f5f9">
|
|
||||||
<div style="text-align:center;background:white;padding:2rem;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,0.08)">
|
|
||||||
<div style="font-size:3rem;margin-bottom:1rem">🔗</div>
|
|
||||||
<h2 style="color:#1e293b">Lien expiré</h2>
|
|
||||||
<p style="color:#64748b;margin-top:0.5rem">Ce lien d'acceptation a expiré ou est invalide.<br>Contactez-nous pour recevoir un nouveau lien.</p>
|
|
||||||
</div></body></html>`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -591,15 +549,8 @@ async function handle (req, res, method, path) {
|
||||||
result.submission_id = dsResult.submissionId
|
result.submission_id = dsResult.submissionId
|
||||||
|
|
||||||
// Persist signing URL on the Quotation so the print-format QR code is populated
|
// Persist signing URL on the Quotation so the print-format QR code is populated
|
||||||
try {
|
const up = await erp.update('Quotation', quotation, { custom_docuseal_signing_url: dsResult.signUrl })
|
||||||
const { erpFetch } = require('./helpers')
|
if (!up.ok) log('Failed to save DocuSeal signing URL to Quotation:', up.error)
|
||||||
await erpFetch(`/api/resource/Quotation/${encodeURIComponent(quotation)}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ custom_docuseal_signing_url: dsResult.signUrl }),
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
log('Failed to save DocuSeal signing URL to Quotation:', e.message)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback to JWT if DocuSeal fails
|
// Fallback to JWT if DocuSeal fails
|
||||||
result.method = 'jwt'
|
result.method = 'jwt'
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@
|
||||||
const https = require('https')
|
const https = require('https')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const cfg = require('./config')
|
const cfg = require('./config')
|
||||||
const { log, json, parseBody, erpFetch, erpRequest } = require('./helpers')
|
const { log, json, parseBody } = require('./helpers')
|
||||||
|
const erp = require('./erp')
|
||||||
const sse = require('./sse')
|
const sse = require('./sse')
|
||||||
|
|
||||||
// Stripe config from environment
|
// Stripe config from environment
|
||||||
|
|
@ -135,57 +136,55 @@ function verifyWebhookSignature (rawBody, sigHeader) {
|
||||||
// ────────────────────────────────────────────
|
// ────────────────────────────────────────────
|
||||||
async function getCustomerBalance (customerId) {
|
async function getCustomerBalance (customerId) {
|
||||||
// Sum outstanding amounts from unpaid invoices
|
// Sum outstanding amounts from unpaid invoices
|
||||||
const filters = encodeURIComponent(JSON.stringify([
|
const invoices = await erp.list('Sales Invoice', {
|
||||||
['customer', '=', customerId],
|
filters: [
|
||||||
['outstanding_amount', '>', 0],
|
['customer', '=', customerId],
|
||||||
['docstatus', '=', 1],
|
['outstanding_amount', '>', 0],
|
||||||
]))
|
['docstatus', '=', 1],
|
||||||
const fields = encodeURIComponent(JSON.stringify(['name', 'posting_date', 'grand_total', 'outstanding_amount']))
|
],
|
||||||
const res = await erpFetch(`/api/resource/Sales%20Invoice?filters=${filters}&fields=${fields}&order_by=posting_date asc&limit_page_length=100`)
|
fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount'],
|
||||||
if (res.status !== 200) return { balance: 0, invoices: [] }
|
orderBy: 'posting_date asc',
|
||||||
const invoices = res.data?.data || []
|
limit: 100,
|
||||||
|
})
|
||||||
const balance = invoices.reduce((sum, inv) => sum + (inv.outstanding_amount || 0), 0)
|
const balance = invoices.reduce((sum, inv) => sum + (inv.outstanding_amount || 0), 0)
|
||||||
return { balance: Math.round(balance * 100) / 100, invoices }
|
return { balance: Math.round(balance * 100) / 100, invoices }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getInvoiceDoc (invoiceName) {
|
async function getInvoiceDoc (invoiceName) {
|
||||||
const res = await erpFetch(`/api/resource/Sales%20Invoice/${encodeURIComponent(invoiceName)}?fields=["name","customer","customer_name","posting_date","due_date","grand_total","outstanding_amount","status","docstatus","currency"]`)
|
return erp.get('Sales Invoice', invoiceName, {
|
||||||
if (res.status !== 200) return null
|
fields: ['name', 'customer', 'customer_name', 'posting_date', 'due_date', 'grand_total', 'outstanding_amount', 'status', 'docstatus', 'currency'],
|
||||||
return res.data?.data || null
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCustomerDoc (customerId) {
|
async function getCustomerDoc (customerId) {
|
||||||
const res = await erpFetch(`/api/resource/Customer/${encodeURIComponent(customerId)}`)
|
return erp.get('Customer', customerId)
|
||||||
if (res.status !== 200) return null
|
|
||||||
return res.data?.data || null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPaymentMethods (customerId) {
|
async function getPaymentMethods (customerId) {
|
||||||
const filters = encodeURIComponent(JSON.stringify([['customer', '=', customerId]]))
|
return erp.list('Payment Method', {
|
||||||
const fields = encodeURIComponent(JSON.stringify([
|
filters: [['customer', '=', customerId]],
|
||||||
'name', 'provider', 'is_active', 'is_auto_ppa',
|
fields: [
|
||||||
'stripe_customer_id', 'stripe_ppa_enabled', 'stripe_ppa_nocc',
|
'name', 'provider', 'is_active', 'is_auto_ppa',
|
||||||
'paysafe_profile_id', 'paysafe_card_id', 'paysafe_token',
|
'stripe_customer_id', 'stripe_ppa_enabled', 'stripe_ppa_nocc',
|
||||||
'ppa_name', 'ppa_institution', 'ppa_branch', 'ppa_account', 'ppa_amount', 'ppa_buffer',
|
'paysafe_profile_id', 'paysafe_card_id', 'paysafe_token',
|
||||||
]))
|
'ppa_name', 'ppa_institution', 'ppa_branch', 'ppa_account', 'ppa_amount', 'ppa_buffer',
|
||||||
const res = await erpFetch(`/api/resource/Payment%20Method?filters=${filters}&fields=${fields}&limit_page_length=20`)
|
],
|
||||||
if (res.status !== 200) {
|
limit: 20,
|
||||||
log('getPaymentMethods error:', JSON.stringify(res.data).slice(0, 300))
|
})
|
||||||
return []
|
|
||||||
}
|
|
||||||
return res.data?.data || []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for existing Payment Entry with same reference_no (idempotency)
|
// Check for existing Payment Entry with same reference_no (idempotency)
|
||||||
async function paymentEntryExists (referenceNo) {
|
async function paymentEntryExists (referenceNo) {
|
||||||
if (!referenceNo) return false
|
if (!referenceNo) return false
|
||||||
const filters = encodeURIComponent(JSON.stringify([
|
const rows = await erp.list('Payment Entry', {
|
||||||
['reference_no', '=', referenceNo],
|
filters: [
|
||||||
['docstatus', '!=', 2], // not cancelled
|
['reference_no', '=', referenceNo],
|
||||||
]))
|
['docstatus', '!=', 2], // not cancelled
|
||||||
const res = await erpFetch(`/api/resource/Payment%20Entry?filters=${filters}&fields=["name"]&limit_page_length=1`)
|
],
|
||||||
if (res.status !== 200) return false
|
fields: ['name'],
|
||||||
return (res.data?.data || []).length > 0
|
limit: 1,
|
||||||
|
})
|
||||||
|
return rows.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────
|
// ────────────────────────────────────────────
|
||||||
|
|
@ -232,9 +231,9 @@ async function saveStripeCustomerId (customerId, stripeId) {
|
||||||
const methods = await getPaymentMethods(customerId)
|
const methods = await getPaymentMethods(customerId)
|
||||||
const existing = methods.find(m => m.provider === 'Stripe')
|
const existing = methods.find(m => m.provider === 'Stripe')
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await erpRequest('PUT', `/api/resource/Payment%20Method/${existing.name}`, { stripe_customer_id: stripeId })
|
await erp.update('Payment Method', existing.name, { stripe_customer_id: stripeId })
|
||||||
} else {
|
} else {
|
||||||
await erpRequest('POST', '/api/resource/Payment%20Method', {
|
await erp.create('Payment Method', {
|
||||||
customer: customerId, provider: 'Stripe', stripe_customer_id: stripeId,
|
customer: customerId, provider: 'Stripe', stripe_customer_id: stripeId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -620,13 +619,13 @@ async function handle (req, res, method, path, url) {
|
||||||
|
|
||||||
// Update ERPNext Payment Method
|
// Update ERPNext Payment Method
|
||||||
if (stripePm) {
|
if (stripePm) {
|
||||||
await erpRequest('PUT', `/api/resource/Payment%20Method/${stripePm.name}`, {
|
await erp.update('Payment Method', stripePm.name, {
|
||||||
is_auto_ppa: enabled ? 1 : 0,
|
is_auto_ppa: enabled ? 1 : 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync ppa_enabled flag on Customer doc
|
// Sync ppa_enabled flag on Customer doc
|
||||||
await erpRequest('PUT', `/api/resource/Customer/${encodeURIComponent(customer)}`, {
|
await erp.update('Customer', customer, {
|
||||||
ppa_enabled: enabled ? 1 : 0,
|
ppa_enabled: enabled ? 1 : 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -790,9 +789,7 @@ async function handle (req, res, method, path, url) {
|
||||||
if (!payment_entry) return json(res, 400, { error: 'payment_entry required' })
|
if (!payment_entry) return json(res, 400, { error: 'payment_entry required' })
|
||||||
|
|
||||||
// Look up the Payment Entry in ERPNext to find the Stripe reference
|
// Look up the Payment Entry in ERPNext to find the Stripe reference
|
||||||
const peRes = await erpFetch(`/api/resource/Payment%20Entry/${encodeURIComponent(payment_entry)}`)
|
const pe = await erp.get('Payment Entry', payment_entry)
|
||||||
if (peRes.status !== 200) return json(res, 404, { error: 'Payment Entry not found' })
|
|
||||||
const pe = peRes.data?.data
|
|
||||||
if (!pe) return json(res, 404, { error: 'Payment Entry not found' })
|
if (!pe) return json(res, 404, { error: 'Payment Entry not found' })
|
||||||
|
|
||||||
const refNo = pe.reference_no || ''
|
const refNo = pe.reference_no || ''
|
||||||
|
|
@ -856,15 +853,14 @@ async function handle (req, res, method, path, url) {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await erpRequest('POST', '/api/resource/Payment%20Entry', returnPe)
|
const result = await erp.create('Payment Entry', returnPe)
|
||||||
let returnName = result.data?.data?.name
|
let returnName = result.name
|
||||||
|
|
||||||
// Submit the return entry (fetch full doc for modified timestamp)
|
// Submit the return entry (fetch full doc for modified timestamp)
|
||||||
if (returnName) {
|
if (returnName) {
|
||||||
const fullReturnRes = await erpFetch(`/api/resource/Payment%20Entry/${returnName}`)
|
const fullReturnDoc = await erp.get('Payment Entry', returnName)
|
||||||
const fullReturnDoc = fullReturnRes.data?.data
|
|
||||||
if (fullReturnDoc) {
|
if (fullReturnDoc) {
|
||||||
await erpFetch('/api/method/frappe.client.submit', {
|
await erp.raw('/api/method/frappe.client.submit', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ doc: fullReturnDoc }),
|
body: JSON.stringify({ doc: fullReturnDoc }),
|
||||||
})
|
})
|
||||||
|
|
@ -877,8 +873,7 @@ async function handle (req, res, method, path, url) {
|
||||||
for (const ref of pe.references) {
|
for (const ref of pe.references) {
|
||||||
if (ref.reference_doctype === 'Sales Invoice') {
|
if (ref.reference_doctype === 'Sales Invoice') {
|
||||||
try {
|
try {
|
||||||
const invRes = await erpFetch(`/api/resource/Sales%20Invoice/${encodeURIComponent(ref.reference_name)}`)
|
const origInv = await erp.get('Sales Invoice', ref.reference_name)
|
||||||
const origInv = invRes.data?.data
|
|
||||||
if (origInv && origInv.docstatus === 1) {
|
if (origInv && origInv.docstatus === 1) {
|
||||||
const creditNote = {
|
const creditNote = {
|
||||||
doctype: 'Sales Invoice',
|
doctype: 'Sales Invoice',
|
||||||
|
|
@ -903,13 +898,12 @@ async function handle (req, res, method, path, url) {
|
||||||
rate: tax.rate,
|
rate: tax.rate,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
const cnResult = await erpRequest('POST', '/api/resource/Sales%20Invoice', creditNote)
|
const cnResult = await erp.create('Sales Invoice', creditNote)
|
||||||
const cnName = cnResult.data?.data?.name
|
const cnName = cnResult.name
|
||||||
if (cnName) {
|
if (cnName) {
|
||||||
const fullCnRes = await erpFetch(`/api/resource/Sales%20Invoice/${cnName}`)
|
const fullCnDoc = await erp.get('Sales Invoice', cnName)
|
||||||
const fullCnDoc = fullCnRes.data?.data
|
|
||||||
if (fullCnDoc) {
|
if (fullCnDoc) {
|
||||||
await erpFetch('/api/method/frappe.client.submit', {
|
await erp.raw('/api/method/frappe.client.submit', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ doc: fullCnDoc }),
|
body: JSON.stringify({ doc: fullCnDoc }),
|
||||||
})
|
})
|
||||||
|
|
@ -1218,17 +1212,16 @@ async function recordPayment (customerId, amount, reference, modeOfPayment, targ
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await erpRequest('POST', '/api/resource/Payment%20Entry', pe)
|
const result = await erp.create('Payment Entry', pe)
|
||||||
if (result.status === 200 && result.data?.data?.name) {
|
if (result.ok && result.name) {
|
||||||
// Submit the payment entry
|
// Submit the payment entry
|
||||||
const peName = result.data.data.name
|
const peName = result.name
|
||||||
|
|
||||||
// Fetch full doc for frappe.client.submit (PostgreSQL compatibility)
|
// Fetch full doc for frappe.client.submit (PostgreSQL compatibility)
|
||||||
const fullPeRes = await erpFetch(`/api/resource/Payment%20Entry/${peName}`)
|
const fullPeDoc = await erp.get('Payment Entry', peName)
|
||||||
const fullPeDoc = fullPeRes.data?.data
|
|
||||||
if (fullPeDoc) {
|
if (fullPeDoc) {
|
||||||
fullPeDoc.docstatus = 1
|
fullPeDoc.docstatus = 1
|
||||||
await erpFetch('/api/method/frappe.client.submit', {
|
await erp.raw('/api/method/frappe.client.submit', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ doc: fullPeDoc }),
|
body: JSON.stringify({ doc: fullPeDoc }),
|
||||||
})
|
})
|
||||||
|
|
@ -1243,7 +1236,7 @@ async function recordPayment (customerId, amount, reference, modeOfPayment, targ
|
||||||
if (inv) {
|
if (inv) {
|
||||||
const newOutstanding = Math.max(0, Math.round((inv.outstanding_amount - ref.allocated_amount) * 100) / 100)
|
const newOutstanding = Math.max(0, Math.round((inv.outstanding_amount - ref.allocated_amount) * 100) / 100)
|
||||||
const newStatus = newOutstanding <= 0 ? 'Paid' : (newOutstanding < inv.grand_total ? 'Partly Paid' : 'Unpaid')
|
const newStatus = newOutstanding <= 0 ? 'Paid' : (newOutstanding < inv.grand_total ? 'Partly Paid' : 'Unpaid')
|
||||||
await erpRequest('PUT', `/api/resource/Sales%20Invoice/${encodeURIComponent(ref.reference_name)}`, {
|
await erp.update('Sales Invoice', ref.reference_name, {
|
||||||
outstanding_amount: newOutstanding,
|
outstanding_amount: newOutstanding,
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
})
|
})
|
||||||
|
|
@ -1253,7 +1246,7 @@ async function recordPayment (customerId, amount, reference, modeOfPayment, targ
|
||||||
|
|
||||||
return peName
|
return peName
|
||||||
} else {
|
} else {
|
||||||
log(`Failed to create Payment Entry: ${JSON.stringify(result.data).slice(0, 500)}`)
|
log(`Failed to create Payment Entry: ${result.error || 'unknown error'}`)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(`Payment Entry creation error: ${e.message}`)
|
log(`Payment Entry creation error: ${e.message}`)
|
||||||
|
|
@ -1277,14 +1270,15 @@ async function runPPACron () {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find all Payment Methods with is_auto_ppa=1 and provider=Stripe
|
// Find all Payment Methods with is_auto_ppa=1 and provider=Stripe
|
||||||
const filters = encodeURIComponent(JSON.stringify([
|
const ppaMethods = await erp.list('Payment Method', {
|
||||||
['is_auto_ppa', '=', 1],
|
filters: [
|
||||||
['provider', '=', 'Stripe'],
|
['is_auto_ppa', '=', 1],
|
||||||
['stripe_customer_id', '!=', ''],
|
['provider', '=', 'Stripe'],
|
||||||
]))
|
['stripe_customer_id', '!=', ''],
|
||||||
const fields = encodeURIComponent(JSON.stringify(['name', 'customer', 'stripe_customer_id']))
|
],
|
||||||
const res = await erpFetch(`/api/resource/Payment%20Method?filters=${filters}&fields=${fields}&limit_page_length=1000`)
|
fields: ['name', 'customer', 'stripe_customer_id'],
|
||||||
const ppaMethods = res.data?.data || []
|
limit: 1000,
|
||||||
|
})
|
||||||
|
|
||||||
log(`PPA Cron: ${ppaMethods.length} customers with auto-pay enabled`)
|
log(`PPA Cron: ${ppaMethods.length} customers with auto-pay enabled`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
const cfg = require('./config')
|
const cfg = require('./config')
|
||||||
const { log, json } = require('./helpers')
|
const { log, json } = require('./helpers')
|
||||||
const erp = require('./erp')
|
const erp = require('./erp')
|
||||||
|
const types = require('./types')
|
||||||
const { verifyJwt } = require('./magic-link')
|
const { verifyJwt } = require('./magic-link')
|
||||||
const { extractField } = require('./vision')
|
const { extractField } = require('./vision')
|
||||||
const ui = require('./ui')
|
const ui = require('./ui')
|
||||||
|
|
@ -380,9 +381,9 @@ async function handleEquipList (req, res, path) {
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function jobCard (j, today) {
|
function jobCard (j, today) {
|
||||||
const urgent = j.priority === 'urgent' || j.priority === 'high'
|
const urgent = types.isUrgent(j.priority)
|
||||||
const done = j.status === 'Completed' || j.status === 'Cancelled'
|
const done = types.isTerminal(j.status)
|
||||||
const inProg = j.status === 'In Progress' || j.status === 'in_progress'
|
const inProg = types.isInProgress(j.status)
|
||||||
const border = urgent ? 'var(--danger)' : done ? 'var(--text-dim)' : inProg ? 'var(--warning)' : 'var(--brand)'
|
const border = urgent ? 'var(--danger)' : done ? 'var(--text-dim)' : inProg ? 'var(--warning)' : 'var(--brand)'
|
||||||
const overdue = !done && j.scheduled_date && j.scheduled_date < today
|
const overdue = !done && j.scheduled_date && j.scheduled_date < today
|
||||||
const dlbl = ui.dateLabelFr(j.scheduled_date, today)
|
const dlbl = ui.dateLabelFr(j.scheduled_date, today)
|
||||||
|
|
@ -411,12 +412,12 @@ function renderPage ({ tech, jobs, token, today }) {
|
||||||
const techName = tech.full_name || tech.name
|
const techName = tech.full_name || tech.name
|
||||||
|
|
||||||
// Partition for the home view — rest is client-side filtering
|
// Partition for the home view — rest is client-side filtering
|
||||||
const inProgress = jobs.filter(j => j.status === 'In Progress' || j.status === 'in_progress')
|
const inProgress = jobs.filter(j => types.isInProgress(j.status))
|
||||||
const pending = jobs.filter(j => !['Completed', 'Cancelled', 'In Progress', 'in_progress'].includes(j.status))
|
const pending = jobs.filter(j => !types.isTerminal(j.status) && !types.isInProgress(j.status))
|
||||||
const overdue = pending.filter(j => j.scheduled_date && j.scheduled_date < today)
|
const overdue = pending.filter(j => j.scheduled_date && j.scheduled_date < today)
|
||||||
const todayJobs = pending.filter(j => j.scheduled_date === today)
|
const todayJobs = pending.filter(j => j.scheduled_date === today)
|
||||||
const upcoming = pending.filter(j => j.scheduled_date && j.scheduled_date > today).slice(0, 20)
|
const upcoming = pending.filter(j => j.scheduled_date && j.scheduled_date > today).slice(0, 20)
|
||||||
const history = jobs.filter(j => j.status === 'Completed' || j.status === 'Cancelled')
|
const history = jobs.filter(j => types.isTerminal(j.status))
|
||||||
const nodate = pending.filter(j => !j.scheduled_date)
|
const nodate = pending.filter(j => !j.scheduled_date)
|
||||||
const activeCount = inProgress.length + overdue.length + todayJobs.length + nodate.length
|
const activeCount = inProgress.length + overdue.length + todayJobs.length + nodate.length
|
||||||
|
|
||||||
|
|
@ -540,8 +541,8 @@ function renderHome ({ techName, inProgress, overdue, todayJobs, nodate, upcomin
|
||||||
|
|
||||||
function renderHist ({ history, overdue, today }) {
|
function renderHist ({ history, overdue, today }) {
|
||||||
const all = [...overdue, ...history]
|
const all = [...overdue, ...history]
|
||||||
const doneCount = history.filter(j => j.status === 'Completed').length
|
const doneCount = history.filter(j => types.isDone(j.status)).length
|
||||||
const cancCount = history.filter(j => j.status === 'Cancelled').length
|
const cancCount = history.filter(j => types.isCancelled(j.status)).length
|
||||||
|
|
||||||
return `<div class="view" id="view-hist">
|
return `<div class="view" id="view-hist">
|
||||||
<div class="hdr">
|
<div class="hdr">
|
||||||
|
|
@ -643,6 +644,8 @@ function renderEquipOverlay () {
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const CLIENT_SCRIPT = `
|
const CLIENT_SCRIPT = `
|
||||||
|
${types.CLIENT_TYPES_JS}
|
||||||
|
|
||||||
// Current detail-view job, customer, location (set by openDetail)
|
// Current detail-view job, customer, location (set by openDetail)
|
||||||
var CJ='',CC='',CL='',CMODEL='',CTYPE='';
|
var CJ='',CC='',CL='',CMODEL='',CTYPE='';
|
||||||
// Equipment overlay scanner (separate from field-scan)
|
// Equipment overlay scanner (separate from field-scan)
|
||||||
|
|
@ -703,9 +706,9 @@ function applyHistFilter(){
|
||||||
var j=JOBS[jidEl.textContent]; if(!j){c.style.display='none';continue}
|
var j=JOBS[jidEl.textContent]; if(!j){c.style.display='none';continue}
|
||||||
var okQ = !q || txt.indexOf(q)>=0;
|
var okQ = !q || txt.indexOf(q)>=0;
|
||||||
var okF = true;
|
var okF = true;
|
||||||
if(f==='done') okF = j.status==='Completed';
|
if(f==='done') okF = isDone(j.status);
|
||||||
else if(f==='cancelled') okF = j.status==='Cancelled';
|
else if(f==='cancelled') okF = j.status==='Cancelled';
|
||||||
else if(f==='overdue') okF = j.status!=='Completed' && j.status!=='Cancelled' && j.scheduled_date && j.scheduled_date<TODAY;
|
else if(f==='overdue') okF = !isTerminal(j.status) && j.scheduled_date && j.scheduled_date<TODAY;
|
||||||
c.style.display = (okQ&&okF) ? 'block' : 'none';
|
c.style.display = (okQ&&okF) ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -723,14 +726,14 @@ function openDetail(name){
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDetail(j){
|
function renderDetail(j){
|
||||||
var done = j.status==='Completed' || j.status==='Cancelled';
|
var done = isTerminal(j.status);
|
||||||
var canStart = ['Scheduled','assigned','open'].indexOf(j.status) >= 0;
|
var canStart = ['Scheduled','assigned','open'].indexOf(j.status) >= 0;
|
||||||
var canFinish= j.status==='In Progress' || j.status==='in_progress';
|
var canFinish= isInProgress(j.status);
|
||||||
var sMeta = {Scheduled:['Planifié','#818cf8'], assigned:['Assigné','#818cf8'], open:['Ouvert','#818cf8'],
|
var sMeta = {Scheduled:['Planifié','#818cf8'], assigned:['Assigné','#818cf8'], open:['Ouvert','#818cf8'],
|
||||||
'In Progress':['En cours','#f59e0b'], in_progress:['En cours','#f59e0b'],
|
'In Progress':['En cours','#f59e0b'], in_progress:['En cours','#f59e0b'],
|
||||||
Completed:['Terminé','#22c55e'], Cancelled:['Annulé','#94a3b8']};
|
Completed:['Terminé','#22c55e'], done:['Terminé','#22c55e'], Cancelled:['Annulé','#94a3b8']};
|
||||||
var sm = sMeta[j.status] || [j.status||'—','#94a3b8'];
|
var sm = sMeta[j.status] || [j.status||'—','#94a3b8'];
|
||||||
var urgent = j.priority==='urgent' || j.priority==='high';
|
var urgent = isUrgent(j.priority);
|
||||||
var addr = j.address || j.service_location_name || '';
|
var addr = j.address || j.service_location_name || '';
|
||||||
var gps = j.service_location_name ? 'https://www.google.com/maps/dir/?api=1&destination='+encodeURIComponent(j.service_location_name) : '';
|
var gps = j.service_location_name ? 'https://www.google.com/maps/dir/?api=1&destination='+encodeURIComponent(j.service_location_name) : '';
|
||||||
|
|
||||||
|
|
|
||||||
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,
|
||||||
|
}
|
||||||