Initial commit — dispatch app baseline before Quasar migration
Current state: custom CSS + vanilla Vue components Architecture: modular with composables, provide/inject pattern Ready for progressive migration to Quasar native components Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
10
.eslintrc.cjs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
parserOptions: { ecmaVersion: 'latest' },
|
||||||
|
env: { browser: true },
|
||||||
|
extends: ['plugin:vue/vue3-essential', 'eslint:recommended'],
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': 'warn',
|
||||||
|
'vue/multi-word-component-names': 'off',
|
||||||
|
},
|
||||||
|
}
|
||||||
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.quasar/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
14
Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies first (cached layer)
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# The built app lives in /app/dist/pwa/
|
||||||
|
# It is extracted by deploy.sh using `docker cp`
|
||||||
41
deploy.sh
Executable file
|
|
@ -0,0 +1,41 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# deploy.sh — Build the Quasar PWA and deploy to ERPNext Docker
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# chmod +x deploy.sh
|
||||||
|
# ./deploy.sh
|
||||||
|
#
|
||||||
|
# Accès après déploiement :
|
||||||
|
# http://localhost:8080/assets/dispatch-app/
|
||||||
|
# http://localhost:8080/assets/dispatch-app/#/mobile
|
||||||
|
#
|
||||||
|
# To change the target container or path, edit CONTAINER and DEST below.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CONTAINER="frappe_docker-frontend-1"
|
||||||
|
DEST="/home/frappe/frappe-bench/sites/assets/dispatch-app"
|
||||||
|
IMAGE="dispatch-app-builder"
|
||||||
|
|
||||||
|
echo "==> Building Docker image..."
|
||||||
|
docker build -t "$IMAGE" "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
echo "==> Extracting build artifacts..."
|
||||||
|
TMPDIR="$(mktemp -d)"
|
||||||
|
# Create a temporary container (not running) to copy files out
|
||||||
|
CID=$(docker create "$IMAGE")
|
||||||
|
docker cp "$CID:/app/dist/pwa/." "$TMPDIR/"
|
||||||
|
docker rm "$CID"
|
||||||
|
|
||||||
|
echo "==> Deploying to ERPNext container ($CONTAINER:$DEST)..."
|
||||||
|
docker exec "$CONTAINER" mkdir -p "$DEST"
|
||||||
|
docker cp "$TMPDIR/." "$CONTAINER:$DEST/"
|
||||||
|
|
||||||
|
rm -rf "$TMPDIR"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done!"
|
||||||
|
echo " Dispatch : http://localhost:8080/assets/dispatch-app/index.html"
|
||||||
|
echo " Mobile : http://localhost:8080/assets/dispatch-app/index.html#/mobile"
|
||||||
23
frappe-setup/add_start_time_field.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""
|
||||||
|
Add start_time field to Dispatch Job doctype
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
def run():
|
||||||
|
meta = frappe.get_meta('Dispatch Job')
|
||||||
|
if meta.has_field('start_time'):
|
||||||
|
print("✓ Field 'start_time' already exists on Dispatch Job")
|
||||||
|
return
|
||||||
|
|
||||||
|
doc = frappe.get_doc('DocType', 'Dispatch Job')
|
||||||
|
doc.append('fields', {
|
||||||
|
'fieldname': 'start_time',
|
||||||
|
'fieldtype': 'Time',
|
||||||
|
'label': 'Heure de début',
|
||||||
|
'insert_after': 'scheduled_date',
|
||||||
|
})
|
||||||
|
doc.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
print("✓ Field 'start_time' added to Dispatch Job")
|
||||||
|
|
||||||
|
run()
|
||||||
99
frappe-setup/create_dispatch_settings.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""
|
||||||
|
Dispatch Settings — création du DocType Single dans ERPNext/Frappe
|
||||||
|
==================================================================
|
||||||
|
Exécution (depuis le host) :
|
||||||
|
|
||||||
|
docker cp frappe-setup/create_dispatch_settings.py frappe_docker-backend-1:/home/frappe/
|
||||||
|
docker exec frappe_docker-backend-1 bash -c \
|
||||||
|
"cd /home/frappe/frappe-bench && bench --site $(bench --site-list | head -1) execute /home/frappe/create_dispatch_settings.py"
|
||||||
|
|
||||||
|
Ou directement dans la console bench :
|
||||||
|
bench --site <site> console
|
||||||
|
>>> exec(open('/home/frappe/create_dispatch_settings.py').read())
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
|
||||||
|
FIELDS = [
|
||||||
|
# ── ERPNext / Frappe ─────────────────────────────────────────────────────
|
||||||
|
{'fieldname': 'erp_section', 'fieldtype': 'Section Break', 'label': 'ERPNext / Frappe'},
|
||||||
|
{'fieldname': 'erp_url', 'fieldtype': 'Data', 'label': 'URL du serveur',
|
||||||
|
'description': 'Ex: http://localhost:8080 ou https://erp.monentreprise.com',
|
||||||
|
'default': 'http://localhost:8080'},
|
||||||
|
{'fieldname': 'erp_api_key', 'fieldtype': 'Data', 'label': 'API Key',
|
||||||
|
'description': 'Profil utilisateur ERPNext → API Access → API Key'},
|
||||||
|
{'fieldname': 'erp_api_secret', 'fieldtype': 'Password', 'label': 'API Secret'},
|
||||||
|
|
||||||
|
# ── Mapbox ───────────────────────────────────────────────────────────────
|
||||||
|
{'fieldname': 'mapbox_section', 'fieldtype': 'Section Break', 'label': 'Mapbox'},
|
||||||
|
{'fieldname': 'mapbox_token', 'fieldtype': 'Data', 'label': 'Token public (pk_)',
|
||||||
|
'description': 'Token public — visible dans le navigateur, limitez le scope dans le dashboard Mapbox',
|
||||||
|
'default': 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'},
|
||||||
|
|
||||||
|
# ── Twilio (SMS) ─────────────────────────────────────────────────────────
|
||||||
|
{'fieldname': 'twilio_section', 'fieldtype': 'Section Break', 'label': 'Twilio — SMS'},
|
||||||
|
{'fieldname': 'twilio_account_sid', 'fieldtype': 'Data', 'label': 'Account SID',
|
||||||
|
'description': 'Commence par AC — console.twilio.com'},
|
||||||
|
{'fieldname': 'twilio_auth_token', 'fieldtype': 'Password', 'label': 'Auth Token'},
|
||||||
|
{'fieldname': 'twilio_from_number', 'fieldtype': 'Data', 'label': 'Numéro expéditeur',
|
||||||
|
'description': 'Format E.164 : +15141234567'},
|
||||||
|
|
||||||
|
# ── Stripe ───────────────────────────────────────────────────────────────
|
||||||
|
{'fieldname': 'stripe_section', 'fieldtype': 'Section Break', 'label': 'Stripe — Paiements'},
|
||||||
|
{'fieldname': 'stripe_mode', 'fieldtype': 'Select', 'label': 'Mode',
|
||||||
|
'options': 'test\nlive', 'default': 'test'},
|
||||||
|
{'fieldname': 'stripe_publishable_key','fieldtype': 'Data', 'label': 'Clé publique (pk_)'},
|
||||||
|
{'fieldname': 'stripe_secret_key', 'fieldtype': 'Password', 'label': 'Clé secrète (sk_)'},
|
||||||
|
{'fieldname': 'stripe_webhook_secret', 'fieldtype': 'Password', 'label': 'Webhook Secret (whsec_)'},
|
||||||
|
|
||||||
|
# ── n8n ──────────────────────────────────────────────────────────────────
|
||||||
|
{'fieldname': 'n8n_section', 'fieldtype': 'Section Break', 'label': 'n8n — Automatisation'},
|
||||||
|
{'fieldname': 'n8n_url', 'fieldtype': 'Data', 'label': 'URL n8n',
|
||||||
|
'default': 'http://localhost:5678'},
|
||||||
|
{'fieldname': 'n8n_api_key', 'fieldtype': 'Password', 'label': 'API Key n8n'},
|
||||||
|
{'fieldname': 'n8n_webhook_base','fieldtype': 'Data', 'label': 'Base URL webhooks',
|
||||||
|
'description': 'Ex: http://localhost:5678/webhook — préfixe des webhooks ERPNext → n8n',
|
||||||
|
'default': 'http://localhost:5678/webhook'},
|
||||||
|
|
||||||
|
# ── Templates SMS ────────────────────────────────────────────────────────
|
||||||
|
{'fieldname': 'sms_section', 'fieldtype': 'Section Break', 'label': 'Templates SMS'},
|
||||||
|
{'fieldname': 'sms_enroute', 'fieldtype': 'Text', 'label': 'Technicien en route',
|
||||||
|
'default': 'Bonjour {client_name}, votre technicien {tech_name} est en route et arrivera dans environ {eta} minutes. Réf: {job_id}'},
|
||||||
|
{'fieldname': 'sms_completed', 'fieldtype': 'Text', 'label': 'Service complété',
|
||||||
|
'default': 'Bonjour {client_name}, votre service ({job_id}) a été complété avec succès. Merci de votre confiance !'},
|
||||||
|
{'fieldname': 'sms_assigned', 'fieldtype': 'Text', 'label': 'Job assigné (technicien)',
|
||||||
|
'default': 'Nouveau job assigné : {job_id} — {client_name}, {address}. Durée estimée : {duration}h.'},
|
||||||
|
]
|
||||||
|
|
||||||
|
PERMISSIONS = [
|
||||||
|
{'role': 'System Manager', 'read': 1, 'write': 1, 'create': 1, 'delete': 1},
|
||||||
|
{'role': 'Administrator', 'read': 1, 'write': 1, 'create': 1, 'delete': 1},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def create_dispatch_settings():
|
||||||
|
if frappe.db.exists('DocType', 'Dispatch Settings'):
|
||||||
|
print("✓ DocType 'Dispatch Settings' existe déjà")
|
||||||
|
print(" UI : ERPNext Desk → Dispatch Settings")
|
||||||
|
print(" API : /api/resource/Dispatch Settings/Dispatch Settings")
|
||||||
|
return
|
||||||
|
|
||||||
|
doc = frappe.new_doc('DocType')
|
||||||
|
doc.update({
|
||||||
|
'name': 'Dispatch Settings',
|
||||||
|
'module': 'Core',
|
||||||
|
'custom': 1,
|
||||||
|
'is_single': 1,
|
||||||
|
'track_changes': 0,
|
||||||
|
'fields': FIELDS,
|
||||||
|
'permissions': PERMISSIONS,
|
||||||
|
})
|
||||||
|
doc.insert(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
print("✓ DocType 'Dispatch Settings' créé avec succès")
|
||||||
|
print(" UI : ERPNext Desk → Dispatch Settings")
|
||||||
|
print(" API : /api/resource/Dispatch Settings/Dispatch Settings")
|
||||||
|
|
||||||
|
|
||||||
|
create_dispatch_settings()
|
||||||
14
index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Dispatch</title>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<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/favicon-128x128.png" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- quasar:entry-point -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10018
package-lock.json
generated
Normal file
37
package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"name": "dispatch-app",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Dispatch & Field Service app for ERPNext",
|
||||||
|
"productName": "Dispatch",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "quasar dev",
|
||||||
|
"build": "quasar build -m pwa",
|
||||||
|
"lint": "eslint --ext .js,.vue ./src"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@quasar/extras": "^1.16.12",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"quasar": "^2.16.10",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@quasar/app-vite": "^1.10.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-vue": "^9.24.0",
|
||||||
|
"sass": "^1.72.0",
|
||||||
|
"workbox-build": "7.0.x",
|
||||||
|
"workbox-cacheable-response": "7.0.x",
|
||||||
|
"workbox-core": "7.0.x",
|
||||||
|
"workbox-expiration": "7.0.x",
|
||||||
|
"workbox-precaching": "7.0.x",
|
||||||
|
"workbox-routing": "7.0.x",
|
||||||
|
"workbox-strategies": "7.0.x"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || ^20",
|
||||||
|
"npm": ">= 6.13.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/icons/apple-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/icons/apple-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/icons/apple-icon-167x167.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/icons/apple-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/icons/icon-256x256.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
public/icons/ms-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
public/icons/safari-pinned-tab.svg
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
75
quasar.config.js
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
/* eslint-env node */
|
||||||
|
const { configure } = require('quasar/wrappers')
|
||||||
|
|
||||||
|
module.exports = configure(function (ctx) {
|
||||||
|
return {
|
||||||
|
boot: ['pinia'],
|
||||||
|
|
||||||
|
css: ['app.scss'],
|
||||||
|
|
||||||
|
extras: ['roboto-font', 'material-icons'],
|
||||||
|
|
||||||
|
build: {
|
||||||
|
target: {
|
||||||
|
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
|
||||||
|
node: 'node20',
|
||||||
|
},
|
||||||
|
vueRouterMode: 'hash',
|
||||||
|
// Base path = where the app is deployed under ERPNext
|
||||||
|
// Change this if you move the app to a different path
|
||||||
|
extendViteConf (viteConf) {
|
||||||
|
viteConf.base = '/assets/dispatch-app/'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
devServer: {
|
||||||
|
open: false,
|
||||||
|
// Listen on all interfaces so the container port is reachable from the host
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 9000,
|
||||||
|
proxy: {
|
||||||
|
// Proxy ERPNext API calls to the frontend container
|
||||||
|
// host.docker.internal resolves to the Docker host on Mac / Windows
|
||||||
|
'/api': {
|
||||||
|
target: 'http://host.docker.internal:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
cookieDomainRewrite: 'localhost',
|
||||||
|
},
|
||||||
|
'/assets': {
|
||||||
|
target: 'http://host.docker.internal:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
framework: {
|
||||||
|
config: {},
|
||||||
|
// Only load what we actually use — add more as needed
|
||||||
|
plugins: ['Notify', 'Loading', 'LocalStorage'],
|
||||||
|
},
|
||||||
|
|
||||||
|
animations: [],
|
||||||
|
|
||||||
|
pwa: {
|
||||||
|
workboxMode: 'generateSW',
|
||||||
|
injectPwaMetaTags: true,
|
||||||
|
swFilename: 'sw.js',
|
||||||
|
manifestFilename: 'manifest.json',
|
||||||
|
useCredentialForManifestTag: false,
|
||||||
|
workboxOptions: {
|
||||||
|
skipWaiting: true,
|
||||||
|
clientsClaim: true,
|
||||||
|
},
|
||||||
|
extendManifestJson (json) {
|
||||||
|
json.name = 'Dispatch'
|
||||||
|
json.short_name = 'Dispatch'
|
||||||
|
json.description = 'Dispatch & Field Service'
|
||||||
|
json.display = 'standalone'
|
||||||
|
json.orientation = 'portrait'
|
||||||
|
json.background_color = '#ffffff'
|
||||||
|
json.theme_color = '#6366f1'
|
||||||
|
json.start_url = '.'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
88
scripts/fix_client_script.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import frappe, os
|
||||||
|
os.chdir('/home/frappe/frappe-bench')
|
||||||
|
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
|
||||||
|
frappe.connect()
|
||||||
|
|
||||||
|
SCRIPT = """
|
||||||
|
frappe.ui.form.on('Dispatch Job', {
|
||||||
|
setup(frm) {
|
||||||
|
frm._addr_bound = false;
|
||||||
|
},
|
||||||
|
refresh(frm) {
|
||||||
|
if (frm._addr_bound) return;
|
||||||
|
frappe.run_serially([
|
||||||
|
() => frappe.timeout(1),
|
||||||
|
() => {
|
||||||
|
try { _bind_address_autocomplete(frm); }
|
||||||
|
catch(e) { console.warn('Address autocomplete deferred:', e.message); }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function _bind_address_autocomplete(frm) {
|
||||||
|
var ctrl = frm.fields_dict && frm.fields_dict.address;
|
||||||
|
if (!ctrl) return;
|
||||||
|
var input = ctrl.input || (ctrl.$input && ctrl.$input[0]);
|
||||||
|
if (!input) return;
|
||||||
|
if (frm._addr_bound) return;
|
||||||
|
frm._addr_bound = true;
|
||||||
|
|
||||||
|
var dropdown = document.createElement('div');
|
||||||
|
dropdown.style.cssText = 'position:absolute;z-index:1000;background:#fff;border:1px solid #d1d5db;border-radius:6px;max-height:250px;overflow-y:auto;width:100%;box-shadow:0 4px 12px rgba(0,0,0,0.15);display:none;';
|
||||||
|
input.parentElement.style.position = 'relative';
|
||||||
|
input.parentElement.appendChild(dropdown);
|
||||||
|
|
||||||
|
var timer = null;
|
||||||
|
input.addEventListener('input', function() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
var q = this.value.trim();
|
||||||
|
if (q.length < 3) { dropdown.style.display = 'none'; return; }
|
||||||
|
timer = setTimeout(function() {
|
||||||
|
frappe.call({
|
||||||
|
method: 'search_address',
|
||||||
|
args: { q: q },
|
||||||
|
callback: function(r) {
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
var results = (r && r.results) || (r && r.message && r.message.results) || [];
|
||||||
|
if (!results.length) {
|
||||||
|
dropdown.innerHTML = '<div style="padding:8px 12px;color:#6b7280;font-size:12px">Aucun resultat</div>';
|
||||||
|
} else {
|
||||||
|
results.forEach(function(addr) {
|
||||||
|
var item = document.createElement('div');
|
||||||
|
item.style.cssText = 'padding:8px 12px;cursor:pointer;font-size:13px;border-bottom:1px solid #f3f4f6;';
|
||||||
|
var html = '<strong>' + addr.address_full + '</strong>';
|
||||||
|
if (addr.ville) html += ' <span style="float:right;color:#6b7280;font-size:11px">' + addr.ville + '</span>';
|
||||||
|
item.innerHTML = html;
|
||||||
|
item.addEventListener('mousedown', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
frm.set_value('address', addr.address_full);
|
||||||
|
if (addr.latitude) frm.set_value('latitude', parseFloat(addr.latitude));
|
||||||
|
if (addr.longitude) frm.set_value('longitude', parseFloat(addr.longitude));
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
frm.dirty();
|
||||||
|
});
|
||||||
|
item.addEventListener('mouseenter', function() { this.style.background = '#f3f4f6'; });
|
||||||
|
item.addEventListener('mouseleave', function() { this.style.background = ''; });
|
||||||
|
dropdown.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dropdown.style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('blur', function() {
|
||||||
|
setTimeout(function() { dropdown.style.display = 'none'; }, 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
cs = frappe.get_doc('Client Script', 'Dispatch Job Address Autocomplete')
|
||||||
|
cs.enabled = 1
|
||||||
|
cs.script = SCRIPT
|
||||||
|
cs.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
print('Client Script fixed and re-enabled')
|
||||||
|
frappe.destroy()
|
||||||
37
scripts/fix_search.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import frappe, os
|
||||||
|
os.chdir('/home/frappe/frappe-bench')
|
||||||
|
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
|
||||||
|
frappe.connect()
|
||||||
|
|
||||||
|
ss = frappe.get_doc('Server Script', 'Address Autocomplete')
|
||||||
|
ss.script = """
|
||||||
|
query = frappe.form_dict.get("q", "")
|
||||||
|
if not query or len(query) < 3:
|
||||||
|
frappe.response["results"] = []
|
||||||
|
else:
|
||||||
|
words = query.strip().split()
|
||||||
|
conditions = []
|
||||||
|
params = {}
|
||||||
|
for i, w in enumerate(words):
|
||||||
|
key = "w" + str(i)
|
||||||
|
conditions.append("f_unaccent(address_full) ILIKE f_unaccent(%({})s)".format(key))
|
||||||
|
params[key] = "%" + w + "%"
|
||||||
|
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
|
||||||
|
results = frappe.db.sql(
|
||||||
|
"SELECT address_full, ville, code_postal, latitude, longitude "
|
||||||
|
"FROM rqa_addresses "
|
||||||
|
"WHERE " + where + " "
|
||||||
|
"ORDER BY "
|
||||||
|
"CASE WHEN code_postal LIKE 'J0L%%' THEN 0 WHEN code_postal LIKE 'J0S%%' THEN 1 ELSE 2 END, "
|
||||||
|
"length(address_full) "
|
||||||
|
"LIMIT 10",
|
||||||
|
params, as_dict=True)
|
||||||
|
|
||||||
|
frappe.response["results"] = results
|
||||||
|
"""
|
||||||
|
ss.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
print('Fixed: AND-based word search')
|
||||||
|
frappe.destroy()
|
||||||
42
scripts/fix_search2.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import frappe, os
|
||||||
|
os.chdir('/home/frappe/frappe-bench')
|
||||||
|
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
|
||||||
|
frappe.connect()
|
||||||
|
|
||||||
|
ss = frappe.get_doc('Server Script', 'Address Autocomplete')
|
||||||
|
ss.script = """
|
||||||
|
query = frappe.form_dict.get("q", "")
|
||||||
|
if not query or len(query) < 3:
|
||||||
|
frappe.response["results"] = []
|
||||||
|
else:
|
||||||
|
words = query.strip().split()
|
||||||
|
conditions = []
|
||||||
|
params = {}
|
||||||
|
for i, w in enumerate(words):
|
||||||
|
key = "w" + str(i)
|
||||||
|
params[key] = "%" + w + "%"
|
||||||
|
conditions.append(
|
||||||
|
"(f_unaccent(address_full) ILIKE f_unaccent(%({k})s) "
|
||||||
|
"OR f_unaccent(rue) ILIKE f_unaccent(%({k})s) "
|
||||||
|
"OR f_unaccent(ville) ILIKE f_unaccent(%({k})s) "
|
||||||
|
"OR numero ILIKE %({k})s)".format(k=key)
|
||||||
|
)
|
||||||
|
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
|
||||||
|
results = frappe.db.sql(
|
||||||
|
"SELECT address_full, ville, code_postal, latitude, longitude "
|
||||||
|
"FROM rqa_addresses "
|
||||||
|
"WHERE " + where + " "
|
||||||
|
"ORDER BY "
|
||||||
|
"CASE WHEN code_postal LIKE 'J0L%%' THEN 0 WHEN code_postal LIKE 'J0S%%' THEN 1 ELSE 2 END, "
|
||||||
|
"length(address_full) "
|
||||||
|
"LIMIT 10",
|
||||||
|
params, as_dict=True)
|
||||||
|
|
||||||
|
frappe.response["results"] = results
|
||||||
|
"""
|
||||||
|
ss.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
print('Fixed: search across address_full, rue, ville, numero')
|
||||||
|
frappe.destroy()
|
||||||
43
scripts/fix_search3.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import frappe, os
|
||||||
|
os.chdir('/home/frappe/frappe-bench')
|
||||||
|
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
|
||||||
|
frappe.connect()
|
||||||
|
|
||||||
|
ss = frappe.get_doc('Server Script', 'Address Autocomplete')
|
||||||
|
ss.script = """
|
||||||
|
query = frappe.form_dict.get("q", "")
|
||||||
|
if not query or len(query) < 3:
|
||||||
|
frappe.response["results"] = []
|
||||||
|
else:
|
||||||
|
words = query.strip().split()
|
||||||
|
conditions = []
|
||||||
|
params = {}
|
||||||
|
idx = 0
|
||||||
|
for w in words:
|
||||||
|
k = "w" + str(idx)
|
||||||
|
params[k] = "%" + w + "%"
|
||||||
|
conditions.append(
|
||||||
|
"(f_unaccent(address_full) ILIKE f_unaccent(%%(%s)s) "
|
||||||
|
"OR f_unaccent(rue) ILIKE f_unaccent(%%(%s)s) "
|
||||||
|
"OR f_unaccent(ville) ILIKE f_unaccent(%%(%s)s) "
|
||||||
|
"OR numero ILIKE %%(%s)s)" % (k, k, k, k)
|
||||||
|
)
|
||||||
|
idx = idx + 1
|
||||||
|
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
|
||||||
|
sql = ("SELECT address_full, ville, code_postal, latitude, longitude "
|
||||||
|
"FROM rqa_addresses "
|
||||||
|
"WHERE " + where + " "
|
||||||
|
"ORDER BY "
|
||||||
|
"CASE WHEN code_postal LIKE 'J0L%%%%' THEN 0 WHEN code_postal LIKE 'J0S%%%%' THEN 1 ELSE 2 END, "
|
||||||
|
"length(address_full) "
|
||||||
|
"LIMIT 10")
|
||||||
|
|
||||||
|
results = frappe.db.sql(sql, params, as_dict=True)
|
||||||
|
frappe.response["results"] = results
|
||||||
|
"""
|
||||||
|
ss.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
print('Fixed: no .format(), using % operator')
|
||||||
|
frappe.destroy()
|
||||||
40
scripts/fix_search_abbrev.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import frappe, os
|
||||||
|
os.chdir('/home/frappe/frappe-bench')
|
||||||
|
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
|
||||||
|
frappe.connect()
|
||||||
|
|
||||||
|
ss = frappe.get_doc('Server Script', 'Address Autocomplete')
|
||||||
|
ss.script = """
|
||||||
|
query = frappe.form_dict.get("q", "")
|
||||||
|
if not query or len(query) < 3:
|
||||||
|
frappe.response["results"] = []
|
||||||
|
else:
|
||||||
|
q = query.strip().lower()
|
||||||
|
q = q.replace("ste-", "sainte-").replace("ste ", "sainte-")
|
||||||
|
q = q.replace("st-", "saint-").replace("st ", "saint-")
|
||||||
|
q = q.replace("boul ", "boulevard ").replace("boul.", "boulevard")
|
||||||
|
q = q.replace("ave ", "avenue ").replace("ave.", "avenue")
|
||||||
|
words = q.split()
|
||||||
|
conditions = []
|
||||||
|
for w in words:
|
||||||
|
escaped = frappe.db.escape("%" + w + "%")
|
||||||
|
conditions.append("search_text LIKE " + escaped)
|
||||||
|
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
|
||||||
|
sql = ("SELECT address_full, ville, code_postal, latitude, longitude "
|
||||||
|
"FROM rqa_addresses "
|
||||||
|
"WHERE " + where + " "
|
||||||
|
"ORDER BY "
|
||||||
|
"CASE WHEN code_postal LIKE 'J0L%' THEN 0 "
|
||||||
|
"WHEN code_postal LIKE 'J0S%' THEN 1 ELSE 2 END, "
|
||||||
|
"length(address_full) "
|
||||||
|
"LIMIT 10")
|
||||||
|
|
||||||
|
results = frappe.db.sql(sql, as_dict=True)
|
||||||
|
frappe.response["results"] = results
|
||||||
|
"""
|
||||||
|
ss.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
print('Updated: query normalizes ste->sainte, st->saint, boul->boulevard')
|
||||||
|
frappe.destroy()
|
||||||
35
scripts/fix_search_fast.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import frappe, os
|
||||||
|
os.chdir('/home/frappe/frappe-bench')
|
||||||
|
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
|
||||||
|
frappe.connect()
|
||||||
|
|
||||||
|
ss = frappe.get_doc('Server Script', 'Address Autocomplete')
|
||||||
|
ss.script = """
|
||||||
|
query = frappe.form_dict.get("q", "")
|
||||||
|
if not query or len(query) < 3:
|
||||||
|
frappe.response["results"] = []
|
||||||
|
else:
|
||||||
|
words = query.strip().lower().split()
|
||||||
|
conditions = []
|
||||||
|
for w in words:
|
||||||
|
escaped = frappe.db.escape("%" + w + "%")
|
||||||
|
conditions.append("search_text LIKE " + escaped)
|
||||||
|
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
|
||||||
|
sql = ("SELECT address_full, ville, code_postal, latitude, longitude "
|
||||||
|
"FROM rqa_addresses "
|
||||||
|
"WHERE " + where + " "
|
||||||
|
"ORDER BY "
|
||||||
|
"CASE WHEN code_postal LIKE 'J0L%' THEN 0 "
|
||||||
|
"WHEN code_postal LIKE 'J0S%' THEN 1 ELSE 2 END, "
|
||||||
|
"length(address_full) "
|
||||||
|
"LIMIT 10")
|
||||||
|
|
||||||
|
results = frappe.db.sql(sql, as_dict=True)
|
||||||
|
frappe.response["results"] = results
|
||||||
|
"""
|
||||||
|
ss.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
print('Updated: using frappe.db.escape, no params')
|
||||||
|
frappe.destroy()
|
||||||
52
scripts/import_rqa_addresses.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Import RQA (Réseau Québécois d'Adresses) CSV into PostgreSQL civic_addresses table.
|
||||||
|
Handles the ~2.8GB CSV file with streaming/batched inserts.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 import_rqa_addresses.py /tmp/RQA_CSV/RQA.csv
|
||||||
|
|
||||||
|
Or from Docker:
|
||||||
|
docker cp import_rqa_addresses.py frappe_docker-db-1:/tmp/
|
||||||
|
docker exec frappe_docker-db-1 python3 /tmp/import_rqa_addresses.py /tmp/RQA.csv
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import io
|
||||||
|
|
||||||
|
DB = "_171cf82a99ac0463"
|
||||||
|
BATCH_SIZE = 10000
|
||||||
|
|
||||||
|
def get_csv_path():
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
return sys.argv[1]
|
||||||
|
# Auto-detect from unzipped location
|
||||||
|
for p in ['/tmp/RQA_CSV/RQA.csv', '/tmp/RQA.csv', '/tmp/RQA_CSV.csv']:
|
||||||
|
if os.path.exists(p):
|
||||||
|
return p
|
||||||
|
print("Usage: python3 import_rqa_addresses.py <path_to_csv>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
csv_path = get_csv_path()
|
||||||
|
print(f"Reading: {csv_path}")
|
||||||
|
|
||||||
|
# First peek at the header to understand columns
|
||||||
|
with open(csv_path, 'r', encoding='utf-8-sig', errors='replace') as f:
|
||||||
|
reader = csv.reader(f, delimiter=',')
|
||||||
|
header = next(reader)
|
||||||
|
print(f"Columns ({len(header)}): {header[:15]}...")
|
||||||
|
|
||||||
|
# Show first row
|
||||||
|
row = next(reader)
|
||||||
|
print(f"Sample row: {row[:15]}...")
|
||||||
|
|
||||||
|
print(f"\nHeader fields:")
|
||||||
|
for i, h in enumerate(header):
|
||||||
|
print(f" {i}: {h}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
30
src-pwa/custom-service-worker.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/* eslint-env serviceworker */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file (which will be your service worker)
|
||||||
|
* is picked up by the build system ONLY if
|
||||||
|
* quasar.config.js > pwa > workboxMode is set to "injectManifest"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { clientsClaim } from 'workbox-core'
|
||||||
|
import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL } from 'workbox-precaching'
|
||||||
|
import { registerRoute, NavigationRoute } from 'workbox-routing'
|
||||||
|
|
||||||
|
self.skipWaiting()
|
||||||
|
clientsClaim()
|
||||||
|
|
||||||
|
// Use with precache injection
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST)
|
||||||
|
|
||||||
|
cleanupOutdatedCaches()
|
||||||
|
|
||||||
|
// Non-SSR fallback to index.html
|
||||||
|
// Production SSR fallback to offline.html (except for dev)
|
||||||
|
if (process.env.MODE !== 'ssr' || process.env.PROD) {
|
||||||
|
registerRoute(
|
||||||
|
new NavigationRoute(
|
||||||
|
createHandlerBoundToURL(process.env.PWA_FALLBACK_HTML),
|
||||||
|
{ denylist: [/sw\.js$/, /workbox-(.)*\.js$/] }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src-pwa/manifest.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#027be3",
|
||||||
|
"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
src-pwa/pwa-flag.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src-pwa/register-service-worker.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { register } from 'register-service-worker'
|
||||||
|
|
||||||
|
// The ready(), registered(), cached(), updatefound() and updated()
|
||||||
|
// events passes a ServiceWorkerRegistration instance in their arguments.
|
||||||
|
// ServiceWorkerRegistration: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration
|
||||||
|
|
||||||
|
register(process.env.SERVICE_WORKER_FILE, {
|
||||||
|
// The registrationOptions object will be passed as the second argument
|
||||||
|
// to ServiceWorkerContainer.register()
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register#Parameter
|
||||||
|
|
||||||
|
// registrationOptions: { scope: './' },
|
||||||
|
|
||||||
|
ready (/* registration */) {
|
||||||
|
// console.log('Service worker is active.')
|
||||||
|
},
|
||||||
|
|
||||||
|
registered (/* registration */) {
|
||||||
|
// console.log('Service worker has been registered.')
|
||||||
|
},
|
||||||
|
|
||||||
|
cached (/* registration */) {
|
||||||
|
// console.log('Content has been cached for offline use.')
|
||||||
|
},
|
||||||
|
|
||||||
|
updatefound (/* registration */) {
|
||||||
|
// console.log('New content is downloading.')
|
||||||
|
},
|
||||||
|
|
||||||
|
updated (/* registration */) {
|
||||||
|
// console.log('New content is available; please refresh.')
|
||||||
|
},
|
||||||
|
|
||||||
|
offline () {
|
||||||
|
// console.log('No internet connection found. App is running in offline mode.')
|
||||||
|
},
|
||||||
|
|
||||||
|
error (/* err */) {
|
||||||
|
// console.error('Error during service worker registration:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
3
src/App.vue
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
60
src/api/auth.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
// ── ERPNext session-cookie auth ──────────────────────────────────────────────
|
||||||
|
// To swap to JWT or another auth method:
|
||||||
|
// 1. Replace login() / logout() / getLoggedUser() implementations here.
|
||||||
|
// 2. The stores/auth.js calls these — no changes needed there.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
|
||||||
|
let _csrf = null
|
||||||
|
|
||||||
|
export async function getCSRF () {
|
||||||
|
if (_csrf) return _csrf
|
||||||
|
try {
|
||||||
|
const res = await fetch(BASE_URL + '/', { credentials: 'include' })
|
||||||
|
const html = await res.text()
|
||||||
|
const m = html.match(/csrf_token\s*[:=]\s*['"]([^'"]+)['"]/)
|
||||||
|
if (m) _csrf = m[1]
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return _csrf
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateCSRF () { _csrf = null }
|
||||||
|
|
||||||
|
export async function login (usr, pwd) {
|
||||||
|
const res = await fetch(BASE_URL + '/api/method/login', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ usr, pwd }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok || data.exc_type === 'AuthenticationError') {
|
||||||
|
throw new Error(data.message || 'Identifiants incorrects')
|
||||||
|
}
|
||||||
|
invalidateCSRF()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout () {
|
||||||
|
try {
|
||||||
|
await fetch(BASE_URL + '/api/method/frappe.auth.logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
invalidateCSRF()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns email string if logged in, null if guest/error
|
||||||
|
export async function getLoggedUser () {
|
||||||
|
try {
|
||||||
|
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
const user = data.message
|
||||||
|
return user && user !== 'Guest' ? user : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/api/booking.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
// ── Booking API — crée une demande client dans ERPNext ────────────────────────
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
import { getCSRF } from './auth'
|
||||||
|
|
||||||
|
const SLOT_LABELS = {
|
||||||
|
matin: 'Matin (8h00 – 12h00)',
|
||||||
|
aprem: 'Après-midi (12h00 – 17h00)',
|
||||||
|
soir: 'Soirée (17h00 – 20h00)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDescription (data) {
|
||||||
|
const dateLabel = { today: "Aujourd'hui", tomorrow: 'Demain' }[data.date] ?? data.date
|
||||||
|
return [
|
||||||
|
`SERVICE: ${data.service.label}`,
|
||||||
|
data.serviceNote ? `Détail: ${data.serviceNote}` : null,
|
||||||
|
`ADRESSE: ${data.address}`,
|
||||||
|
`DATE: ${dateLabel} — ${SLOT_LABELS[data.slot] ?? data.slot}`,
|
||||||
|
data.urgent ? '*** URGENT — intervention dans les 2h ***' : null,
|
||||||
|
'---',
|
||||||
|
`Client: ${data.contact.name}`,
|
||||||
|
`Téléphone: ${data.contact.phone}`,
|
||||||
|
data.contact.email ? `Courriel: ${data.contact.email}` : null,
|
||||||
|
data.contact.note ? `Note: ${data.contact.note}` : null,
|
||||||
|
].filter(Boolean).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function localRef () {
|
||||||
|
return 'DSP-' + Date.now().toString(36).toUpperCase().slice(-6)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBooking (data) {
|
||||||
|
const csrf = await getCSRF().catch(() => '')
|
||||||
|
|
||||||
|
// Try ERPNext Lead (CRM module — standard in ERPNext)
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${BASE_URL}/api/resource/Lead`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
|
||||||
|
body: JSON.stringify({
|
||||||
|
lead_name: data.contact.name,
|
||||||
|
mobile_no: data.contact.phone,
|
||||||
|
email_id: data.contact.email || '',
|
||||||
|
source: 'Dispatch Booking',
|
||||||
|
notes: buildDescription(data),
|
||||||
|
status: 'Open',
|
||||||
|
lead_owner: '',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const body = await r.json().catch(() => ({}))
|
||||||
|
if (r.ok && body.data?.name) return body.data.name
|
||||||
|
} catch (_) { /* fall through */ }
|
||||||
|
|
||||||
|
// Fallback: localStorage + generated ref
|
||||||
|
const ref = localRef()
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_bookings') || '[]')
|
||||||
|
list.push({ ref, ...data, created: new Date().toISOString() })
|
||||||
|
localStorage.setItem('dispatch_bookings', JSON.stringify(list))
|
||||||
|
return ref
|
||||||
|
}
|
||||||
74
src/api/contractor.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// ── Contractor API — inscrit un sous-traitant dans ERPNext ────────────────────
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
import { getCSRF } from './auth'
|
||||||
|
|
||||||
|
function buildNotes (data) {
|
||||||
|
const services = data.services
|
||||||
|
.map(s => ` • ${s.label}: ${s.rate}$ / ${s.rateType === 'hourly' ? 'heure' : 'forfait'}`)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const days = data.availability.days
|
||||||
|
.map(d => ({ mon: 'Lun', tue: 'Mar', wed: 'Mer', thu: 'Jeu', fri: 'Ven', sat: 'Sam', sun: 'Dim' }[d]))
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
return [
|
||||||
|
`SERVICES OFFERTS:`,
|
||||||
|
services,
|
||||||
|
``,
|
||||||
|
`ZONE: ${data.availability.city} — rayon ${data.availability.radius}`,
|
||||||
|
`DISPONIBILITÉ: ${days}`,
|
||||||
|
data.availability.urgent ? 'Disponible pour urgences' : '',
|
||||||
|
``,
|
||||||
|
data.profile.license ? `Licence/RBQ: ${data.profile.license}` : '',
|
||||||
|
data.profile.company ? `Entreprise: ${data.profile.company}` : '',
|
||||||
|
].filter(s => s !== undefined).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function localRef () {
|
||||||
|
return 'TECH-' + Date.now().toString(36).toUpperCase().slice(-6)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerContractor (data) {
|
||||||
|
const csrf = await getCSRF().catch(() => '')
|
||||||
|
|
||||||
|
// Try ERPNext Supplier (standard ERPNext)
|
||||||
|
try {
|
||||||
|
const supplierName = data.profile.company
|
||||||
|
|| `${data.profile.firstname} ${data.profile.lastname}`
|
||||||
|
|
||||||
|
const r = await fetch(`${BASE_URL}/api/resource/Supplier`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
|
||||||
|
body: JSON.stringify({
|
||||||
|
supplier_name: supplierName,
|
||||||
|
supplier_type: 'Individual',
|
||||||
|
supplier_group: 'Services',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const body = await r.json().catch(() => ({}))
|
||||||
|
if (r.ok && body.data?.name) {
|
||||||
|
// Try to create a Contact linked to the supplier
|
||||||
|
await fetch(`${BASE_URL}/api/resource/Contact`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
|
||||||
|
body: JSON.stringify({
|
||||||
|
first_name: data.profile.firstname,
|
||||||
|
last_name: data.profile.lastname,
|
||||||
|
email_ids: [{ email_id: data.profile.email, is_primary: 1 }],
|
||||||
|
phone_nos: [{ phone: data.profile.phone, is_primary_phone: 1 }],
|
||||||
|
links: [{ link_doctype: 'Supplier', link_name: body.data.name }],
|
||||||
|
}),
|
||||||
|
}).catch(() => {})
|
||||||
|
return body.data.name
|
||||||
|
}
|
||||||
|
} catch (_) { /* fall through */ }
|
||||||
|
|
||||||
|
// Fallback: localStorage + generated ref
|
||||||
|
const ref = localRef()
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_contractors') || '[]')
|
||||||
|
list.push({ ref, ...data, created: new Date().toISOString(), status: 'pending_review' })
|
||||||
|
localStorage.setItem('dispatch_contractors', JSON.stringify(list))
|
||||||
|
return ref
|
||||||
|
}
|
||||||
102
src/api/dispatch.js
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
// ── ERPNext Dispatch resource calls ─────────────────────────────────────────
|
||||||
|
// All ERPNext fetch() calls live here.
|
||||||
|
// Swap BASE_URL in config/erpnext.js to change the target server.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
import { getCSRF } from './auth'
|
||||||
|
|
||||||
|
async function apiGet (path) {
|
||||||
|
const res = await fetch(BASE_URL + path, { credentials: 'include' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.exc) throw new Error(data.exc)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiPut (doctype, name, body) {
|
||||||
|
const token = await getCSRF()
|
||||||
|
const res = await fetch(
|
||||||
|
`${BASE_URL}/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Frappe-CSRF-Token': token,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.exc) throw new Error(data.exc)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTechnicians () {
|
||||||
|
const list = await apiGet('/api/resource/Dispatch%20Technician?fields=["name"]&limit=100')
|
||||||
|
const names = (list.data || []).map(t => t.name)
|
||||||
|
if (!names.length) return []
|
||||||
|
const docs = await Promise.all(
|
||||||
|
names.map(n => apiGet(`/api/resource/Dispatch%20Technician/${encodeURIComponent(n)}`).then(d => d.data))
|
||||||
|
)
|
||||||
|
return docs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all jobs with child tables (assistants)
|
||||||
|
export async function fetchJobs (filters = null) {
|
||||||
|
// Step 1: get job names from list endpoint
|
||||||
|
let url = '/api/resource/Dispatch%20Job?fields=["name"]&limit=200'
|
||||||
|
if (filters) url += '&filters=' + encodeURIComponent(JSON.stringify(filters))
|
||||||
|
const list = await apiGet(url)
|
||||||
|
const names = (list.data || []).map(j => j.name)
|
||||||
|
if (!names.length) return []
|
||||||
|
// Step 2: fetch each doc individually (includes child tables)
|
||||||
|
const docs = await Promise.all(
|
||||||
|
names.map(n => apiGet(`/api/resource/Dispatch%20Job/${encodeURIComponent(n)}`).then(d => d.data))
|
||||||
|
)
|
||||||
|
return docs
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateJob (name, payload) {
|
||||||
|
return apiPut('Dispatch Job', name, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createJob (payload) {
|
||||||
|
const token = await getCSRF()
|
||||||
|
const res = await fetch(
|
||||||
|
`${BASE_URL}/api/resource/Dispatch%20Job`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': token },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.exc) throw new Error(data.exc)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTech (name, payload) {
|
||||||
|
return apiPut('Dispatch Technician', name, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTags () {
|
||||||
|
const data = await apiGet('/api/resource/Dispatch%20Tag?fields=["name","label","color","category"]&limit=200')
|
||||||
|
return data.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTag (label, category = 'Custom', color = '#6b7280') {
|
||||||
|
const token = await getCSRF()
|
||||||
|
const res = await fetch(
|
||||||
|
`${BASE_URL}/api/resource/Dispatch%20Tag`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': token },
|
||||||
|
body: JSON.stringify({ label, category, color }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.exc) throw new Error(data.exc)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
273
src/api/service-request.js
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
/**
|
||||||
|
* API — ServiceRequest, ServiceBid, EquipmentInstall
|
||||||
|
*
|
||||||
|
* Tries Frappe custom doctypes first, falls back to Lead + localStorage
|
||||||
|
* so the app works before the backend doctypes are created.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = ''
|
||||||
|
|
||||||
|
async function getCSRF () {
|
||||||
|
const m = document.cookie.match(/csrftoken=([^;]+)/)
|
||||||
|
if (m) return m[1]
|
||||||
|
const r = await fetch('/api/method/frappe.auth.get_csrf_token', { credentials: 'include' })
|
||||||
|
const d = await r.json().catch(() => ({}))
|
||||||
|
return d.csrf_token || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function frappePOST (doctype, data) {
|
||||||
|
const csrf = await getCSRF().catch(() => '')
|
||||||
|
const r = await fetch(`${BASE}/api/resource/${encodeURIComponent(doctype)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||||
|
const body = await r.json()
|
||||||
|
return body.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function frappePUT (doctype, name, data) {
|
||||||
|
const csrf = await getCSRF().catch(() => '')
|
||||||
|
const r = await fetch(`${BASE}/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||||
|
const body = await r.json()
|
||||||
|
return body.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function frappeGET (doctype, filters = {}, fields = ['name']) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
fields: JSON.stringify(fields),
|
||||||
|
filters: JSON.stringify(filters),
|
||||||
|
limit: 50,
|
||||||
|
})
|
||||||
|
const r = await fetch(`${BASE}/api/resource/${encodeURIComponent(doctype)}?${params}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||||
|
const body = await r.json()
|
||||||
|
return body.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// ServiceRequest
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createServiceRequest (data) {
|
||||||
|
/**
|
||||||
|
* data = {
|
||||||
|
* service_type: 'internet' | 'tv' | 'telephone' | 'multi',
|
||||||
|
* problem_type: string,
|
||||||
|
* description: string,
|
||||||
|
* address: string,
|
||||||
|
* coordinates: [lng, lat],
|
||||||
|
* preferred_dates: [{ date, time_slot, time_slots[] }, ...], // up to 3
|
||||||
|
* contact: { name, phone, email },
|
||||||
|
* urgency: 'normal' | 'urgent',
|
||||||
|
* budget: { id, label, min, max } | null,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
const ref = 'SR-' + Date.now().toString(36).toUpperCase().slice(-6)
|
||||||
|
|
||||||
|
// Try Frappe ServiceRequest doctype
|
||||||
|
try {
|
||||||
|
const doc = await frappePOST('Service Request', {
|
||||||
|
customer_name: data.contact.name,
|
||||||
|
phone: data.contact.phone,
|
||||||
|
email: data.contact.email,
|
||||||
|
service_type: data.service_type,
|
||||||
|
problem_type: data.problem_type,
|
||||||
|
description: data.description,
|
||||||
|
address: data.address,
|
||||||
|
lng: data.coordinates?.[0] || 0,
|
||||||
|
lat: data.coordinates?.[1] || 0,
|
||||||
|
preferred_date_1: data.preferred_dates[0]?.date || '',
|
||||||
|
time_slot_1: data.preferred_dates[0]?.time_slot || '',
|
||||||
|
preferred_date_2: data.preferred_dates[1]?.date || '',
|
||||||
|
time_slot_2: data.preferred_dates[1]?.time_slot || '',
|
||||||
|
preferred_date_3: data.preferred_dates[2]?.date || '',
|
||||||
|
time_slot_3: data.preferred_dates[2]?.time_slot || '',
|
||||||
|
urgency: data.urgency || 'normal',
|
||||||
|
budget_label: data.budget?.label || '',
|
||||||
|
budget_min: data.budget?.min || 0,
|
||||||
|
budget_max: data.budget?.max || 0,
|
||||||
|
status: 'New',
|
||||||
|
})
|
||||||
|
return { ref: doc.name, source: 'frappe' }
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Fallback: create as Frappe Lead + HD Ticket
|
||||||
|
try {
|
||||||
|
const notes = buildNotes(data)
|
||||||
|
const doc = await frappePOST('Lead', {
|
||||||
|
lead_name: data.contact.name,
|
||||||
|
mobile_no: data.contact.phone,
|
||||||
|
email_id: data.contact.email || '',
|
||||||
|
source: 'Dispatch Booking',
|
||||||
|
lead_owner: '',
|
||||||
|
status: 'Open',
|
||||||
|
notes,
|
||||||
|
})
|
||||||
|
return { ref: doc.name, source: 'lead' }
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Final fallback: localStorage
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_service_requests') || '[]')
|
||||||
|
list.push({ ref, ...data, lng: data.coordinates?.[0] || 0, lat: data.coordinates?.[1] || 0, budget_label: data.budget?.label || '', created: new Date().toISOString(), status: 'new' })
|
||||||
|
localStorage.setItem('dispatch_service_requests', JSON.stringify(list))
|
||||||
|
return { ref, source: 'local' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNotes (data) {
|
||||||
|
const dates = data.preferred_dates
|
||||||
|
.filter(d => d.date)
|
||||||
|
.map((d, i) => ` Date ${i + 1}: ${d.date} — ${d.time_slot}`)
|
||||||
|
.join('\n')
|
||||||
|
return [
|
||||||
|
`SERVICE: ${data.service_type?.toUpperCase()}`,
|
||||||
|
`PROBLÈME: ${data.problem_type}`,
|
||||||
|
`DESCRIPTION: ${data.description}`,
|
||||||
|
`ADRESSE: ${data.address}`,
|
||||||
|
`URGENCE: ${data.urgency}`,
|
||||||
|
'',
|
||||||
|
'DATES PRÉFÉRÉES:',
|
||||||
|
dates,
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchServiceRequests (status = null) {
|
||||||
|
try {
|
||||||
|
const filters = status ? { status } : {}
|
||||||
|
return await frappeGET('Service Request', filters, [
|
||||||
|
'name', 'customer_name', 'phone', 'service_type', 'problem_type',
|
||||||
|
'description', 'address', 'status', 'urgency',
|
||||||
|
'preferred_date_1', 'time_slot_1',
|
||||||
|
'preferred_date_2', 'time_slot_2',
|
||||||
|
'preferred_date_3', 'time_slot_3',
|
||||||
|
'confirmed_date', 'creation',
|
||||||
|
'budget_label', 'budget_min', 'budget_max',
|
||||||
|
])
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.parse(localStorage.getItem('dispatch_service_requests') || '[]')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateServiceRequestStatus (name, status, confirmedDate = null) {
|
||||||
|
try {
|
||||||
|
const data = {}
|
||||||
|
if (status) data.status = status
|
||||||
|
if (confirmedDate) data.confirmed_date = confirmedDate
|
||||||
|
if (Object.keys(data).length === 0) return
|
||||||
|
return await frappePUT('Service Request', name, data)
|
||||||
|
} catch (_) {
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_service_requests') || '[]')
|
||||||
|
const item = list.find(r => r.ref === name || r.name === name)
|
||||||
|
if (item) {
|
||||||
|
if (status) item.status = status
|
||||||
|
if (confirmedDate) item.confirmed_date = confirmedDate
|
||||||
|
}
|
||||||
|
localStorage.setItem('dispatch_service_requests', JSON.stringify(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// ServiceBid (tech bids on a date)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createServiceBid (data) {
|
||||||
|
/**
|
||||||
|
* data = { request, technician, proposed_date, time_slot, estimated_duration, notes, price }
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
return await frappePOST('Service Bid', { ...data, status: 'Pending' })
|
||||||
|
} catch (_) {
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_service_bids') || '[]')
|
||||||
|
const bid = { ref: 'BID-' + Date.now().toString(36).toUpperCase().slice(-6), ...data, status: 'pending', created: new Date().toISOString() }
|
||||||
|
list.push(bid)
|
||||||
|
localStorage.setItem('dispatch_service_bids', JSON.stringify(list))
|
||||||
|
return bid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBidsForRequest (requestName) {
|
||||||
|
try {
|
||||||
|
return await frappeGET('Service Bid', { request: requestName }, [
|
||||||
|
'name', 'technician', 'proposed_date', 'time_slot',
|
||||||
|
'estimated_duration', 'notes', 'status', 'creation',
|
||||||
|
])
|
||||||
|
} catch (_) {
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_service_bids') || '[]')
|
||||||
|
return list.filter(b => b.request === requestName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBidsForTech (techName) {
|
||||||
|
try {
|
||||||
|
return await frappeGET('Service Bid', { technician: techName }, [
|
||||||
|
'name', 'request', 'proposed_date', 'time_slot', 'status', 'creation',
|
||||||
|
])
|
||||||
|
} catch (_) {
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_service_bids') || '[]')
|
||||||
|
return list.filter(b => b.technician === techName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOpenRequests () {
|
||||||
|
try {
|
||||||
|
return await frappeGET('Service Request', { status: ['in', ['New', 'Bidding']] }, [
|
||||||
|
'name', 'customer_name', 'service_type', 'problem_type', 'description',
|
||||||
|
'address', 'lng', 'lat', 'urgency', 'preferred_date_1', 'time_slot_1',
|
||||||
|
'preferred_date_2', 'time_slot_2', 'preferred_date_3', 'time_slot_3',
|
||||||
|
'creation', 'budget_label', 'budget_min', 'budget_max',
|
||||||
|
])
|
||||||
|
} catch (_) {
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_service_requests') || '[]')
|
||||||
|
return list.filter(r => ['new', 'bidding'].includes(r.status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptBid (bidName, requestName, confirmedDate) {
|
||||||
|
try {
|
||||||
|
await frappePUT('Service Bid', bidName, { status: 'Accepted' })
|
||||||
|
await frappePUT('Service Request', requestName, { status: 'Confirmed', confirmed_date: confirmedDate })
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// EquipmentInstall (barcode scan on site)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createEquipmentInstall (data) {
|
||||||
|
/**
|
||||||
|
* data = { request, barcode, equipment_type, brand, model, notes, photo_base64 }
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
return await frappePOST('Equipment Install', {
|
||||||
|
...data,
|
||||||
|
installation_date: new Date().toISOString().split('T')[0],
|
||||||
|
})
|
||||||
|
} catch (_) {
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_equipment') || '[]')
|
||||||
|
const item = { ref: 'EQ-' + Date.now().toString(36).toUpperCase().slice(-6), ...data, created: new Date().toISOString() }
|
||||||
|
list.push(item)
|
||||||
|
localStorage.setItem('dispatch_equipment', JSON.stringify(list))
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEquipmentForRequest (requestName) {
|
||||||
|
try {
|
||||||
|
return await frappeGET('Equipment Install', { request: requestName }, [
|
||||||
|
'name', 'barcode', 'equipment_type', 'brand', 'model', 'notes', 'installation_date',
|
||||||
|
])
|
||||||
|
} catch (_) {
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_equipment') || '[]')
|
||||||
|
return list.filter(e => e.request === requestName)
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/api/settings.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// ── Dispatch Settings — lecture/écriture du DocType Single ERPNext ───────────
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
import { getCSRF } from './auth'
|
||||||
|
|
||||||
|
const DOCTYPE = 'Dispatch Settings'
|
||||||
|
const NAME = 'Dispatch Settings'
|
||||||
|
|
||||||
|
function isDocTypeError (body) {
|
||||||
|
const s = JSON.stringify(body)
|
||||||
|
return s.includes('dispatch_settings') || s.includes('DoesNotExist') || s.includes('No module named')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSettings () {
|
||||||
|
const r = await fetch(`${BASE_URL}/api/resource/${DOCTYPE}/${NAME}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!r.ok) {
|
||||||
|
const body = await r.json().catch(() => ({}))
|
||||||
|
if (r.status === 404 || isDocTypeError(body)) throw new Error('DOCTYPE_NOT_FOUND')
|
||||||
|
throw new Error(`Erreur HTTP ${r.status}`)
|
||||||
|
}
|
||||||
|
const body = await r.json()
|
||||||
|
// Frappe peut retourner 200 avec une exception dans le corps
|
||||||
|
if (body.exc_type || body.exception) {
|
||||||
|
if (isDocTypeError(body)) throw new Error('DOCTYPE_NOT_FOUND')
|
||||||
|
throw new Error(body.exc_type || 'Erreur Frappe')
|
||||||
|
}
|
||||||
|
return body.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSettings (payload) {
|
||||||
|
const csrf = await getCSRF()
|
||||||
|
const r = await fetch(`${BASE_URL}/api/resource/${DOCTYPE}/${NAME}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf || '' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
const body = await r.json().catch(() => ({}))
|
||||||
|
if (!r.ok || body.exc_type || body.exception) {
|
||||||
|
if (isDocTypeError(body)) throw new Error('DOCTYPE_NOT_FOUND')
|
||||||
|
throw new Error(body._error_message || body.exc_type || `Erreur HTTP ${r.status}`)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Création du DocType via API (bouton Initialiser dans l'admin) ─────────────
|
||||||
|
const DOCTYPE_FIELDS = [
|
||||||
|
{ fieldname: 'erp_section', fieldtype: 'Section Break', label: 'ERPNext / Frappe' },
|
||||||
|
{ fieldname: 'erp_url', fieldtype: 'Data', label: 'URL du serveur', default: 'http://localhost:8080' },
|
||||||
|
{ fieldname: 'erp_api_key', fieldtype: 'Data', label: 'API Key' },
|
||||||
|
{ fieldname: 'erp_api_secret', fieldtype: 'Password', label: 'API Secret' },
|
||||||
|
{ fieldname: 'mapbox_section', fieldtype: 'Section Break', label: 'Mapbox' },
|
||||||
|
{ fieldname: 'mapbox_token', fieldtype: 'Data', label: 'Token public (pk_)' },
|
||||||
|
{ fieldname: 'twilio_section', fieldtype: 'Section Break', label: 'Twilio — SMS' },
|
||||||
|
{ fieldname: 'twilio_account_sid', fieldtype: 'Data', label: 'Account SID' },
|
||||||
|
{ fieldname: 'twilio_auth_token', fieldtype: 'Password', label: 'Auth Token' },
|
||||||
|
{ fieldname: 'twilio_from_number', fieldtype: 'Data', label: 'Numéro expéditeur' },
|
||||||
|
{ fieldname: 'stripe_section', fieldtype: 'Section Break', label: 'Stripe — Paiements' },
|
||||||
|
{ fieldname: 'stripe_mode', fieldtype: 'Select', label: 'Mode', options: 'test\nlive', default: 'test' },
|
||||||
|
{ fieldname: 'stripe_publishable_key', fieldtype: 'Data', label: 'Clé publique (pk_)' },
|
||||||
|
{ fieldname: 'stripe_secret_key', fieldtype: 'Password', label: 'Clé secrète (sk_)' },
|
||||||
|
{ fieldname: 'stripe_webhook_secret',fieldtype: 'Password', label: 'Webhook Secret (whsec_)' },
|
||||||
|
{ fieldname: 'n8n_section', fieldtype: 'Section Break', label: 'n8n — Automatisation' },
|
||||||
|
{ fieldname: 'n8n_url', fieldtype: 'Data', label: 'URL n8n', default: 'http://localhost:5678' },
|
||||||
|
{ fieldname: 'n8n_api_key', fieldtype: 'Password', label: 'API Key n8n' },
|
||||||
|
{ fieldname: 'n8n_webhook_base', fieldtype: 'Data', label: 'Base URL webhooks', default: 'http://localhost:5678/webhook' },
|
||||||
|
{ fieldname: 'sms_section', fieldtype: 'Section Break', label: 'Templates SMS' },
|
||||||
|
{ fieldname: 'sms_enroute', fieldtype: 'Text', label: 'Technicien en route',
|
||||||
|
default: 'Bonjour {client_name}, votre technicien {tech_name} est en route et arrivera dans environ {eta} minutes. Réf: {job_id}' },
|
||||||
|
{ fieldname: 'sms_completed', fieldtype: 'Text', label: 'Service complété',
|
||||||
|
default: 'Bonjour {client_name}, votre service ({job_id}) a été complété avec succès. Merci de votre confiance !' },
|
||||||
|
{ fieldname: 'sms_assigned', fieldtype: 'Text', label: 'Job assigné (technicien)',
|
||||||
|
default: 'Nouveau job assigné : {job_id} — {client_name}, {address}. Durée estimée : {duration}h.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function createDocType () {
|
||||||
|
const csrf = await getCSRF()
|
||||||
|
const r = await fetch(`${BASE_URL}/api/resource/DocType`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf || '' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: DOCTYPE, module: 'Core', custom: 1, is_single: 1, track_changes: 0,
|
||||||
|
fields: DOCTYPE_FIELDS,
|
||||||
|
permissions: [
|
||||||
|
{ role: 'System Manager', read: 1, write: 1, create: 1 },
|
||||||
|
{ role: 'Administrator', read: 1, write: 1, create: 1 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const body = await r.json().catch(() => ({}))
|
||||||
|
if (!r.ok || body.exc_type) {
|
||||||
|
throw new Error(body._error_message || body.exc_type || `Erreur HTTP ${r.status}`)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
5
src/boot/pinia.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
export default ({ app }) => {
|
||||||
|
app.use(createPinia())
|
||||||
|
}
|
||||||
137
src/components/TagInput.vue
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Array, default: () => [] }, // current tag labels
|
||||||
|
allTags: { type: Array, default: () => [] }, // { label, color, category }
|
||||||
|
getColor: { type: Function, default: () => '#6b7280' },
|
||||||
|
placeholder:{ type: String, default: 'Ajouter un tag…' },
|
||||||
|
canCreate: { type: Boolean, default: true },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue', 'create'])
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const focused = ref(false)
|
||||||
|
const inputEl = ref(null)
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = query.value.trim().toLowerCase()
|
||||||
|
if (!q) return props.allTags.filter(t => !props.modelValue.includes(t.label)).slice(0, 12)
|
||||||
|
return props.allTags
|
||||||
|
.filter(t => !props.modelValue.includes(t.label) && t.label.toLowerCase().includes(q))
|
||||||
|
.slice(0, 12)
|
||||||
|
})
|
||||||
|
|
||||||
|
const showCreate = computed(() => {
|
||||||
|
if (!props.canCreate) return false
|
||||||
|
const q = query.value.trim()
|
||||||
|
if (!q || q.length < 2) return false
|
||||||
|
return !props.allTags.some(t => t.label.toLowerCase() === q.toLowerCase())
|
||||||
|
})
|
||||||
|
|
||||||
|
function addTag (label) {
|
||||||
|
if (!label || props.modelValue.includes(label)) return
|
||||||
|
emit('update:modelValue', [...props.modelValue, label])
|
||||||
|
query.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag (label) {
|
||||||
|
emit('update:modelValue', props.modelValue.filter(t => t !== label))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAndAdd () {
|
||||||
|
const label = query.value.trim()
|
||||||
|
if (!label) return
|
||||||
|
emit('create', label)
|
||||||
|
addTag(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur () {
|
||||||
|
setTimeout(() => { focused.value = false }, 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown (e) {
|
||||||
|
if (e.key === 'Enter' && query.value.trim()) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (filtered.value.length) addTag(filtered.value[0].label)
|
||||||
|
else if (showCreate.value) createAndAdd()
|
||||||
|
}
|
||||||
|
if (e.key === 'Backspace' && !query.value && props.modelValue.length) {
|
||||||
|
removeTag(props.modelValue[props.modelValue.length - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ti-wrap" :class="{ 'ti-focused': focused }">
|
||||||
|
<!-- Existing tags as chips -->
|
||||||
|
<span v-for="t in modelValue" :key="t" class="ti-chip" :style="'background:'+getColor(t)">
|
||||||
|
{{ t }}
|
||||||
|
<button class="ti-chip-rm" @click.stop="removeTag(t)">×</button>
|
||||||
|
</span>
|
||||||
|
<!-- Input -->
|
||||||
|
<input ref="inputEl" class="ti-input" type="text"
|
||||||
|
v-model="query" :placeholder="modelValue.length ? '' : placeholder"
|
||||||
|
@focus="focused=true" @blur="onBlur" @keydown="onKeydown" />
|
||||||
|
<!-- Dropdown -->
|
||||||
|
<div v-if="focused && (filtered.length || showCreate)" class="ti-dropdown">
|
||||||
|
<div v-for="t in filtered" :key="t.label" class="ti-option" @mousedown.prevent="addTag(t.label)">
|
||||||
|
<span class="ti-opt-dot" :style="'background:'+getColor(t.label)"></span>
|
||||||
|
<span class="ti-opt-label">{{ t.label }}</span>
|
||||||
|
<span class="ti-opt-cat">{{ t.category }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="showCreate" class="ti-option ti-option-create" @mousedown.prevent="createAndAdd">
|
||||||
|
<span class="ti-create-plus">+</span>
|
||||||
|
<span>Créer « <strong>{{ query.trim() }}</strong> »</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ti-wrap {
|
||||||
|
display:flex; flex-wrap:wrap; gap:3px; align-items:center;
|
||||||
|
background:#181c2e; border:1px solid rgba(255,255,255,0.06); border-radius:6px;
|
||||||
|
padding:3px 6px; min-height:28px; position:relative; cursor:text;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
.ti-wrap.ti-focused { border-color:rgba(99,102,241,0.4); }
|
||||||
|
.ti-chip {
|
||||||
|
display:inline-flex; align-items:center; gap:2px;
|
||||||
|
font-size:0.58rem; font-weight:600; color:#fff;
|
||||||
|
padding:1px 6px; border-radius:10px; white-space:nowrap;
|
||||||
|
}
|
||||||
|
.ti-chip-rm {
|
||||||
|
background:none; border:none; color:rgba(255,255,255,0.6); cursor:pointer;
|
||||||
|
font-size:0.7rem; padding:0 1px; margin-left:1px; line-height:1;
|
||||||
|
}
|
||||||
|
.ti-chip-rm:hover { color:#fff; }
|
||||||
|
.ti-input {
|
||||||
|
flex:1; min-width:60px; background:none; border:none; outline:none;
|
||||||
|
color:#e2e4ef; font-size:0.72rem; padding:2px 0;
|
||||||
|
}
|
||||||
|
.ti-input::placeholder { color:#7b80a0; }
|
||||||
|
.ti-dropdown {
|
||||||
|
position:absolute; top:100%; left:0; right:0; z-index:50;
|
||||||
|
background:#181c2e; border:1px solid rgba(99,102,241,0.3); border-radius:6px;
|
||||||
|
max-height:180px; overflow-y:auto; box-shadow:0 8px 24px rgba(0,0,0,0.45);
|
||||||
|
margin-top:2px;
|
||||||
|
}
|
||||||
|
.ti-dropdown::-webkit-scrollbar { width:3px; }
|
||||||
|
.ti-dropdown::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
|
||||||
|
.ti-option {
|
||||||
|
display:flex; align-items:center; gap:6px;
|
||||||
|
padding:5px 10px; cursor:pointer; font-size:0.72rem; color:#e2e4ef;
|
||||||
|
transition:background 0.1s;
|
||||||
|
}
|
||||||
|
.ti-option:hover { background:rgba(99,102,241,0.12); }
|
||||||
|
.ti-opt-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
||||||
|
.ti-opt-label { flex:1; }
|
||||||
|
.ti-opt-cat { font-size:0.55rem; color:#7b80a0; }
|
||||||
|
.ti-option-create { color:#6366f1; font-weight:600; border-top:1px solid rgba(255,255,255,0.06); }
|
||||||
|
.ti-create-plus {
|
||||||
|
width:18px; height:18px; border-radius:50%; background:rgba(99,102,241,0.2);
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
font-size:0.75rem; font-weight:800; color:#6366f1; flex-shrink:0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
137
src/components/shared/TagInput.vue
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Array, default: () => [] }, // current tag labels
|
||||||
|
allTags: { type: Array, default: () => [] }, // { label, color, category }
|
||||||
|
getColor: { type: Function, default: () => '#6b7280' },
|
||||||
|
placeholder:{ type: String, default: 'Ajouter un tag…' },
|
||||||
|
canCreate: { type: Boolean, default: true },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue', 'create'])
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const focused = ref(false)
|
||||||
|
const inputEl = ref(null)
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = query.value.trim().toLowerCase()
|
||||||
|
if (!q) return props.allTags.filter(t => !props.modelValue.includes(t.label)).slice(0, 12)
|
||||||
|
return props.allTags
|
||||||
|
.filter(t => !props.modelValue.includes(t.label) && t.label.toLowerCase().includes(q))
|
||||||
|
.slice(0, 12)
|
||||||
|
})
|
||||||
|
|
||||||
|
const showCreate = computed(() => {
|
||||||
|
if (!props.canCreate) return false
|
||||||
|
const q = query.value.trim()
|
||||||
|
if (!q || q.length < 2) return false
|
||||||
|
return !props.allTags.some(t => t.label.toLowerCase() === q.toLowerCase())
|
||||||
|
})
|
||||||
|
|
||||||
|
function addTag (label) {
|
||||||
|
if (!label || props.modelValue.includes(label)) return
|
||||||
|
emit('update:modelValue', [...props.modelValue, label])
|
||||||
|
query.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag (label) {
|
||||||
|
emit('update:modelValue', props.modelValue.filter(t => t !== label))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAndAdd () {
|
||||||
|
const label = query.value.trim()
|
||||||
|
if (!label) return
|
||||||
|
emit('create', label)
|
||||||
|
addTag(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur () {
|
||||||
|
setTimeout(() => { focused.value = false }, 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown (e) {
|
||||||
|
if (e.key === 'Enter' && query.value.trim()) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (filtered.value.length) addTag(filtered.value[0].label)
|
||||||
|
else if (showCreate.value) createAndAdd()
|
||||||
|
}
|
||||||
|
if (e.key === 'Backspace' && !query.value && props.modelValue.length) {
|
||||||
|
removeTag(props.modelValue[props.modelValue.length - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ti-wrap" :class="{ 'ti-focused': focused }">
|
||||||
|
<!-- Existing tags as chips -->
|
||||||
|
<span v-for="t in modelValue" :key="t" class="ti-chip" :style="'background:'+getColor(t)">
|
||||||
|
{{ t }}
|
||||||
|
<button class="ti-chip-rm" @click.stop="removeTag(t)">×</button>
|
||||||
|
</span>
|
||||||
|
<!-- Input -->
|
||||||
|
<input ref="inputEl" class="ti-input" type="text"
|
||||||
|
v-model="query" :placeholder="modelValue.length ? '' : placeholder"
|
||||||
|
@focus="focused=true" @blur="onBlur" @keydown="onKeydown" />
|
||||||
|
<!-- Dropdown -->
|
||||||
|
<div v-if="focused && (filtered.length || showCreate)" class="ti-dropdown">
|
||||||
|
<div v-for="t in filtered" :key="t.label" class="ti-option" @mousedown.prevent="addTag(t.label)">
|
||||||
|
<span class="ti-opt-dot" :style="'background:'+getColor(t.label)"></span>
|
||||||
|
<span class="ti-opt-label">{{ t.label }}</span>
|
||||||
|
<span class="ti-opt-cat">{{ t.category }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="showCreate" class="ti-option ti-option-create" @mousedown.prevent="createAndAdd">
|
||||||
|
<span class="ti-create-plus">+</span>
|
||||||
|
<span>Créer « <strong>{{ query.trim() }}</strong> »</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ti-wrap {
|
||||||
|
display:flex; flex-wrap:wrap; gap:3px; align-items:center;
|
||||||
|
background:#181c2e; border:1px solid rgba(255,255,255,0.06); border-radius:6px;
|
||||||
|
padding:3px 6px; min-height:28px; position:relative; cursor:text;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
.ti-wrap.ti-focused { border-color:rgba(99,102,241,0.4); }
|
||||||
|
.ti-chip {
|
||||||
|
display:inline-flex; align-items:center; gap:2px;
|
||||||
|
font-size:0.58rem; font-weight:600; color:#fff;
|
||||||
|
padding:1px 6px; border-radius:10px; white-space:nowrap;
|
||||||
|
}
|
||||||
|
.ti-chip-rm {
|
||||||
|
background:none; border:none; color:rgba(255,255,255,0.6); cursor:pointer;
|
||||||
|
font-size:0.7rem; padding:0 1px; margin-left:1px; line-height:1;
|
||||||
|
}
|
||||||
|
.ti-chip-rm:hover { color:#fff; }
|
||||||
|
.ti-input {
|
||||||
|
flex:1; min-width:60px; background:none; border:none; outline:none;
|
||||||
|
color:#e2e4ef; font-size:0.72rem; padding:2px 0;
|
||||||
|
}
|
||||||
|
.ti-input::placeholder { color:#7b80a0; }
|
||||||
|
.ti-dropdown {
|
||||||
|
position:absolute; top:100%; left:0; right:0; z-index:50;
|
||||||
|
background:#181c2e; border:1px solid rgba(99,102,241,0.3); border-radius:6px;
|
||||||
|
max-height:180px; overflow-y:auto; box-shadow:0 8px 24px rgba(0,0,0,0.45);
|
||||||
|
margin-top:2px;
|
||||||
|
}
|
||||||
|
.ti-dropdown::-webkit-scrollbar { width:3px; }
|
||||||
|
.ti-dropdown::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
|
||||||
|
.ti-option {
|
||||||
|
display:flex; align-items:center; gap:6px;
|
||||||
|
padding:5px 10px; cursor:pointer; font-size:0.72rem; color:#e2e4ef;
|
||||||
|
transition:background 0.1s;
|
||||||
|
}
|
||||||
|
.ti-option:hover { background:rgba(99,102,241,0.12); }
|
||||||
|
.ti-opt-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
||||||
|
.ti-opt-label { flex:1; }
|
||||||
|
.ti-opt-cat { font-size:0.55rem; color:#7b80a0; }
|
||||||
|
.ti-option-create { color:#6366f1; font-weight:600; border-top:1px solid rgba(255,255,255,0.06); }
|
||||||
|
.ti-create-plus {
|
||||||
|
width:18px; height:18px; border-radius:50%; background:rgba(99,102,241,0.2);
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
font-size:0.75rem; font-weight:800; color:#6366f1; flex-shrink:0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
120
src/composables/useBottomPanel.js
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
// ── Bottom panel composable: unassigned jobs table, multi-select, criteria ────
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { localDateStr } from './useHelpers'
|
||||||
|
|
||||||
|
export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, smartAssign, invalidateRoutes, periodStart }) {
|
||||||
|
const bottomPanelOpen = ref(localStorage.getItem('sbv2-bottomPanel') !== 'false')
|
||||||
|
const bottomPanelH = ref(parseInt(localStorage.getItem('sbv2-bottomH')) || 220)
|
||||||
|
watch(bottomPanelOpen, v => localStorage.setItem('sbv2-bottomPanel', v ? 'true' : 'false'))
|
||||||
|
|
||||||
|
// ── Grouped by date ──────────────────────────────────────────────────────────
|
||||||
|
const unassignedGrouped = computed(() => {
|
||||||
|
const today = todayStr
|
||||||
|
const jobs = unscheduledJobs.value.slice()
|
||||||
|
jobs.sort((a, b) => {
|
||||||
|
const da = a.scheduledDate || '9999-99-99'
|
||||||
|
const db = b.scheduledDate || '9999-99-99'
|
||||||
|
const aToday = da === today ? 0 : 1
|
||||||
|
const bToday = db === today ? 0 : 1
|
||||||
|
if (aToday !== bToday) return aToday - bToday
|
||||||
|
if (da !== db) return da.localeCompare(db)
|
||||||
|
const prio = { high: 0, medium: 1, low: 2 }
|
||||||
|
return (prio[a.priority] ?? 2) - (prio[b.priority] ?? 2)
|
||||||
|
})
|
||||||
|
const groups = []
|
||||||
|
let currentDate = null
|
||||||
|
jobs.forEach(job => {
|
||||||
|
const d = job.scheduledDate || null
|
||||||
|
if (d !== currentDate) {
|
||||||
|
currentDate = d
|
||||||
|
let label = d === today ? "Aujourd'hui" : d ? d : 'Sans date'
|
||||||
|
if (d && d !== today) {
|
||||||
|
const dt = new Date(d + 'T00:00:00')
|
||||||
|
label = dt.toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
groups.push({ date: d, label, jobs: [] })
|
||||||
|
}
|
||||||
|
groups.at(-1).jobs.push(job)
|
||||||
|
})
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Resize ───────────────────────────────────────────────────────────────────
|
||||||
|
function startBottomResize (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const startY = e.clientY, startH = bottomPanelH.value
|
||||||
|
function onMove (ev) { bottomPanelH.value = Math.max(100, Math.min(window.innerHeight * 0.6, startH - (ev.clientY - startY))) }
|
||||||
|
function onUp () { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); localStorage.setItem('sbv2-bottomH', String(bottomPanelH.value)) }
|
||||||
|
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-select ─────────────────────────────────────────────────────────────
|
||||||
|
const bottomSelected = ref(new Set())
|
||||||
|
function toggleBottomSelect (jobId, event) {
|
||||||
|
const s = new Set(bottomSelected.value)
|
||||||
|
// Checkbox click: always toggle (no modifier needed)
|
||||||
|
// Shift+click: range select
|
||||||
|
if (event?.shiftKey && s.size) {
|
||||||
|
const flat = unassignedGrouped.value.flatMap(g => g.jobs)
|
||||||
|
const ids = flat.map(j => j.id)
|
||||||
|
const lastId = [...s].pop()
|
||||||
|
const fromIdx = ids.indexOf(lastId), toIdx = ids.indexOf(jobId)
|
||||||
|
if (fromIdx >= 0 && toIdx >= 0) {
|
||||||
|
const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx]
|
||||||
|
for (let i = lo; i <= hi; i++) s.add(ids[i])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Simple toggle (no Ctrl needed)
|
||||||
|
if (s.has(jobId)) s.delete(jobId); else s.add(jobId)
|
||||||
|
}
|
||||||
|
bottomSelected.value = s
|
||||||
|
}
|
||||||
|
function selectAllBottom () { const s = new Set(); unscheduledJobs.value.forEach(j => s.add(j.id)); bottomSelected.value = s }
|
||||||
|
function clearBottomSelect () { bottomSelected.value = new Set() }
|
||||||
|
function batchAssignBottom (techId) {
|
||||||
|
const dayStr = localDateStr(periodStart.value)
|
||||||
|
bottomSelected.value.forEach(jobId => {
|
||||||
|
const job = store.jobs.find(j => j.id === jobId)
|
||||||
|
if (job) {
|
||||||
|
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
|
||||||
|
smartAssign(job, techId, dayStr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
bottomSelected.value = new Set()
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dispatch criteria ────────────────────────────────────────────────────────
|
||||||
|
const defaultCriteria = [
|
||||||
|
{ id: 'urgency', label: 'Urgence (priorité haute en premier)', enabled: true },
|
||||||
|
{ id: 'balance', label: 'Équilibrage de charge (tech le moins chargé)', enabled: true },
|
||||||
|
{ id: 'proximity', label: 'Proximité géographique', enabled: true },
|
||||||
|
{ id: 'skills', label: 'Correspondance des tags/skills', enabled: false },
|
||||||
|
]
|
||||||
|
const dispatchCriteria = ref(JSON.parse(localStorage.getItem('sbv2-dispatchCriteria') || 'null') || defaultCriteria.map(c => ({ ...c })))
|
||||||
|
const dispatchCriteriaModal = ref(false)
|
||||||
|
function saveDispatchCriteria () { localStorage.setItem('sbv2-dispatchCriteria', JSON.stringify(dispatchCriteria.value)); dispatchCriteriaModal.value = false }
|
||||||
|
function moveCriterion (idx, dir) {
|
||||||
|
const arr = dispatchCriteria.value, newIdx = idx + dir
|
||||||
|
if (newIdx < 0 || newIdx >= arr.length) return
|
||||||
|
const tmp = arr[idx]; arr[idx] = arr[newIdx]; arr[newIdx] = tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Column widths ────────────────────────────────────────────────────────────
|
||||||
|
const btColWidths = ref(JSON.parse(localStorage.getItem('sbv2-btColW') || '{}'))
|
||||||
|
function btColW (col, def) { return (btColWidths.value[col] || def) + 'px' }
|
||||||
|
function startColResize (e, col) {
|
||||||
|
e.preventDefault(); e.stopPropagation()
|
||||||
|
const startX = e.clientX, startW = btColWidths.value[col] || parseInt(getComputedStyle(e.target.parentElement).width)
|
||||||
|
function onMove (ev) { btColWidths.value = { ...btColWidths.value, [col]: Math.max(40, startW + (ev.clientX - startX)) } }
|
||||||
|
function onUp () { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); localStorage.setItem('sbv2-btColW', JSON.stringify(btColWidths.value)) }
|
||||||
|
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize,
|
||||||
|
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
|
||||||
|
dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion,
|
||||||
|
btColWidths, btColW, startColResize,
|
||||||
|
}
|
||||||
|
}
|
||||||
157
src/composables/useHelpers.js
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
// ── Pure utility functions (no Vue dependencies) ─────────────────────────────
|
||||||
|
export function localDateStr (d) {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfWeek (d) {
|
||||||
|
const r = new Date(d); r.setHours(0,0,0,0)
|
||||||
|
const diff = r.getDay() === 0 ? -6 : 1 - r.getDay()
|
||||||
|
r.setDate(r.getDate() + diff); return r
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfMonth (d) { return new Date(d.getFullYear(), d.getMonth(), 1) }
|
||||||
|
|
||||||
|
export function timeToH (t) {
|
||||||
|
const [h, m] = t.split(':').map(Number)
|
||||||
|
return h + m / 60
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hToTime (h) {
|
||||||
|
const totalMin = Math.round(h * 60)
|
||||||
|
const hh = Math.floor(totalMin / 60)
|
||||||
|
const mm = totalMin % 60
|
||||||
|
return `${String(hh).padStart(2,'0')}:${String(mm).padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDur (h) {
|
||||||
|
const totalMin = Math.round((parseFloat(h) || 0) * 60)
|
||||||
|
const hh = Math.floor(totalMin / 60)
|
||||||
|
const mm = totalMin % 60
|
||||||
|
if (hh === 0) return `${mm}m`
|
||||||
|
if (mm === 0) return `${hh}h`
|
||||||
|
return `${hh}h${String(mm).padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SNAP_MIN = 5
|
||||||
|
export const SNAP = SNAP_MIN / 60
|
||||||
|
export function snapH (h) { return Math.round(h * 60 / SNAP_MIN) * SNAP_MIN / 60 }
|
||||||
|
|
||||||
|
export function dayLoadColor (ratio) {
|
||||||
|
const r = Math.min(ratio, 1.2)
|
||||||
|
if (r <= 0.5) return '#10b981'
|
||||||
|
if (r <= 0.75) return '#f59e0b'
|
||||||
|
if (r <= 1) return '#f97316'
|
||||||
|
return '#ef4444'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortAddr (addr) {
|
||||||
|
if (!addr) return ''
|
||||||
|
const parts = addr.replace(/[A-Z]\d[A-Z]\s?\d[A-Z]\d/g, '').trim().split(/[\s,]+/)
|
||||||
|
for (let i = parts.length - 1; i >= 0; i--) {
|
||||||
|
if (parts[i].length > 2 && /^[A-ZÀ-Ú]/.test(parts[i])) return parts[i]
|
||||||
|
}
|
||||||
|
return parts.slice(-2).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service colors & labels
|
||||||
|
export const SVC_COLORS = { 'Internet':'#3b82f6','Télévisión':'#a855f7','Téléphonie':'#10b981','Multi-service':'#f59e0b' }
|
||||||
|
export const SVC_ICONS = { 'Internet':'🌐','Télévisión':'📺','Téléphonie':'📞','Multi-service':'🔧' }
|
||||||
|
const SVC_CODES = { 'Internet':'WEB','Télévisión':'TV','Téléphonie':'TEL','Multi-service':'MX' }
|
||||||
|
|
||||||
|
export function jobSvcCode (job) {
|
||||||
|
if (SVC_CODES[job.service_type]) return SVC_CODES[job.service_type]
|
||||||
|
const s = (job.subject || '').toLowerCase()
|
||||||
|
if (s.includes('internet')) return 'WEB'
|
||||||
|
if (s.includes('tv') || s.includes('télév')) return 'TV'
|
||||||
|
if (s.includes('téléph')) return 'TEL'
|
||||||
|
if (s.includes('multi')) return 'MX'
|
||||||
|
return 'WO'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobColor (job, techColors, store) {
|
||||||
|
if (SVC_COLORS[job.service_type]) return SVC_COLORS[job.service_type]
|
||||||
|
const s = (job.subject||'').toLowerCase()
|
||||||
|
if (s.includes('internet')) return '#3b82f6'
|
||||||
|
if (s.includes('tv')||s.includes('télév')) return '#a855f7'
|
||||||
|
if (s.includes('téléph')) return '#10b981'
|
||||||
|
if (s.includes('multi')) return '#f59e0b'
|
||||||
|
if (job.assignedTech && store) {
|
||||||
|
const t = store.technicians.find(x=>x.id===job.assignedTech)
|
||||||
|
if (t) return techColors[t.colorIdx]
|
||||||
|
}
|
||||||
|
return '#6b7280'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobSpansDate (job, ds) {
|
||||||
|
const start = job.scheduledDate
|
||||||
|
const end = job.endDate
|
||||||
|
if (!start) return false
|
||||||
|
if (!end) return start === ds
|
||||||
|
return ds >= start && ds <= end
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortJobsByTime (jobs) {
|
||||||
|
return jobs.slice().sort((a, b) => {
|
||||||
|
const aH = a.startTime ? timeToH(a.startTime) : (a.startHour ?? 8)
|
||||||
|
const bH = b.startTime ? timeToH(b.startTime) : (b.startHour ?? 8)
|
||||||
|
return aH - bH
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status helpers
|
||||||
|
export const STATUS_MAP = {
|
||||||
|
'available': { cls:'st-available', label:'Disponible' },
|
||||||
|
'en-route': { cls:'st-enroute', label:'En route' },
|
||||||
|
'busy': { cls:'st-busy', label:'En cours' },
|
||||||
|
'in progress': { cls:'st-busy', label:'En cours' },
|
||||||
|
'off': { cls:'st-off', label:'Hors shift' },
|
||||||
|
}
|
||||||
|
export function stOf (t) { return STATUS_MAP[(t.status||'').toLowerCase()] || STATUS_MAP['available'] }
|
||||||
|
|
||||||
|
export function prioLabel (p) { return { high:'Haute', medium:'Moyenne', low:'Basse' }[p] || p || '—' }
|
||||||
|
export function prioClass (p) { return { high:'prio-high', medium:'prio-med', low:'prio-low' }[p] || '' }
|
||||||
|
|
||||||
|
// Lucide-style inline SVG icons (stroke-based)
|
||||||
|
const _s = (d, w=10) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`
|
||||||
|
|
||||||
|
export const ICON = {
|
||||||
|
pin: _s('<path d="M12 17v5"/><path d="M9 11V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v7"/><path d="M4 15h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2z"/>'),
|
||||||
|
mapPin: _s('<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>'),
|
||||||
|
wifi: _s('<path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/>'),
|
||||||
|
tv: _s('<rect x="2" y="7" width="20" height="15" rx="2" ry="2"/><path d="m17 2-5 5-5-5"/>'),
|
||||||
|
phone: _s('<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/>'),
|
||||||
|
wrench: _s('<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>'),
|
||||||
|
cable: _s('<path d="M4 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"/><path d="M8 7V4"/><path d="M16 7V4"/><path d="M12 16v4"/>'),
|
||||||
|
check: _s('<path d="M20 6L9 17l-5-5"/>'),
|
||||||
|
x: _s('<path d="M18 6L6 18M6 6l12 12"/>'),
|
||||||
|
clock: _s('<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>'),
|
||||||
|
loader: _s('<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>'),
|
||||||
|
truck: _s('<path d="M5 17h2l3-6h4l3 6h2M7 17a2 2 0 1 1-4 0M21 17a2 2 0 1 1-4 0"/>'),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job type icon based on service/subject
|
||||||
|
export function jobTypeIcon (job) {
|
||||||
|
const s = (job.subject || '').toLowerCase()
|
||||||
|
const svc = job.service_type || ''
|
||||||
|
if (svc === 'Internet' || s.includes('internet') || s.includes('fibre') || s.includes('routeur') || s.includes('wifi')) return ICON.wifi
|
||||||
|
if (svc === 'Télévisión' || s.includes('tv') || s.includes('télév')) return ICON.tv
|
||||||
|
if (svc === 'Téléphonie' || s.includes('téléph') || s.includes('phone')) return ICON.phone
|
||||||
|
if (s.includes('cable') || s.includes('câble') || s.includes('cablage')) return ICON.cable
|
||||||
|
if (s.includes('camera') || s.includes('install')) return ICON.wrench
|
||||||
|
return ICON.wrench
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority color
|
||||||
|
export function prioColor (p) {
|
||||||
|
return { high: '#ef4444', medium: '#f59e0b', low: '#7b80a0' }[p] || '#7b80a0'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status icon (minimal, for timeline blocks)
|
||||||
|
export function jobStatusIcon (job) {
|
||||||
|
const st = (job.status || '').toLowerCase()
|
||||||
|
if (st === 'completed') return { svg: ICON.check, cls: 'si-done' }
|
||||||
|
if (st === 'cancelled') return { svg: ICON.x, cls: 'si-cancelled' }
|
||||||
|
if (st === 'en-route') return { svg: ICON.truck, cls: 'si-enroute' }
|
||||||
|
if (st === 'in progress') return { svg: ICON.loader, cls: 'si-progress' }
|
||||||
|
return { svg: '', cls: '' } // no icon for open/assigned — the type icon is enough
|
||||||
|
}
|
||||||
332
src/composables/useMap.js
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
// ── Map composable: Mapbox GL map, markers, routes, geo-fix, map-drag ────────
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
import { localDateStr, jobSpansDate, jobSvcCode, SVC_COLORS } from './useHelpers'
|
||||||
|
|
||||||
|
export function useMap (deps) {
|
||||||
|
const {
|
||||||
|
store, MAPBOX_TOKEN, TECH_COLORS,
|
||||||
|
currentView, periodStart, filteredResources, mapVisible,
|
||||||
|
routeLegs, routeGeometry,
|
||||||
|
getJobDate, jobColor, pushUndo, smartAssign, invalidateRoutes,
|
||||||
|
dragJob, dragIsAssist, rightPanel, openCtxMenu,
|
||||||
|
} = deps
|
||||||
|
|
||||||
|
let map = null
|
||||||
|
let mapResizeObs = null
|
||||||
|
const mapContainer = ref(null)
|
||||||
|
const selectedTechId = ref(null)
|
||||||
|
const mapMarkers = ref([])
|
||||||
|
const mapPanelW = ref(parseInt(localStorage.getItem('sbv2-mapW')) || 340)
|
||||||
|
const geoFixJob = ref(null)
|
||||||
|
const mapDragJob = ref(null)
|
||||||
|
let _mapGhost = null
|
||||||
|
|
||||||
|
// ── Geo-fix ──────────────────────────────────────────────────────────────────
|
||||||
|
function startGeoFix (job) {
|
||||||
|
geoFixJob.value = job
|
||||||
|
if (!mapVisible.value) mapVisible.value = true
|
||||||
|
if (map) map.getCanvas().style.cursor = 'crosshair'
|
||||||
|
}
|
||||||
|
function cancelGeoFix () {
|
||||||
|
geoFixJob.value = null
|
||||||
|
if (map) map.getCanvas().style.cursor = ''
|
||||||
|
}
|
||||||
|
watch(geoFixJob, v => { if (map) map.getCanvas().style.cursor = v ? 'crosshair' : '' })
|
||||||
|
|
||||||
|
// ── Panel resize ─────────────────────────────────────────────────────────────
|
||||||
|
function startMapResize (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const startX = e.clientX, startW = mapPanelW.value
|
||||||
|
function onMove (ev) {
|
||||||
|
mapPanelW.value = Math.max(220, Math.min(window.innerWidth * 0.65, startW - (ev.clientX - startX)))
|
||||||
|
}
|
||||||
|
function onUp () {
|
||||||
|
document.removeEventListener('mousemove', onMove)
|
||||||
|
document.removeEventListener('mouseup', onUp)
|
||||||
|
localStorage.setItem('sbv2-mapW', String(mapPanelW.value))
|
||||||
|
if (map) map.resize()
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove)
|
||||||
|
document.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||||
|
async function initMap () {
|
||||||
|
if (!mapContainer.value || map) return
|
||||||
|
if (!window.mapboxgl) {
|
||||||
|
if (!document.getElementById('mapbox-js')) {
|
||||||
|
await new Promise(resolve => {
|
||||||
|
const s = document.createElement('script'); s.id = 'mapbox-js'
|
||||||
|
s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'
|
||||||
|
s.onload = resolve; document.head.appendChild(s)
|
||||||
|
})
|
||||||
|
} else { await new Promise(r => setTimeout(r, 200)) }
|
||||||
|
}
|
||||||
|
const mapboxgl = window.mapboxgl
|
||||||
|
mapboxgl.accessToken = MAPBOX_TOKEN
|
||||||
|
map = new mapboxgl.Map({
|
||||||
|
container: mapContainer.value,
|
||||||
|
style: 'mapbox://styles/mapbox/dark-v11',
|
||||||
|
center: [-73.567, 45.502], zoom: 10,
|
||||||
|
})
|
||||||
|
if (mapResizeObs) mapResizeObs.disconnect()
|
||||||
|
mapResizeObs = new ResizeObserver(() => { if (map) map.resize() })
|
||||||
|
mapResizeObs.observe(mapContainer.value)
|
||||||
|
|
||||||
|
map.on('load', () => {
|
||||||
|
map.resize()
|
||||||
|
// Route layers
|
||||||
|
map.addSource('sb-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||||
|
map.addLayer({ id: 'sb-route-halo', type: 'line', source: 'sb-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#6366f1', 'line-width': 12, 'line-opacity': 0.18 } })
|
||||||
|
map.addLayer({ id: 'sb-route-line', type: 'line', source: 'sb-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#6366f1', 'line-width': 3.5, 'line-opacity': 0.85 } })
|
||||||
|
// Job layers
|
||||||
|
map.addSource('sb-jobs', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||||
|
map.addLayer({ id: 'sb-jobs-halo', type: 'circle', source: 'sb-jobs', paint: { 'circle-radius': 22, 'circle-color': ['get', 'color'], 'circle-opacity': ['*', ['get', 'opacity'], 0.18], 'circle-blur': 0.7 } })
|
||||||
|
map.addLayer({ id: 'sb-jobs-circle', type: 'circle', source: 'sb-jobs', paint: { 'circle-radius': 15, 'circle-color': ['get', 'color'], 'circle-opacity': ['get', 'opacity'], 'circle-stroke-width': 2, 'circle-stroke-color': ['case', ['get', 'unassigned'], 'rgba(255,255,255,0.4)', 'rgba(255,255,255,0.85)'], 'circle-stroke-opacity': ['get', 'opacity'] } })
|
||||||
|
map.addLayer({ id: 'sb-jobs-label', type: 'symbol', source: 'sb-jobs', layout: { 'text-field': ['get', 'label'], 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-size': 9, 'text-allow-overlap': true, 'text-ignore-placement': true }, paint: { 'text-color': '#ffffff', 'text-opacity': ['get', 'opacity'] } })
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
map.on('mouseenter', 'sb-jobs-circle', () => { if (!mapDragJob.value && !geoFixJob.value) map.getCanvas().style.cursor = 'grab' })
|
||||||
|
map.on('mouseleave', 'sb-jobs-circle', () => { if (!mapDragJob.value && !geoFixJob.value) map.getCanvas().style.cursor = '' })
|
||||||
|
map.on('mousedown', 'sb-jobs-circle', e => {
|
||||||
|
if (geoFixJob.value) return
|
||||||
|
e.preventDefault()
|
||||||
|
const job = store.jobs.find(j => j.id === e.features[0].properties.id)
|
||||||
|
if (job) startMapDrag(e.originalEvent, job)
|
||||||
|
})
|
||||||
|
map.on('click', 'sb-jobs-circle', e => {
|
||||||
|
if (geoFixJob.value) return
|
||||||
|
const job = store.jobs.find(j => j.id === e.features[0].properties.id)
|
||||||
|
if (job) {
|
||||||
|
const tech = job.assignedTech ? store.technicians.find(t => t.id === job.assignedTech) : null
|
||||||
|
rightPanel.value = { mode: 'details', data: { job, tech } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
map.on('contextmenu', 'sb-jobs-circle', e => {
|
||||||
|
const job = store.jobs.find(j => j.id === e.features[0].properties.id)
|
||||||
|
if (job) {
|
||||||
|
const tech = job.assignedTech ? store.technicians.find(t => t.id === job.assignedTech) : null
|
||||||
|
openCtxMenu(e.originalEvent, job, tech?.id || null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
map.on('mouseenter', 'sb-route-line', () => { if (mapDragJob.value) map.getCanvas().style.cursor = 'copy' })
|
||||||
|
map.on('mouseleave', 'sb-route-line', () => { if (!mapDragJob.value) map.getCanvas().style.cursor = '' })
|
||||||
|
|
||||||
|
// Geo-fix click
|
||||||
|
map.on('click', e => {
|
||||||
|
if (!geoFixJob.value) return
|
||||||
|
const job = geoFixJob.value
|
||||||
|
const saved = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}')
|
||||||
|
saved[job.id] = [e.lngLat.lng, e.lngLat.lat]
|
||||||
|
localStorage.setItem('dispatch-job-coords', JSON.stringify(saved))
|
||||||
|
store.updateJobCoords(job.id, e.lngLat.lng, e.lngLat.lat)
|
||||||
|
routeLegs.value = {}; routeGeometry.value = {}
|
||||||
|
geoFixJob.value = null
|
||||||
|
map.getCanvas().style.cursor = ''
|
||||||
|
nextTick(() => {
|
||||||
|
drawMapMarkers()
|
||||||
|
const dayStr = localDateStr(periodStart.value)
|
||||||
|
filteredResources.value.forEach(tech => computeDayRoute(tech, dayStr))
|
||||||
|
drawSelectedRoute()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
drawMapMarkers()
|
||||||
|
drawSelectedRoute()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw markers ─────────────────────────────────────────────────────────────
|
||||||
|
function drawMapMarkers () {
|
||||||
|
if (!map || !window.mapboxgl) return
|
||||||
|
const dayStr = localDateStr(periodStart.value)
|
||||||
|
const mbgl = window.mapboxgl
|
||||||
|
|
||||||
|
const jobFeatures = store.jobs
|
||||||
|
.filter(j => j.coords && !(j.coords[0] === 0 && j.coords[1] === 0))
|
||||||
|
.filter(j => {
|
||||||
|
if (!j.assignedTech) return (j.scheduledDate || null) === dayStr
|
||||||
|
return jobSpansDate(j, dayStr)
|
||||||
|
})
|
||||||
|
.map(job => {
|
||||||
|
const isUnassigned = !job.assignedTech
|
||||||
|
const isCompleted = (job.status || '').toLowerCase() === 'completed'
|
||||||
|
const isSelected = selectedTechId.value && job.assignedTech === selectedTechId.value
|
||||||
|
const opacity = isCompleted ? 0.4 : (isSelected || isUnassigned || !selectedTechId.value ? 0.92 : 0.4)
|
||||||
|
let label = jobSvcCode(job)
|
||||||
|
if (!isUnassigned) {
|
||||||
|
const tech = store.technicians.find(t => t.id === job.assignedTech)
|
||||||
|
if (tech) { const idx = tech.queue.filter(j2 => getJobDate(j2.id) === dayStr).indexOf(job); if (idx >= 0) label = String(idx + 1) }
|
||||||
|
}
|
||||||
|
return { type: 'Feature', geometry: { type: 'Point', coordinates: job.coords }, properties: { id: job.id, color: jobColor(job), label, title: job.subject, opacity, unassigned: isUnassigned, completed: isCompleted } }
|
||||||
|
})
|
||||||
|
if (map.getSource('sb-jobs')) map.getSource('sb-jobs').setData({ type: 'FeatureCollection', features: jobFeatures })
|
||||||
|
|
||||||
|
// Tech avatar markers
|
||||||
|
mapMarkers.value.forEach(m => m.remove())
|
||||||
|
mapMarkers.value = []
|
||||||
|
filteredResources.value.forEach(tech => {
|
||||||
|
if (!tech.coords || (tech.coords[0] === 0 && tech.coords[1] === 0)) return
|
||||||
|
const initials = tech.fullName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
||||||
|
const color = TECH_COLORS[tech.colorIdx]
|
||||||
|
const outer = document.createElement('div')
|
||||||
|
outer.style.cssText = 'cursor:pointer;'
|
||||||
|
outer.dataset.techId = tech.id
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.className = 'sb-map-tech-pin'
|
||||||
|
el.style.cssText = `background:${color};border-color:${color};`
|
||||||
|
el.textContent = initials; el.title = tech.fullName
|
||||||
|
outer.appendChild(el)
|
||||||
|
outer.addEventListener('dragover', e => { e.preventDefault(); el.style.transform = 'scale(1.25)' })
|
||||||
|
outer.addEventListener('dragleave', () => { el.style.transform = '' })
|
||||||
|
outer.addEventListener('drop', e => {
|
||||||
|
e.preventDefault(); el.style.transform = ''
|
||||||
|
const job = dragJob.value
|
||||||
|
if (job) {
|
||||||
|
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
|
||||||
|
smartAssign(job, tech.id, dayStr)
|
||||||
|
dragJob.value = null
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
outer.addEventListener('mouseenter', () => { if (mapDragJob.value) el.style.transform = 'scale(1.3)' })
|
||||||
|
outer.addEventListener('mouseleave', () => { el.style.transform = '' })
|
||||||
|
const m = new mbgl.Marker({ element: outer, anchor: 'bottom' }).setLngLat(tech.coords).addTo(map)
|
||||||
|
mapMarkers.value.push(m)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Map drag (job pin → tech) ────────────────────────────────────────────────
|
||||||
|
function startMapDrag (e, job) {
|
||||||
|
e.preventDefault()
|
||||||
|
mapDragJob.value = job
|
||||||
|
if (map) map.dragPan.disable()
|
||||||
|
_mapGhost = document.createElement('div')
|
||||||
|
_mapGhost.className = 'sb-map-drag-ghost'
|
||||||
|
_mapGhost.textContent = job.subject
|
||||||
|
_mapGhost.style.cssText = `position:fixed;pointer-events:none;z-index:9999;left:${e.clientX + 14}px;top:${e.clientY + 14}px`
|
||||||
|
document.body.appendChild(_mapGhost)
|
||||||
|
document.addEventListener('mousemove', _onMapDragMove)
|
||||||
|
document.addEventListener('mouseup', _onMapDragEnd)
|
||||||
|
}
|
||||||
|
function _onMapDragMove (e) { if (_mapGhost) { _mapGhost.style.left = (e.clientX + 14) + 'px'; _mapGhost.style.top = (e.clientY + 14) + 'px' } }
|
||||||
|
function _onMapDragEnd (e) {
|
||||||
|
document.removeEventListener('mousemove', _onMapDragMove)
|
||||||
|
document.removeEventListener('mouseup', _onMapDragEnd)
|
||||||
|
if (_mapGhost) { _mapGhost.remove(); _mapGhost = null }
|
||||||
|
if (map) { map.getCanvas().style.cursor = ''; map.dragPan.enable() }
|
||||||
|
const job = mapDragJob.value; mapDragJob.value = null
|
||||||
|
if (!job) return
|
||||||
|
const els = document.elementsFromPoint(e.clientX, e.clientY)
|
||||||
|
const dateStr = localDateStr(periodStart.value)
|
||||||
|
function assignFromMap (tech) {
|
||||||
|
if (dragIsAssist.value) { dragIsAssist.value = false; return }
|
||||||
|
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
|
||||||
|
smartAssign(job, tech.id, dateStr)
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
const domTarget = els.find(el => el.dataset?.techId)
|
||||||
|
if (domTarget) { const tech = store.technicians.find(t => t.id === domTarget.dataset.techId); if (tech) assignFromMap(tech); return }
|
||||||
|
if (map && selectedTechId.value) {
|
||||||
|
const canvas = map.getCanvas(), rect = canvas.getBoundingClientRect()
|
||||||
|
if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) {
|
||||||
|
const tech = store.technicians.find(t => t.id === selectedTechId.value)
|
||||||
|
if (tech) assignFromMap(tech)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Route computation ────────────────────────────────────────────────────────
|
||||||
|
async function computeDayRoute (tech, dateStr) {
|
||||||
|
const key = `${tech.id}||${dateStr}`
|
||||||
|
if (routeLegs.value[key] !== undefined) return
|
||||||
|
const points = []
|
||||||
|
if (tech.coords?.[0] && tech.coords?.[1]) points.push(`${tech.coords[0]},${tech.coords[1]}`)
|
||||||
|
const allJobs = [...tech.queue.filter(j => jobSpansDate(j, dateStr)), ...(tech.assistJobs || []).filter(j => jobSpansDate(j, dateStr))]
|
||||||
|
allJobs.forEach(j => { if (j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0)) points.push(`${j.coords[0]},${j.coords[1]}`) })
|
||||||
|
function setCache (legs, geom) {
|
||||||
|
routeLegs.value = { ...routeLegs.value, [key]: legs }
|
||||||
|
routeGeometry.value = { ...routeGeometry.value, [key]: geom }
|
||||||
|
}
|
||||||
|
if (points.length < 2) { setCache([], null); return }
|
||||||
|
try {
|
||||||
|
const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${points.join(';')}?overview=full&geometries=geojson&access_token=${MAPBOX_TOKEN}`
|
||||||
|
const r = await fetch(url)
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||||
|
const data = await r.json()
|
||||||
|
if (data.routes?.[0]) setCache(data.routes[0].legs.map(l => Math.round(l.duration / 60)), data.routes[0].geometry.coordinates)
|
||||||
|
else setCache([], null)
|
||||||
|
} catch (e) { console.warn('[route] fetch error', e); setCache([], null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw route ───────────────────────────────────────────────────────────────
|
||||||
|
function drawSelectedRoute () {
|
||||||
|
if (!map || !mapVisible.value) return
|
||||||
|
const src = map.getSource('sb-route'); if (!src) return
|
||||||
|
const empty = { type: 'FeatureCollection', features: [] }
|
||||||
|
if (currentView.value !== 'day') { src.setData(empty); return }
|
||||||
|
const dayStr = localDateStr(periodStart.value)
|
||||||
|
const features = []
|
||||||
|
const techs = selectedTechId.value ? filteredResources.value.filter(t => t.id === selectedTechId.value) : filteredResources.value
|
||||||
|
techs.forEach(tech => {
|
||||||
|
const coords = routeGeometry.value[`${tech.id}||${dayStr}`]
|
||||||
|
if (coords?.length) features.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: TECH_COLORS[tech.colorIdx] } })
|
||||||
|
})
|
||||||
|
src.setData({ type: 'FeatureCollection', features })
|
||||||
|
map.setPaintProperty('sb-route-halo', 'line-color', ['get', 'color'])
|
||||||
|
map.setPaintProperty('sb-route-line', 'line-color', ['get', 'color'])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Select tech on board ─────────────────────────────────────────────────────
|
||||||
|
function selectTechOnBoard (tech) {
|
||||||
|
const wasSelected = selectedTechId.value === tech.id
|
||||||
|
selectedTechId.value = wasSelected ? null : tech.id
|
||||||
|
if (!wasSelected && currentView.value === 'day') {
|
||||||
|
if (!mapVisible.value) {
|
||||||
|
mapPanelW.value = Math.round(window.innerWidth * 0.5)
|
||||||
|
localStorage.setItem('sbv2-mapW', String(mapPanelW.value))
|
||||||
|
mapVisible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (map) { drawMapMarkers(); drawSelectedRoute() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Watchers ─────────────────────────────────────────────────────────────────
|
||||||
|
watch([selectedTechId, () => periodStart.value?.getTime(), currentView, routeGeometry], () => { if (map) { drawMapMarkers(); drawSelectedRoute() } })
|
||||||
|
watch(mapVisible, async v => {
|
||||||
|
if (v) {
|
||||||
|
if (map) { try { map.remove() } catch (_) {} map = null }
|
||||||
|
await nextTick(); await initMap()
|
||||||
|
if (map) {
|
||||||
|
const r = () => { if (!map) return; map.resize(); drawMapMarkers(); drawSelectedRoute() }
|
||||||
|
await nextTick(); r(); setTimeout(r, 100); setTimeout(r, 300); setTimeout(r, 600)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (mapResizeObs) { mapResizeObs.disconnect(); mapResizeObs = null }
|
||||||
|
if (map) { try { map.remove() } catch (_) {} map = null }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
watch([() => periodStart.value?.getTime(), filteredResources], () => {
|
||||||
|
if (currentView.value === 'day' && mapVisible.value && map) { drawMapMarkers(); drawSelectedRoute() }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Lifecycle helpers ────────────────────────────────────────────────────────
|
||||||
|
function destroyMap () {
|
||||||
|
if (map) { map.remove(); map = null }
|
||||||
|
if (mapResizeObs) { mapResizeObs.disconnect(); mapResizeObs = null }
|
||||||
|
}
|
||||||
|
function loadMapboxCss () {
|
||||||
|
if (!document.getElementById('mapbox-css')) {
|
||||||
|
const l = document.createElement('link'); l.id = 'mapbox-css'; l.rel = 'stylesheet'
|
||||||
|
l.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getMap () { return map }
|
||||||
|
|
||||||
|
return {
|
||||||
|
mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, mapDragJob,
|
||||||
|
startGeoFix, cancelGeoFix, startMapResize, initMap,
|
||||||
|
drawMapMarkers, drawSelectedRoute, computeDayRoute,
|
||||||
|
selectTechOnBoard, destroyMap, loadMapboxCss, getMap,
|
||||||
|
}
|
||||||
|
}
|
||||||
209
src/composables/useScheduler.js
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
// ── Scheduling logic: timeline computation, route cache, job placement ───────
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { localDateStr, timeToH, hToTime, sortJobsByTime, jobSpansDate } from './useHelpers'
|
||||||
|
|
||||||
|
export function useScheduler (store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColorFn) {
|
||||||
|
const H_START = 7
|
||||||
|
const H_END = 20
|
||||||
|
|
||||||
|
// ── Route cache ────────────────────────────────────────────────────────────
|
||||||
|
const routeLegs = ref({})
|
||||||
|
const routeGeometry = ref({})
|
||||||
|
|
||||||
|
// ── Parent start position cache ────────────────────────────────────────────
|
||||||
|
let _parentStartCache = {}
|
||||||
|
|
||||||
|
function getParentStartH (job) {
|
||||||
|
if (!store.technicians.length) return job.startHour ?? 8
|
||||||
|
const key = `${job.assignedTech}||${job.id}`
|
||||||
|
if (_parentStartCache[key] !== undefined) return _parentStartCache[key]
|
||||||
|
const leadTech = store.technicians.find(t => t.id === job.assignedTech)
|
||||||
|
if (!leadTech) return job.startHour ?? 8
|
||||||
|
const dayStr = localDateStr(periodStart.value)
|
||||||
|
const leadJobs = sortJobsByTime(leadTech.queue.filter(j => getJobDate(j.id) === dayStr))
|
||||||
|
const cacheKey = `${leadTech.id}||${dayStr}`
|
||||||
|
const legMins = routeLegs.value[cacheKey]
|
||||||
|
const hasHome = !!(leadTech.coords?.[0] && leadTech.coords?.[1])
|
||||||
|
let cursor = 8, result = job.startHour ?? 8
|
||||||
|
leadJobs.forEach((j, idx) => {
|
||||||
|
const showTravel = idx > 0 || (idx === 0 && hasHome)
|
||||||
|
if (showTravel) {
|
||||||
|
const legIdx = hasHome ? idx : idx - 1
|
||||||
|
const routeMin = legMins?.[legIdx]
|
||||||
|
cursor += (routeMin != null ? routeMin : (parseFloat(j.legDur) > 0 ? parseFloat(j.legDur) : 20)) / 60
|
||||||
|
}
|
||||||
|
const pinnedH = j.startTime ? timeToH(j.startTime) : null
|
||||||
|
const startH = pinnedH ?? cursor
|
||||||
|
if (j.id === job.id) result = startH
|
||||||
|
cursor = startH + (parseFloat(j.duration) || 1)
|
||||||
|
})
|
||||||
|
_parentStartCache[key] = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── All jobs for a tech on a date (primary + assists) ──────────────────────
|
||||||
|
function techAllJobsForDate (tech, dateStr) {
|
||||||
|
_parentStartCache = {}
|
||||||
|
const primary = tech.queue.filter(j => jobSpansDate(j, dateStr))
|
||||||
|
const assists = (tech.assistJobs || [])
|
||||||
|
.filter(j => jobSpansDate(j, dateStr))
|
||||||
|
.map(j => {
|
||||||
|
const a = j.assistants.find(x => x.techId === tech.id)
|
||||||
|
const parentH = getParentStartH(j)
|
||||||
|
return {
|
||||||
|
...j,
|
||||||
|
duration: a?.duration || j.duration,
|
||||||
|
startTime: hToTime(parentH),
|
||||||
|
startHour: parentH,
|
||||||
|
_isAssist: true,
|
||||||
|
_assistPinned: !!a?.pinned,
|
||||||
|
_assistNote: a?.note || '',
|
||||||
|
_parentJob: j,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return sortJobsByTime([...primary, ...assists])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Day view: schedule blocks with pinned anchors + auto-flow ──────────────
|
||||||
|
function techDayJobsWithTravel (tech) {
|
||||||
|
const dayStr = localDateStr(periodStart.value)
|
||||||
|
const cacheKey = `${tech.id}||${dayStr}`
|
||||||
|
const legMins = routeLegs.value[cacheKey]
|
||||||
|
const hasHome = !!(tech.coords?.[0] && tech.coords?.[1])
|
||||||
|
const allJobs = techAllJobsForDate(tech, dayStr)
|
||||||
|
|
||||||
|
const flowEntries = []
|
||||||
|
const floatingEntries = []
|
||||||
|
allJobs.forEach(job => {
|
||||||
|
const isAssist = !!job._isAssist
|
||||||
|
const dur = parseFloat(job.duration) || 1
|
||||||
|
const isPinned = isAssist ? !!job._assistPinned : !!getJobTime(job.id)
|
||||||
|
const pinH = isAssist ? job.startHour : (getJobTime(job.id) ? timeToH(getJobTime(job.id)) : null)
|
||||||
|
const entry = { job, dur, isAssist, isPinned, pinH }
|
||||||
|
if (isAssist && !job._assistPinned) floatingEntries.push(entry)
|
||||||
|
else flowEntries.push(entry)
|
||||||
|
})
|
||||||
|
|
||||||
|
const pinnedAnchors = flowEntries.filter(e => e.isPinned).map(e => ({ start: e.pinH, end: e.pinH + e.dur }))
|
||||||
|
const placed = []
|
||||||
|
const occupied = pinnedAnchors.map(a => ({ ...a }))
|
||||||
|
const sortedFlow = [...flowEntries].sort((a, b) => {
|
||||||
|
if (a.isPinned && b.isPinned) return a.pinH - b.pinH
|
||||||
|
if (a.isPinned) return -1
|
||||||
|
if (b.isPinned) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
sortedFlow.filter(e => e.isPinned).forEach(e => placed.push({ entry: e, startH: e.pinH }))
|
||||||
|
|
||||||
|
let cursor = 8, flowIdx = 0
|
||||||
|
sortedFlow.filter(e => !e.isPinned).forEach(e => {
|
||||||
|
const legIdx = hasHome ? flowIdx : flowIdx - 1
|
||||||
|
const routeMin = legMins?.[legIdx >= 0 ? legIdx : 0]
|
||||||
|
const travelH = (routeMin != null ? routeMin : (parseFloat(e.job.legDur) > 0 ? parseFloat(e.job.legDur) : 20)) / 60
|
||||||
|
let startH = cursor + (flowIdx > 0 || hasHome ? travelH : 0)
|
||||||
|
let safe = false
|
||||||
|
while (!safe) {
|
||||||
|
const endH = startH + e.dur
|
||||||
|
const overlap = occupied.find(o => startH < o.end && endH > o.start)
|
||||||
|
if (overlap) startH = overlap.end + travelH
|
||||||
|
else safe = true
|
||||||
|
}
|
||||||
|
placed.push({ entry: e, startH })
|
||||||
|
occupied.push({ start: startH, end: startH + e.dur })
|
||||||
|
cursor = startH + e.dur
|
||||||
|
flowIdx++
|
||||||
|
})
|
||||||
|
|
||||||
|
placed.sort((a, b) => a.startH - b.startH)
|
||||||
|
|
||||||
|
const result = []
|
||||||
|
let prevEndH = null
|
||||||
|
placed.forEach((p, pIdx) => {
|
||||||
|
const { entry, startH } = p
|
||||||
|
const { job, dur, isAssist, isPinned } = entry
|
||||||
|
const realJob = isAssist ? job._parentJob : job
|
||||||
|
const travelStart = prevEndH ?? (hasHome ? 8 : null)
|
||||||
|
if (travelStart != null && startH > travelStart + 0.01) {
|
||||||
|
const gapH = startH - travelStart
|
||||||
|
const legIdx = hasHome ? pIdx : pIdx - 1
|
||||||
|
const routeMin = legMins?.[legIdx >= 0 ? legIdx : 0]
|
||||||
|
const fromRoute = routeMin != null
|
||||||
|
result.push({
|
||||||
|
type: 'travel', job: realJob, travelMin: fromRoute ? routeMin : Math.round(gapH * 60), fromRoute, isAssist: false,
|
||||||
|
style: { left: (travelStart - H_START) * pxPerHr.value + 'px', width: Math.max(8, gapH * pxPerHr.value) + 'px', top: '10px', bottom: '10px', position: 'absolute' },
|
||||||
|
color: jobColorFn(realJob),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const jLeft = (startH - H_START) * pxPerHr.value
|
||||||
|
const jWidth = Math.max(18, dur * pxPerHr.value)
|
||||||
|
result.push({
|
||||||
|
type: isAssist ? 'assist' : 'job', job: realJob,
|
||||||
|
pinned: isPinned, pinnedTime: isPinned ? hToTime(startH) : null, isAssist,
|
||||||
|
assistPinned: isAssist ? isPinned : false, assistDur: isAssist ? dur : null,
|
||||||
|
assistNote: isAssist ? job._assistNote : null, assistTechId: isAssist ? tech.id : null,
|
||||||
|
style: { left: jLeft + 'px', width: jWidth + 'px', top: '6px', bottom: '6px', position: 'absolute' },
|
||||||
|
})
|
||||||
|
prevEndH = startH + dur
|
||||||
|
})
|
||||||
|
|
||||||
|
floatingEntries.forEach(entry => {
|
||||||
|
const { job, dur } = entry
|
||||||
|
const startH = job.startHour ?? 8
|
||||||
|
result.push({
|
||||||
|
type: 'assist', job: job._parentJob, pinned: false, pinnedTime: null, isAssist: true,
|
||||||
|
assistPinned: false, assistDur: dur, assistNote: job._assistNote, assistTechId: tech.id,
|
||||||
|
style: { left: (startH - H_START) * pxPerHr.value + 'px', width: Math.max(18, dur * pxPerHr.value) + 'px', top: '52%', bottom: '4px', position: 'absolute' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Week view helpers ──────────────────────────────────────────────────────
|
||||||
|
function techBookingsByDay (tech) {
|
||||||
|
return dayColumns.value.map(d => {
|
||||||
|
const ds = localDateStr(d)
|
||||||
|
const primary = tech.queue.filter(j => jobSpansDate(j, ds))
|
||||||
|
const assists = (tech.assistJobs || [])
|
||||||
|
.filter(j => jobSpansDate(j, ds) && j.assistants.find(a => a.techId === tech.id)?.pinned)
|
||||||
|
.map(j => ({ ...j, _isAssistChip: true, _assistDur: j.assistants.find(a => a.techId === tech.id)?.duration || j.duration }))
|
||||||
|
return { day: d, dateStr: ds, jobs: [...primary, ...assists] }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodLoadH (tech) {
|
||||||
|
const dateSet = new Set(dayColumns.value.map(d => localDateStr(d)))
|
||||||
|
let total = tech.queue.reduce((sum, j) => {
|
||||||
|
const ds = getJobDate(j.id)
|
||||||
|
return ds && dateSet.has(ds) ? sum + (parseFloat(j.duration) || 0) : sum
|
||||||
|
}, 0)
|
||||||
|
;(tech.assistJobs || []).forEach(j => {
|
||||||
|
const ds = getJobDate(j.id)
|
||||||
|
if (ds && dateSet.has(ds)) {
|
||||||
|
const a = j.assistants.find(x => x.techId === tech.id)
|
||||||
|
if (a?.pinned) total += parseFloat(a?.duration || j.duration) || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
function techsActiveOnDay (dateStr, resources) {
|
||||||
|
return resources.filter(tech =>
|
||||||
|
tech.queue.some(j => jobSpansDate(j, dateStr)) ||
|
||||||
|
(tech.assistJobs || []).some(j => jobSpansDate(j, dateStr) && j.assistants.find(a => a.techId === tech.id)?.pinned)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayJobCount (dateStr, resources) {
|
||||||
|
const jobIds = new Set()
|
||||||
|
resources.forEach(t => t.queue.filter(j => jobSpansDate(j, dateStr)).forEach(j => jobIds.add(j.id)))
|
||||||
|
return jobIds.size
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
H_START, H_END, routeLegs, routeGeometry,
|
||||||
|
techAllJobsForDate, techDayJobsWithTravel,
|
||||||
|
techBookingsByDay, periodLoadH, techsActiveOnDay, dayJobCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/composables/useUndo.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// ── Undo stack composable ────────────────────────────────────────────────────
|
||||||
|
import { ref, nextTick } from 'vue'
|
||||||
|
import { updateJob } from 'src/api/dispatch'
|
||||||
|
|
||||||
|
export function useUndo (store, invalidateRoutes) {
|
||||||
|
const undoStack = ref([])
|
||||||
|
|
||||||
|
function pushUndo (action) {
|
||||||
|
undoStack.value.push(action)
|
||||||
|
if (undoStack.value.length > 30) undoStack.value.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
function performUndo () {
|
||||||
|
const action = undoStack.value.pop()
|
||||||
|
if (!action) return
|
||||||
|
|
||||||
|
if (action.type === 'removeAssistant') {
|
||||||
|
store.addAssistant(action.jobId, action.techId)
|
||||||
|
nextTick(() => {
|
||||||
|
const job = store.jobs.find(j => j.id === action.jobId)
|
||||||
|
const a = job?.assistants.find(x => x.techId === action.techId)
|
||||||
|
if (a) { a.duration = action.duration; a.note = action.note }
|
||||||
|
updateJob(job.name || job.id, {
|
||||||
|
assistants: job.assistants.map(x => ({ tech_id: x.techId, tech_name: x.techName, duration_h: x.duration, note: x.note || '', pinned: x.pinned ? 1 : 0 })),
|
||||||
|
}).catch(() => {})
|
||||||
|
})
|
||||||
|
} else if (action.type === 'optimizeRoute') {
|
||||||
|
const tech = store.technicians.find(t => t.id === action.techId)
|
||||||
|
if (tech) {
|
||||||
|
tech.queue = action.prevQueue
|
||||||
|
action.prevQueue.forEach((j, i) => { j.routeOrder = i })
|
||||||
|
}
|
||||||
|
} else if (action.type === 'autoDistribute') {
|
||||||
|
// Unassign all the jobs that were auto-assigned
|
||||||
|
action.assignments.forEach(a => {
|
||||||
|
const job = store.jobs.find(j => j.id === a.jobId)
|
||||||
|
if (job) {
|
||||||
|
store.technicians.forEach(t => { t.queue = t.queue.filter(q => q.id !== a.jobId) })
|
||||||
|
job.assignedTech = a.techId || null
|
||||||
|
job.status = a.techId ? 'assigned' : 'open'
|
||||||
|
job.scheduledDate = a.scheduledDate
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Restore original queues
|
||||||
|
if (action.prevQueues) {
|
||||||
|
store.technicians.forEach(t => {
|
||||||
|
if (action.prevQueues[t.id]) t.queue = action.prevQueues[t.id]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (action.type === 'unassignJob') {
|
||||||
|
store.assignJobToTech(action.jobId, action.techId, action.routeOrder, action.scheduledDate)
|
||||||
|
nextTick(() => {
|
||||||
|
const job = store.jobs.find(j => j.id === action.jobId)
|
||||||
|
if (job && action.assistants?.length) {
|
||||||
|
job.assistants = action.assistants
|
||||||
|
updateJob(job.name || job.id, {
|
||||||
|
assistants: job.assistants.map(x => ({ tech_id: x.techId, tech_name: x.techName, duration_h: x.duration, note: x.note || '', pinned: x.pinned ? 1 : 0 })),
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { undoStack, pushUndo, performUndo }
|
||||||
|
}
|
||||||
24
src/config/erpnext.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// ── ERPNext connection config ────────────────────────────────────────────────
|
||||||
|
// To host the app separately from ERPNext (e.g. Nginx, Vercel):
|
||||||
|
// - Set BASE_URL to 'https://your-erpnext.example.com'
|
||||||
|
// - Add CORS + session/JWT config on the ERPNext side
|
||||||
|
// - Update api/auth.js if switching from session cookie to JWT
|
||||||
|
// For same-origin (ERPNext serves the app): keep BASE_URL as empty string.
|
||||||
|
|
||||||
|
export const BASE_URL = ''
|
||||||
|
|
||||||
|
// Mapbox public token — safe to expose (scope-limited in Mapbox dashboard)
|
||||||
|
export const MAPBOX_TOKEN = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
|
||||||
|
|
||||||
|
export const TECH_COLORS = [
|
||||||
|
'#6366f1', // Indigo
|
||||||
|
'#10b981', // Emerald
|
||||||
|
'#f59e0b', // Amber
|
||||||
|
'#8b5cf6', // Violet
|
||||||
|
'#06b6d4', // Cyan
|
||||||
|
'#f43f5e', // Rose
|
||||||
|
'#f97316', // Orange
|
||||||
|
'#14b8a6', // Teal
|
||||||
|
'#d946ef', // Fuchsia
|
||||||
|
'#3b82f6', // Blue
|
||||||
|
]
|
||||||
41
src/css/app.scss
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// ── Global CSS variables ────────────────────────────────────────────────────
|
||||||
|
// Shared between DispatchPage and MobilePage.
|
||||||
|
// To add a new theme: duplicate the :root block with a body class selector.
|
||||||
|
|
||||||
|
:root {
|
||||||
|
// Dark theme (default for dispatch desktop)
|
||||||
|
--bg: #0b0f1a;
|
||||||
|
--sidebar-bg: rgba(15, 23, 42, 0.9);
|
||||||
|
--card-bg: rgba(30, 41, 59, 0.5);
|
||||||
|
--card-hover: rgba(51, 65, 85, 0.6);
|
||||||
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
|
--border-accent: rgba(99, 102, 241, 0.3);
|
||||||
|
--text-primary: #f8fafc;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-glow: rgba(99, 102, 241, 0.3);
|
||||||
|
--green: #10b981;
|
||||||
|
--green-glow: rgba(16, 185, 129, 0.2);
|
||||||
|
--orange: #f59e0b;
|
||||||
|
--red: #f43f5e;
|
||||||
|
--card-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-mode {
|
||||||
|
--bg: #ffffff;
|
||||||
|
--sidebar-bg: #ffffff;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--card-hover: #f1f5f9;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--border-accent: #cbd5e1;
|
||||||
|
--text-primary: #0f172a;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--accent-glow: rgba(79, 70, 229, 0.1);
|
||||||
|
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
// Quasar resets some of these — keep them consistent
|
||||||
|
html, body { height: 100%; }
|
||||||
120
src/modules/dispatch/components/BottomPanel.vue
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { fmtDur, shortAddr, prioLabel, prioClass, prioColor, dayLoadColor, ICON } from 'src/composables/useHelpers'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: Boolean,
|
||||||
|
height: Number,
|
||||||
|
groups: Array,
|
||||||
|
unscheduledCount: Number,
|
||||||
|
selected: Object, // Set
|
||||||
|
dropActive: Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'update:open', 'update:height', 'resize-start',
|
||||||
|
'toggle-select', 'select-all', 'clear-select', 'batch-assign',
|
||||||
|
'auto-distribute', 'open-criteria',
|
||||||
|
'row-click', 'row-dblclick', 'row-dragstart',
|
||||||
|
'drop-unassign',
|
||||||
|
])
|
||||||
|
|
||||||
|
const store = inject('store')
|
||||||
|
const TECH_COLORS = inject('TECH_COLORS')
|
||||||
|
const jobColor = inject('jobColor')
|
||||||
|
const btColW = inject('btColW')
|
||||||
|
const startColResize = inject('startColResize')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="open" class="sb-bottom-panel" :style="'height:'+height+'px'">
|
||||||
|
<div class="sb-bottom-resize" @mousedown.prevent="emit('resize-start', $event)"></div>
|
||||||
|
<div class="sb-bottom-hdr">
|
||||||
|
<span class="sb-bottom-title">
|
||||||
|
Jobs non assignées
|
||||||
|
<span class="sbf-count">{{ unscheduledCount }}</span>
|
||||||
|
</span>
|
||||||
|
<button v-if="unscheduledCount" class="sbf-auto-btn" @click="emit('auto-distribute')" title="Répartir automatiquement">⚡ Répartir auto</button>
|
||||||
|
<button class="sbf-auto-btn" style="border-color:rgba(255,255,255,0.12)" @click="emit('open-criteria')" title="Critères de dispatch">⚙ Critères</button>
|
||||||
|
<!-- Batch assign bar -->
|
||||||
|
<template v-if="selected.size">
|
||||||
|
<span class="sb-bottom-sel-count">{{ selected.size }} sélectionnée{{ selected.size>1?'s':'' }}</span>
|
||||||
|
<span class="sb-bottom-sel-lbl">→</span>
|
||||||
|
<button v-for="t in store.technicians" :key="t.id" class="sb-multi-tech"
|
||||||
|
:style="'border-color:'+TECH_COLORS[t.colorIdx]" :title="t.fullName"
|
||||||
|
@click="emit('batch-assign', t.id)">
|
||||||
|
{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
|
||||||
|
</button>
|
||||||
|
<button class="sb-bottom-sel-clear" @click="emit('clear-select')">✕</button>
|
||||||
|
</template>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button v-if="unscheduledCount" class="sb-bottom-sel-all" @click="emit('select-all')" title="Tout sélectionner">☐ Tout</button>
|
||||||
|
<button class="sb-bottom-close" @click="emit('update:open', false)">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="sb-bottom-body"
|
||||||
|
:class="{ 'sbf-drop-active': dropActive }"
|
||||||
|
@dragover.prevent="$emit('drop-unassign', $event, 'over')"
|
||||||
|
@dragleave="$emit('drop-unassign', $event, 'leave')"
|
||||||
|
@drop="$emit('drop-unassign', $event, 'drop')">
|
||||||
|
<div v-if="dropActive" class="sbf-drop-hint" style="margin:4px">↩ Désaffecter ici</div>
|
||||||
|
<table class="sb-bottom-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="sb-bt-chk" style="width:28px"></th>
|
||||||
|
<th class="sb-bt-prio" style="width:12px"></th>
|
||||||
|
<th class="sb-bt-name" :style="'width:'+btColW('name',200)"><span>Nom</span><div class="sb-col-resize" @mousedown="startColResize($event,'name')"></div></th>
|
||||||
|
<th class="sb-bt-addr" :style="'width:'+btColW('addr',180)"><span>Adresse</span><div class="sb-col-resize" @mousedown="startColResize($event,'addr')"></div></th>
|
||||||
|
<th class="sb-bt-dur" :style="'width:'+btColW('dur',130)"><span>Durée</span><div class="sb-col-resize" @mousedown="startColResize($event,'dur')"></div></th>
|
||||||
|
<th class="sb-bt-prio-lbl" style="width:70px">Priorité</th>
|
||||||
|
<th class="sb-bt-tags">Skills / Tags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
<div class="sb-bottom-scroll">
|
||||||
|
<template v-for="group in groups" :key="group.date||'nodate'">
|
||||||
|
<div class="sb-bottom-date-sep">
|
||||||
|
<span class="sb-bottom-date-label">{{ group.label }}</span>
|
||||||
|
<span class="sb-bottom-date-count">{{ group.jobs.length }}</span>
|
||||||
|
</div>
|
||||||
|
<table class="sb-bottom-table">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="job in group.jobs" :key="job.id"
|
||||||
|
class="sb-bottom-row" :class="{ 'sb-bottom-row-sel': selected.has(job.id) }"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="emit('row-dragstart', $event, job)"
|
||||||
|
@click="emit('row-click', job, $event)"
|
||||||
|
@dblclick.stop="emit('row-dblclick', job)">
|
||||||
|
<td class="sb-bt-chk" style="width:28px" @click.stop="emit('toggle-select', job.id, $event)">
|
||||||
|
<span class="sb-bt-checkbox" :class="{ checked: selected.has(job.id) }"></span>
|
||||||
|
</td>
|
||||||
|
<td class="sb-bt-prio" style="width:12px">
|
||||||
|
<span class="sb-bt-prio-dot" :style="'background:'+prioColor(job.priority)" :title="prioLabel(job.priority)"></span>
|
||||||
|
</td>
|
||||||
|
<td class="sb-bt-name" :style="'width:'+btColW('name',200)">
|
||||||
|
<span class="sb-bt-name-text">{{ job.subject }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="sb-bt-addr" :style="'width:'+btColW('addr',180)">{{ shortAddr(job.address) || '—' }}</td>
|
||||||
|
<td class="sb-bt-dur" :style="'width:'+btColW('dur',130)">
|
||||||
|
<div class="sb-bt-dur-wrap">
|
||||||
|
<div class="sb-bt-dur-bar">
|
||||||
|
<div class="sb-bt-dur-fill" :style="{ width: Math.min(100,(parseFloat(job.duration)||0)/8*100)+'%', background: dayLoadColor((parseFloat(job.duration)||0)/8) }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="sb-bt-dur-lbl">{{ fmtDur(job.duration) }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="sb-bt-prio-lbl" style="width:70px">
|
||||||
|
<span :class="prioClass(job.priority)" class="sb-bt-prio-tag">{{ prioLabel(job.priority) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="sb-bt-tags">
|
||||||
|
<span v-for="t in (job.tags||[])" :key="t" class="sb-bt-skill-chip">{{ t }}</span>
|
||||||
|
<span v-if="!(job.tags||[]).length" class="sb-bt-no-tag">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
<div v-if="!unscheduledCount" class="sbf-empty" style="padding:1rem;text-align:center">Aucune job non assignée</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
80
src/modules/dispatch/components/JobEditModal.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { ICON } from 'src/composables/useHelpers'
|
||||||
|
import TagInput from 'src/components/TagInput.vue'
|
||||||
|
|
||||||
|
const props = defineProps({ modelValue: Object }) // { job, subject, address, note, duration, priority, tags, latitude, longitude }
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
|
||||||
|
|
||||||
|
const store = inject('store')
|
||||||
|
const MAPBOX_TOKEN = inject('MAPBOX_TOKEN')
|
||||||
|
const getTagColor = inject('getTagColor')
|
||||||
|
const onCreateTag = inject('onCreateTag')
|
||||||
|
const searchAddr = inject('searchAddr')
|
||||||
|
const addrResults = inject('addrResults')
|
||||||
|
const selectAddr = inject('selectAddr')
|
||||||
|
|
||||||
|
function close () { emit('update:modelValue', null); emit('cancel') }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="modelValue" class="sb-overlay" @click.self="close">
|
||||||
|
<div class="sb-modal sb-modal-wo">
|
||||||
|
<div class="sb-modal-hdr">
|
||||||
|
<span>✏ Modifier la job</span>
|
||||||
|
<button class="sb-rp-close" @click="close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="sb-modal-body sb-wo-body">
|
||||||
|
<div class="sb-wo-form">
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Titre</label>
|
||||||
|
<input class="sb-form-input" v-model="modelValue.subject" autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Adresse</label>
|
||||||
|
<div class="sb-addr-wrap">
|
||||||
|
<input class="sb-form-input" v-model="modelValue.address" placeholder="123 rue Exemple"
|
||||||
|
@input="searchAddr(modelValue.address)" @blur="setTimeout(()=>addrResults.length=0,200)" autocomplete="off" />
|
||||||
|
<div v-if="addrResults.length" class="sb-addr-dropdown">
|
||||||
|
<div v-for="a in addrResults" :key="a.address_full" class="sb-addr-item"
|
||||||
|
@mousedown.prevent="selectAddr(a, modelValue)">
|
||||||
|
<strong>{{ a.address_full }}</strong>
|
||||||
|
<span v-if="a.code_postal" class="sb-addr-cp">{{ a.code_postal }}</span>
|
||||||
|
<span v-if="a.ville" class="sb-addr-city">{{ a.ville }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Note</label>
|
||||||
|
<textarea class="sb-form-input" v-model="modelValue.note" rows="2" placeholder="Ex: chien dangereux, sonner 2x…" style="resize:vertical"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Durée (h)</label>
|
||||||
|
<input type="number" class="sb-form-input" v-model.number="modelValue.duration" min="0.25" max="24" step="0.25" />
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Priorité</label>
|
||||||
|
<select class="sb-form-sel" v-model="modelValue.priority">
|
||||||
|
<option value="low">Basse</option>
|
||||||
|
<option value="medium">Moyenne</option>
|
||||||
|
<option value="high">Haute</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Tags / Skills</label>
|
||||||
|
<TagInput v-model="modelValue.tags" :all-tags="store.allTags" :get-color="getTagColor" @create="onCreateTag" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="modelValue.latitude" class="sb-wo-minimap">
|
||||||
|
<img :src="'https://api.mapbox.com/styles/v1/mapbox/dark-v11/static/pin-s+6366f1('+modelValue.longitude+','+modelValue.latitude+')/'+modelValue.longitude+','+modelValue.latitude+',14,0/280x280@2x?access_token='+MAPBOX_TOKEN"
|
||||||
|
alt="Carte" class="sb-minimap-img" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sb-modal-ftr">
|
||||||
|
<button class="sbf-primary-btn" @click="emit('confirm')">✓ Enregistrer</button>
|
||||||
|
<button class="sb-rp-btn" @click="close">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
128
src/modules/dispatch/components/TimelineRow.vue
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { ICON, fmtDur, shortAddr, jobStatusIcon, dayLoadColor, stOf } from 'src/composables/useHelpers'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
tech: Object,
|
||||||
|
segments: Array, // from techDayJobsWithTravel
|
||||||
|
hourTicks: Array,
|
||||||
|
totalW: Number,
|
||||||
|
pxPerHr: Number,
|
||||||
|
hStart: Number,
|
||||||
|
hEnd: Number,
|
||||||
|
rowH: Number,
|
||||||
|
isSelected: Boolean,
|
||||||
|
isElevated: Boolean,
|
||||||
|
dropGhostX: { type: Number, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'select-tech', 'ctx-tech', 'drag-tech-start', 'reorder-drop',
|
||||||
|
'timeline-dragover', 'timeline-dragleave', 'timeline-drop',
|
||||||
|
'job-dragstart', 'job-click', 'job-dblclick', 'job-ctx',
|
||||||
|
'assist-ctx', 'hover-job', 'unhover-job',
|
||||||
|
'block-move', 'block-resize',
|
||||||
|
])
|
||||||
|
|
||||||
|
const TECH_COLORS = inject('TECH_COLORS')
|
||||||
|
const jobColor = inject('jobColor')
|
||||||
|
const selectedJob = inject('selectedJob')
|
||||||
|
const hoveredJobId = inject('hoveredJobId')
|
||||||
|
const periodLoadH = inject('periodLoadH')
|
||||||
|
const getTagColor = inject('getTagColor')
|
||||||
|
const isJobMultiSelected = inject('isJobMultiSelected')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sb-row" :class="{ 'sb-row-sel': isSelected, 'sb-row-elevated': isElevated }"
|
||||||
|
:style="'height:'+rowH+'px'" :data-tech-id="tech.id">
|
||||||
|
|
||||||
|
<!-- Resource cell -->
|
||||||
|
<div class="sb-res-cell" @click="emit('select-tech', tech)" @contextmenu.prevent="emit('ctx-tech', $event, tech)"
|
||||||
|
draggable="true" @dragstart="emit('drag-tech-start', $event, tech)"
|
||||||
|
@dragover.prevent="()=>{}" @drop.prevent="emit('reorder-drop', $event, tech)">
|
||||||
|
<div class="sb-avatar" :style="'background:'+TECH_COLORS[tech.colorIdx]">
|
||||||
|
{{ tech.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
|
||||||
|
</div>
|
||||||
|
<div class="sb-res-info">
|
||||||
|
<div class="sb-res-name">{{ tech.fullName }}
|
||||||
|
<span v-for="t in (tech.tags||[]).slice(0,3)" :key="t" class="sb-res-tag-dot" :style="'background:'+getTagColor(t)" :title="t"></span>
|
||||||
|
</div>
|
||||||
|
<div class="sb-res-sub">
|
||||||
|
<span class="sb-st" :class="stOf(tech).cls">{{ stOf(tech).label }}</span>
|
||||||
|
<span class="sb-load">{{ fmtDur(periodLoadH(tech)) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sb-util-bar">
|
||||||
|
<div class="sb-util-fill" :style="{ width: Math.min(100,periodLoadH(tech)/8*100)+'%', background: dayLoadColor(periodLoadH(tech)/8) }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<div class="sb-timeline" :style="'width:'+totalW+'px'"
|
||||||
|
@dragover.prevent="emit('timeline-dragover', $event, tech)"
|
||||||
|
@dragleave="emit('timeline-dragleave', $event)"
|
||||||
|
@drop.prevent="emit('timeline-drop', $event, tech)">
|
||||||
|
<!-- Hour guides -->
|
||||||
|
<div v-for="tick in hourTicks.filter(t=>!t.isDay)" :key="'hg-'+tick.x"
|
||||||
|
class="sb-hour-guide" :style="'left:'+tick.x+'px'"></div>
|
||||||
|
<template v-for="h in (hEnd - hStart)" :key="'qg-'+h">
|
||||||
|
<div v-for="q in [1,2,3]" :key="'q-'+h+'-'+q" class="sb-quarter-guide"
|
||||||
|
:style="'left:'+(((h + q*0.25) * pxPerHr))+'px'"></div>
|
||||||
|
</template>
|
||||||
|
<div class="sb-capacity-line" :style="'left:'+((16 - hStart) * pxPerHr)+'px'" title="8h"></div>
|
||||||
|
<div v-if="dropGhostX!=null" class="sb-drop-line" :style="'left:'+dropGhostX+'px'"></div>
|
||||||
|
|
||||||
|
<template v-for="seg in segments" :key="seg.type+'-'+seg.job.id+(seg.isAssist?'-a':'')+(seg.type==='travel'?'-t':'')">
|
||||||
|
<!-- Travel -->
|
||||||
|
<div v-if="seg.type==='travel'" class="sb-travel-trail"
|
||||||
|
:class="[seg.fromRoute?'sb-travel-route':'sb-travel-est', seg.isAssist?'sb-travel-assist':'']"
|
||||||
|
:style="{ ...seg.style, background:seg.color+(seg.fromRoute?'40':'22'), borderLeft:'2px solid '+seg.color+(seg.fromRoute?'88':'44') }">
|
||||||
|
<span v-if="parseFloat(seg.style.width)>36" class="sb-travel-lbl">{{ seg.fromRoute?'':'~' }}{{ seg.travelMin }}min</span>
|
||||||
|
</div>
|
||||||
|
<!-- Assist block -->
|
||||||
|
<div v-else-if="seg.type==='assist'" class="sb-block sb-block-assist"
|
||||||
|
:class="{ 'sb-block-assist-pinned':seg.assistPinned, 'sb-block-sel':selectedJob?.isAssist&&selectedJob?.job?.id===seg.job.id&&selectedJob?.assistTechId===seg.assistTechId, 'sb-block-linked':(selectedJob?.job?.id===seg.job.id&&!selectedJob?.isAssist)||hoveredJobId===seg.job.id }"
|
||||||
|
:style="{ ...seg.style, background:((selectedJob?.job?.id===seg.job.id&&!selectedJob?.isAssist)||hoveredJobId===seg.job.id)?jobColor(seg.job)+'dd':(seg.assistPinned?jobColor(seg.job)+'99':jobColor(seg.job)+'44') }"
|
||||||
|
:draggable="seg.assistPinned?'true':'false'"
|
||||||
|
@dragstart="seg.assistPinned && emit('job-dragstart',$event,seg.job,tech.id,true)"
|
||||||
|
@mouseenter="emit('hover-job',seg.job.id)" @mouseleave="emit('unhover-job')"
|
||||||
|
@click.stop="emit('job-click',seg.job,seg.job.assignedTech,true,seg.assistTechId,$event)"
|
||||||
|
@dblclick.stop="emit('job-dblclick',seg.job)"
|
||||||
|
@contextmenu.prevent="emit('assist-ctx',$event,seg.job,seg.assistTechId)">
|
||||||
|
<div class="sb-block-color-bar" :style="'background:'+jobColor(seg.job)"></div>
|
||||||
|
<div class="sb-block-inner">
|
||||||
|
<div class="sb-block-title"><span v-if="seg.assistPinned" class="sb-block-pin" title="Priorisé" v-html="ICON.pin"></span>{{ seg.assistNote||seg.job.subject }}</div>
|
||||||
|
<div class="sb-block-meta">{{ fmtDur(seg.assistDur) }} · {{ seg.job.subject }}{{ seg.job.address?' · '+shortAddr(seg.job.address):'' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sb-resize-handle" @mousedown.stop.prevent="emit('block-resize',$event,seg.job,'assist',seg.assistTechId)"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Job block -->
|
||||||
|
<div v-else class="sb-block"
|
||||||
|
:class="{ 'sb-block-done':seg.job.status==='completed', 'sb-block-sel':selectedJob?.job?.id===seg.job.id&&!selectedJob?.isAssist, 'sb-block-multi':isJobMultiSelected(seg.job.id), 'sb-block-linked':selectedJob?.job?.id===seg.job.id&&selectedJob?.isAssist, 'sb-block-team':seg.job.assistants?.length }"
|
||||||
|
:style="{ ...seg.style, background:jobColor(seg.job)+'dd' }"
|
||||||
|
:data-job-id="seg.job.id" draggable="true"
|
||||||
|
@dragstart="emit('job-dragstart',$event,seg.job,tech.id,false)"
|
||||||
|
@mouseenter="emit('hover-job',seg.job.id)" @mouseleave="emit('unhover-job')"
|
||||||
|
@click.stop="emit('job-click',seg.job,tech.id,false,null,$event)"
|
||||||
|
@dblclick.stop="emit('job-dblclick',seg.job)"
|
||||||
|
@contextmenu.prevent="emit('job-ctx',$event,seg.job,tech.id)">
|
||||||
|
<div class="sb-move-handle" @mousedown.stop.prevent="emit('block-move',$event,seg.job,$event.target.parentElement)" title="Déplacer"></div>
|
||||||
|
<div class="sb-block-color-bar" :style="'background:'+jobColor(seg.job)"></div>
|
||||||
|
<div class="sb-block-inner">
|
||||||
|
<div class="sb-block-title"><span v-if="seg.pinned" class="sb-block-pin" title="Heure fixée" v-html="ICON.pin"></span>{{ seg.job.subject }}</div>
|
||||||
|
<div class="sb-block-meta">{{ seg.pinnedTime||'' }}{{ seg.pinnedTime?' · ':'' }}{{ fmtDur(seg.job.duration) }}</div>
|
||||||
|
<div v-if="seg.job.address" class="sb-block-addr"><span v-html="ICON.mapPin"></span> {{ shortAddr(seg.job.address) }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="seg.job.assistants?.length" class="sb-block-assistants">
|
||||||
|
<span v-for="a in seg.job.assistants" :key="a.techId" class="sb-assist-badge"
|
||||||
|
:style="'background:'+TECH_COLORS[$root?.$store?.technicians?.find(t=>t.id===a.techId)?.colorIdx||0]"
|
||||||
|
:title="a.techName">{{ (a.techName||'').split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="jobStatusIcon(seg.job).svg" class="sb-block-status-icon" :class="jobStatusIcon(seg.job).cls" :title="seg.job.status" v-html="jobStatusIcon(seg.job).svg"></span>
|
||||||
|
<div class="sb-resize-handle" @mousedown.stop.prevent="emit('block-resize',$event,seg.job,'job')"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
93
src/modules/dispatch/components/WoCreateModal.vue
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import TagInput from 'src/components/TagInput.vue'
|
||||||
|
|
||||||
|
const props = defineProps({ modelValue: Object })
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
|
||||||
|
|
||||||
|
const store = inject('store')
|
||||||
|
const MAPBOX_TOKEN = inject('MAPBOX_TOKEN')
|
||||||
|
const getTagColor = inject('getTagColor')
|
||||||
|
const onCreateTag = inject('onCreateTag')
|
||||||
|
const searchAddr = inject('searchAddr')
|
||||||
|
const addrResults = inject('addrResults')
|
||||||
|
const selectAddr = inject('selectAddr')
|
||||||
|
|
||||||
|
function close () { emit('update:modelValue', null); emit('cancel') }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="modelValue" class="sb-overlay" @click.self="close">
|
||||||
|
<div class="sb-modal sb-modal-wo">
|
||||||
|
<div class="sb-modal-hdr">
|
||||||
|
<span>+ Nouveau work order</span>
|
||||||
|
<button class="sb-rp-close" @click="close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="sb-modal-body sb-wo-body">
|
||||||
|
<div class="sb-wo-form">
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Titre *</label>
|
||||||
|
<input class="sb-form-input" v-model="modelValue.subject" placeholder="Ex: Remplacement modem" autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Adresse</label>
|
||||||
|
<div class="sb-addr-wrap">
|
||||||
|
<input class="sb-form-input" v-model="modelValue.address" placeholder="123 rue Exemple, Montréal"
|
||||||
|
@input="searchAddr(modelValue.address)" @blur="setTimeout(()=>addrResults.length=0,200)" autocomplete="off" />
|
||||||
|
<div v-if="addrResults.length" class="sb-addr-dropdown">
|
||||||
|
<div v-for="a in addrResults" :key="a.address_full" class="sb-addr-item"
|
||||||
|
@mousedown.prevent="selectAddr(a, modelValue)">
|
||||||
|
<strong>{{ a.address_full }}</strong>
|
||||||
|
<span v-if="a.code_postal" class="sb-addr-cp">{{ a.code_postal }}</span>
|
||||||
|
<span v-if="a.ville" class="sb-addr-city">{{ a.ville }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="modelValue.latitude" class="sb-addr-confirmed">
|
||||||
|
✓ {{ modelValue.ville || '' }} {{ modelValue.code_postal || '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Note</label>
|
||||||
|
<textarea class="sb-form-input" v-model="modelValue.note" rows="2" placeholder="Ex: chien dangereux, sonner 2x…" style="resize:vertical"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Durée (h)</label>
|
||||||
|
<input type="number" class="sb-form-input" v-model.number="modelValue.duration_h" min="0.5" max="12" step="0.5" />
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Priorité</label>
|
||||||
|
<select class="sb-form-sel" v-model="modelValue.priority">
|
||||||
|
<option value="low">Basse</option>
|
||||||
|
<option value="medium">Moyenne</option>
|
||||||
|
<option value="high">Haute</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Tags / Skills</label>
|
||||||
|
<TagInput v-model="modelValue.tags" :all-tags="store.allTags" :get-color="getTagColor" @create="onCreateTag" />
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Technicien</label>
|
||||||
|
<select class="sb-form-sel" v-model="modelValue.techId">
|
||||||
|
<option value="">— Non assigné —</option>
|
||||||
|
<option v-for="t in store.technicians" :key="t.id" :value="t.id">{{ t.fullName }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row" v-if="modelValue.techId">
|
||||||
|
<label class="sb-form-lbl">Date planifiée</label>
|
||||||
|
<input type="date" class="sb-form-input" v-model="modelValue.date" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="modelValue.latitude" class="sb-wo-minimap">
|
||||||
|
<img :src="'https://api.mapbox.com/styles/v1/mapbox/dark-v11/static/pin-s+6366f1('+modelValue.longitude+','+modelValue.latitude+')/'+modelValue.longitude+','+modelValue.latitude+',14,0/280x280@2x?access_token='+MAPBOX_TOKEN"
|
||||||
|
alt="Carte" class="sb-minimap-img" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sb-modal-ftr">
|
||||||
|
<button class="sbf-primary-btn" :disabled="!modelValue.subject?.trim()" @click="emit('confirm')">✓ Créer</button>
|
||||||
|
<button class="sb-rp-btn" @click="close">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
557
src/pages/AdminPage.vue
Normal file
|
|
@ -0,0 +1,557 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { fetchSettings, saveSettings, createDocType } from 'src/api/settings'
|
||||||
|
import { MAPBOX_TOKEN } from 'src/config/erpnext'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// ── Valeurs par défaut (pré-remplissage) ────────────────────────────────────
|
||||||
|
const form = ref({
|
||||||
|
// ERPNext
|
||||||
|
erp_url: window.location.origin,
|
||||||
|
erp_api_key: '',
|
||||||
|
erp_api_secret: '',
|
||||||
|
// Mapbox
|
||||||
|
mapbox_token: MAPBOX_TOKEN,
|
||||||
|
// Twilio
|
||||||
|
twilio_account_sid: '',
|
||||||
|
twilio_auth_token: '',
|
||||||
|
twilio_from_number: '',
|
||||||
|
// Stripe
|
||||||
|
stripe_mode: 'test',
|
||||||
|
stripe_publishable_key: '',
|
||||||
|
stripe_secret_key: '',
|
||||||
|
stripe_webhook_secret:'',
|
||||||
|
// n8n
|
||||||
|
n8n_url: 'http://localhost:5678',
|
||||||
|
n8n_api_key: '',
|
||||||
|
n8n_webhook_base: 'http://localhost:5678/webhook',
|
||||||
|
// Templates SMS
|
||||||
|
sms_enroute: 'Bonjour {client_name}, votre technicien {tech_name} est en route et arrivera dans environ {eta} minutes. Réf: {job_id}',
|
||||||
|
sms_completed: 'Bonjour {client_name}, votre service ({job_id}) a été complété avec succès. Merci de votre confiance !',
|
||||||
|
sms_assigned: 'Nouveau job assigné : {job_id} — {client_name}, {address}. Durée estimée : {duration}h.',
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── État page ────────────────────────────────────────────────────────────────
|
||||||
|
const loading = ref(true)
|
||||||
|
const docTypeError = ref(false)
|
||||||
|
const initStatus = ref(null) // null | 'creating' | 'done' | 'error'
|
||||||
|
const initError = ref('')
|
||||||
|
const saveStatus = ref(null) // null | 'saving' | 'saved' | 'error'
|
||||||
|
const saveError = ref('')
|
||||||
|
|
||||||
|
// ── Statuts de connexion ─────────────────────────────────────────────────────
|
||||||
|
const st = ref({ erp: null, mapbox: null, twilio: null, stripe: null, n8n: null })
|
||||||
|
// null | 'testing' | 'ok' | 'error' | 'warn'
|
||||||
|
|
||||||
|
// ── Révéler / masquer les mots de passe ──────────────────────────────────────
|
||||||
|
const show = ref({
|
||||||
|
erp_api_secret: false, twilio_auth_token: false,
|
||||||
|
stripe_secret_key: false, stripe_webhook_secret: false, n8n_api_key: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Chargement initial ───────────────────────────────────────────────────────
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchSettings()
|
||||||
|
Object.keys(form.value).forEach(k => {
|
||||||
|
if (data[k] !== undefined && data[k] !== null && data[k] !== '') {
|
||||||
|
form.value[k] = data[k]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message === 'DOCTYPE_NOT_FOUND') docTypeError.value = true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Sauvegarde ───────────────────────────────────────────────────────────────
|
||||||
|
async function init () {
|
||||||
|
initStatus.value = 'creating'
|
||||||
|
initError.value = ''
|
||||||
|
try {
|
||||||
|
await createDocType()
|
||||||
|
initStatus.value = 'done'
|
||||||
|
docTypeError.value = false
|
||||||
|
// Reload settings after creation
|
||||||
|
const data = await fetchSettings().catch(() => ({}))
|
||||||
|
Object.keys(form.value).forEach(k => {
|
||||||
|
if (data[k] !== undefined && data[k] !== null && data[k] !== '') form.value[k] = data[k]
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
initStatus.value = 'error'
|
||||||
|
initError.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save () {
|
||||||
|
saveStatus.value = 'saving'
|
||||||
|
saveError.value = ''
|
||||||
|
try {
|
||||||
|
await saveSettings(form.value)
|
||||||
|
saveStatus.value = 'saved'
|
||||||
|
setTimeout(() => { saveStatus.value = null }, 2500)
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message === 'DOCTYPE_NOT_FOUND') { docTypeError.value = true }
|
||||||
|
saveStatus.value = 'error'
|
||||||
|
saveError.value = e.message === 'DOCTYPE_NOT_FOUND'
|
||||||
|
? 'DocType manquant — cliquez sur Initialiser'
|
||||||
|
: e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests de connexion ───────────────────────────────────────────────────────
|
||||||
|
async function testErp () {
|
||||||
|
st.value.erp = 'testing'
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${form.value.erp_url}/api/method/frappe.auth.get_logged_user`, { credentials: 'include' })
|
||||||
|
const d = await r.json()
|
||||||
|
st.value.erp = (d.message && d.message !== 'Guest') ? 'ok' : 'error'
|
||||||
|
} catch { st.value.erp = 'error' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testMapbox () {
|
||||||
|
st.value.mapbox = 'testing'
|
||||||
|
try {
|
||||||
|
const r = await fetch(`https://api.mapbox.com/tokens/v2?access_token=${form.value.mapbox_token}`)
|
||||||
|
st.value.mapbox = r.ok ? 'ok' : 'error'
|
||||||
|
} catch { st.value.mapbox = 'error' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function testTwilio () {
|
||||||
|
const sid = form.value.twilio_account_sid
|
||||||
|
if (!sid) { st.value.twilio = 'warn'; return }
|
||||||
|
st.value.twilio = (sid.startsWith('AC') && sid.length === 34) ? 'ok' : 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
function testStripe () {
|
||||||
|
const key = form.value.stripe_secret_key
|
||||||
|
if (!key) { st.value.stripe = 'warn'; return }
|
||||||
|
st.value.stripe = (key.startsWith('sk_test_') || key.startsWith('sk_live_')) ? 'ok' : 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testN8n () {
|
||||||
|
st.value.n8n = 'testing'
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${form.value.n8n_url}/healthz`)
|
||||||
|
st.value.n8n = r.ok ? 'ok' : 'error'
|
||||||
|
} catch { st.value.n8n = 'error' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function stLabel (s) {
|
||||||
|
return { ok: '● Connecté', error: '✗ Erreur', warn: '○ Non configuré', testing: '… Test…' }[s] ?? '○ Non testé'
|
||||||
|
}
|
||||||
|
function stClass (s) {
|
||||||
|
return { ok: 'st-ok', error: 'st-error', warn: 'st-warn', testing: 'st-testing' }[s] ?? 'st-none'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-root">
|
||||||
|
|
||||||
|
<!-- ── Header ── -->
|
||||||
|
<div class="admin-header">
|
||||||
|
<div class="admin-header-left">
|
||||||
|
<button class="btn-back" @click="router.push('/')">← Dispatch</button>
|
||||||
|
<div class="admin-title">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||||||
|
</svg>
|
||||||
|
Paramètres de la plateforme
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-header-right">
|
||||||
|
<span v-if="saveStatus === 'saved'" class="save-feedback ok">✓ Sauvegardé</span>
|
||||||
|
<span v-if="saveStatus === 'error'" class="save-feedback err">✗ {{ saveError }}</span>
|
||||||
|
<button class="btn-save" :disabled="saveStatus === 'saving' || docTypeError" @click="save">
|
||||||
|
{{ saveStatus === 'saving' ? 'Sauvegarde…' : 'Sauvegarder' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── DocType manquant — bouton d'initialisation ── -->
|
||||||
|
<div v-if="docTypeError" class="doctype-error">
|
||||||
|
<strong>⚠ Première utilisation — DocType non initialisé</strong>
|
||||||
|
<p>
|
||||||
|
Le DocType <code>Dispatch Settings</code> n'existe pas encore dans ERPNext.
|
||||||
|
Cliquez sur le bouton ci-dessous pour le créer automatiquement.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;align-items:center;gap:1rem;margin-top:0.75rem;flex-wrap:wrap;">
|
||||||
|
<button class="btn-init" :disabled="initStatus === 'creating'" @click="init">
|
||||||
|
{{ initStatus === 'creating' ? '⏳ Création en cours…' : '⚡ Initialiser dans ERPNext' }}
|
||||||
|
</button>
|
||||||
|
<span v-if="initStatus === 'done'" style="color:#10b981;font-weight:700;">✓ DocType créé — paramètres disponibles</span>
|
||||||
|
<span v-if="initStatus === 'error'" style="color:#f43f5e;font-size:0.8rem;">✗ {{ initError }}</span>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top:0.75rem;font-size:0.78rem;color:#64748b;">
|
||||||
|
Requiert le rôle <strong>System Manager</strong> dans ERPNext.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Chargement ── -->
|
||||||
|
<div v-if="loading" class="loading-state">Chargement des paramètres…</div>
|
||||||
|
|
||||||
|
<!-- ── Formulaire ── -->
|
||||||
|
<div v-else-if="!docTypeError" class="admin-body">
|
||||||
|
|
||||||
|
<!-- ── ERPNext ── -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-icon">🔗</span>
|
||||||
|
<span class="card-title">ERPNext / Frappe</span>
|
||||||
|
<span class="st-badge" :class="stClass(st.erp)">{{ stLabel(st.erp) }}</span>
|
||||||
|
<button class="btn-test" @click="testErp">Tester</button>
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>URL du serveur</label>
|
||||||
|
<input v-model="form.erp_url" type="text" placeholder="http://localhost:8080" />
|
||||||
|
<span class="field-hint">Vide = même origine que l'app</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>API Key</label>
|
||||||
|
<input v-model="form.erp_api_key" type="text" placeholder="Profil → API Access → API Key" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>API Secret</label>
|
||||||
|
<div class="input-pw">
|
||||||
|
<input v-model="form.erp_api_secret" :type="show.erp_api_secret ? 'text' : 'password'" placeholder="••••••••••••••" />
|
||||||
|
<button class="btn-reveal" @click="show.erp_api_secret = !show.erp_api_secret">{{ show.erp_api_secret ? '🙈' : '👁' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Mapbox ── -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-icon">🗺️</span>
|
||||||
|
<span class="card-title">Mapbox</span>
|
||||||
|
<span class="st-badge" :class="stClass(st.mapbox)">{{ stLabel(st.mapbox) }}</span>
|
||||||
|
<button class="btn-test" @click="testMapbox">Tester</button>
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>Token public (pk_…)</label>
|
||||||
|
<input v-model="form.mapbox_token" type="text" placeholder="pk.eyJ1Ij…" />
|
||||||
|
<span class="field-hint">Token public — visible navigateur. Limitez le scope dans le dashboard Mapbox.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Twilio ── -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-icon">💬</span>
|
||||||
|
<span class="card-title">Twilio — SMS</span>
|
||||||
|
<span class="st-badge" :class="stClass(st.twilio)">{{ stLabel(st.twilio) }}</span>
|
||||||
|
<button class="btn-test" @click="testTwilio">Vérifier</button>
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Account SID</label>
|
||||||
|
<input v-model="form.twilio_account_sid" type="text" placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" />
|
||||||
|
<span class="field-hint">Commence par AC, 34 caractères — console.twilio.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Auth Token</label>
|
||||||
|
<div class="input-pw">
|
||||||
|
<input v-model="form.twilio_auth_token" :type="show.twilio_auth_token ? 'text' : 'password'" placeholder="••••••••••••••" />
|
||||||
|
<button class="btn-reveal" @click="show.twilio_auth_token = !show.twilio_auth_token">{{ show.twilio_auth_token ? '🙈' : '👁' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="max-width:260px">
|
||||||
|
<label>Numéro expéditeur</label>
|
||||||
|
<input v-model="form.twilio_from_number" type="text" placeholder="+15141234567" />
|
||||||
|
<span class="field-hint">Format E.164 — numéro Twilio acheté</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Stripe ── -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-icon">💳</span>
|
||||||
|
<span class="card-title">Stripe — Paiements</span>
|
||||||
|
<span class="st-badge" :class="stClass(st.stripe)">{{ stLabel(st.stripe) }}</span>
|
||||||
|
<button class="btn-test" @click="testStripe">Vérifier</button>
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field" style="max-width:200px">
|
||||||
|
<label>Mode</label>
|
||||||
|
<select v-model="form.stripe_mode">
|
||||||
|
<option value="test">Test</option>
|
||||||
|
<option value="live">Production (live)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Clé publique (pk_…)</label>
|
||||||
|
<input v-model="form.stripe_publishable_key" type="text" placeholder="pk_test_…" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Clé secrète (sk_…)</label>
|
||||||
|
<div class="input-pw">
|
||||||
|
<input v-model="form.stripe_secret_key" :type="show.stripe_secret_key ? 'text' : 'password'" placeholder="sk_test_…" />
|
||||||
|
<button class="btn-reveal" @click="show.stripe_secret_key = !show.stripe_secret_key">{{ show.stripe_secret_key ? '🙈' : '👁' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Webhook Secret (whsec_…)</label>
|
||||||
|
<div class="input-pw">
|
||||||
|
<input v-model="form.stripe_webhook_secret" :type="show.stripe_webhook_secret ? 'text' : 'password'" placeholder="whsec_…" />
|
||||||
|
<button class="btn-reveal" @click="show.stripe_webhook_secret = !show.stripe_webhook_secret">{{ show.stripe_webhook_secret ? '🙈' : '👁' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── n8n ── -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-icon">⚙️</span>
|
||||||
|
<span class="card-title">n8n — Automatisation</span>
|
||||||
|
<span class="st-badge" :class="stClass(st.n8n)">{{ stLabel(st.n8n) }}</span>
|
||||||
|
<button class="btn-test" @click="testN8n">Tester</button>
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>URL n8n</label>
|
||||||
|
<input v-model="form.n8n_url" type="text" placeholder="http://localhost:5678" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>API Key n8n</label>
|
||||||
|
<div class="input-pw">
|
||||||
|
<input v-model="form.n8n_api_key" :type="show.n8n_api_key ? 'text' : 'password'" placeholder="••••••••••••••" />
|
||||||
|
<button class="btn-reveal" @click="show.n8n_api_key = !show.n8n_api_key">{{ show.n8n_api_key ? '🙈' : '👁' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Base URL webhooks ERPNext → n8n</label>
|
||||||
|
<input v-model="form.n8n_webhook_base" type="text" placeholder="http://localhost:5678/webhook" />
|
||||||
|
<span class="field-hint">Préfixe utilisé pour configurer les webhooks ERPNext. Ex: {base}/job-enroute</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Templates SMS ── -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-icon">📝</span>
|
||||||
|
<span class="card-title">Templates SMS</span>
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<span class="field-hint" style="margin-bottom:0.75rem;display:block;">
|
||||||
|
Variables disponibles : <code>{client_name}</code> <code>{tech_name}</code> <code>{job_id}</code> <code>{eta}</code> <code>{address}</code> <code>{duration}</code>
|
||||||
|
</span>
|
||||||
|
<div class="field">
|
||||||
|
<label>Technicien en route</label>
|
||||||
|
<textarea v-model="form.sms_enroute" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Service complété</label>
|
||||||
|
<textarea v-model="form.sms_completed" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Job assigné (notification technicien)</label>
|
||||||
|
<textarea v-model="form.sms_assigned" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Bouton bas ── -->
|
||||||
|
<div class="bottom-bar">
|
||||||
|
<span v-if="saveStatus === 'saved'" class="save-feedback ok">✓ Paramètres sauvegardés dans ERPNext</span>
|
||||||
|
<span v-if="saveStatus === 'error'" class="save-feedback err">✗ {{ saveError }}</span>
|
||||||
|
<button class="btn-save large" :disabled="saveStatus === 'saving'" @click="save">
|
||||||
|
{{ saveStatus === 'saving' ? 'Sauvegarde…' : 'Sauvegarder les paramètres' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Thème (reprend les variables CSS de DispatchPage) ── */
|
||||||
|
.admin-root {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg, #0f1117);
|
||||||
|
color: var(--text-primary, #f1f5f9);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.admin-header {
|
||||||
|
position: sticky; top: 0; z-index: 20;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--sidebar-bg, #161b27);
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.admin-header-left { display: flex; align-items: center; gap: 1rem; }
|
||||||
|
.admin-header-right { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
|
.admin-title {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
font-size: 1rem; font-weight: 700; color: var(--text-primary, #f1f5f9);
|
||||||
|
}
|
||||||
|
.btn-back {
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
border-radius: 6px; padding: 0.3rem 0.75rem;
|
||||||
|
cursor: pointer; font-size: 0.8rem; font-weight: 600;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.btn-back:hover { color: var(--text-primary, #f1f5f9); border-color: var(--accent, #6366f1); }
|
||||||
|
.btn-save {
|
||||||
|
background: var(--accent, #6366f1); border: none; color: white;
|
||||||
|
border-radius: 8px; padding: 0.45rem 1.25rem;
|
||||||
|
cursor: pointer; font-size: 0.82rem; font-weight: 700;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn-save:hover:not(:disabled) { opacity: 0.85; }
|
||||||
|
.btn-save:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.btn-save.large { padding: 0.6rem 2rem; font-size: 0.9rem; }
|
||||||
|
.save-feedback { font-size: 0.8rem; font-weight: 600; }
|
||||||
|
.save-feedback.ok { color: #10b981; }
|
||||||
|
.save-feedback.err { color: #f43f5e; }
|
||||||
|
|
||||||
|
/* ── Body ── */
|
||||||
|
.admin-body {
|
||||||
|
max-width: 860px; margin: 0 auto;
|
||||||
|
padding: 1.5rem 1.5rem 4rem;
|
||||||
|
display: flex; flex-direction: column; gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards ── */
|
||||||
|
.settings-card {
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 12px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
padding: 0.9rem 1.25rem;
|
||||||
|
background: var(--sidebar-bg, #161b27);
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
}
|
||||||
|
.card-icon { font-size: 1.1rem; }
|
||||||
|
.card-title { font-size: 0.9rem; font-weight: 700; flex: 1; }
|
||||||
|
|
||||||
|
/* ── Status badges ── */
|
||||||
|
.st-badge {
|
||||||
|
font-size: 0.7rem; font-weight: 700; padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 8px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.st-ok { background: rgba(16,185,129,0.15); color: #10b981; }
|
||||||
|
.st-error { background: rgba(244,63,94,0.15); color: #f43f5e; }
|
||||||
|
.st-warn { background: rgba(245,158,11,0.15); color: #f59e0b; }
|
||||||
|
.st-testing { background: rgba(99,102,241,0.15); color: #6366f1; }
|
||||||
|
.st-none { background: rgba(148,163,184,0.1); color: #64748b; }
|
||||||
|
|
||||||
|
.btn-test {
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
border-radius: 6px; padding: 0.2rem 0.6rem;
|
||||||
|
cursor: pointer; font-size: 0.72rem; font-weight: 600;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.btn-test:hover { border-color: var(--accent, #6366f1); color: var(--accent, #6366f1); }
|
||||||
|
|
||||||
|
/* ── Fields ── */
|
||||||
|
.fields { padding: 1.1rem 1.25rem; display: flex; flex-direction: column; gap: 0.9rem; }
|
||||||
|
.field-row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
.field-row .field { flex: 1; min-width: 180px; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.field label {
|
||||||
|
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em; color: var(--text-secondary, #94a3b8);
|
||||||
|
}
|
||||||
|
.field input, .field select, .field textarea {
|
||||||
|
background: var(--bg, #0f1117);
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 6px; color: var(--text-primary, #f1f5f9);
|
||||||
|
padding: 0.45rem 0.75rem; font-size: 0.82rem;
|
||||||
|
font-family: 'Inter', monospace; outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.field input:focus, .field select:focus, .field textarea:focus {
|
||||||
|
border-color: var(--accent, #6366f1);
|
||||||
|
}
|
||||||
|
.field textarea { resize: vertical; line-height: 1.5; }
|
||||||
|
.field select { cursor: pointer; }
|
||||||
|
.field-hint { font-size: 0.7rem; color: var(--text-secondary, #64748b); line-height: 1.4; }
|
||||||
|
.field code {
|
||||||
|
font-family: monospace; font-size: 0.7rem;
|
||||||
|
background: rgba(99,102,241,0.12); color: #a5b4fc;
|
||||||
|
padding: 0.1rem 0.3rem; border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Password reveal ── */
|
||||||
|
.input-pw { display: flex; gap: 0; }
|
||||||
|
.input-pw input {
|
||||||
|
flex: 1; border-radius: 6px 0 0 6px;
|
||||||
|
border-right: none !important;
|
||||||
|
}
|
||||||
|
.btn-reveal {
|
||||||
|
background: var(--bg, #0f1117);
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-left: none; border-radius: 0 6px 6px 0;
|
||||||
|
padding: 0 0.55rem; cursor: pointer; font-size: 0.9rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-reveal:hover { background: var(--card-bg, rgba(255,255,255,0.04)); }
|
||||||
|
|
||||||
|
.btn-init {
|
||||||
|
background: var(--accent, #6366f1); border: none; color: white;
|
||||||
|
border-radius: 8px; padding: 0.55rem 1.25rem;
|
||||||
|
cursor: pointer; font-size: 0.85rem; font-weight: 700;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn-init:hover:not(:disabled) { opacity: 0.85; }
|
||||||
|
.btn-init:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Erreur DocType ── */
|
||||||
|
.doctype-error {
|
||||||
|
max-width: 780px; margin: 2rem auto;
|
||||||
|
background: rgba(244,63,94,0.08);
|
||||||
|
border: 1px solid rgba(244,63,94,0.25);
|
||||||
|
border-radius: 12px; padding: 1.5rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.doctype-error strong { color: #f43f5e; display: block; margin-bottom: 0.5rem; }
|
||||||
|
.doctype-error pre {
|
||||||
|
background: rgba(0,0,0,0.4); border-radius: 8px; padding: 0.9rem 1rem;
|
||||||
|
font-size: 0.75rem; white-space: pre-wrap; overflow-x: auto;
|
||||||
|
color: #a5b4fc; margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
.doctype-error code {
|
||||||
|
font-family: monospace; font-size: 0.82rem; color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chargement ── */
|
||||||
|
.loading-state {
|
||||||
|
text-align: center; padding: 4rem;
|
||||||
|
color: var(--text-secondary, #94a3b8); font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bottom bar ── */
|
||||||
|
.bottom-bar {
|
||||||
|
display: flex; align-items: center; justify-content: flex-end;
|
||||||
|
gap: 1rem; padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
583
src/pages/BookingPage.vue
Normal file
|
|
@ -0,0 +1,583 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { MAPBOX_TOKEN } from 'src/config/erpnext'
|
||||||
|
import { createServiceRequest } from 'src/api/service-request'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// ── Services télécom ──────────────────────────────────────────────────────────
|
||||||
|
const SERVICES = [
|
||||||
|
{ id: 'internet', icon: '🌐', label: 'Internet', desc: 'Connexion lente, coupures, Wi-Fi' },
|
||||||
|
{ id: 'tv', icon: '📺', label: 'Télévision', desc: 'Câble, satellite, IPTV, décodeur' },
|
||||||
|
{ id: 'telephone', icon: '📞', label: 'Téléphonie', desc: 'Résidentiel, VoIP, interphones' },
|
||||||
|
{ id: 'multi', icon: '🔧', label: 'Services multiples', desc: 'Problème combiné' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PROBLEMS = {
|
||||||
|
internet: [
|
||||||
|
'Pas de connexion internet', 'Connexion intermittente', 'Vitesse très lente',
|
||||||
|
'Signal Wi-Fi faible', 'Modem / routeur défaillant', 'Installation câblage réseau',
|
||||||
|
'Configuration réseau (IP, DNS)', 'Autre',
|
||||||
|
],
|
||||||
|
tv: [
|
||||||
|
"Pas de signal TV", 'Image pixelisée / gelée', 'Canaux manquants',
|
||||||
|
'Décodeur défaillant', 'Installation antenne / câble', 'Configuration IPTV',
|
||||||
|
'Télécommande défectueuse', 'Autre',
|
||||||
|
],
|
||||||
|
telephone: [
|
||||||
|
"Pas de tonalité", 'Mauvaise qualité audio', 'Ligne coupée',
|
||||||
|
'Installation VoIP', 'Portabilité de numéro', 'Installation câblage téléphonique',
|
||||||
|
'Configuration central téléphonique', 'Autre',
|
||||||
|
],
|
||||||
|
multi: ['Décrire le problème dans la zone de texte ci-dessous'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_SLOTS = [
|
||||||
|
{ id: 'morning', label: 'Matin', sub: '8h–12h', icon: '🌅' },
|
||||||
|
{ id: 'afternoon', label: 'Après-midi', sub: '12h–17h', icon: '☀️' },
|
||||||
|
{ id: 'evening', label: 'Soir', sub: '17h–20h', icon: '🌙' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const BUDGET_OPTIONS = [
|
||||||
|
{ id: 'b50', label: '50–100 $', min: 50, max: 100 },
|
||||||
|
{ id: 'b100', label: '100–200 $', min: 100, max: 200 },
|
||||||
|
{ id: 'b200', label: '200–350 $', min: 200, max: 350 },
|
||||||
|
{ id: 'b350', label: '350 $+', min: 350, max: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TOTAL_STEPS = 5
|
||||||
|
const step = ref(1)
|
||||||
|
|
||||||
|
// ── Étape 1 : type de service ─────────────────────────────────────────────────
|
||||||
|
const selectedService = ref(null)
|
||||||
|
|
||||||
|
// ── Étape 2 : description du problème ────────────────────────────────────────
|
||||||
|
const selectedProblem = ref(null)
|
||||||
|
const description = ref('')
|
||||||
|
|
||||||
|
// ── Étape 3 : adresse ─────────────────────────────────────────────────────────
|
||||||
|
const address = ref(null)
|
||||||
|
const addressQuery = ref('')
|
||||||
|
const addressSuggestions = ref([])
|
||||||
|
const addressLoading = ref(false)
|
||||||
|
let debounceTimer = null
|
||||||
|
|
||||||
|
function onAddressInput (e) {
|
||||||
|
addressQuery.value = e.target.value
|
||||||
|
address.value = null
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
if (addressQuery.value.length < 3) { addressSuggestions.value = []; return }
|
||||||
|
debounceTimer = setTimeout(fetchSuggestions, 350)
|
||||||
|
}
|
||||||
|
async function fetchSuggestions () {
|
||||||
|
addressLoading.value = true
|
||||||
|
try {
|
||||||
|
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(addressQuery.value)}.json`
|
||||||
|
+ `?access_token=${MAPBOX_TOKEN}&country=CA&language=fr&limit=5`
|
||||||
|
const r = await fetch(url)
|
||||||
|
const d = await r.json()
|
||||||
|
addressSuggestions.value = d.features || []
|
||||||
|
} catch (_) { addressSuggestions.value = [] }
|
||||||
|
addressLoading.value = false
|
||||||
|
}
|
||||||
|
function selectAddress (f) {
|
||||||
|
address.value = f
|
||||||
|
addressQuery.value = f.place_name
|
||||||
|
addressSuggestions.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Étape 4 : 3 dates préférées ───────────────────────────────────────────────
|
||||||
|
const minDate = computed(() => new Date().toISOString().split('T')[0])
|
||||||
|
const preferredDates = ref([
|
||||||
|
{ date: '', timeSlots: [] }, // timeSlots = array of slot IDs (multi-select)
|
||||||
|
{ date: '', timeSlots: [] },
|
||||||
|
{ date: '', timeSlots: [] },
|
||||||
|
])
|
||||||
|
const urgency = ref('normal')
|
||||||
|
const budgetId = ref(null) // selected BUDGET_OPTIONS id
|
||||||
|
|
||||||
|
const activeDateIdx = ref(0) // which date card is open
|
||||||
|
|
||||||
|
function dateLabel (iso) {
|
||||||
|
if (!iso) return null
|
||||||
|
const d = new Date(iso + 'T12:00:00')
|
||||||
|
return d.toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSlot (pd, slotId) {
|
||||||
|
if (pd.timeSlots.includes(slotId)) {
|
||||||
|
pd.timeSlots = pd.timeSlots.filter(s => s !== slotId)
|
||||||
|
} else {
|
||||||
|
pd.timeSlots = [...pd.timeSlots, slotId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validDates = computed(() => preferredDates.value.filter(d => d.date && d.timeSlots.length > 0))
|
||||||
|
|
||||||
|
// ── Étape 5 : contact ─────────────────────────────────────────────────────────
|
||||||
|
const contact = ref({ name: '', phone: '', email: '' })
|
||||||
|
|
||||||
|
// ── Validation ────────────────────────────────────────────────────────────────
|
||||||
|
const canNext = computed(() => {
|
||||||
|
if (step.value === 1) return !!selectedService.value
|
||||||
|
if (step.value === 2) return !!selectedProblem.value
|
||||||
|
if (step.value === 3) return !!address.value
|
||||||
|
if (step.value === 4) return validDates.value.length >= 1 && !!budgetId.value
|
||||||
|
if (step.value === 5) return contact.value.name.trim() && contact.value.phone.trim()
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
function next () { if (canNext.value && step.value < TOTAL_STEPS) step.value++ }
|
||||||
|
function prev () { if (step.value > 1) step.value-- }
|
||||||
|
|
||||||
|
// ── Soumission ────────────────────────────────────────────────────────────────
|
||||||
|
const submitting = ref(false)
|
||||||
|
const confirmed = ref(false)
|
||||||
|
const refNumber = ref('')
|
||||||
|
|
||||||
|
async function submit () {
|
||||||
|
if (!canNext.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const result = await createServiceRequest({
|
||||||
|
service_type: selectedService.value,
|
||||||
|
problem_type: selectedProblem.value,
|
||||||
|
description: description.value,
|
||||||
|
address: address.value?.place_name || addressQuery.value,
|
||||||
|
coordinates: address.value?.center || [0, 0],
|
||||||
|
preferred_dates: validDates.value.map(d => ({
|
||||||
|
date: d.date,
|
||||||
|
time_slots: d.timeSlots,
|
||||||
|
time_slot: d.timeSlots[0] || '', // backward-compat primary slot
|
||||||
|
})),
|
||||||
|
urgency: urgency.value,
|
||||||
|
budget: BUDGET_OPTIONS.find(b => b.id === budgetId.value) || null,
|
||||||
|
contact: contact.value,
|
||||||
|
})
|
||||||
|
refNumber.value = result.ref
|
||||||
|
confirmed.value = true
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="booking-root">
|
||||||
|
|
||||||
|
<!-- Confirmation ─────────────────────────────────────────────────────────── -->
|
||||||
|
<div v-if="confirmed" class="confirm-screen">
|
||||||
|
<div class="confirm-card">
|
||||||
|
<div class="confirm-icon">✓</div>
|
||||||
|
<h2>Demande envoyée !</h2>
|
||||||
|
<p>Nos techniciens vont examiner votre demande et vous proposer une confirmation de rendez-vous.</p>
|
||||||
|
<div class="ref-box">
|
||||||
|
<span class="ref-label">Numéro de référence</span>
|
||||||
|
<span class="ref-val">{{ refNumber }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="confirm-sub">Vous recevrez une confirmation par SMS ou courriel une fois une date confirmée.</p>
|
||||||
|
<button class="btn-primary" @click="$router.push('/')">Retour à l'accueil</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wizard ───────────────────────────────────────────────────────────────── -->
|
||||||
|
<template v-else>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="booking-header">
|
||||||
|
<button class="btn-back" @click="step > 1 ? prev() : $router.push('/')" aria-label="Retour">←</button>
|
||||||
|
<div class="header-center">
|
||||||
|
<div class="header-logo">🌐</div>
|
||||||
|
<span>Demande de service</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-pill">{{ step }}/{{ TOTAL_STEPS }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="progress-bar"><div class="progress-fill" :style="{ width: (step / TOTAL_STEPS * 100) + '%' }"></div></div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="booking-body">
|
||||||
|
|
||||||
|
<!-- ── Étape 1 : Sélection du service ── -->
|
||||||
|
<transition name="fade-up" mode="out-in">
|
||||||
|
<div v-if="step === 1" key="s1" class="step-content">
|
||||||
|
<div class="step-title">Quel service avez-vous besoin ?</div>
|
||||||
|
<div class="service-grid">
|
||||||
|
<button v-for="s in SERVICES" :key="s.id"
|
||||||
|
class="service-card"
|
||||||
|
:class="{ selected: selectedService === s.id }"
|
||||||
|
@click="selectedService = s.id; selectedProblem = null">
|
||||||
|
<span class="svc-icon">{{ s.icon }}</span>
|
||||||
|
<span class="svc-label">{{ s.label }}</span>
|
||||||
|
<span class="svc-desc">{{ s.desc }}</span>
|
||||||
|
<span v-if="selectedService === s.id" class="svc-check">✓</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- ── Étape 2 : Description du problème ── -->
|
||||||
|
<transition name="fade-up" mode="out-in">
|
||||||
|
<div v-if="step === 2" key="s2" class="step-content">
|
||||||
|
<div class="step-title">Quel est le problème ?</div>
|
||||||
|
<div class="service-label-chip">
|
||||||
|
{{ SERVICES.find(s => s.id === selectedService)?.icon }}
|
||||||
|
{{ SERVICES.find(s => s.id === selectedService)?.label }}
|
||||||
|
</div>
|
||||||
|
<div class="problem-list">
|
||||||
|
<button v-for="p in PROBLEMS[selectedService]" :key="p"
|
||||||
|
class="problem-item"
|
||||||
|
:class="{ selected: selectedProblem === p }"
|
||||||
|
@click="selectedProblem = p">
|
||||||
|
<span class="problem-radio">{{ selectedProblem === p ? '●' : '○' }}</span>
|
||||||
|
{{ p }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea class="textarea-desc" v-model="description"
|
||||||
|
placeholder="Détails supplémentaires (optionnel)…"
|
||||||
|
rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- ── Étape 3 : Adresse ── -->
|
||||||
|
<transition name="fade-up" mode="out-in">
|
||||||
|
<div v-if="step === 3" key="s3" class="step-content">
|
||||||
|
<div class="step-title">Adresse de l'intervention</div>
|
||||||
|
<div class="address-wrap">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-icon">📍</span>
|
||||||
|
<input class="addr-input" type="text"
|
||||||
|
:value="addressQuery"
|
||||||
|
@input="onAddressInput"
|
||||||
|
placeholder="Entrez votre adresse…"
|
||||||
|
autocomplete="off" />
|
||||||
|
<span v-if="addressLoading" class="input-spin">⟳</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="addressSuggestions.length" class="suggestions">
|
||||||
|
<button v-for="f in addressSuggestions" :key="f.id"
|
||||||
|
class="suggestion-item"
|
||||||
|
@click="selectAddress(f)">
|
||||||
|
📍 {{ f.place_name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="address" class="addr-confirmed">
|
||||||
|
✓ {{ address.place_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- ── Étape 4 : 3 dates préférées ── -->
|
||||||
|
<transition name="fade-up" mode="out-in">
|
||||||
|
<div v-if="step === 4" key="s4" class="step-content">
|
||||||
|
<div class="step-title">Disponibilités & budget</div>
|
||||||
|
<p class="step-sub">Indiquez jusqu'à 3 dates et les plages horaires qui vous conviennent. Nous confirmerons la meilleure date.</p>
|
||||||
|
|
||||||
|
<!-- Urgence toggle -->
|
||||||
|
<div class="urgency-row">
|
||||||
|
<button class="urgency-btn" :class="{ active: urgency === 'normal' }" @click="urgency = 'normal'">Standard</button>
|
||||||
|
<button class="urgency-btn urgency-urgent" :class="{ active: urgency === 'urgent' }" @click="urgency = 'urgent'">Urgent 🚨</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3 date cards -->
|
||||||
|
<div v-for="(pd, i) in preferredDates" :key="i" class="date-card"
|
||||||
|
:class="{ 'date-card-filled': pd.date && pd.timeSlots.length > 0, 'date-card-active': activeDateIdx === i }"
|
||||||
|
@click="activeDateIdx = i">
|
||||||
|
<div class="date-card-header">
|
||||||
|
<span class="date-priority">{{ ['1re', '2e', '3e'][i] }} priorité</span>
|
||||||
|
<span v-if="pd.date && pd.timeSlots.length > 0" class="date-summary">
|
||||||
|
{{ dateLabel(pd.date) }} · {{ pd.timeSlots.map(id => TIME_SLOTS.find(t => t.id === id)?.label).join(', ') }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="date-empty">Non définie</span>
|
||||||
|
<span class="date-toggle">{{ activeDateIdx === i ? '▲' : '▼' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="activeDateIdx === i" class="date-card-body">
|
||||||
|
<input type="date" class="date-input" v-model="pd.date" :min="minDate" />
|
||||||
|
|
||||||
|
<div class="slot-label">Plage(s) horaire</div>
|
||||||
|
<div class="slot-checks">
|
||||||
|
<label v-for="slot in TIME_SLOTS" :key="slot.id"
|
||||||
|
class="slot-check-row"
|
||||||
|
:class="{ checked: pd.timeSlots.includes(slot.id) }"
|
||||||
|
@click.stop="toggleSlot(pd, slot.id)">
|
||||||
|
<span class="slot-checkbox">
|
||||||
|
<span v-if="pd.timeSlots.includes(slot.id)" class="slot-checkbox-tick">✓</span>
|
||||||
|
</span>
|
||||||
|
<span class="slot-check-icon">{{ slot.icon }}</span>
|
||||||
|
<span class="slot-check-text">
|
||||||
|
<strong>{{ slot.label }}</strong>
|
||||||
|
<span>{{ slot.sub }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="slot-check-row"
|
||||||
|
:class="{ checked: pd.timeSlots.includes('flexible') }"
|
||||||
|
@click.stop="pd.timeSlots = pd.timeSlots.includes('flexible') ? [] : ['flexible']">
|
||||||
|
<span class="slot-checkbox">
|
||||||
|
<span v-if="pd.timeSlots.includes('flexible')" class="slot-checkbox-tick">✓</span>
|
||||||
|
</span>
|
||||||
|
<span class="slot-check-icon">🕐</span>
|
||||||
|
<span class="slot-check-text">
|
||||||
|
<strong>Je suis flexible</strong>
|
||||||
|
<span>Au choix du technicien</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="validDates.length === 0" class="hint-text">Remplissez au moins une date pour continuer.</p>
|
||||||
|
<p v-else class="hint-ok">✓ {{ validDates.length }} date{{ validDates.length > 1 ? 's' : '' }} sélectionnée{{ validDates.length > 1 ? 's' : '' }}</p>
|
||||||
|
|
||||||
|
<!-- Budget estimé -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-title">Budget estimé</div>
|
||||||
|
<p class="budget-sub">Les techniciens soumettront leur tarif en fonction de votre budget.</p>
|
||||||
|
<div class="budget-grid">
|
||||||
|
<button v-for="b in BUDGET_OPTIONS" :key="b.id"
|
||||||
|
class="budget-btn"
|
||||||
|
:class="{ selected: budgetId === b.id }"
|
||||||
|
@click="budgetId = b.id">
|
||||||
|
{{ b.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!budgetId" class="hint-text">Sélectionnez un budget pour continuer.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- ── Étape 5 : Contact + résumé ── -->
|
||||||
|
<transition name="fade-up" mode="out-in">
|
||||||
|
<div v-if="step === 5" key="s5" class="step-content">
|
||||||
|
<div class="step-title">Vos coordonnées</div>
|
||||||
|
|
||||||
|
<div class="form-fields">
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Nom complet *</label>
|
||||||
|
<input v-model="contact.name" type="text" placeholder="Jean Tremblay" class="field-input" />
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Téléphone *</label>
|
||||||
|
<input v-model="contact.phone" type="tel" placeholder="514 555-0000" class="field-input" />
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Courriel</label>
|
||||||
|
<input v-model="contact.email" type="email" placeholder="jean@exemple.com" class="field-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Résumé -->
|
||||||
|
<div class="summary-box">
|
||||||
|
<div class="summary-row">
|
||||||
|
<span>Service</span>
|
||||||
|
<strong>{{ SERVICES.find(s => s.id === selectedService)?.label }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span>Problème</span>
|
||||||
|
<strong>{{ selectedProblem }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span>Adresse</span>
|
||||||
|
<strong>{{ address?.place_name || addressQuery }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row" v-for="(pd, i) in validDates" :key="i">
|
||||||
|
<span>Date {{ i + 1 }}</span>
|
||||||
|
<strong>{{ dateLabel(pd.date) }} · {{ pd.timeSlots.map(id => TIME_SLOTS.find(t => t.id === id)?.label).join(', ') }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span>Budget</span>
|
||||||
|
<strong>{{ BUDGET_OPTIONS.find(b => b.id === budgetId)?.label || '—' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row" v-if="urgency === 'urgent'">
|
||||||
|
<span>Urgence</span>
|
||||||
|
<strong style="color:#f43f5e">🚨 Urgent</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
</div><!-- /booking-body -->
|
||||||
|
|
||||||
|
<!-- Footer nav -->
|
||||||
|
<div class="booking-footer">
|
||||||
|
<button v-if="step < TOTAL_STEPS" class="btn-next" :disabled="!canNext" @click="next">
|
||||||
|
Continuer →
|
||||||
|
</button>
|
||||||
|
<button v-else class="btn-next btn-submit" :disabled="!canNext || submitting" @click="submit">
|
||||||
|
{{ submitting ? 'Envoi en cours…' : 'Envoyer la demande ✓' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Tokens ── */
|
||||||
|
.booking-root {
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent2: #818cf8;
|
||||||
|
--bg: #0f1117;
|
||||||
|
--surface: rgba(255,255,255,0.04);
|
||||||
|
--surface2: rgba(255,255,255,0.07);
|
||||||
|
--border: rgba(255,255,255,0.09);
|
||||||
|
--text: #f1f5f9;
|
||||||
|
--text2: #94a3b8;
|
||||||
|
--green: #10b981;
|
||||||
|
--red: #f43f5e;
|
||||||
|
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.booking-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem 0.75rem;
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
background: rgba(15,17,23,0.9); backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.btn-back { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: 8px; width: 36px; height: 36px; font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.btn-back:hover { border-color: var(--accent); }
|
||||||
|
.header-center { display: flex; align-items: center; gap: 0.5rem; font-weight: 700; font-size: 0.95rem; }
|
||||||
|
.header-logo { font-size: 1.3rem; }
|
||||||
|
.step-pill { background: rgba(99,102,241,0.2); color: var(--accent2); border: 1px solid rgba(99,102,241,0.3); border-radius: 20px; padding: 0.2rem 0.65rem; font-size: 0.75rem; font-weight: 700; }
|
||||||
|
|
||||||
|
/* ── Progress ── */
|
||||||
|
.progress-bar { height: 3px; background: var(--border); }
|
||||||
|
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2)); transition: width 0.4s ease; border-radius: 0 2px 2px 0; }
|
||||||
|
|
||||||
|
/* ── Body ── */
|
||||||
|
.booking-body { flex: 1; overflow-y: auto; padding: 1.5rem 1.25rem 6rem; }
|
||||||
|
.step-content { animation: fadeUp 0.25s ease; }
|
||||||
|
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
.step-title { font-size: 1.35rem; font-weight: 800; margin-bottom: 0.35rem; }
|
||||||
|
.step-sub { color: var(--text2); font-size: 0.85rem; margin-bottom: 1.25rem; line-height: 1.6; }
|
||||||
|
|
||||||
|
/* ── Service grid ── */
|
||||||
|
.service-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.85rem; margin-top: 1.25rem; }
|
||||||
|
.service-card { background: var(--surface); border: 1.5px solid var(--border); border-radius: 14px; padding: 1.1rem 0.9rem; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: 0.2rem; transition: all 0.18s; position: relative; }
|
||||||
|
.service-card:hover { border-color: rgba(99,102,241,0.4); background: var(--surface2); }
|
||||||
|
.service-card.selected { border-color: var(--accent); background: rgba(99,102,241,0.1); box-shadow: 0 0 0 3px rgba(99,102,241,0.18); }
|
||||||
|
.svc-icon { font-size: 1.8rem; margin-bottom: 0.25rem; }
|
||||||
|
.svc-label { font-size: 0.95rem; font-weight: 700; }
|
||||||
|
.svc-desc { font-size: 0.72rem; color: var(--text2); }
|
||||||
|
.svc-check { position: absolute; top: 0.75rem; right: 0.75rem; background: var(--accent); color: white; border-radius: 50%; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; font-weight: 800; }
|
||||||
|
|
||||||
|
/* ── Problem list ── */
|
||||||
|
.service-label-chip { display: inline-flex; align-items: center; gap: 0.4rem; background: rgba(99,102,241,0.12); border: 1px solid rgba(99,102,241,0.25); color: var(--accent2); border-radius: 20px; padding: 0.3rem 0.85rem; font-size: 0.82rem; font-weight: 600; margin-bottom: 1.25rem; }
|
||||||
|
.problem-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
|
||||||
|
.problem-item { background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.75rem 1rem; cursor: pointer; text-align: left; font-size: 0.88rem; display: flex; align-items: center; gap: 0.75rem; transition: all 0.15s; }
|
||||||
|
.problem-item:hover { border-color: rgba(99,102,241,0.35); }
|
||||||
|
.problem-item.selected { border-color: var(--accent); background: rgba(99,102,241,0.1); color: white; }
|
||||||
|
.problem-radio { font-size: 1rem; color: var(--accent); flex-shrink: 0; }
|
||||||
|
.textarea-desc { width: 100%; background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.85rem 1rem; color: var(--text); font-size: 0.88rem; resize: vertical; font-family: inherit; box-sizing: border-box; }
|
||||||
|
.textarea-desc:focus { border-color: var(--accent); outline: none; }
|
||||||
|
|
||||||
|
/* ── Address ── */
|
||||||
|
.address-wrap { margin-top: 1rem; }
|
||||||
|
.input-group { position: relative; display: flex; align-items: center; }
|
||||||
|
.input-icon { position: absolute; left: 0.9rem; font-size: 1rem; pointer-events: none; }
|
||||||
|
.addr-input { width: 100%; background: var(--surface); border: 1.5px solid var(--border); border-radius: 12px; padding: 0.85rem 3rem 0.85rem 2.5rem; color: var(--text); font-size: 0.9rem; box-sizing: border-box; }
|
||||||
|
.addr-input:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.input-spin { position: absolute; right: 0.9rem; animation: spin 1s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.suggestions { background: #1a1d27; border: 1px solid var(--border); border-radius: 12px; margin-top: 0.5rem; overflow: hidden; }
|
||||||
|
.suggestion-item { width: 100%; background: none; border: none; border-bottom: 1px solid var(--border); color: var(--text); padding: 0.75rem 1rem; cursor: pointer; text-align: left; font-size: 0.82rem; transition: background 0.12s; }
|
||||||
|
.suggestion-item:last-child { border-bottom: none; }
|
||||||
|
.suggestion-item:hover { background: var(--surface2); }
|
||||||
|
.addr-confirmed { margin-top: 0.85rem; background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.3); border-radius: 10px; padding: 0.75rem 1rem; font-size: 0.85rem; color: var(--green); }
|
||||||
|
|
||||||
|
/* ── Dates ── */
|
||||||
|
.urgency-row { display: flex; gap: 0.5rem; margin-bottom: 1.25rem; }
|
||||||
|
.urgency-btn { flex: 1; background: var(--surface); border: 1.5px solid var(--border); color: var(--text2); border-radius: 10px; padding: 0.65rem; cursor: pointer; font-size: 0.85rem; font-weight: 600; transition: all 0.15s; }
|
||||||
|
.urgency-btn.active { border-color: var(--accent); background: rgba(99,102,241,0.12); color: var(--text); }
|
||||||
|
.urgency-urgent.active { border-color: var(--red); background: rgba(244,63,94,0.1); color: var(--red); }
|
||||||
|
.date-card { background: var(--surface); border: 1.5px solid var(--border); border-radius: 14px; margin-bottom: 0.75rem; overflow: hidden; cursor: pointer; transition: border-color 0.15s; }
|
||||||
|
.date-card:hover { border-color: rgba(99,102,241,0.35); }
|
||||||
|
.date-card-filled { border-color: rgba(16,185,129,0.4); }
|
||||||
|
.date-card-active { border-color: var(--accent); }
|
||||||
|
.date-card-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.9rem 1rem; }
|
||||||
|
.date-priority { background: rgba(99,102,241,0.15); color: var(--accent2); border-radius: 6px; padding: 0.15rem 0.5rem; font-size: 0.7rem; font-weight: 700; flex-shrink: 0; }
|
||||||
|
.date-summary { flex: 1; font-size: 0.82rem; font-weight: 600; }
|
||||||
|
.date-empty { flex: 1; font-size: 0.82rem; color: var(--text2); font-style: italic; }
|
||||||
|
.date-toggle { color: var(--text2); font-size: 0.65rem; }
|
||||||
|
.date-card-body { padding: 0 1rem 1rem; border-top: 1px solid var(--border); }
|
||||||
|
.date-input { width: 100%; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 0.65rem 0.85rem; color: var(--text); font-size: 0.9rem; margin: 0.75rem 0; box-sizing: border-box; }
|
||||||
|
.date-input:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.hint-text { font-size: 0.8rem; color: var(--text2); text-align: center; margin-top: 0.5rem; }
|
||||||
|
.hint-ok { font-size: 0.8rem; color: var(--green); text-align: center; margin-top: 0.5rem; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Slot checkboxes ── */
|
||||||
|
.slot-label { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text2); margin-bottom: 0.5rem; }
|
||||||
|
.slot-checks { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.slot-check-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.75rem; border-radius: 10px; border: 1.5px solid var(--border); cursor: pointer; background: var(--surface2); transition: all 0.15s; user-select: none; }
|
||||||
|
.slot-check-row:hover { border-color: rgba(99,102,241,0.3); }
|
||||||
|
.slot-check-row.checked { border-color: var(--accent); background: rgba(99,102,241,0.1); }
|
||||||
|
.slot-checkbox { width: 20px; height: 20px; border-radius: 5px; border: 1.5px solid var(--border); flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: var(--surface); }
|
||||||
|
.slot-check-row.checked .slot-checkbox { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.slot-checkbox-tick { color: white; font-size: 0.7rem; font-weight: 800; }
|
||||||
|
.slot-check-icon { font-size: 1.1rem; }
|
||||||
|
.slot-check-text { display: flex; flex-direction: column; gap: 0.05rem; }
|
||||||
|
.slot-check-text strong { font-size: 0.85rem; color: var(--text); }
|
||||||
|
.slot-check-text span { font-size: 0.7rem; color: var(--text2); }
|
||||||
|
|
||||||
|
/* ── Budget ── */
|
||||||
|
.budget-section { margin-top: 1.5rem; border-top: 1px solid var(--border); padding-top: 1.25rem; }
|
||||||
|
.budget-title { font-size: 1rem; font-weight: 800; margin-bottom: 0.25rem; }
|
||||||
|
.budget-sub { font-size: 0.8rem; color: var(--text2); margin-bottom: 0.85rem; }
|
||||||
|
.budget-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
|
||||||
|
.budget-btn { background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.75rem 0.5rem; cursor: pointer; color: var(--text); font-size: 0.9rem; font-weight: 700; transition: all 0.15s; }
|
||||||
|
.budget-btn:hover { border-color: rgba(99,102,241,0.35); }
|
||||||
|
.budget-btn.selected { border-color: var(--accent); background: rgba(99,102,241,0.12); color: #a5b4fc; }
|
||||||
|
|
||||||
|
/* ── Contact + résumé ── */
|
||||||
|
.form-fields { display: flex; flex-direction: column; gap: 1rem; margin-bottom: 1.5rem; }
|
||||||
|
.field-group { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
.field-group label { font-size: 0.78rem; font-weight: 700; color: var(--text2); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.field-input { background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.75rem 1rem; color: var(--text); font-size: 0.9rem; }
|
||||||
|
.field-input:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.summary-box { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; }
|
||||||
|
.summary-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.82rem; }
|
||||||
|
.summary-row:last-child { border-bottom: none; }
|
||||||
|
.summary-row span { color: var(--text2); flex-shrink: 0; }
|
||||||
|
.summary-row strong { text-align: right; }
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
.booking-footer {
|
||||||
|
position: fixed; bottom: 0; left: 50%; transform: translateX(-50%);
|
||||||
|
width: 100%; max-width: 560px; padding: 1rem 1.25rem;
|
||||||
|
background: linear-gradient(to top, var(--bg) 70%, transparent);
|
||||||
|
}
|
||||||
|
.btn-next { width: 100%; background: var(--accent); border: none; color: white; border-radius: 14px; padding: 1rem; font-size: 1rem; font-weight: 700; cursor: pointer; transition: opacity 0.15s; }
|
||||||
|
.btn-next:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.btn-next:hover:not(:disabled) { opacity: 0.88; }
|
||||||
|
.btn-submit { background: linear-gradient(135deg, var(--accent), #a855f7); }
|
||||||
|
|
||||||
|
/* ── Confirmation ── */
|
||||||
|
.confirm-screen { min-height: 100dvh; display: flex; align-items: center; justify-content: center; padding: 2rem; }
|
||||||
|
.confirm-card { text-align: center; max-width: 380px; }
|
||||||
|
.confirm-icon { width: 72px; height: 72px; background: rgba(16,185,129,0.15); border: 2px solid var(--green); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 2rem; color: var(--green); margin: 0 auto 1.5rem; }
|
||||||
|
.confirm-card h2 { font-size: 1.6rem; font-weight: 800; margin-bottom: 0.75rem; }
|
||||||
|
.confirm-card p { color: var(--text2); line-height: 1.6; margin-bottom: 1.5rem; font-size: 0.9rem; }
|
||||||
|
.ref-box { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1rem 1.5rem; margin-bottom: 1.25rem; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
.ref-label { font-size: 0.72rem; color: var(--text2); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
.ref-val { font-size: 1.5rem; font-weight: 800; color: var(--accent2); letter-spacing: 0.08em; }
|
||||||
|
.confirm-sub { font-size: 0.8rem; color: var(--text2); margin-bottom: 2rem; }
|
||||||
|
.btn-primary { background: var(--accent); border: none; color: white; border-radius: 12px; padding: 0.85rem 2rem; font-size: 0.95rem; font-weight: 700; cursor: pointer; }
|
||||||
|
|
||||||
|
/* ── Transitions ── */
|
||||||
|
.fade-up-enter-active, .fade-up-leave-active { transition: all 0.22s ease; }
|
||||||
|
.fade-up-enter-from { opacity: 0; transform: translateY(12px); }
|
||||||
|
.fade-up-leave-to { opacity: 0; transform: translateY(-8px); }
|
||||||
|
</style>
|
||||||
716
src/pages/ContractorPage.vue
Normal file
|
|
@ -0,0 +1,716 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { registerContractor } from 'src/api/contractor'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const ALL_SERVICES = [
|
||||||
|
{ id: 'informatique', icon: '💻', label: 'Informatique' },
|
||||||
|
{ id: 'formatage', icon: '🖥️', label: 'Formatage PC' },
|
||||||
|
{ id: 'nettoyage', icon: '🧹', label: 'Nettoyage' },
|
||||||
|
{ id: 'camera', icon: '📷', label: 'Caméras sécurité' },
|
||||||
|
{ id: 'plomberie', icon: '🔧', label: 'Plomberie' },
|
||||||
|
{ id: 'electricite', icon: '⚡', label: 'Électricité' },
|
||||||
|
{ id: 'climatisation', icon: '❄️', label: 'Climatisation' },
|
||||||
|
{ id: 'telephone', icon: '📱', label: 'Téléphones' },
|
||||||
|
{ id: 'serrurerie', icon: '🔒', label: 'Serrurerie' },
|
||||||
|
{ id: 'peinture', icon: '🎨', label: 'Peinture' },
|
||||||
|
{ id: 'jardinage', icon: '🌿', label: 'Entretien extérieur' },
|
||||||
|
{ id: 'autre', icon: '🔨', label: 'Autre' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DAYS = [
|
||||||
|
{ id: 'mon', label: 'Lun' },
|
||||||
|
{ id: 'tue', label: 'Mar' },
|
||||||
|
{ id: 'wed', label: 'Mer' },
|
||||||
|
{ id: 'thu', label: 'Jeu' },
|
||||||
|
{ id: 'fri', label: 'Ven' },
|
||||||
|
{ id: 'sat', label: 'Sam' },
|
||||||
|
{ id: 'sun', label: 'Dim' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TOTAL_STEPS = 4
|
||||||
|
const step = ref(1)
|
||||||
|
|
||||||
|
// ── Step 1 — Profil ──────────────────────────────────────────────────────────
|
||||||
|
const profile = ref({
|
||||||
|
firstname: '',
|
||||||
|
lastname: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
company: '',
|
||||||
|
license: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Step 2 — Services ────────────────────────────────────────────────────────
|
||||||
|
// selectedServices: { [id]: { rate: '', rateType: 'hourly' } }
|
||||||
|
const selectedServices = ref({})
|
||||||
|
|
||||||
|
function toggleService (svc) {
|
||||||
|
if (selectedServices.value[svc.id]) {
|
||||||
|
const copy = { ...selectedServices.value }
|
||||||
|
delete copy[svc.id]
|
||||||
|
selectedServices.value = copy
|
||||||
|
} else {
|
||||||
|
selectedServices.value = {
|
||||||
|
...selectedServices.value,
|
||||||
|
[svc.id]: { rate: '', rateType: 'hourly' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function isSelected (id) { return !!selectedServices.value[id] }
|
||||||
|
|
||||||
|
const selectedServiceList = computed(() =>
|
||||||
|
ALL_SERVICES
|
||||||
|
.filter(s => selectedServices.value[s.id])
|
||||||
|
.map(s => ({
|
||||||
|
...s,
|
||||||
|
rate: selectedServices.value[s.id].rate,
|
||||||
|
rateType: selectedServices.value[s.id].rateType,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Step 3 — Zone & disponibilité ────────────────────────────────────────────
|
||||||
|
const availability = ref({
|
||||||
|
city: '',
|
||||||
|
radius: '25km',
|
||||||
|
days: ['mon','tue','wed','thu','fri'],
|
||||||
|
urgent: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleDay (id) {
|
||||||
|
const days = availability.value.days
|
||||||
|
if (days.includes(id)) {
|
||||||
|
availability.value.days = days.filter(d => d !== id)
|
||||||
|
} else {
|
||||||
|
availability.value.days = [...days, id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Submit ───────────────────────────────────────────────────────────────────
|
||||||
|
const submitting = ref(false)
|
||||||
|
const submitError = ref('')
|
||||||
|
const contractorRef = ref('')
|
||||||
|
|
||||||
|
async function submit () {
|
||||||
|
submitting.value = true
|
||||||
|
submitError.value = ''
|
||||||
|
try {
|
||||||
|
const ref = await registerContractor({
|
||||||
|
profile: profile.value,
|
||||||
|
services: selectedServiceList.value,
|
||||||
|
availability: availability.value,
|
||||||
|
})
|
||||||
|
contractorRef.value = ref
|
||||||
|
step.value = 5
|
||||||
|
} catch (e) {
|
||||||
|
submitError.value = e.message || 'Erreur lors de la soumission.'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigation ───────────────────────────────────────────────────────────────
|
||||||
|
const canNext = computed(() => {
|
||||||
|
if (step.value === 1) {
|
||||||
|
const p = profile.value
|
||||||
|
return p.firstname.trim().length >= 2
|
||||||
|
&& p.lastname.trim().length >= 2
|
||||||
|
&& p.phone.replace(/\D/g, '').length >= 10
|
||||||
|
&& p.email.includes('@')
|
||||||
|
}
|
||||||
|
if (step.value === 2) return selectedServiceList.value.length >= 1
|
||||||
|
&& selectedServiceList.value.every(s => s.rate.trim() !== '')
|
||||||
|
if (step.value === 3) return availability.value.city.trim().length >= 2
|
||||||
|
&& availability.value.days.length >= 1
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
function next () { if (canNext.value && step.value < TOTAL_STEPS) step.value++ }
|
||||||
|
function prev () { if (step.value > 1) step.value-- }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ct-root">
|
||||||
|
|
||||||
|
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="ct-header">
|
||||||
|
<button class="btn-back" @click="router.push('/')">← Retour</button>
|
||||||
|
<div class="ct-brand">Dispatch</div>
|
||||||
|
<div v-if="step <= TOTAL_STEPS" class="step-dots">
|
||||||
|
<span
|
||||||
|
v-for="i in TOTAL_STEPS"
|
||||||
|
:key="i"
|
||||||
|
class="dot"
|
||||||
|
:class="{ active: step === i, done: step > i }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Hero intro (before step 1) ── not shown, header serves this role -->
|
||||||
|
|
||||||
|
<!-- ── Body ───────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="ct-body">
|
||||||
|
|
||||||
|
<!-- Step 1 — Profil -->
|
||||||
|
<div v-if="step === 1" class="step-panel">
|
||||||
|
<div class="step-eyebrow">Étape 1 sur {{ TOTAL_STEPS }}</div>
|
||||||
|
<h1 class="step-title">Votre profil</h1>
|
||||||
|
<p class="step-sub">
|
||||||
|
Rejoignez notre réseau de techniciens et sous-traitants.<br>
|
||||||
|
Nous vous contactons sous 24h après révision de votre profil.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Prénom *</label>
|
||||||
|
<input v-model="profile.firstname" type="text" placeholder="Jean" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Nom *</label>
|
||||||
|
<input v-model="profile.lastname" type="text" placeholder="Tremblay" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Téléphone *</label>
|
||||||
|
<input v-model="profile.phone" type="tel" placeholder="514-555-0123" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Courriel *</label>
|
||||||
|
<input v-model="profile.email" type="email" placeholder="jean@exemple.com" />
|
||||||
|
</div>
|
||||||
|
<div class="field span2">
|
||||||
|
<label>Entreprise (optionnel)</label>
|
||||||
|
<input v-model="profile.company" type="text" placeholder="Technologies XYZ inc." />
|
||||||
|
</div>
|
||||||
|
<div class="field span2">
|
||||||
|
<label>Numéro RBQ / Licence (optionnel)</label>
|
||||||
|
<input v-model="profile.license" type="text" placeholder="8301-1234-56" />
|
||||||
|
<span class="field-hint">Requis pour plomberie, électricité et certains travaux de construction</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2 — Services -->
|
||||||
|
<div v-if="step === 2" class="step-panel">
|
||||||
|
<div class="step-eyebrow">Étape 2 sur {{ TOTAL_STEPS }}</div>
|
||||||
|
<h1 class="step-title">Vos services et tarifs</h1>
|
||||||
|
<p class="step-sub">Sélectionnez les services que vous offrez et indiquez votre tarif pour chacun</p>
|
||||||
|
|
||||||
|
<div class="service-grid">
|
||||||
|
<button
|
||||||
|
v-for="s in ALL_SERVICES"
|
||||||
|
:key="s.id"
|
||||||
|
class="service-chip"
|
||||||
|
:class="{ selected: isSelected(s.id) }"
|
||||||
|
@click="toggleService(s)"
|
||||||
|
>
|
||||||
|
<span>{{ s.icon }}</span>
|
||||||
|
<span class="chip-label">{{ s.label }}</span>
|
||||||
|
<span v-if="isSelected(s.id)" class="chip-check">✓</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rate inputs for selected services -->
|
||||||
|
<div v-if="selectedServiceList.length" class="rates-section">
|
||||||
|
<div class="rates-title">Tarifs pour les services sélectionnés</div>
|
||||||
|
<div
|
||||||
|
v-for="s in selectedServiceList"
|
||||||
|
:key="s.id"
|
||||||
|
class="rate-row"
|
||||||
|
>
|
||||||
|
<div class="rate-svc">
|
||||||
|
<span class="rate-icon">{{ s.icon }}</span>
|
||||||
|
<span class="rate-label">{{ s.label }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rate-inputs">
|
||||||
|
<input
|
||||||
|
v-model="selectedServices[s.id].rate"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="75"
|
||||||
|
class="rate-amount"
|
||||||
|
/>
|
||||||
|
<span class="rate-currency">$</span>
|
||||||
|
<select v-model="selectedServices[s.id].rateType" class="rate-type">
|
||||||
|
<option value="hourly">/ heure</option>
|
||||||
|
<option value="flat">forfait</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!selectedServiceList.length" class="hint-box">
|
||||||
|
Sélectionnez au moins un service ci-dessus
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3 — Zone & disponibilité -->
|
||||||
|
<div v-if="step === 3" class="step-panel">
|
||||||
|
<div class="step-eyebrow">Étape 3 sur {{ TOTAL_STEPS }}</div>
|
||||||
|
<h1 class="step-title">Zone et disponibilité</h1>
|
||||||
|
<p class="step-sub">Définissez où vous opérez et quand vous êtes disponible</p>
|
||||||
|
|
||||||
|
<div class="zone-section">
|
||||||
|
<div class="field">
|
||||||
|
<label>Ville principale *</label>
|
||||||
|
<input v-model="availability.city" type="text" placeholder="Montréal, Laval, Longueuil…" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Rayon d'intervention</label>
|
||||||
|
<div class="radius-group">
|
||||||
|
<button
|
||||||
|
v-for="r in ['10km','25km','50km','Province']"
|
||||||
|
:key="r"
|
||||||
|
class="radius-btn"
|
||||||
|
:class="{ selected: availability.radius === r }"
|
||||||
|
@click="availability.radius = r"
|
||||||
|
>{{ r }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Jours disponibles *</label>
|
||||||
|
<div class="days-group">
|
||||||
|
<button
|
||||||
|
v-for="d in DAYS"
|
||||||
|
:key="d.id"
|
||||||
|
class="day-btn"
|
||||||
|
:class="{ selected: availability.days.includes(d.id) }"
|
||||||
|
@click="toggleDay(d.id)"
|
||||||
|
>{{ d.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="urgent-row">
|
||||||
|
<input type="checkbox" v-model="availability.urgent" />
|
||||||
|
<span>Disponible pour les urgences (interventions rapides)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4 — Révision -->
|
||||||
|
<div v-if="step === 4" class="step-panel">
|
||||||
|
<div class="step-eyebrow">Étape 4 sur {{ TOTAL_STEPS }} — Révision</div>
|
||||||
|
<h1 class="step-title">Confirmer votre inscription</h1>
|
||||||
|
<p class="step-sub">Vérifiez vos informations avant de soumettre</p>
|
||||||
|
|
||||||
|
<div class="review-card">
|
||||||
|
<div class="review-section">
|
||||||
|
<div class="review-section-title">Profil</div>
|
||||||
|
<div class="review-row"><span>Nom</span><strong>{{ profile.firstname }} {{ profile.lastname }}</strong></div>
|
||||||
|
<div class="review-row"><span>Téléphone</span><strong>{{ profile.phone }}</strong></div>
|
||||||
|
<div class="review-row"><span>Courriel</span><strong>{{ profile.email }}</strong></div>
|
||||||
|
<div v-if="profile.company" class="review-row"><span>Entreprise</span><strong>{{ profile.company }}</strong></div>
|
||||||
|
<div v-if="profile.license" class="review-row"><span>Licence</span><strong>{{ profile.license }}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="review-section">
|
||||||
|
<div class="review-section-title">Services offerts</div>
|
||||||
|
<div v-for="s in selectedServiceList" :key="s.id" class="review-row">
|
||||||
|
<span>{{ s.icon }} {{ s.label }}</span>
|
||||||
|
<strong>{{ s.rate }} $ / {{ s.rateType === 'hourly' ? 'heure' : 'forfait' }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="review-section">
|
||||||
|
<div class="review-section-title">Zone et disponibilité</div>
|
||||||
|
<div class="review-row"><span>Ville</span><strong>{{ availability.city }}</strong></div>
|
||||||
|
<div class="review-row"><span>Rayon</span><strong>{{ availability.radius }}</strong></div>
|
||||||
|
<div class="review-row">
|
||||||
|
<span>Jours</span>
|
||||||
|
<strong>
|
||||||
|
{{ DAYS.filter(d => availability.days.includes(d.id)).map(d => d.label).join(', ') }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div v-if="availability.urgent" class="review-row">
|
||||||
|
<span>Urgences</span><strong>Disponible</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="submitError" class="submit-error">{{ submitError }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 5 — Confirmation -->
|
||||||
|
<div v-if="step === 5" class="step-panel step-confirm">
|
||||||
|
<div class="confirm-anim">🎉</div>
|
||||||
|
<h1 class="step-title">Candidature reçue !</h1>
|
||||||
|
<p class="step-sub">
|
||||||
|
Votre profil est en cours de révision.<br>
|
||||||
|
Un responsable vous contactera sous 24h.
|
||||||
|
</p>
|
||||||
|
<div class="confirm-ref">
|
||||||
|
Référence : <strong>{{ contractorRef }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="next-steps">
|
||||||
|
<div class="next-step-title">Prochaines étapes</div>
|
||||||
|
<div class="next-step-item">
|
||||||
|
<span class="ns-num">1</span>
|
||||||
|
<span>Vérification de votre profil et de vos certifications</span>
|
||||||
|
</div>
|
||||||
|
<div class="next-step-item">
|
||||||
|
<span class="ns-num">2</span>
|
||||||
|
<span>Entretien téléphonique avec notre équipe</span>
|
||||||
|
</div>
|
||||||
|
<div class="next-step-item">
|
||||||
|
<span class="ns-num">3</span>
|
||||||
|
<span>Activation de votre compte et réception de vos premiers jobs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary-lg" @click="router.push('/')">Retour à l'accueil</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /ct-body -->
|
||||||
|
|
||||||
|
<!-- ── Footer nav ──────────────────────────────────────────────────────── -->
|
||||||
|
<div v-if="step <= TOTAL_STEPS" class="ct-footer">
|
||||||
|
<button v-if="step > 1" class="btn-prev" @click="prev">← Précédent</button>
|
||||||
|
<div v-else class="footer-spacer" />
|
||||||
|
|
||||||
|
<div class="footer-progress">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" :style="{ width: ((step - 1) / TOTAL_STEPS * 100) + '%' }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="step < TOTAL_STEPS"
|
||||||
|
class="btn-next"
|
||||||
|
:disabled="!canNext"
|
||||||
|
@click="next"
|
||||||
|
>
|
||||||
|
Suivant →
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="btn-submit"
|
||||||
|
:disabled="!canNext || submitting"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Envoi…' : 'Soumettre mon profil' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Root ── */
|
||||||
|
.ct-root {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg, #0f1117);
|
||||||
|
color: var(--text-primary, #f1f5f9);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.ct-header {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
display: flex; align-items: center; gap: 1rem;
|
||||||
|
padding: 0.9rem 1.5rem;
|
||||||
|
background: rgba(15,17,23,0.92);
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.ct-brand {
|
||||||
|
font-size: 1rem; font-weight: 800;
|
||||||
|
color: #10b981; flex: 1;
|
||||||
|
}
|
||||||
|
.btn-back {
|
||||||
|
background: none; border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
border-radius: 6px; padding: 0.3rem 0.75rem;
|
||||||
|
cursor: pointer; font-size: 0.8rem; font-weight: 600;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-back:hover { color: var(--text-primary, #f1f5f9); border-color: #10b981; }
|
||||||
|
.step-dots { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: var(--border, rgba(255,255,255,0.12));
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
.dot.active { background: #10b981; width: 22px; border-radius: 4px; }
|
||||||
|
.dot.done { background: #10b981; opacity: 0.5; }
|
||||||
|
|
||||||
|
/* ── Body ── */
|
||||||
|
.ct-body {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: 2rem 1.5rem 6rem;
|
||||||
|
max-width: 680px; margin: 0 auto; width: 100%;
|
||||||
|
}
|
||||||
|
.step-panel { animation: fadeUp 0.25s ease; }
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.step-eyebrow {
|
||||||
|
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em; color: #10b981; margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.step-title { font-size: 1.6rem; font-weight: 800; margin: 0 0 0.4rem; line-height: 1.2; }
|
||||||
|
.step-sub {
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
font-size: 0.92rem; margin: 0 0 1.75rem; line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Step 1 — Form grid ── */
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.span2 { grid-column: span 2; }
|
||||||
|
|
||||||
|
/* ── Step 2 — Service chips ── */
|
||||||
|
.service-grid {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.service-chip {
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
padding: 0.5rem 0.85rem; border-radius: 99px;
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 2px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
cursor: pointer; font-size: 0.82rem;
|
||||||
|
transition: all 0.18s; color: var(--text-primary, #f1f5f9);
|
||||||
|
}
|
||||||
|
.service-chip:hover { border-color: rgba(16,185,129,0.4); }
|
||||||
|
.service-chip.selected {
|
||||||
|
border-color: #10b981;
|
||||||
|
background: rgba(16,185,129,0.1);
|
||||||
|
}
|
||||||
|
.chip-label { font-weight: 600; }
|
||||||
|
.chip-check { color: #10b981; font-weight: 700; font-size: 0.7rem; }
|
||||||
|
|
||||||
|
.rates-section {
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 12px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.rates-title {
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
background: var(--sidebar-bg, #161b27);
|
||||||
|
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em; color: var(--text-secondary, #94a3b8);
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
}
|
||||||
|
.rate-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 1rem; padding: 0.7rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.05));
|
||||||
|
}
|
||||||
|
.rate-row:last-child { border-bottom: none; }
|
||||||
|
.rate-svc { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.rate-icon { font-size: 1rem; }
|
||||||
|
.rate-label { font-size: 0.85rem; font-weight: 600; }
|
||||||
|
.rate-inputs { display: flex; align-items: center; gap: 0.35rem; }
|
||||||
|
.rate-amount {
|
||||||
|
width: 72px; background: var(--bg, #0f1117);
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 6px; color: var(--text-primary, #f1f5f9);
|
||||||
|
padding: 0.35rem 0.5rem; font-size: 0.85rem; text-align: right;
|
||||||
|
outline: none; transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.rate-amount:focus { border-color: #10b981; }
|
||||||
|
.rate-currency { font-size: 0.82rem; color: var(--text-secondary, #94a3b8); }
|
||||||
|
.rate-type {
|
||||||
|
background: var(--bg, #0f1117);
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 6px; color: var(--text-primary, #f1f5f9);
|
||||||
|
padding: 0.35rem 0.5rem; font-size: 0.8rem; cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-box {
|
||||||
|
text-align: center; padding: 2rem;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
font-size: 0.88rem; font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Step 3 — Zone ── */
|
||||||
|
.zone-section { display: flex; flex-direction: column; gap: 1.25rem; }
|
||||||
|
.radius-group { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.radius-btn {
|
||||||
|
padding: 0.45rem 1rem; border-radius: 8px;
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 2px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
color: var(--text-primary, #f1f5f9); cursor: pointer;
|
||||||
|
font-size: 0.82rem; font-weight: 600; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.radius-btn:hover { border-color: rgba(16,185,129,0.4); }
|
||||||
|
.radius-btn.selected { border-color: #10b981; background: rgba(16,185,129,0.1); }
|
||||||
|
|
||||||
|
.days-group { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.day-btn {
|
||||||
|
width: 44px; height: 44px; border-radius: 8px;
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 2px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
color: var(--text-primary, #f1f5f9); cursor: pointer;
|
||||||
|
font-size: 0.8rem; font-weight: 700; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.day-btn:hover { border-color: rgba(16,185,129,0.4); }
|
||||||
|
.day-btn.selected { border-color: #10b981; background: rgba(16,185,129,0.1); color: #10b981; }
|
||||||
|
|
||||||
|
.urgent-row {
|
||||||
|
display: flex; align-items: center; gap: 0.65rem;
|
||||||
|
cursor: pointer; font-size: 0.88rem; color: var(--text-secondary, #94a3b8);
|
||||||
|
}
|
||||||
|
.urgent-row input { accent-color: #10b981; width: 16px; height: 16px; cursor: pointer; }
|
||||||
|
|
||||||
|
/* ── Step 4 — Review ── */
|
||||||
|
.review-card {
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 12px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.review-section { border-bottom: 1px solid var(--border, rgba(255,255,255,0.08)); }
|
||||||
|
.review-section:last-child { border-bottom: none; }
|
||||||
|
.review-section-title {
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
background: var(--sidebar-bg, #161b27);
|
||||||
|
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em; color: var(--text-secondary, #94a3b8);
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
}
|
||||||
|
.review-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 0.6rem 1rem; font-size: 0.82rem;
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.05));
|
||||||
|
}
|
||||||
|
.review-row:last-child { border-bottom: none; }
|
||||||
|
.review-row span { color: var(--text-secondary, #94a3b8); }
|
||||||
|
.review-row strong { color: var(--text-primary, #f1f5f9); }
|
||||||
|
|
||||||
|
/* ── Step 5 — Confirm ── */
|
||||||
|
.step-confirm { text-align: center; padding-top: 2rem; }
|
||||||
|
.confirm-anim {
|
||||||
|
font-size: 4rem; margin-bottom: 1rem;
|
||||||
|
animation: popIn 0.4s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
}
|
||||||
|
@keyframes popIn {
|
||||||
|
from { transform: scale(0.4); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
.confirm-ref {
|
||||||
|
display: inline-block; margin: 1.5rem auto;
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 8px; padding: 0.65rem 1.25rem;
|
||||||
|
font-size: 0.88rem; color: var(--text-secondary, #94a3b8);
|
||||||
|
}
|
||||||
|
.confirm-ref strong { color: #10b981; font-size: 1rem; }
|
||||||
|
|
||||||
|
.next-steps {
|
||||||
|
text-align: left; margin: 1.5rem 0 2rem;
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 12px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.next-step-title {
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
background: var(--sidebar-bg, #161b27);
|
||||||
|
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em; color: var(--text-secondary, #94a3b8);
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
}
|
||||||
|
.next-step-item {
|
||||||
|
display: flex; align-items: flex-start; gap: 0.9rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.05));
|
||||||
|
font-size: 0.85rem; color: var(--text-primary, #f1f5f9);
|
||||||
|
}
|
||||||
|
.next-step-item:last-child { border-bottom: none; }
|
||||||
|
.ns-num {
|
||||||
|
flex-shrink: 0; width: 22px; height: 22px;
|
||||||
|
background: #10b981; border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 0.7rem; font-weight: 700; color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-error {
|
||||||
|
margin-top: 0.75rem; padding: 0.65rem 0.9rem;
|
||||||
|
background: rgba(244,63,94,0.08);
|
||||||
|
border: 1px solid rgba(244,63,94,0.25);
|
||||||
|
border-radius: 8px; font-size: 0.82rem; color: #f43f5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
.ct-footer {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0;
|
||||||
|
display: flex; align-items: center; gap: 1rem;
|
||||||
|
padding: 0.9rem 1.5rem;
|
||||||
|
background: rgba(15,17,23,0.96);
|
||||||
|
border-top: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.footer-spacer { flex: 0 0 80px; }
|
||||||
|
.footer-progress { flex: 1; }
|
||||||
|
.progress-bar {
|
||||||
|
height: 3px; background: var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 2px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%; background: #10b981;
|
||||||
|
border-radius: 2px; transition: width 0.35s ease;
|
||||||
|
}
|
||||||
|
.btn-prev {
|
||||||
|
flex: 0 0 auto; background: none;
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
border-radius: 8px; padding: 0.55rem 1rem;
|
||||||
|
cursor: pointer; font-size: 0.82rem; font-weight: 600;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.btn-prev:hover { color: var(--text-primary, #f1f5f9); border-color: rgba(255,255,255,0.2); }
|
||||||
|
.btn-next {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: #10b981; border: none; color: white;
|
||||||
|
border-radius: 8px; padding: 0.55rem 1.25rem;
|
||||||
|
cursor: pointer; font-size: 0.88rem; font-weight: 700;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn-next:hover:not(:disabled) { opacity: 0.85; }
|
||||||
|
.btn-next:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.btn-submit {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: #10b981; border: none; color: white;
|
||||||
|
border-radius: 8px; padding: 0.6rem 1.5rem;
|
||||||
|
cursor: pointer; font-size: 0.88rem; font-weight: 700;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn-submit:hover:not(:disabled) { opacity: 0.85; }
|
||||||
|
.btn-submit:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.btn-primary-lg {
|
||||||
|
background: #10b981; border: none; color: white;
|
||||||
|
border-radius: 10px; padding: 0.75rem 2rem;
|
||||||
|
cursor: pointer; font-size: 0.95rem; font-weight: 700;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn-primary-lg:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
/* ── Shared field styles ── */
|
||||||
|
.field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.field label {
|
||||||
|
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em; color: var(--text-secondary, #94a3b8);
|
||||||
|
}
|
||||||
|
.field input, .field select, .field textarea {
|
||||||
|
background: var(--bg, #0f1117);
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 8px; color: var(--text-primary, #f1f5f9);
|
||||||
|
padding: 0.6rem 0.85rem; font-size: 0.88rem;
|
||||||
|
font-family: inherit; outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.field input:focus, .field select:focus { border-color: #10b981; }
|
||||||
|
.field-hint { font-size: 0.7rem; color: var(--text-secondary, #64748b); }
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
|
.span2 { grid-column: span 1; }
|
||||||
|
.step-title { font-size: 1.3rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1959
src/pages/DispatchPage.vue
Normal file
2752
src/pages/DispatchV2Page.vue
Normal file
700
src/pages/MobilePage.vue
Normal file
|
|
@ -0,0 +1,700 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { useAuthStore } from 'src/stores/auth'
|
||||||
|
import { useDispatchStore } from 'src/stores/dispatch'
|
||||||
|
import { fetchTechnicians } from 'src/api/dispatch'
|
||||||
|
import { createEquipmentInstall } from 'src/api/service-request'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const store = useDispatchStore()
|
||||||
|
|
||||||
|
// ── UI state ────────────────────────────────────────────────────────────────
|
||||||
|
const phase = ref('loading') // 'loading' | 'login' | 'select-tech' | 'jobs'
|
||||||
|
const tab = ref('jobs') // 'jobs' | 'equipment' | 'map' | 'profile'
|
||||||
|
const showCompleted = ref(false)
|
||||||
|
const showToast = ref(false)
|
||||||
|
const toastMsg = ref('')
|
||||||
|
const detailJob = ref(null)
|
||||||
|
|
||||||
|
// Login form
|
||||||
|
const loginUser = ref('')
|
||||||
|
const loginPass = ref('')
|
||||||
|
const showPwd = ref(false)
|
||||||
|
|
||||||
|
// Tech selector
|
||||||
|
const techList = ref([])
|
||||||
|
const selTechId = ref('')
|
||||||
|
const selTech = computed(() => techList.value.find(t => t.name === selTechId.value) || null)
|
||||||
|
const techName = computed(() => selTech.value?.fullName || selTech.value?.name || '')
|
||||||
|
|
||||||
|
const COLORS = ['#6366f1','#10b981','#f59e0b','#8b5cf6','#06b6d4','#f43f5e','#f97316','#14b8a6']
|
||||||
|
const techColor = computed(() => {
|
||||||
|
const idx = techList.value.indexOf(selTech.value)
|
||||||
|
return COLORS[idx >= 0 ? idx % COLORS.length : 0]
|
||||||
|
})
|
||||||
|
const initials = computed(() =>
|
||||||
|
(techName.value || 'T').split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
||||||
|
)
|
||||||
|
const today = computed(() =>
|
||||||
|
new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Job lists ────────────────────────────────────────────────────────────────
|
||||||
|
const myJobs = computed(() => store.jobs)
|
||||||
|
const activeJob = computed(() => myJobs.value.find(j => j.status === 'in_progress') || null)
|
||||||
|
const upcomingJobs = computed(() =>
|
||||||
|
myJobs.value.filter(j => !j.completed && j.status !== 'in_progress' && j.status !== 'completed')
|
||||||
|
.sort((a, b) => (a.routeOrder || 99) - (b.routeOrder || 99))
|
||||||
|
)
|
||||||
|
const completedJobs = computed(() => myJobs.value.filter(j => j.status === 'completed'))
|
||||||
|
const stats = computed(() => [
|
||||||
|
{ lbl: 'Total', val: myJobs.value.length },
|
||||||
|
{ lbl: 'A faire', val: upcomingJobs.value.length + (activeJob.value ? 1 : 0) },
|
||||||
|
{ lbl: 'Faits', val: completedJobs.value.length },
|
||||||
|
])
|
||||||
|
|
||||||
|
// ── Auth + boot ──────────────────────────────────────────────────────────────
|
||||||
|
async function loadTechs () {
|
||||||
|
const raw = await fetchTechnicians()
|
||||||
|
techList.value = raw.map((t, idx) => ({
|
||||||
|
name: t.name,
|
||||||
|
fullName: t.full_name || t.name,
|
||||||
|
techId: t.technician_id || t.name,
|
||||||
|
user: t.user || null,
|
||||||
|
colorIdx: idx,
|
||||||
|
}))
|
||||||
|
const linked = techList.value.find(t => t.user === auth.user)
|
||||||
|
selTechId.value = linked ? linked.name : (techList.value[0]?.name || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot () {
|
||||||
|
await auth.checkSession()
|
||||||
|
if (auth.user) {
|
||||||
|
loginUser.value = auth.user
|
||||||
|
await loadTechs()
|
||||||
|
phase.value = 'select-tech'
|
||||||
|
} else {
|
||||||
|
phase.value = 'login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogin () {
|
||||||
|
await auth.doLogin(loginUser.value, loginPass.value)
|
||||||
|
if (auth.user) {
|
||||||
|
await loadTechs()
|
||||||
|
phase.value = 'select-tech'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout () {
|
||||||
|
await auth.doLogout()
|
||||||
|
store.jobs = []
|
||||||
|
selTechId.value = ''
|
||||||
|
phase.value = 'login'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadJobs () {
|
||||||
|
if (!selTechId.value || !selTech.value) return
|
||||||
|
await store.loadJobsForTech(selTech.value.techId)
|
||||||
|
phase.value = 'jobs'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Actions ──────────────────────────────────────────────────────────────────
|
||||||
|
async function markComplete (job) {
|
||||||
|
if (!job || job.status === 'completed') return
|
||||||
|
await store.setJobStatus(job.id, 'completed')
|
||||||
|
job.status = 'completed'
|
||||||
|
toast(job.id + ' complété !')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markEnRoute (job) {
|
||||||
|
if (!job) return
|
||||||
|
myJobs.value.forEach(j => { if (j.status === 'in_progress') j.status = 'assigned' })
|
||||||
|
await store.setJobStatus(job.id, 'in_progress')
|
||||||
|
job.status = 'in_progress'
|
||||||
|
detailJob.value = null
|
||||||
|
toast('En route vers ' + job.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast (msg) {
|
||||||
|
toastMsg.value = msg
|
||||||
|
showToast.value = true
|
||||||
|
setTimeout(() => { showToast.value = false }, 2800)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTime (idx) {
|
||||||
|
let m = 8 * 60
|
||||||
|
if (activeJob.value) {
|
||||||
|
m += (parseInt(activeJob.value.legDur) || 0) + (parseFloat(activeJob.value.duration) || 1) * 60
|
||||||
|
}
|
||||||
|
for (let i = 0; i < idx; i++) {
|
||||||
|
const j = upcomingJobs.value[i]
|
||||||
|
m += (parseInt(j.legDur) || 0) + (parseFloat(j.duration) || 1) * 60
|
||||||
|
}
|
||||||
|
m += parseInt(upcomingJobs.value[idx]?.legDur) || 0
|
||||||
|
return String(Math.floor(m / 60)).padStart(2, '0') + 'h' + String(m % 60).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
function prioLbl (p) { return { high: 'Urgent', medium: 'Moyen', low: 'Faible' }[p] || p }
|
||||||
|
function prioStyle (p) {
|
||||||
|
return {
|
||||||
|
high: 'background:#fef2f2;color:#dc2626',
|
||||||
|
medium: 'background:#fffbeb;color:#d97706',
|
||||||
|
low: 'background:#f0fdf4;color:#16a34a',
|
||||||
|
}[p] || ''
|
||||||
|
}
|
||||||
|
function mapsUrl (addr) { return 'https://maps.google.com/?q=' + encodeURIComponent(addr) }
|
||||||
|
|
||||||
|
// ── Equipment / Barcode ───────────────────────────────────────────────────────
|
||||||
|
const EQUIPMENT_TYPES = ['Modem', 'Routeur', 'Décodeur TV', 'Téléphone IP', 'Câble coaxial', 'Amplificateur', 'Splitter', 'ONT/ONU', 'Autre']
|
||||||
|
|
||||||
|
const eqRequestName = ref('') // which service request we're working on
|
||||||
|
const eqItems = ref([]) // array of equipment items to submit
|
||||||
|
const eqSubmitting = ref(false)
|
||||||
|
const eqDone = ref(false)
|
||||||
|
const scannerActive = ref(false)
|
||||||
|
let _scanner = null
|
||||||
|
|
||||||
|
const eqJobs = computed(() =>
|
||||||
|
myJobs.value.filter(j => j.status !== 'completed')
|
||||||
|
)
|
||||||
|
|
||||||
|
function newEqItem (barcode = '') {
|
||||||
|
return { barcode, equipment_type: 'Modem', brand: '', model: '', notes: '', photo_base64: '', _id: Date.now() + Math.random() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEqItem () {
|
||||||
|
eqItems.value.push(newEqItem())
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEqItem (item) {
|
||||||
|
eqItems.value = eqItems.value.filter(e => e._id !== item._id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startScanner () {
|
||||||
|
scannerActive.value = true
|
||||||
|
await nextTick()
|
||||||
|
try {
|
||||||
|
const { Html5Qrcode } = await import('html5-qrcode')
|
||||||
|
_scanner = new Html5Qrcode('qr-reader')
|
||||||
|
await _scanner.start(
|
||||||
|
{ facingMode: 'environment' },
|
||||||
|
{ fps: 10, qrbox: { width: 260, height: 80 } },
|
||||||
|
(decoded) => {
|
||||||
|
stopScanner()
|
||||||
|
const existing = eqItems.value.find(e => e.barcode === decoded)
|
||||||
|
if (!existing) {
|
||||||
|
eqItems.value.push(newEqItem(decoded))
|
||||||
|
toast('Scanné : ' + decoded)
|
||||||
|
} else {
|
||||||
|
toast('Déjà dans la liste')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
scannerActive.value = false
|
||||||
|
toast('Caméra non disponible')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopScanner () {
|
||||||
|
if (_scanner) {
|
||||||
|
await _scanner.stop().catch(() => {})
|
||||||
|
_scanner = null
|
||||||
|
}
|
||||||
|
scannerActive.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPhotoChange (item, event) {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = e => { item.photo_base64 = e.target.result }
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEquipment () {
|
||||||
|
if (!eqRequestName.value || eqItems.value.length === 0) return
|
||||||
|
eqSubmitting.value = true
|
||||||
|
try {
|
||||||
|
for (const item of eqItems.value) {
|
||||||
|
await createEquipmentInstall({
|
||||||
|
request: eqRequestName.value,
|
||||||
|
barcode: item.barcode,
|
||||||
|
equipment_type: item.equipment_type,
|
||||||
|
brand: item.brand,
|
||||||
|
model: item.model,
|
||||||
|
notes: item.notes,
|
||||||
|
photo_base64: item.photo_base64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const count = eqItems.value.length
|
||||||
|
eqItems.value = []
|
||||||
|
eqDone.value = true
|
||||||
|
toast(count + ' équipement(s) enregistré(s)')
|
||||||
|
} catch {
|
||||||
|
toast('Erreur lors de la soumission')
|
||||||
|
} finally {
|
||||||
|
eqSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => { stopScanner() })
|
||||||
|
|
||||||
|
onMounted(boot)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mobile-app">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="app-header">
|
||||||
|
<div class="app-header-bar">
|
||||||
|
<div>
|
||||||
|
<div class="app-header-sub">{{ today }}</div>
|
||||||
|
<div class="app-header-title">
|
||||||
|
<span v-if="phase === 'jobs'">{{ techName }}</span>
|
||||||
|
<span v-else-if="phase === 'select-tech'">Choisir un technicien</span>
|
||||||
|
<span v-else>Dispatch Mobile</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
|
<span :class="auth.user ? 'badge badge-online' : 'badge badge-offline'">
|
||||||
|
{{ auth.user ? 'En ligne' : 'Hors ligne' }}
|
||||||
|
</span>
|
||||||
|
<button v-if="phase === 'jobs'" class="btn-icon"
|
||||||
|
@click="phase = 'select-tech'" title="Changer de tech">⇆</button>
|
||||||
|
<div v-if="phase === 'jobs'" class="avatar" :style="'background:' + techColor">
|
||||||
|
{{ initials }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="phase === 'jobs'" class="stats-strip">
|
||||||
|
<div v-for="s in stats" :key="s.lbl" class="stat-box">
|
||||||
|
<div class="stat-val">{{ s.val }}</div>
|
||||||
|
<div class="stat-lbl">{{ s.lbl }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="app-content">
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="phase === 'loading'" style="display:flex;flex-direction:column;align-items:center;padding:3rem 1rem;gap:1rem;">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div style="color:#94a3b8;font-size:0.88rem;">Chargement...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login -->
|
||||||
|
<div v-else-if="phase === 'login'" class="login-wrap">
|
||||||
|
<div class="login-hero">
|
||||||
|
<div class="login-icon">⚡</div>
|
||||||
|
<div style="font-size:1.2rem;font-weight:700;color:#1e293b;">Connexion ERPNext</div>
|
||||||
|
<div style="color:#64748b;font-size:0.82rem;margin-top:0.3rem;">Entrez vos identifiants pour continuer</div>
|
||||||
|
</div>
|
||||||
|
<div class="login-card">
|
||||||
|
<label class="field-label">Utilisateur (email)</label>
|
||||||
|
<input v-model="loginUser" type="email" placeholder="admin@example.com"
|
||||||
|
class="field-input" :class="{ err: !!auth.error }" @keyup.enter="doLogin" />
|
||||||
|
<label class="field-label">Mot de passe</label>
|
||||||
|
<input v-model="loginPass" :type="showPwd ? 'text' : 'password'" placeholder="••••••••"
|
||||||
|
class="field-input" :class="{ err: !!auth.error }" @keyup.enter="doLogin" />
|
||||||
|
<div v-if="auth.error" class="error-msg">{{ auth.error }}</div>
|
||||||
|
<label class="show-pwd">
|
||||||
|
<input type="checkbox" v-model="showPwd" /> Afficher le mot de passe
|
||||||
|
</label>
|
||||||
|
<button class="btn-primary"
|
||||||
|
:disabled="!loginUser || !loginPass || auth.loading"
|
||||||
|
@click="doLogin">
|
||||||
|
{{ auth.loading ? 'Connexion...' : 'Se connecter' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Select tech -->
|
||||||
|
<div v-else-if="phase === 'select-tech'" class="login-wrap">
|
||||||
|
<div class="login-hero">
|
||||||
|
<div class="login-icon" style="font-size:1.6rem;">👷</div>
|
||||||
|
<div style="font-size:1.2rem;font-weight:700;color:#1e293b;">Choisir un technicien</div>
|
||||||
|
<div style="color:#64748b;font-size:0.82rem;margin-top:0.3rem;">
|
||||||
|
Connecté : <strong>{{ auth.user }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="login-card">
|
||||||
|
<label class="field-label">Technicien</label>
|
||||||
|
<select v-model="selTechId" class="field-select">
|
||||||
|
<option value="" disabled>-- Choisir --</option>
|
||||||
|
<option v-for="t in techList" :key="t.name" :value="t.name">{{ t.fullName }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-primary" :disabled="!selTechId || store.loading" @click="loadJobs">
|
||||||
|
{{ store.loading ? 'Chargement...' : 'Voir les jobs →' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary" @click="doLogout">Changer de compte</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jobs -->
|
||||||
|
<template v-else-if="phase === 'jobs'">
|
||||||
|
|
||||||
|
<!-- En cours -->
|
||||||
|
<div v-if="activeJob">
|
||||||
|
<div class="section-label">En cours</div>
|
||||||
|
<div class="job-card active-card" :style="'border-left-color:' + techColor"
|
||||||
|
@click="detailJob = activeJob">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem;">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
|
<span class="prio-dot" :class="'prio-' + activeJob.priority"></span>
|
||||||
|
<span style="font-size:0.72rem;font-weight:700;color:#6366f1;">{{ activeJob.id }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-active">En cours</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.97rem;font-weight:700;margin-bottom:0.3rem;">{{ activeJob.subject }}</div>
|
||||||
|
<div style="font-size:0.77rem;color:#64748b;margin-bottom:0.6rem;">📍 {{ activeJob.address }}</div>
|
||||||
|
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
|
||||||
|
<span class="chip">⏲ {{ activeJob.duration }}h</span>
|
||||||
|
<span v-if="activeJob.legDur" class="chip">🚘 {{ activeJob.legDur }}min</span>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
<button class="btn-green" style="flex:0;padding:4px 14px;font-size:0.75rem;border-radius:8px;"
|
||||||
|
@click.stop="markComplete(activeJob)">Terminer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- A venir -->
|
||||||
|
<div class="section-label">A venir ({{ upcomingJobs.length }})</div>
|
||||||
|
<div v-if="upcomingJobs.length === 0"
|
||||||
|
style="text-align:center;padding:1.5rem;color:#94a3b8;font-size:0.85rem;">
|
||||||
|
Aucun job à venir
|
||||||
|
</div>
|
||||||
|
<div v-for="(job, idx) in upcomingJobs" :key="job.id"
|
||||||
|
class="job-card" :style="'border-left-color:' + techColor" @click="detailJob = job">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.4rem;">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
|
<div class="num-bubble" :style="'background:' + techColor">
|
||||||
|
{{ idx + (activeJob ? 2 : 1) }}
|
||||||
|
</div>
|
||||||
|
<span style="font-size:0.72rem;font-weight:600;color:#6366f1;">{{ job.id }}</span>
|
||||||
|
<span class="prio-dot" :class="'prio-' + job.priority"></span>
|
||||||
|
</div>
|
||||||
|
<span class="chip">{{ startTime(idx) }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.92rem;font-weight:600;margin-bottom:0.25rem;">{{ job.subject }}</div>
|
||||||
|
<div style="font-size:0.75rem;color:#64748b;margin-bottom:0.5rem;">📍 {{ job.address }}</div>
|
||||||
|
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;">
|
||||||
|
<span class="chip">⏲ {{ job.duration }}h</span>
|
||||||
|
<span v-if="job.legDur" class="chip">🚘 {{ job.legDur }}m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Complétés -->
|
||||||
|
<div v-if="completedJobs.length > 0">
|
||||||
|
<div class="section-label" @click="showCompleted = !showCompleted">
|
||||||
|
Complétés ({{ completedJobs.length }})
|
||||||
|
<span style="font-size:0.9rem;">{{ showCompleted ? '⌃' : '⌄' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="showCompleted">
|
||||||
|
<div v-for="job in completedJobs" :key="job.id"
|
||||||
|
class="job-card done-card" @click="detailJob = job">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.6rem;">
|
||||||
|
<div class="check-circle">✓</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:0.85rem;font-weight:600;text-decoration:line-through;color:#64748b;">
|
||||||
|
{{ job.subject }}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.71rem;color:#94a3b8;">{{ job.id }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aucun job -->
|
||||||
|
<div v-if="myJobs.length === 0" style="text-align:center;padding:3rem 1rem;">
|
||||||
|
<div style="font-size:3rem;margin-bottom:0.75rem;">📅</div>
|
||||||
|
<div style="font-size:1rem;font-weight:600;color:#374151;margin-bottom:0.3rem;">Aucun job aujourd'hui</div>
|
||||||
|
<div style="color:#94a3b8;font-size:0.83rem;">Votre planning est vide.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ── Equipment tab ──────────────────────────────────────────────────── -->
|
||||||
|
<template v-else-if="phase === 'jobs' && tab === 'equipment'">
|
||||||
|
|
||||||
|
<!-- Confirm done banner -->
|
||||||
|
<div v-if="eqDone" class="eq-done-banner">
|
||||||
|
✓ Équipements enregistrés avec succès !
|
||||||
|
<button @click="eqDone = false" style="margin-left:0.75rem;background:none;border:none;color:inherit;font-size:1rem;cursor:pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request picker -->
|
||||||
|
<div class="section-label">Appel de service</div>
|
||||||
|
<select v-model="eqRequestName" class="field-select" style="margin-bottom:0.5rem;">
|
||||||
|
<option value="" disabled>-- Choisir un ticket --</option>
|
||||||
|
<option v-for="j in eqJobs" :key="j.id" :value="j.id">{{ j.id }} — {{ j.subject }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Scanner -->
|
||||||
|
<div class="section-label">Scanner un code-barres</div>
|
||||||
|
<div v-if="!scannerActive" style="margin-bottom:1rem;">
|
||||||
|
<button class="btn-indigo-full" @click="startScanner">
|
||||||
|
📷 Activer la caméra
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else style="margin-bottom:1rem;">
|
||||||
|
<div id="qr-reader" class="qr-reader-box"></div>
|
||||||
|
<button class="btn-secondary" style="width:100%;margin-top:0.5rem;" @click="stopScanner">
|
||||||
|
Arrêter le scanner
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scanned items -->
|
||||||
|
<div class="section-label">Équipements ({{ eqItems.length }})</div>
|
||||||
|
<div v-if="eqItems.length === 0" style="text-align:center;padding:1.5rem;color:#94a3b8;font-size:0.85rem;">
|
||||||
|
Scannez un code-barres ou ajoutez manuellement.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="item in eqItems" :key="item._id" class="eq-card">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
|
||||||
|
<span style="font-size:0.72rem;font-weight:700;color:#6366f1;flex:1;">CODE-BARRES</span>
|
||||||
|
<button @click="removeEqItem(item)" style="background:none;border:none;color:#ef4444;font-size:1.1rem;cursor:pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
<input v-model="item.barcode" placeholder="Code-barres ou numéro de série" class="eq-input" />
|
||||||
|
|
||||||
|
<label class="eq-label">Type d'équipement</label>
|
||||||
|
<select v-model="item.equipment_type" class="eq-select">
|
||||||
|
<option v-for="t in EQUIPMENT_TYPES" :key="t" :value="t">{{ t }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;">
|
||||||
|
<div>
|
||||||
|
<label class="eq-label">Marque</label>
|
||||||
|
<input v-model="item.brand" placeholder="ex: Cisco" class="eq-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="eq-label">Modèle</label>
|
||||||
|
<input v-model="item.model" placeholder="ex: DPC3829" class="eq-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="eq-label">Notes</label>
|
||||||
|
<input v-model="item.notes" placeholder="Observations, port, emplacement..." class="eq-input" />
|
||||||
|
|
||||||
|
<label class="eq-label">Photo</label>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||||
|
<label class="btn-photo">
|
||||||
|
📷 Prendre une photo
|
||||||
|
<input type="file" accept="image/*" capture="environment" style="display:none"
|
||||||
|
@change="onPhotoChange(item, $event)" />
|
||||||
|
</label>
|
||||||
|
<img v-if="item.photo_base64" :src="item.photo_base64" class="eq-thumb" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-secondary" style="width:100%;margin-top:0.5rem;margin-bottom:0.75rem;" @click="addEqItem">
|
||||||
|
+ Ajouter manuellement
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn-green-full"
|
||||||
|
:disabled="!eqRequestName || eqItems.length === 0 || eqSubmitting"
|
||||||
|
@click="submitEquipment">
|
||||||
|
{{ eqSubmitting ? 'Enregistrement...' : 'Enregistrer les équipements (' + eqItems.length + ')' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer tabs -->
|
||||||
|
<div class="app-footer">
|
||||||
|
<button class="tab-btn" :class="{ active: tab === 'jobs' }" @click="tab = 'jobs'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||||
|
</svg>
|
||||||
|
Jobs
|
||||||
|
</button>
|
||||||
|
<button class="tab-btn" :class="{ active: tab === 'map' }" @click="tab = 'map'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/>
|
||||||
|
<line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/>
|
||||||
|
</svg>
|
||||||
|
Carte
|
||||||
|
</button>
|
||||||
|
<button class="tab-btn" :class="{ active: tab === 'equipment' }"
|
||||||
|
@click="tab = 'equipment'; if(scannerActive) stopScanner()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 7h.01M12 7h.01M17 7h.01M7 12h.01M12 12h.01M17 12h.01M7 17h.01M12 17h.01M17 17h.01"/>
|
||||||
|
</svg>
|
||||||
|
Équip.
|
||||||
|
</button>
|
||||||
|
<button class="tab-btn" :class="{ active: tab === 'profile' }" @click="tab = 'profile'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/>
|
||||||
|
<circle cx="12" cy="7" r="4"/>
|
||||||
|
</svg>
|
||||||
|
Profil
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail modal -->
|
||||||
|
<div v-if="detailJob" class="modal-backdrop" @click.self="detailJob = null">
|
||||||
|
<div class="modal-sheet">
|
||||||
|
<div class="modal-handle"><div class="modal-handle-bar"></div></div>
|
||||||
|
<div style="padding:1rem 1.25rem 0.25rem;display:flex;align-items:flex-start;justify-content:space-between;gap:0.5rem;">
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.35rem;">
|
||||||
|
<span style="font-size:0.75rem;font-weight:700;color:#6366f1;">{{ detailJob.id }}</span>
|
||||||
|
<span class="prio-dot" :class="'prio-' + detailJob.priority"></span>
|
||||||
|
<span style="font-size:0.68rem;font-weight:600;padding:2px 8px;border-radius:6px;"
|
||||||
|
:style="prioStyle(detailJob.priority)">{{ prioLbl(detailJob.priority) }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:1.05rem;font-weight:700;color:#1e293b;">{{ detailJob.subject }}</div>
|
||||||
|
</div>
|
||||||
|
<button @click="detailJob = null"
|
||||||
|
style="background:none;border:none;font-size:1.4rem;color:#94a3b8;cursor:pointer;line-height:1;">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-row">
|
||||||
|
<div class="modal-row-icon">📍</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div class="modal-row-label">Adresse</div>
|
||||||
|
<div class="modal-row-value">{{ detailJob.address }}</div>
|
||||||
|
</div>
|
||||||
|
<a :href="mapsUrl(detailJob.address)" target="_blank"
|
||||||
|
style="color:#6366f1;font-size:0.8rem;text-decoration:none;font-weight:600;">Carte</a>
|
||||||
|
</div>
|
||||||
|
<div class="modal-row">
|
||||||
|
<div class="modal-row-icon">⏲</div>
|
||||||
|
<div>
|
||||||
|
<div class="modal-row-label">Durée estimée</div>
|
||||||
|
<div class="modal-row-value">{{ detailJob.duration }}h</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="detailJob.legDist" class="modal-row">
|
||||||
|
<div class="modal-row-icon">🚘</div>
|
||||||
|
<div>
|
||||||
|
<div class="modal-row-label">Trajet jusqu'au job</div>
|
||||||
|
<div class="modal-row-value">{{ detailJob.legDist }} km · {{ detailJob.legDur }} min</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button v-if="detailJob.status !== 'completed'" class="btn-indigo"
|
||||||
|
@click="markEnRoute(detailJob)">En route</button>
|
||||||
|
<button v-if="detailJob.status !== 'completed'" class="btn-green"
|
||||||
|
@click="markComplete(detailJob); detailJob = null">Terminer</button>
|
||||||
|
<button v-if="detailJob.status === 'completed'" disabled
|
||||||
|
style="flex:1;padding:0.7rem;background:#f1f5f9;color:#94a3b8;border:none;border-radius:10px;font-weight:700;font-family:inherit;">
|
||||||
|
Ticket complété
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<div v-if="showToast" class="toast">✓ {{ toastMsg }}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
.mobile-app { height: 100vh; display: flex; flex-direction: column; background: #f1f5f9; color: #1e293b; font-family: 'Inter', system-ui, sans-serif; }
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.app-header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; flex-shrink: 0; }
|
||||||
|
.app-header-bar { display: flex; align-items: center; justify-content: space-between; padding: 0.9rem 1rem; }
|
||||||
|
.app-header-title { font-size: 1.1rem; font-weight: 700; }
|
||||||
|
.app-header-sub { font-size: 0.7rem; opacity: 0.75; margin-bottom: 2px; }
|
||||||
|
.app-content { flex: 1; overflow-y: auto; padding: 1rem; padding-bottom: 5rem; }
|
||||||
|
.app-footer { position: fixed; bottom: 0; left: 0; right: 0; background: white; border-top: 1px solid #e2e8f0; display: flex; z-index: 100; }
|
||||||
|
.tab-btn { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 0.55rem 0; font-size: 0.65rem; font-weight: 600; color: #94a3b8; border: none; background: none; cursor: pointer; transition: color 0.15s; }
|
||||||
|
.tab-btn.active { color: #6366f1; }
|
||||||
|
.tab-btn svg { width: 20px; height: 20px; }
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats-strip { background: #4f46e5; display: flex; padding: 0.5rem 1rem; gap: 0.5rem; }
|
||||||
|
.stat-box { flex: 1; background: rgba(255,255,255,0.12); border-radius: 8px; padding: 0.4rem 0.5rem; text-align: center; }
|
||||||
|
.stat-val { font-size: 1.1rem; font-weight: 700; color: white; }
|
||||||
|
.stat-lbl { font-size: 0.6rem; color: rgba(255,255,255,0.75); margin-top: 1px; }
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge { display: inline-flex; align-items: center; font-size: 0.65rem; font-weight: 700; padding: 2px 8px; border-radius: 20px; }
|
||||||
|
.badge-online { background: rgba(74,222,128,0.25); color: #16a34a; }
|
||||||
|
.badge-offline { background: rgba(248,113,113,0.25); color: #dc2626; }
|
||||||
|
.badge-active { background: #e0e7ff; color: #4338ca; }
|
||||||
|
|
||||||
|
/* Login */
|
||||||
|
.login-wrap { max-width: 400px; margin: 0 auto; padding-top: 0.5rem; }
|
||||||
|
.login-hero { text-align: center; padding: 1.75rem 0 1.25rem; }
|
||||||
|
.login-icon { width: 68px; height: 68px; border-radius: 20px; background: linear-gradient(135deg,#6366f1,#8b5cf6); display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem; font-size: 2rem; }
|
||||||
|
.login-card { background: white; border-radius: 16px; padding: 1.5rem; box-shadow: 0 4px 24px rgba(0,0,0,0.07); }
|
||||||
|
.field-label { font-size: 0.75rem; font-weight: 600; color: #475569; display: block; margin-bottom: 0.35rem; }
|
||||||
|
.field-input { width: 100%; padding: 0.7rem 0.9rem; font-size: 0.9rem; font-family: inherit; border: 1.5px solid #e2e8f0; border-radius: 10px; outline: none; transition: border-color 0.15s; margin-bottom: 0.85rem; }
|
||||||
|
.field-input:focus { border-color: #6366f1; }
|
||||||
|
.field-input.err { border-color: #ef4444; }
|
||||||
|
.show-pwd { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 1rem; font-size: 0.78rem; color: #64748b; cursor: pointer; }
|
||||||
|
.show-pwd input { accent-color: #6366f1; }
|
||||||
|
.error-msg { font-size: 0.78rem; color: #ef4444; margin: -0.6rem 0 0.75rem; padding-left: 0.1rem; }
|
||||||
|
.field-select { width: 100%; padding: 0.7rem 0.9rem; font-size: 0.9rem; font-family: inherit; border: 1.5px solid #e2e8f0; border-radius: 10px; outline: none; background: white; margin-bottom: 1rem; cursor: pointer; }
|
||||||
|
.field-select:focus { border-color: #6366f1; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary { width: 100%; padding: 0.75rem; font-size: 0.92rem; font-weight: 700; font-family: inherit; background: #6366f1; color: white; border: none; border-radius: 10px; cursor: pointer; transition: background 0.15s; }
|
||||||
|
.btn-primary:hover { background: #4f46e5; }
|
||||||
|
.btn-primary:disabled { background: #c7d2fe; cursor: not-allowed; }
|
||||||
|
.btn-secondary { width: 100%; padding: 0.65rem; font-size: 0.85rem; font-weight: 600; font-family: inherit; background: transparent; color: #64748b; border: 1.5px solid #e2e8f0; border-radius: 10px; cursor: pointer; margin-top: 0.6rem; transition: all 0.15s; }
|
||||||
|
.btn-secondary:hover { border-color: #6366f1; color: #6366f1; }
|
||||||
|
.btn-green { flex: 1; padding: 0.7rem; font-size: 0.88rem; font-weight: 700; font-family: inherit; background: #22c55e; color: white; border: none; border-radius: 10px; cursor: pointer; }
|
||||||
|
.btn-indigo { flex: 1; padding: 0.7rem; font-size: 0.88rem; font-weight: 700; font-family: inherit; background: #6366f1; color: white; border: none; border-radius: 10px; cursor: pointer; }
|
||||||
|
.btn-icon { background: rgba(255,255,255,0.2); border: none; color: white; width: 34px; height: 34px; border-radius: 50%; cursor: pointer; font-size: 1.1rem; display: flex; align-items: center; justify-content: center; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.section-label { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #94a3b8; margin: 1.25rem 0 0.5rem; padding: 0 0.1rem; display: flex; align-items: center; gap: 0.4rem; cursor: pointer; }
|
||||||
|
.job-card { background: white; border-radius: 14px; padding: 0.9rem 1rem; margin-bottom: 0.7rem; box-shadow: 0 2px 8px rgba(0,0,0,0.06); border-left: 4px solid #e2e8f0; cursor: pointer; transition: transform 0.12s, box-shadow 0.12s; }
|
||||||
|
.job-card:active { transform: scale(0.985); }
|
||||||
|
.active-card { box-shadow: 0 4px 20px rgba(99,102,241,0.18); }
|
||||||
|
.done-card { opacity: 0.6; border-left-color: #22c55e !important; background: #f9fafb; }
|
||||||
|
.prio-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||||
|
.prio-high { background: #ef4444; }
|
||||||
|
.prio-medium { background: #f59e0b; }
|
||||||
|
.prio-low { background: #10b981; }
|
||||||
|
.chip { background: #f1f5f9; border-radius: 6px; padding: 2px 8px; font-size: 0.7rem; font-weight: 600; color: #475569; }
|
||||||
|
.num-bubble { width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.68rem; font-weight: 700; color: white; flex-shrink: 0; }
|
||||||
|
.avatar { width: 36px; height: 36px; border-radius: 50%; font-weight: 700; font-size: 0.85rem; color: white; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.check-circle { width: 32px; height: 32px; border-radius: 50%; background: #22c55e; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: white; font-size: 1rem; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 200; display: flex; align-items: flex-end; }
|
||||||
|
.modal-sheet { background: white; border-radius: 18px 18px 0 0; width: 100%; max-width: 600px; margin: 0 auto; padding-bottom: env(safe-area-inset-bottom, 16px); animation: slideUp 0.22s ease; }
|
||||||
|
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||||
|
.modal-handle { display: flex; justify-content: center; padding: 0.75rem 0 0; }
|
||||||
|
.modal-handle-bar { width: 40px; height: 4px; border-radius: 2px; background: #e2e8f0; }
|
||||||
|
.modal-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 1.25rem; }
|
||||||
|
.modal-row-icon { color: #94a3b8; font-size: 1.1rem; width: 22px; text-align: center; }
|
||||||
|
.modal-row-label { font-size: 0.68rem; color: #94a3b8; }
|
||||||
|
.modal-row-value { font-size: 0.88rem; font-weight: 600; color: #1e293b; }
|
||||||
|
.modal-actions { display: flex; gap: 0.5rem; padding: 0.75rem 1.25rem 1.25rem; }
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.toast { position: fixed; top: 1rem; left: 50%; transform: translateX(-50%); background: #22c55e; color: white; border-radius: 12px; padding: 0.65rem 1.2rem; font-weight: 700; font-size: 0.88rem; z-index: 300; white-space: nowrap; box-shadow: 0 4px 16px rgba(0,0,0,0.15); animation: fadeIn 0.2s ease; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateX(-50%) translateY(-8px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.spinner { width: 40px; height: 40px; border: 3px solid #e2e8f0; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.7s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Equipment tab */
|
||||||
|
.eq-done-banner { background: #dcfce7; color: #16a34a; border-radius: 10px; padding: 0.75rem 1rem; font-size: 0.85rem; font-weight: 600; margin-bottom: 1rem; display: flex; align-items: center; }
|
||||||
|
.eq-card { background: white; border-radius: 14px; padding: 1rem; margin-bottom: 0.75rem; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||||
|
.eq-label { display: block; font-size: 0.68rem; font-weight: 600; color: #64748b; margin: 0.55rem 0 0.2rem; }
|
||||||
|
.eq-input { width: 100%; padding: 0.55rem 0.75rem; font-size: 0.85rem; font-family: inherit; border: 1.5px solid #e2e8f0; border-radius: 8px; outline: none; margin-bottom: 0.1rem; }
|
||||||
|
.eq-input:focus { border-color: #6366f1; }
|
||||||
|
.eq-select { width: 100%; padding: 0.55rem 0.75rem; font-size: 0.85rem; font-family: inherit; border: 1.5px solid #e2e8f0; border-radius: 8px; outline: none; background: white; }
|
||||||
|
.eq-select:focus { border-color: #6366f1; }
|
||||||
|
.qr-reader-box { width: 100%; border-radius: 12px; overflow: hidden; border: 2px solid #6366f1; background: #000; min-height: 200px; }
|
||||||
|
.btn-photo { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.45rem 0.9rem; font-size: 0.78rem; font-weight: 600; font-family: inherit; background: #ede9fe; color: #6366f1; border: none; border-radius: 8px; cursor: pointer; }
|
||||||
|
.eq-thumb { width: 52px; height: 52px; object-fit: cover; border-radius: 8px; border: 2px solid #e2e8f0; }
|
||||||
|
.btn-indigo-full { width: 100%; padding: 0.72rem; font-size: 0.9rem; font-weight: 700; font-family: inherit; background: #6366f1; color: white; border: none; border-radius: 10px; cursor: pointer; }
|
||||||
|
.btn-green-full { width: 100%; padding: 0.72rem; font-size: 0.9rem; font-weight: 700; font-family: inherit; background: #22c55e; color: white; border: none; border-radius: 10px; cursor: pointer; }
|
||||||
|
.btn-green-full:disabled { background: #86efac; cursor: not-allowed; }
|
||||||
|
</style>
|
||||||
399
src/pages/TechBidPage.vue
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* TechBidPage — Vue Uber pour techniciens
|
||||||
|
* Affiche les demandes disponibles, permet d'accepter une date et soumettre un bid.
|
||||||
|
*/
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { fetchOpenRequests, createServiceBid } from 'src/api/service-request'
|
||||||
|
|
||||||
|
// ── Auth locale (simple, sans store) ─────────────────────────────────────────
|
||||||
|
const techName = ref(localStorage.getItem('dispatch-tech-name') || '')
|
||||||
|
const techId = ref(localStorage.getItem('dispatch-tech-id') || '')
|
||||||
|
const showLogin = ref(!techId.value)
|
||||||
|
|
||||||
|
const loginName = ref('')
|
||||||
|
async function loginAsTech () {
|
||||||
|
if (!loginName.value.trim()) return
|
||||||
|
techName.value = loginName.value.trim()
|
||||||
|
techId.value = loginName.value.trim().toLowerCase().replace(/\s+/g, '-')
|
||||||
|
localStorage.setItem('dispatch-tech-name', techName.value)
|
||||||
|
localStorage.setItem('dispatch-tech-id', techId.value)
|
||||||
|
showLogin.value = false
|
||||||
|
loadRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Demandes disponibles ──────────────────────────────────────────────────────
|
||||||
|
const requests = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const expandedId = ref(null)
|
||||||
|
|
||||||
|
async function loadRequests () {
|
||||||
|
loading.value = true
|
||||||
|
try { requests.value = await fetchOpenRequests() }
|
||||||
|
catch (_) { requests.value = [] }
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { if (!showLogin.value) loadRequests() })
|
||||||
|
|
||||||
|
// ── Bid state ─────────────────────────────────────────────────────────────────
|
||||||
|
const bidState = ref({}) // { [requestName]: { date, timeSlot, duration, notes } }
|
||||||
|
const bidding = ref({}) // { [requestName]: true } = en cours
|
||||||
|
const bidSent = ref({}) // { [requestName]: true } = confirmé
|
||||||
|
|
||||||
|
function getBid (name) {
|
||||||
|
if (!bidState.value[name]) bidState.value[name] = { date: '', timeSlot: '', duration: '2', notes: '', price: '' }
|
||||||
|
return bidState.value[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVICE_ICONS = { internet: '🌐', tv: '📺', telephone: '📞', multi: '🔧' }
|
||||||
|
const SERVICE_LABELS = { internet: 'Internet', tv: 'Télévision', telephone: 'Téléphonie', multi: 'Multiple' }
|
||||||
|
const URGENCY_COLORS = { urgent: '#f43f5e', normal: '#6366f1' }
|
||||||
|
|
||||||
|
const TIME_SLOTS = [
|
||||||
|
{ id: 'morning', label: 'Matin', sub: '8h–12h' },
|
||||||
|
{ id: 'afternoon', label: 'Après-midi', sub: '12h–17h' },
|
||||||
|
{ id: 'evening', label: 'Soir', sub: '17h–20h' },
|
||||||
|
{ id: 'flexible', label: 'Flexible', sub: 'Au choix' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Dates proposées par le client pour cette demande
|
||||||
|
// Supporte 2 formats : champs plats Frappe (preferred_date_1…) et tableau localStorage (preferred_dates[])
|
||||||
|
function getClientDates (req) {
|
||||||
|
// Format tableau localStorage
|
||||||
|
if (Array.isArray(req.preferred_dates) && req.preferred_dates.length > 0) {
|
||||||
|
return req.preferred_dates
|
||||||
|
.filter(d => d.date)
|
||||||
|
.map((d, i) => ({
|
||||||
|
date: d.date,
|
||||||
|
slot: d.time_slot || (Array.isArray(d.time_slots) ? d.time_slots[0] : '') || '',
|
||||||
|
slots: Array.isArray(d.time_slots) ? d.time_slots : (d.time_slot ? [d.time_slot] : []),
|
||||||
|
priority: i + 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
// Format champs plats Frappe
|
||||||
|
const dates = []
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const d = req[`preferred_date_${i}`]
|
||||||
|
const s = req[`time_slot_${i}`]
|
||||||
|
if (d) dates.push({ date: d, slot: s, slots: s ? [s] : [], priority: i })
|
||||||
|
}
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate (iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso + 'T12:00:00')
|
||||||
|
return d.toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo (iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
|
const m = Math.floor(diff / 60000)
|
||||||
|
if (m < 60) return `il y a ${m}min`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
if (h < 24) return `il y a ${h}h`
|
||||||
|
return `il y a ${Math.floor(h / 24)}j`
|
||||||
|
}
|
||||||
|
|
||||||
|
const canBid = (name) => {
|
||||||
|
const b = bidState.value[name]
|
||||||
|
return b?.date && b?.timeSlot && b?.price
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBid (req) {
|
||||||
|
const b = getBid(req.name)
|
||||||
|
if (!canBid(req.name)) return
|
||||||
|
bidding.value = { ...bidding.value, [req.name]: true }
|
||||||
|
try {
|
||||||
|
await createServiceBid({
|
||||||
|
request: req.name,
|
||||||
|
technician: techId.value,
|
||||||
|
proposed_date: b.date,
|
||||||
|
time_slot: b.timeSlot,
|
||||||
|
estimated_duration: b.duration,
|
||||||
|
notes: b.notes,
|
||||||
|
price: b.price,
|
||||||
|
})
|
||||||
|
bidSent.value = { ...bidSent.value, [req.name]: true }
|
||||||
|
expandedId.value = null
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
bidding.value = { ...bidding.value, [req.name]: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decline (name) {
|
||||||
|
// Simply hide from list locally (no API call needed — tech just ignores)
|
||||||
|
requests.value = requests.value.filter(r => r.name !== name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingRequests = computed(() => requests.value.filter(r => !bidSent.value[r.name]))
|
||||||
|
const sentCount = computed(() => Object.keys(bidSent.value).length)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bid-root">
|
||||||
|
|
||||||
|
<!-- Login ────────────────────────────────────────────────────────────────── -->
|
||||||
|
<div v-if="showLogin" class="login-screen">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-icon">👷</div>
|
||||||
|
<h2>Portail Technicien</h2>
|
||||||
|
<p>Entrez votre nom pour voir les mandats disponibles.</p>
|
||||||
|
<input v-model="loginName" type="text" placeholder="Votre nom" class="login-input"
|
||||||
|
@keyup.enter="loginAsTech" />
|
||||||
|
<button class="btn-login" @click="loginAsTech" :disabled="!loginName.trim()">
|
||||||
|
Accéder aux mandats
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main ─────────────────────────────────────────────────────────────────── -->
|
||||||
|
<template v-else>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bid-header">
|
||||||
|
<div class="bid-header-left">
|
||||||
|
<div class="tech-avatar">{{ techName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</div>
|
||||||
|
<div>
|
||||||
|
<div class="tech-name">{{ techName }}</div>
|
||||||
|
<div class="tech-sub">Technicien</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<span v-if="sentCount" class="badge-sent">{{ sentCount }} envoyé{{ sentCount > 1 ? 's' : '' }}</span>
|
||||||
|
<button class="btn-refresh" @click="loadRequests" :disabled="loading" title="Actualiser">
|
||||||
|
<span :class="{ spinning: loading }">⟳</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty / loading -->
|
||||||
|
<div v-if="loading" class="state-center">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Chargement des mandats…</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="pendingRequests.length === 0" class="state-center">
|
||||||
|
<div class="empty-icon">📭</div>
|
||||||
|
<p>Aucun mandat disponible pour le moment.</p>
|
||||||
|
<button class="btn-ghost" @click="loadRequests">Actualiser</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request cards -->
|
||||||
|
<div v-else class="requests-list">
|
||||||
|
<div v-for="req in pendingRequests" :key="req.name" class="req-card"
|
||||||
|
:class="{ expanded: expandedId === req.name, urgent: req.urgency === 'urgent' }">
|
||||||
|
|
||||||
|
<!-- Card header -->
|
||||||
|
<div class="req-card-header" @click="expandedId = expandedId === req.name ? null : req.name">
|
||||||
|
<div class="req-type-badge" :style="{ background: 'rgba(99,102,241,0.15)', borderColor: 'rgba(99,102,241,0.3)' }">
|
||||||
|
{{ SERVICE_ICONS[req.service_type] || '🔧' }} {{ SERVICE_LABELS[req.service_type] || req.service_type }}
|
||||||
|
</div>
|
||||||
|
<div v-if="req.urgency === 'urgent'" class="urgent-badge">🚨 Urgent</div>
|
||||||
|
|
||||||
|
<div class="req-problem">{{ req.problem_type }}</div>
|
||||||
|
<div class="req-addr">📍 {{ req.address }}</div>
|
||||||
|
|
||||||
|
<!-- Client date preferences preview -->
|
||||||
|
<div class="req-dates-preview">
|
||||||
|
<span v-for="pd in getClientDates(req)" :key="pd.priority"
|
||||||
|
class="date-chip" :class="{ 'date-chip-1': pd.priority === 1 }">
|
||||||
|
{{ formatDate(pd.date) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="req-meta">
|
||||||
|
<span>{{ timeAgo(req.creation) }}</span>
|
||||||
|
<span v-if="req.budget_label" class="budget-pill">💰 {{ req.budget_label }}</span>
|
||||||
|
<span>{{ expandedId === req.name ? '▲ Masquer' : '▼ Voir détails' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded bid form -->
|
||||||
|
<div v-if="expandedId === req.name" class="req-bid-form">
|
||||||
|
<div class="bid-section-title">Description du client</div>
|
||||||
|
<p class="bid-description">{{ req.description || 'Aucune description fournie.' }}</p>
|
||||||
|
|
||||||
|
<div class="bid-section-title">Dates proposées par le client</div>
|
||||||
|
<div class="client-dates">
|
||||||
|
<button v-for="pd in getClientDates(req)" :key="pd.priority"
|
||||||
|
class="client-date-btn"
|
||||||
|
:class="{ selected: getBid(req.name).date === pd.date && getBid(req.name).timeSlot === pd.slot }"
|
||||||
|
@click="getBid(req.name).date = pd.date; getBid(req.name).timeSlot = pd.slot">
|
||||||
|
<div class="cd-priority">{{ pd.priority }}e choix</div>
|
||||||
|
<div class="cd-date">{{ formatDate(pd.date) }}</div>
|
||||||
|
<div class="cd-slot">{{ (pd.slots.length > 0 ? pd.slots : [pd.slot]).map(s => TIME_SLOTS.find(t => t.id === s)?.label || s).filter(Boolean).join(' · ') || 'Flexible' }}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bid-section-title">Ou proposer une autre date</div>
|
||||||
|
<div class="alt-date-row">
|
||||||
|
<input type="date" class="date-input" v-model="getBid(req.name).date"
|
||||||
|
:min="new Date().toISOString().split('T')[0]" />
|
||||||
|
<select class="slot-select" v-model="getBid(req.name).timeSlot">
|
||||||
|
<option value="">Plage horaire…</option>
|
||||||
|
<option v-for="s in TIME_SLOTS" :key="s.id" :value="s.id">{{ s.label }} ({{ s.sub }})</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bid-section-title">Durée estimée</div>
|
||||||
|
<div class="duration-row">
|
||||||
|
<button v-for="h in ['1','2','3','4','6']" :key="h"
|
||||||
|
class="dur-btn"
|
||||||
|
:class="{ selected: getBid(req.name).duration === h }"
|
||||||
|
@click="getBid(req.name).duration = h">
|
||||||
|
{{ h }}h
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bid-section-title">Mon tarif <span class="required-star">*</span></div>
|
||||||
|
<div class="price-row">
|
||||||
|
<div class="price-input-wrap">
|
||||||
|
<span class="price-currency">$</span>
|
||||||
|
<input type="number" class="price-input" v-model="getBid(req.name).price"
|
||||||
|
placeholder="0" min="0" step="5"
|
||||||
|
@click.stop />
|
||||||
|
<span class="price-unit">/ projet</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="req.budget_label" class="price-hint">
|
||||||
|
Budget client : <strong>{{ req.budget_label }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea class="notes-input" v-model="getBid(req.name).notes"
|
||||||
|
placeholder="Note pour le dispatcher (optionnel)…" rows="2"></textarea>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="bid-actions">
|
||||||
|
<button class="btn-decline" @click="decline(req.name)">
|
||||||
|
✕ Décliner
|
||||||
|
</button>
|
||||||
|
<button class="btn-accept"
|
||||||
|
:disabled="!canBid(req.name) || bidding[req.name]"
|
||||||
|
@click="submitBid(req)">
|
||||||
|
{{ bidding[req.name] ? '…' : '✓ Soumettre ma disponibilité' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sent confirmations -->
|
||||||
|
<div v-if="sentCount > 0" class="sent-banner">
|
||||||
|
✓ {{ sentCount }} soumission{{ sentCount > 1 ? 's' : '' }} envoyée{{ sentCount > 1 ? 's' : '' }} — en attente de confirmation du dispatcher
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bid-root {
|
||||||
|
--accent: #6366f1;
|
||||||
|
--bg: #0f1117;
|
||||||
|
--surface: rgba(255,255,255,0.04);
|
||||||
|
--surface2: rgba(255,255,255,0.07);
|
||||||
|
--border: rgba(255,255,255,0.09);
|
||||||
|
--text: #f1f5f9;
|
||||||
|
--text2: #94a3b8;
|
||||||
|
--green: #10b981;
|
||||||
|
--red: #f43f5e;
|
||||||
|
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-bottom: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Login ── */
|
||||||
|
.login-screen { min-height: 100dvh; display: flex; align-items: center; justify-content: center; padding: 2rem; }
|
||||||
|
.login-card { text-align: center; max-width: 340px; width: 100%; }
|
||||||
|
.login-icon { font-size: 3rem; margin-bottom: 1rem; }
|
||||||
|
.login-card h2 { font-size: 1.5rem; font-weight: 800; margin-bottom: 0.5rem; }
|
||||||
|
.login-card p { color: var(--text2); margin-bottom: 1.5rem; font-size: 0.9rem; }
|
||||||
|
.login-input { width: 100%; background: var(--surface); border: 1.5px solid var(--border); border-radius: 12px; padding: 0.85rem 1rem; color: var(--text); font-size: 1rem; margin-bottom: 1rem; box-sizing: border-box; }
|
||||||
|
.login-input:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.btn-login { width: 100%; background: var(--accent); border: none; color: white; border-radius: 12px; padding: 0.9rem; font-size: 1rem; font-weight: 700; cursor: pointer; }
|
||||||
|
.btn-login:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.bid-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; background: rgba(15,17,23,0.95); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
|
||||||
|
.bid-header-left { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
|
.tech-avatar { width: 40px; height: 40px; background: rgba(99,102,241,0.2); border: 1px solid rgba(99,102,241,0.4); border-radius: 10px; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 0.9rem; color: #818cf8; }
|
||||||
|
.tech-name { font-weight: 700; font-size: 0.95rem; }
|
||||||
|
.tech-sub { font-size: 0.72rem; color: var(--text2); }
|
||||||
|
.header-right { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
|
.badge-sent { background: rgba(16,185,129,0.15); border: 1px solid rgba(16,185,129,0.3); color: var(--green); border-radius: 20px; padding: 0.2rem 0.65rem; font-size: 0.72rem; font-weight: 700; }
|
||||||
|
.btn-refresh { background: var(--surface); border: 1px solid var(--border); color: var(--text2); border-radius: 8px; width: 34px; height: 34px; cursor: pointer; font-size: 1.1rem; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.spinning { display: inline-block; animation: spin 0.8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ── States ── */
|
||||||
|
.state-center { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 4rem 2rem; gap: 1rem; color: var(--text2); }
|
||||||
|
.spinner { width: 36px; height: 36px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||||
|
.empty-icon { font-size: 3rem; }
|
||||||
|
.btn-ghost { background: var(--surface); border: 1px solid var(--border); color: var(--text2); border-radius: 8px; padding: 0.5rem 1.25rem; cursor: pointer; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* ── Request cards ── */
|
||||||
|
.requests-list { padding: 1rem; display: flex; flex-direction: column; gap: 0.85rem; }
|
||||||
|
.req-card { background: var(--surface); border: 1.5px solid var(--border); border-radius: 16px; overflow: hidden; transition: border-color 0.2s; }
|
||||||
|
.req-card.urgent { border-color: rgba(244,63,94,0.35); }
|
||||||
|
.req-card.expanded { border-color: var(--accent); }
|
||||||
|
.req-card-header { padding: 1rem; cursor: pointer; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.req-card-header:hover { background: var(--surface2); }
|
||||||
|
.req-type-badge { display: inline-flex; align-items: center; gap: 0.35rem; border: 1px solid; border-radius: 20px; padding: 0.2rem 0.65rem; font-size: 0.72rem; font-weight: 700; width: fit-content; }
|
||||||
|
.urgent-badge { background: rgba(244,63,94,0.12); border: 1px solid rgba(244,63,94,0.3); color: var(--red); border-radius: 20px; padding: 0.2rem 0.6rem; font-size: 0.72rem; font-weight: 700; width: fit-content; }
|
||||||
|
.req-problem { font-size: 0.95rem; font-weight: 700; }
|
||||||
|
.req-addr { font-size: 0.8rem; color: var(--text2); }
|
||||||
|
.req-dates-preview { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||||
|
.date-chip { background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.2); color: #818cf8; border-radius: 6px; padding: 0.15rem 0.5rem; font-size: 0.7rem; font-weight: 600; }
|
||||||
|
.date-chip-1 { background: rgba(99,102,241,0.2); border-color: rgba(99,102,241,0.45); }
|
||||||
|
.req-meta { display: flex; justify-content: space-between; font-size: 0.72rem; color: var(--text2); margin-top: 0.25rem; }
|
||||||
|
|
||||||
|
/* ── Bid form ── */
|
||||||
|
.req-bid-form { padding: 1rem; border-top: 1px solid var(--border); background: rgba(0,0,0,0.15); display: flex; flex-direction: column; gap: 0.85rem; }
|
||||||
|
.bid-section-title { font-size: 0.72rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text2); }
|
||||||
|
.bid-description { font-size: 0.85rem; color: var(--text2); background: var(--surface); border-radius: 8px; padding: 0.65rem 0.85rem; line-height: 1.5; }
|
||||||
|
.client-dates { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.client-date-btn { background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.6rem 0.75rem; cursor: pointer; text-align: center; min-width: 100px; transition: all 0.15s; display: flex; flex-direction: column; gap: 0.15rem; }
|
||||||
|
.client-date-btn.selected { border-color: var(--accent); background: rgba(99,102,241,0.12); }
|
||||||
|
.cd-priority { font-size: 0.62rem; color: var(--text2); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.cd-date { font-size: 0.8rem; font-weight: 700; color: var(--text); }
|
||||||
|
.cd-slot { font-size: 0.7rem; color: #818cf8; }
|
||||||
|
.alt-date-row { display: flex; gap: 0.5rem; }
|
||||||
|
.date-input { flex: 1; background: var(--surface); border: 1.5px solid var(--border); border-radius: 8px; padding: 0.6rem 0.75rem; color: var(--text); font-size: 0.85rem; }
|
||||||
|
.date-input:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.slot-select { flex: 1; background: var(--surface); border: 1.5px solid var(--border); border-radius: 8px; padding: 0.6rem 0.75rem; color: var(--text); font-size: 0.85rem; }
|
||||||
|
.slot-select:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.duration-row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.dur-btn { background: var(--surface); border: 1.5px solid var(--border); border-radius: 8px; padding: 0.45rem 0.85rem; cursor: pointer; font-size: 0.85rem; font-weight: 700; color: var(--text2); transition: all 0.12s; }
|
||||||
|
.dur-btn.selected { border-color: var(--accent); color: white; background: rgba(99,102,241,0.2); }
|
||||||
|
.notes-input { background: var(--surface); border: 1.5px solid var(--border); border-radius: 8px; padding: 0.65rem 0.85rem; color: var(--text); font-size: 0.85rem; resize: vertical; font-family: inherit; width: 100%; box-sizing: border-box; }
|
||||||
|
.notes-input:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.bid-actions { display: flex; gap: 0.75rem; }
|
||||||
|
.btn-decline { background: rgba(244,63,94,0.1); border: 1px solid rgba(244,63,94,0.25); color: var(--red); border-radius: 10px; padding: 0.75rem 1rem; cursor: pointer; font-size: 0.85rem; font-weight: 700; flex-shrink: 0; }
|
||||||
|
.btn-accept { flex: 1; background: var(--accent); border: none; color: white; border-radius: 10px; padding: 0.75rem 1rem; cursor: pointer; font-size: 0.9rem; font-weight: 700; transition: opacity 0.15s; }
|
||||||
|
.btn-accept:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Price input ── */
|
||||||
|
.price-row { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.price-input-wrap { display: flex; align-items: center; background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0 0.85rem; gap: 0.4rem; }
|
||||||
|
.price-input-wrap:focus-within { border-color: var(--accent); }
|
||||||
|
.price-currency { color: var(--text2); font-weight: 700; font-size: 1rem; }
|
||||||
|
.price-input { flex: 1; background: none; border: none; outline: none; color: var(--text); font-size: 1.05rem; font-weight: 700; padding: 0.65rem 0; width: 0; min-width: 60px; font-family: inherit; }
|
||||||
|
.price-unit { color: var(--text2); font-size: 0.78rem; }
|
||||||
|
.price-hint { font-size: 0.75rem; color: var(--text2); padding: 0.35rem 0; }
|
||||||
|
.price-hint strong { color: #a5b4fc; }
|
||||||
|
.required-star { color: var(--red); }
|
||||||
|
.budget-pill { background: rgba(16,185,129,0.12); border: 1px solid rgba(16,185,129,0.25); color: var(--green); border-radius: 20px; padding: 0.15rem 0.5rem; font-size: 0.68rem; font-weight: 700; }
|
||||||
|
|
||||||
|
/* ── Sent banner ── */
|
||||||
|
.sent-banner { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); background: rgba(16,185,129,0.12); border: 1px solid rgba(16,185,129,0.3); color: var(--green); border-radius: 12px; padding: 0.75rem 1.5rem; font-size: 0.82rem; font-weight: 600; text-align: center; backdrop-filter: blur(12px); }
|
||||||
|
</style>
|
||||||
20
src/router/index.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { route } from 'quasar/wrappers'
|
||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
|
// Routes — add pages here; no change needed in stores or API
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', component: () => import('pages/DispatchPage.vue') },
|
||||||
|
{ path: '/mobile', component: () => import('pages/MobilePage.vue') },
|
||||||
|
{ path: '/admin', component: () => import('pages/AdminPage.vue') },
|
||||||
|
{ path: '/booking', component: () => import('pages/BookingPage.vue') },
|
||||||
|
{ path: '/bid', component: () => import('pages/TechBidPage.vue') },
|
||||||
|
{ path: '/contractor', component: () => import('pages/ContractorPage.vue') },
|
||||||
|
{ path: '/dispatch2', component: () => import('pages/DispatchV2Page.vue') },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default route(function () {
|
||||||
|
return createRouter({
|
||||||
|
history: createWebHashHistory(process.env.VUE_ROUTER_BASE),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
})
|
||||||
39
src/stores/auth.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
// ── Auth store ───────────────────────────────────────────────────────────────
|
||||||
|
// Holds current session state. Calls api/auth.js only.
|
||||||
|
// To change the auth method: edit api/auth.js. This store stays the same.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { login, logout, getLoggedUser } from 'src/api/auth'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const user = ref(null) // email string when logged in, null when guest
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function checkSession () {
|
||||||
|
loading.value = true
|
||||||
|
user.value = await getLoggedUser()
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogin (usr, pwd) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await login(usr, pwd)
|
||||||
|
user.value = usr
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message || 'Erreur de connexion'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout () {
|
||||||
|
await logout()
|
||||||
|
user.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, loading, error, checkSession, doLogin, doLogout }
|
||||||
|
})
|
||||||
245
src/stores/dispatch.js
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
// ── Dispatch store ───────────────────────────────────────────────────────────
|
||||||
|
// Shared state for both MobilePage and DispatchPage.
|
||||||
|
// All ERPNext calls go through api/dispatch.js — not here.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { fetchTechnicians, fetchJobs, updateJob, createJob as apiCreateJob, fetchTags } from 'src/api/dispatch'
|
||||||
|
import { TECH_COLORS } from 'src/config/erpnext'
|
||||||
|
|
||||||
|
export const useDispatchStore = defineStore('dispatch', () => {
|
||||||
|
const technicians = ref([])
|
||||||
|
const jobs = ref([])
|
||||||
|
const allTags = ref([]) // { name, label, color, category }
|
||||||
|
const loading = ref(false)
|
||||||
|
const erpStatus = ref('pending') // 'pending' | 'ok' | 'error' | 'session_expired'
|
||||||
|
|
||||||
|
// ── Data transformers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _mapJob (j) {
|
||||||
|
return {
|
||||||
|
id: j.ticket_id || j.name,
|
||||||
|
name: j.name, // ERPNext docname (used for PUT calls)
|
||||||
|
subject: j.subject || 'Job sans titre',
|
||||||
|
address: j.address || 'Adresse inconnue',
|
||||||
|
coords: [j.longitude || 0, j.latitude || 0],
|
||||||
|
priority: j.priority || 'low',
|
||||||
|
duration: j.duration_h || 1,
|
||||||
|
status: j.status || 'open',
|
||||||
|
assignedTech: j.assigned_tech || null,
|
||||||
|
routeOrder: j.route_order || 0,
|
||||||
|
legDist: j.leg_distance || null,
|
||||||
|
legDur: j.leg_duration || null,
|
||||||
|
scheduledDate: j.scheduled_date || null,
|
||||||
|
endDate: j.end_date || null,
|
||||||
|
startTime: j.start_time || null,
|
||||||
|
assistants: (j.assistants || []).map(a => ({ techId: a.tech_id, techName: a.tech_name, duration: a.duration_h || 0, note: a.note || '', pinned: !!a.pinned })),
|
||||||
|
tags: (j.tags || []).map(t => t.tag),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _mapTech (t, idx) {
|
||||||
|
return {
|
||||||
|
id: t.technician_id || t.name,
|
||||||
|
name: t.name, // ERPNext docname
|
||||||
|
fullName: t.full_name || t.name,
|
||||||
|
status: t.status || '',
|
||||||
|
user: t.user || null,
|
||||||
|
colorIdx: idx % TECH_COLORS.length,
|
||||||
|
coords: [t.longitude || -73.5673, t.latitude || 45.5017],
|
||||||
|
queue: [], // filled in loadAll()
|
||||||
|
tags: (t.tags || []).map(tg => tg.tag),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loaders ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadAll () {
|
||||||
|
loading.value = true
|
||||||
|
erpStatus.value = 'pending'
|
||||||
|
try {
|
||||||
|
const [rawTechs, rawJobs, rawTags] = await Promise.all([
|
||||||
|
fetchTechnicians(),
|
||||||
|
fetchJobs(),
|
||||||
|
fetchTags(),
|
||||||
|
])
|
||||||
|
allTags.value = rawTags
|
||||||
|
technicians.value = rawTechs.map(_mapTech)
|
||||||
|
jobs.value = rawJobs.map(_mapJob)
|
||||||
|
// Build each tech's ordered queue (primary + assistant jobs)
|
||||||
|
technicians.value.forEach(tech => {
|
||||||
|
tech.queue = jobs.value
|
||||||
|
.filter(j => j.assignedTech === tech.id)
|
||||||
|
.sort((a, b) => a.routeOrder - b.routeOrder)
|
||||||
|
tech.assistJobs = jobs.value
|
||||||
|
.filter(j => j.assistants.some(a => a.techId === tech.id))
|
||||||
|
})
|
||||||
|
erpStatus.value = 'ok'
|
||||||
|
} catch (e) {
|
||||||
|
erpStatus.value = e.message?.includes('session') ? 'session_expired' : 'error'
|
||||||
|
console.error('loadAll error:', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load jobs assigned to one tech — used by MobilePage
|
||||||
|
async function loadJobsForTech (techId) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const raw = await fetchJobs([['assigned_tech', '=', techId]])
|
||||||
|
jobs.value = raw.map(_mapJob)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mutations (also syncs to ERPNext) ────────────────────────────────────
|
||||||
|
|
||||||
|
async function setJobStatus (jobId, status) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
job.status = status
|
||||||
|
await updateJob(job.id, { status })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignJobToTech (jobId, techId, routeOrder, scheduledDate) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
// Remove from old tech queue
|
||||||
|
technicians.value.forEach(t => {
|
||||||
|
t.queue = t.queue.filter(q => q.id !== jobId)
|
||||||
|
})
|
||||||
|
// Add to new tech queue
|
||||||
|
const tech = technicians.value.find(t => t.id === techId)
|
||||||
|
if (tech) {
|
||||||
|
job.assignedTech = techId
|
||||||
|
job.routeOrder = routeOrder
|
||||||
|
job.status = 'assigned'
|
||||||
|
if (scheduledDate !== undefined) job.scheduledDate = scheduledDate
|
||||||
|
tech.queue.splice(routeOrder, 0, job)
|
||||||
|
// Re-number route_order
|
||||||
|
tech.queue.forEach((q, i) => { q.routeOrder = i })
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
assigned_tech: techId,
|
||||||
|
route_order: routeOrder,
|
||||||
|
status: 'assigned',
|
||||||
|
}
|
||||||
|
if (scheduledDate !== undefined) payload.scheduled_date = scheduledDate || ''
|
||||||
|
await updateJob(job.id, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unassignJob (jobId) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
technicians.value.forEach(t => { t.queue = t.queue.filter(q => q.id !== jobId) })
|
||||||
|
job.assignedTech = null
|
||||||
|
job.status = 'open'
|
||||||
|
try { await updateJob(job.name || job.id, { assigned_tech: null, status: 'open' }) } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createJob (fields) {
|
||||||
|
// fields: { subject, address, duration_h, priority, assigned_tech?, scheduled_date?, start_time? }
|
||||||
|
const localId = 'WO-' + Date.now().toString(36).toUpperCase()
|
||||||
|
const job = _mapJob({
|
||||||
|
ticket_id: localId, name: localId,
|
||||||
|
subject: fields.subject || 'Nouveau travail',
|
||||||
|
address: fields.address || '',
|
||||||
|
longitude: fields.longitude || 0,
|
||||||
|
latitude: fields.latitude || 0,
|
||||||
|
duration_h: parseFloat(fields.duration_h) || 1,
|
||||||
|
priority: fields.priority || 'low',
|
||||||
|
status: fields.assigned_tech ? 'assigned' : 'open',
|
||||||
|
assigned_tech: fields.assigned_tech || null,
|
||||||
|
scheduled_date: fields.scheduled_date || null,
|
||||||
|
start_time: fields.start_time || null,
|
||||||
|
route_order: 0,
|
||||||
|
})
|
||||||
|
jobs.value.push(job)
|
||||||
|
if (fields.assigned_tech) {
|
||||||
|
const tech = technicians.value.find(t => t.id === fields.assigned_tech)
|
||||||
|
if (tech) { job.routeOrder = tech.queue.length; tech.queue.push(job) }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await apiCreateJob({
|
||||||
|
subject: job.subject,
|
||||||
|
address: job.address,
|
||||||
|
longitude: job.coords?.[0] || '',
|
||||||
|
latitude: job.coords?.[1] || '',
|
||||||
|
duration_h: job.duration,
|
||||||
|
priority: job.priority,
|
||||||
|
status: job.status,
|
||||||
|
assigned_tech: job.assignedTech || '',
|
||||||
|
scheduled_date: job.scheduledDate || '',
|
||||||
|
start_time: job.startTime || '',
|
||||||
|
})
|
||||||
|
if (created?.name) { job.id = created.name; job.name = created.name }
|
||||||
|
} catch (_) {}
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setJobSchedule (jobId, scheduledDate, startTime) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
job.scheduledDate = scheduledDate || null
|
||||||
|
job.startTime = startTime !== undefined ? startTime : job.startTime
|
||||||
|
const payload = { scheduled_date: job.scheduledDate || '' }
|
||||||
|
if (startTime !== undefined) payload.start_time = startTime || ''
|
||||||
|
try { await updateJob(job.name || job.id, payload) } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateJobCoords (jobId, lng, lat) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
job.coords = [lng, lat]
|
||||||
|
try { await updateJob(job.name || job.id, { longitude: lng, latitude: lat }) } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAssistant (jobId, techId) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
if (job.assignedTech === techId) return // already lead
|
||||||
|
if (job.assistants.some(a => a.techId === techId)) return // already assistant
|
||||||
|
const tech = technicians.value.find(t => t.id === techId)
|
||||||
|
const entry = { techId, techName: tech?.fullName || techId, duration: job.duration, note: '', pinned: false }
|
||||||
|
job.assistants = [...job.assistants, entry]
|
||||||
|
if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
|
||||||
|
try {
|
||||||
|
await updateJob(job.name || job.id, {
|
||||||
|
assistants: job.assistants.map(a => ({ tech_id: a.techId, tech_name: a.techName, duration_h: a.duration, note: a.note || '', pinned: a.pinned ? 1 : 0 })),
|
||||||
|
})
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAssistant (jobId, techId) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
job.assistants = job.assistants.filter(a => a.techId !== techId)
|
||||||
|
const tech = technicians.value.find(t => t.id === techId)
|
||||||
|
if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
|
||||||
|
try {
|
||||||
|
await updateJob(job.name || job.id, {
|
||||||
|
assistants: job.assistants.map(a => ({ tech_id: a.techId, tech_name: a.techName, duration_h: a.duration, note: a.note || '', pinned: a.pinned ? 1 : 0 })),
|
||||||
|
})
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reorderTechQueue (techId, fromIdx, toIdx) {
|
||||||
|
const tech = technicians.value.find(t => t.id === techId)
|
||||||
|
if (!tech) return
|
||||||
|
const [moved] = tech.queue.splice(fromIdx, 1)
|
||||||
|
tech.queue.splice(toIdx, 0, moved)
|
||||||
|
tech.queue.forEach((q, i) => { q.routeOrder = i })
|
||||||
|
// Sync all reordered jobs
|
||||||
|
await Promise.all(
|
||||||
|
tech.queue.map((q, i) => updateJob(q.id, { route_order: i })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
technicians, jobs, allTags, loading, erpStatus,
|
||||||
|
loadAll, loadJobsForTech,
|
||||||
|
setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant,
|
||||||
|
}
|
||||||
|
})
|
||||||