diff --git a/apps/ops/.gitignore b/apps/ops/.gitignore
new file mode 100644
index 0000000..393b567
--- /dev/null
+++ b/apps/ops/.gitignore
@@ -0,0 +1 @@
+public/tiles/
diff --git a/apps/ops/package-lock.json b/apps/ops/package-lock.json
index f78eaaf..f8e8af7 100644
--- a/apps/ops/package-lock.json
+++ b/apps/ops/package-lock.json
@@ -15,13 +15,17 @@
"@vue-flow/core": "^1.48.2",
"chart.js": "^4.5.1",
"cytoscape": "^3.33.2",
+ "dompurify": "^3.4.11",
"grapesjs": "^0.22.16",
"grapesjs-mjml": "^1.0.8",
"grapesjs-preset-newsletter": "^1.0.2",
"hy-vue-gantt": "^5.2.1",
"idb-keyval": "^6.2.1",
"lucide-vue-next": "^1.0.0",
+ "maplibre-gl": "^5.24.0",
"pinia": "^2.1.7",
+ "pmtiles": "^4.4.1",
+ "protomaps-themes-base": "^4.5.0",
"quasar": "^2.16.10",
"sip.js": "^0.21.2",
"vue": "^3.4.21",
@@ -2021,6 +2025,119 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
+ "node_modules/@mapbox/jsonlint-lines-primitives": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
+ "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 22"
+ }
+ },
+ "node_modules/@mapbox/point-geometry": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
+ "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
+ "license": "ISC"
+ },
+ "node_modules/@mapbox/tiny-sdf": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
+ "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/@mapbox/unitbezier": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
+ "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/@mapbox/vector-tile": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
+ "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@mapbox/point-geometry": "~1.1.0",
+ "@types/geojson": "^7946.0.16",
+ "pbf": "^4.0.2"
+ }
+ },
+ "node_modules/@mapbox/whoots-js": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
+ "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@maplibre/geojson-vt": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
+ "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "kdbush": "^4.1.0"
+ }
+ },
+ "node_modules/@maplibre/maplibre-gl-style-spec": {
+ "version": "24.10.0",
+ "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
+ "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
+ "license": "ISC",
+ "dependencies": {
+ "@mapbox/jsonlint-lines-primitives": "~2.0.2",
+ "@mapbox/unitbezier": "^1.0.0",
+ "json-stringify-pretty-compact": "^4.0.0",
+ "minimist": "^1.2.8",
+ "quickselect": "^3.0.0",
+ "tinyqueue": "^3.0.0"
+ },
+ "bin": {
+ "gl-style-format": "dist/gl-style-format.mjs",
+ "gl-style-migrate": "dist/gl-style-migrate.mjs",
+ "gl-style-validate": "dist/gl-style-validate.mjs"
+ }
+ },
+ "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
+ "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/@maplibre/mlt": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz",
+ "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==",
+ "license": "(MIT OR Apache-2.0)",
+ "dependencies": {
+ "@mapbox/point-geometry": "^1.1.0"
+ }
+ },
+ "node_modules/@maplibre/vt-pbf": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
+ "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@mapbox/point-geometry": "^1.1.0",
+ "@types/geojson": "^7946.0.16",
+ "pbf": "^5.1.0"
+ }
+ },
+ "node_modules/@maplibre/vt-pbf/node_modules/pbf": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.0.tgz",
+ "integrity": "sha512-Wv0yo0+uZepnoNEKsquhar1F18LogB8oeEikIhUXG16udbiXG7JecHGySwoo6kuMgjmbQYzdrTZlO+/K9t8eZg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "resolve-protobuf-schema": "^2.1.0"
+ },
+ "bin": {
+ "pbf": "bin/pbf"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2863,6 +2980,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/har-format": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
@@ -4864,8 +4987,6 @@
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
- "optional": true,
- "peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -4912,6 +5033,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/earcut": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
+ "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
+ "license": "ISC"
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -5902,8 +6029,7 @@
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/figures": {
"version": "3.2.0",
@@ -6273,6 +6399,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gl-matrix": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
+ "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
+ "license": "MIT"
+ },
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -7411,6 +7543,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-stringify-pretty-compact": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
+ "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
+ "license": "MIT"
+ },
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -7465,6 +7603,12 @@
"html2canvas": "^1.0.0-rc.5"
}
},
+ "node_modules/kdbush": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
+ "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
+ "license": "ISC"
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -7729,6 +7873,40 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/maplibre-gl": {
+ "version": "5.24.0",
+ "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
+ "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@mapbox/jsonlint-lines-primitives": "^2.0.2",
+ "@mapbox/point-geometry": "^1.1.0",
+ "@mapbox/tiny-sdf": "^2.1.0",
+ "@mapbox/unitbezier": "^0.0.1",
+ "@mapbox/vector-tile": "^2.0.4",
+ "@mapbox/whoots-js": "^3.1.0",
+ "@maplibre/geojson-vt": "^6.1.0",
+ "@maplibre/maplibre-gl-style-spec": "^24.8.1",
+ "@maplibre/mlt": "^1.1.8",
+ "@maplibre/vt-pbf": "^4.3.0",
+ "@types/geojson": "^7946.0.16",
+ "earcut": "^3.0.2",
+ "gl-matrix": "^3.4.4",
+ "kdbush": "^4.0.2",
+ "murmurhash-js": "^1.0.0",
+ "pbf": "^4.0.1",
+ "potpack": "^2.1.0",
+ "quickselect": "^3.0.0",
+ "tinyqueue": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=16.14.0",
+ "npm": ">=8.1.0"
+ },
+ "funding": {
+ "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7873,7 +8051,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
- "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -7915,6 +8092,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/murmurhash-js": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
+ "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
+ "license": "MIT"
+ },
"node_modules/mute-stream": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
@@ -8312,6 +8495,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pbf": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
+ "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "resolve-protobuf-schema": "^2.1.0"
+ },
+ "bin": {
+ "pbf": "bin/pbf"
+ }
+ },
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@@ -8361,6 +8556,15 @@
}
}
},
+ "node_modules/pmtiles": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.4.1.tgz",
+ "integrity": "sha512-5oTeQc/yX/ft1evbpIlnoCZugQuug/iYIAj/ZTqIqzdGek4uZEho99En890EE6NOSI3JTI3IG8R7r8+SltphxA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "fflate": "^0.8.2"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -8413,6 +8617,12 @@
"node": ">=4"
}
},
+ "node_modules/potpack": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
+ "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
+ "license": "ISC"
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -8459,6 +8669,19 @@
"integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==",
"license": "MIT"
},
+ "node_modules/protocol-buffers-schema": {
+ "version": "3.6.1",
+ "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
+ "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
+ "license": "MIT"
+ },
+ "node_modules/protomaps-themes-base": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/protomaps-themes-base/-/protomaps-themes-base-4.5.0.tgz",
+ "integrity": "sha512-z5A6oXAXNi/CHEIbCfzZ/6uVDgnv3pO4C3LFvomxjUFXwyqWZ7HJdfPoNJSIlsvaQubqsaxCbamEzqBr/W7vkg==",
+ "deprecated": "This package has been migrated to @protomaps/basemaps with a new major version. See the CHANGELOG at https://github.com/protomaps/basemaps/blob/main/CHANGELOG.md for how to migrate.",
+ "license": "BSD-3-Clause"
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -8535,6 +8758,12 @@
],
"license": "MIT"
},
+ "node_modules/quickselect": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
+ "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
+ "license": "ISC"
+ },
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
@@ -8821,6 +9050,15 @@
"node": ">=4"
}
},
+ "node_modules/resolve-protobuf-schema": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
+ "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "protocol-buffers-schema": "^3.3.1"
+ }
+ },
"node_modules/restore-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
@@ -9878,6 +10116,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/tinyqueue": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
+ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
+ "license": "ISC"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
diff --git a/apps/ops/package.json b/apps/ops/package.json
index 7c2b6f3..d72c9c5 100644
--- a/apps/ops/package.json
+++ b/apps/ops/package.json
@@ -18,21 +18,24 @@
"@vue-flow/core": "^1.48.2",
"chart.js": "^4.5.1",
"cytoscape": "^3.33.2",
+ "dompurify": "^3.4.11",
"grapesjs": "^0.22.16",
"grapesjs-mjml": "^1.0.8",
"grapesjs-preset-newsletter": "^1.0.2",
"hy-vue-gantt": "^5.2.1",
"idb-keyval": "^6.2.1",
"lucide-vue-next": "^1.0.0",
+ "maplibre-gl": "^5.24.0",
"pinia": "^2.1.7",
+ "pmtiles": "^4.4.1",
+ "protomaps-themes-base": "^4.5.0",
"quasar": "^2.16.10",
"sip.js": "^0.21.2",
"vue": "^3.4.21",
"vue-chartjs": "^5.3.3",
"vue-email-editor": "^2.2.0",
"vue-router": "^4.3.0",
- "vuedraggable": "^4.1.0",
- "dompurify": "^3.4.11"
+ "vuedraggable": "^4.1.0"
},
"devDependencies": {
"@quasar/app-vite": "^1.10.0",
diff --git a/apps/ops/quasar.config.js b/apps/ops/quasar.config.js
index 5cf6d74..c20b596 100644
--- a/apps/ops/quasar.config.js
+++ b/apps/ops/quasar.config.js
@@ -19,7 +19,7 @@ module.exports = configure(function () {
build: {
target: {
- browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
+ browser: ['es2020', 'edge88', 'firefox78', 'chrome87', 'safari14'], // es2020+/safari14 : MapLibre GL v5 utilise des littéraux BigInt (0n)
node: 'node20',
},
vueRouterMode: 'hash',
diff --git a/apps/ops/src/api/address.js b/apps/ops/src/api/address.js
index 60f4d1c..7f50542 100644
--- a/apps/ops/src/api/address.js
+++ b/apps/ops/src/api/address.js
@@ -27,3 +27,6 @@ export const campingsUpsert = (body) => jpost('/address/conformity/campings', bo
export const campingsApply = () => jpost('/address/conformity/campings/apply', {})
// Dernier repli : placer les unmatched restants au centre du code postal (sinon ville) → statut 'area'
export const conformityApplyArea = () => jpost('/address/conformity/apply-area', {})
+
+// Géocodage INVERSE (coord → adresse) via notre RQA locale (plus de Nominatim externe).
+export const reverse = (lat, lon) => jget('/address/reverse?lat=' + encodeURIComponent(lat) + '&lon=' + encodeURIComponent(lon))
diff --git a/apps/ops/src/components/shared/ProjectWizard.vue b/apps/ops/src/components/shared/ProjectWizard.vue
index 7b71967..6309d30 100644
--- a/apps/ops/src/components/shared/ProjectWizard.vue
+++ b/apps/ops/src/components/shared/ProjectWizard.vue
@@ -11,6 +11,12 @@
{{ issue.name }} · {{ issue.subject }}
{{ customer.customer_name || customer.name }}
+
+
+ {{ customer.qualified_address.address }}
+ Zone {{ customer.qualified_address.zone }}
+
+
@@ -190,6 +196,21 @@
+
+
+
+
+
Égaler un concurrent
Total mensuel du concurrent → rabais 24 mois sur l'Internet
+
+
+
+
+
+ {{ competitorInfo.msg }}
+
+
+
+
+
+
+
+
{{ i + 1 }}
+
{{ s.label }}
+
+
+
+
+
+
+
+
+
+ Adresse de service
+ Cherchez l'adresse civique — on vérifie la disponibilité de la fibre avant tout (base RQA + réseau Targo).
+
+
+
+
+
+
+ {{ a.address_full }}{{ a.fiber_available ? ('Fibre disponible' + (a.max_speed ? ' · jusqu\'à ' + a.max_speed + ' Mbps' : '')) : 'Fibre non disponible à cette adresse' }}
+
+
+
+
+ {{ chosen.address }}
+ {{ chosen.fiber ? ('✓ Fibre disponible' + (chosen.maxSpeed ? ' — jusqu\'à ' + chosen.maxSpeed + ' Mbps' : '')) : '⚠ Fibre non desservie ici — soumission possible mais à valider (hors zone / à déployer).' }}
+
+
+
+
+
{{ satellitePref ? 'Vue carte' : 'Vue satellite' }}
+
+
+
+
+
+ Contact
+ Associez une fiche existante, ou créez un prospect (courriel ou mobile suffit pour la confirmation).
+
+
+
+
+
+
+ {{ c.customer_name || c.name }}{{ [c.cell_phone || c.phone, c.email_id].filter(Boolean).join(' · ') || c.name }}
+
+
+
+
+ Fiche existante : {{ chosen.customer.customer_name || chosen.customer.name }}
+
+
+
+ …ou nouveau prospect :
+
+
+
+
+
+
+
+ Au moins un courriel ou un mobile est requis.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/ops/src/components/shared/RouteMap.vue b/apps/ops/src/components/shared/RouteMap.vue
index 3f044af..172249c 100644
--- a/apps/ops/src/components/shared/RouteMap.vue
+++ b/apps/ops/src/components/shared/RouteMap.vue
@@ -10,7 +10,7 @@
* - émet 'metrics' { id:{km,mins} } (routes réelles) ; expose fitTo(id) / fitAll() / setActive(id).
*/
import { watch, onMounted, onBeforeUnmount } from 'vue'
-import { MAPBOX_TOKEN } from 'src/config/erpnext'
+import { installMaplibre, basemapStyle, addSatelliteLayer, satellitePref, setSatellitePref, setSatelliteVisible } from 'src/config/basemap' // fond MapLibre/OSM auto-hébergé (plus de Mapbox) + couche satellite ESRI
import * as roster from 'src/api/roster'
import { initials } from 'src/composables/useFormatters' // initiales (source unique, ex-locale dé-dupliquée)
@@ -28,16 +28,7 @@ const _rmCache = RouteMapCache() // cache module (partagé entre instances) : si
function RouteMapCache () { const g = globalThis; if (!g.__opsRouteMapCache) g.__opsRouteMapCache = new Map(); return g.__opsRouteMapCache }
function setEl (node) { el = node }
-function ensureMapbox () {
- return new Promise((resolve) => {
- 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) }
- if (window.mapboxgl) return resolve(window.mapboxgl)
- let s = document.getElementById('mapbox-js')
- if (!s) { s = document.createElement('script'); s.id = 'mapbox-js'; s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'; document.head.appendChild(s) }
- s.addEventListener('load', () => resolve(window.mapboxgl))
- const iv = setInterval(() => { if (window.mapboxgl) { clearInterval(iv); resolve(window.mapboxgl) } }, 150)
- })
-}
+function ensureMapbox () { return Promise.resolve(installMaplibre()) } // MapLibre bundlé + protocole pmtiles:// (alias window.mapboxgl)
const okLL = (lon, lat) => isFinite(+lon) && isFinite(+lat) && Math.abs(+lat) > 0.01 // écarte (0,0) / invalides qui feraient dézoomer la carte
function boundsOf (routes) {
const b = new window.mapboxgl.LngLatBounds()
@@ -248,14 +239,14 @@ function setActive (rid) {
defineExpose({ fitTo, fitAll, setActive })
onMounted(async () => {
- if (!MAPBOX_TOKEN || !el) return
+ if (!el) return
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !el) return
- mapboxgl.accessToken = MAPBOX_TOKEN
- map = new mapboxgl.Map({ container: el, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.55, 45.2], zoom: 8 })
+ map = new mapboxgl.Map({ container: el, style: basemapStyle('light'), center: [-73.55, 45.2], zoom: 8 })
map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
ro = new ResizeObserver(() => { if (map) map.resize() }); ro.observe(el)
map.on('load', () => {
map.resize()
+ addSatelliteLayer(map, satellitePref.value) // couche satellite (sous les tracés + marqueurs), masquée par défaut
map.addSource('rm-line', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({ id: 'rm-line', type: 'line', source: 'rm-line', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 3, 'line-opacity': 0.7 } })
map.addLayer({ id: 'rm-line-a', type: 'line', source: 'rm-line', filter: ['==', ['get', 'rid'], '___aucun___'], layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 5.5, 'line-opacity': 0.95 } })
@@ -271,14 +262,22 @@ onBeforeUnmount(() => { clearMarkers(); clearLive(); clearPins(); if (ro) { try
watch(() => props.routes, () => { draw() }, { deep: true })
watch(() => props.live, () => { renderLive(props.live) }, { deep: true }) // rafraîchit les positions live sans redessiner les tournées
watch(() => props.pins, () => { renderPins(props.pins) }, { deep: true }) // jobs non assignés (secteur) sans redessiner les tournées
+// Satellite : bouton local + préférence PARTAGÉE (satellitePref) → toutes les cartes suivent.
+function toggleSat () { setSatellitePref(!satellitePref.value); if (map) setSatelliteVisible(map, satellitePref.value) }
+watch(satellitePref, (v) => { if (map) setSatelliteVisible(map, v) })
-
+
+
+
{{ satellitePref ? 'Vue carte' : 'Vue satellite' }}
+
diff --git a/apps/ops/src/composables/useCreateSignal.js b/apps/ops/src/composables/useCreateSignal.js
new file mode 100644
index 0000000..3ed3946
--- /dev/null
+++ b/apps/ops/src/composables/useCreateSignal.js
@@ -0,0 +1,9 @@
+// Signal partagé « ouvrir le chooser Créer » : le FAB global (MainLayout, toutes pages) le déclenche, la
+// Planification (où vivent les flux création) l'écoute. Ref module-level → persiste à travers la navigation
+// (pas de query-route + router.replace fragile qui remonte la page et perd l'état).
+import { ref } from 'vue'
+
+const pending = ref(false)
+export function useCreateSignal () {
+ return { pending, requestCreate: () => { pending.value = true } }
+}
diff --git a/apps/ops/src/composables/useMap.js b/apps/ops/src/composables/useMap.js
index 88e1110..a989a02 100644
--- a/apps/ops/src/composables/useMap.js
+++ b/apps/ops/src/composables/useMap.js
@@ -1,7 +1,8 @@
-// ── Map composable: Mapbox GL map, markers, routes, geo-fix, map-drag ────────
+// ── Map composable: MapLibre GL + tuiles OSM auto-hébergées (Protomaps pmtiles), markers, routes, geo-fix, map-drag ────────
import { ref, watch, nextTick } from 'vue'
import { localDateStr, jobSpansDate, jobSvcCode, SVC_COLORS } from './useHelpers'
import * as roster from 'src/api/roster' // tracés routiers via NOTRE OSRM (plus de Mapbox Directions)
+import { installMaplibre, basemapStyle } from 'src/config/basemap' // fond de carte MapLibre/OSM (plus de Mapbox)
export function useMap (deps) {
const {
@@ -70,20 +71,10 @@ export function useMap (deps) {
// ── 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
+ const mapboxgl = installMaplibre() // MapLibre bundlé + protocole pmtiles:// (alias window.mapboxgl pour compat)
map = new mapboxgl.Map({
container: mapContainer.value,
- style: 'mapbox://styles/mapbox/dark-v11',
+ style: basemapStyle('dark'), // Dispatch = mode sombre
// Default centered on Gigafibre HQ (1867 chemin de la Rivière,
// Sainte-Clotilde, QC). Zoom 10 shows Sainte-Clotilde + the
// surrounding service area (Châteauguay, Napierville, Hemmingford…).
@@ -455,12 +446,7 @@ export function useMap (deps) {
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 loadMapboxCss () { /* no-op : le CSS MapLibre est bundlé via src/config/basemap.js (plus de CDN Mapbox) */ }
function getMap () { return map }
return {
diff --git a/apps/ops/src/composables/useWizardCatalog.js b/apps/ops/src/composables/useWizardCatalog.js
index e19a68d..a7dd5ab 100644
--- a/apps/ops/src/composables/useWizardCatalog.js
+++ b/apps/ops/src/composables/useWizardCatalog.js
@@ -73,6 +73,27 @@ export function useWizardCatalog ({ orderItems, wizardSteps, templates, template
}
}
+ // ── Égaler un prix concurrent (terrain) ──────────────────────────────────
+ // Le concurrent affiche un TOTAL mensuel (tous services − rabais). On calcule le rabais promotionnel
+ // récurrent (SKU rab-x) à appliquer SUR L'INTERNET pour que notre total mensuel = le sien, sur `months` (déf. 24).
+ const COMP_REBATE_CODE = 'RAB_X'
+ function recurringBase () { return orderItems.value.filter(i => i.billing === 'recurring' && i.item_code !== COMP_REBATE_CODE).reduce((s, i) => s + ((i.qty || 1) * i.rate), 0) }
+ function clearCompetitor () { orderItems.value = orderItems.value.filter(i => i.item_code !== COMP_REBATE_CODE) }
+ function matchCompetitor (competitorMonthly, months = 24) {
+ const target = Number(competitorMonthly)
+ const base = Math.round(recurringBase() * 100) / 100
+ clearCompetitor()
+ if (!(target >= 0) || target >= base) return { ok: false, base, rebate: 0, target } // rien à rabattre (concurrent ≥ notre prix)
+ const hero = orderItems.value.find(i => INTERNET_HERO_CODES.has(i.item_code) && !i.is_rebate)
+ const rebate = Math.round((base - target) * 100) / 100
+ orderItems.value.push({
+ item_code: COMP_REBATE_CODE, item_name: 'Rabais concurrentiel (' + months + ' mois)',
+ qty: 1, rate: -rebate, regular_price: 0, billing: 'recurring', billing_interval: 'Month', contract_months: months,
+ is_rebate: true, applies_to_item: hero ? hero.item_code : '',
+ })
+ return { ok: true, base, rebate, target, applied_to: hero ? hero.item_name : null }
+ }
+
// Single-select hero tier swap — generic over a set of codes. Clicking a
// tier REPLACES whatever tier from the same family is in the cart (doesn't
// stack). Re-clicking the active tier toggles it off. `service_type` and
@@ -448,6 +469,8 @@ export function useWizardCatalog ({ orderItems, wizardSteps, templates, template
filteredCatalog,
loadCatalog,
addCatalogItem,
+ matchCompetitor,
+ clearCompetitor,
applyPreset,
loadTemplateFromItem,
removeTemplate,
diff --git a/apps/ops/src/composables/useWizardPublish.js b/apps/ops/src/composables/useWizardPublish.js
index 8d6c2ab..9c3bc7c 100644
--- a/apps/ops/src/composables/useWizardPublish.js
+++ b/apps/ops/src/composables/useWizardPublish.js
@@ -24,7 +24,9 @@ export function useWizardPublish ({ props, emit, state }) {
return [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ')
}
} catch {}
- return ''
+ // Repli : adresse qualifiée par le wizard soumission (prospect sans Service Location encore) — le prix dépend de l'adresse.
+ const qa = props.customer?.qualified_address
+ return (qa && qa.address) || ''
}
async function publish () {
@@ -45,11 +47,14 @@ export function useWizardPublish ({ props, emit, state }) {
const hasPhone = !!clientPhone.value
const hasEmail = !!clientEmail.value
+ const qa = props.customer?.qualified_address || null
const wizardContext = {
issue: props.issue?.name || '',
customer,
address,
service_location: serviceLocation,
+ // Adresse qualifiée + zone tarifaire (le prix dépend de l'adresse) — portées sur le devis même sans Service Location.
+ ...(qa ? { zone_tarifaire: qa.zone ?? null, latitude: qa.lat ?? null, longitude: qa.lon ?? null, fiber_available: !!qa.fiber } : {}),
}
// Step extras — each wizardStep with extra_fee > 0 becomes a one-time
@@ -376,7 +381,7 @@ export function useWizardPublish ({ props, emit, state }) {
})
for (const job of createdJobs) {
- emit('created', job)
+ emit('created', job, { needsAcceptance }) // needsAcceptance → le parent peut enchaîner « Trouver un créneau » quand aucun contrat signé n'est requis
}
// Show success screen. Prefer Quotation as the primary artifact;
diff --git a/apps/ops/src/config/basemap.js b/apps/ops/src/config/basemap.js
new file mode 100644
index 0000000..c2c4d79
--- /dev/null
+++ b/apps/ops/src/config/basemap.js
@@ -0,0 +1,65 @@
+// Fond de carte OSM AUTO-HÉBERGÉ : MapLibre GL + tuiles Protomaps (pmtiles) servies same-origin (/ops/tiles).
+// Zéro Mapbox → aucun jeton, aucun logo, aucun appel à api.mapbox.com. La seule attribution restante est
+// « © OpenStreetMap » (exigée par la licence ODbL des données). Routage = notre OSRM ; tuiles = notre pmtiles.
+import { ref } from 'vue'
+import maplibregl from 'maplibre-gl'
+import 'maplibre-gl/dist/maplibre-gl.css'
+import { Protocol } from 'pmtiles'
+import { layers, namedTheme } from 'protomaps-themes-base'
+
+// Chemin des tuiles = base de l'app + tiles/. Dev : public/tiles/. Prod : /opt/ops-app/tiles/ servi sous /ops/tiles (Traefik strip /ops).
+export const TILES_URL = `${import.meta.env.BASE_URL || '/'}tiles/southern-quebec.pmtiles`
+// Glyphes (étiquettes) + sprites (icônes) : assets ouverts Protomaps — pas de Mapbox. {fontstack}/{range} résolus par MapLibre.
+const GLYPHS = 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf'
+const SPRITE = (t) => `https://protomaps.github.io/basemaps-assets/sprites/v4/${t}`
+
+let _installed = false
+// Alias maplibregl → window.mapboxgl : l'API (Map/Marker/Popup/LngLatBounds/NavigationControl) est compatible,
+// donc tout le code existant qui référence window.mapboxgl continue de marcher sans réécriture. Enregistre aussi pmtiles://.
+export function installMaplibre () {
+ if (!_installed) {
+ const protocol = new Protocol()
+ maplibregl.addProtocol('pmtiles', protocol.tile)
+ window.mapboxgl = maplibregl // compat : marqueurs / popups / bounds existants
+ _installed = true
+ }
+ return maplibregl
+}
+
+// Crée une carte MapLibre de planification avec les réglages COMMUNS (fond OSM + bouton zoom + Street View au clic droit).
+// Réutilisé par les 4 cartes de PlanificationPage (détail job · sélecteur domicile/dépôt · panneau à assigner · éditeur jour)
+// → une seule init au lieu de 4 blocs quasi identiques. Les marqueurs/couches spécifiques restent gérés par chaque appelant.
+export function createPlanMap (el, opts = {}) {
+ installMaplibre()
+ const map = new maplibregl.Map({ container: el, style: basemapStyle(opts.theme || 'light'), center: opts.center || [-73.6756, 45.1599], zoom: opts.zoom != null ? opts.zoom : 9 })
+ map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right')
+ if (typeof opts.streetView === 'function') map.on('contextmenu', (e) => opts.streetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
+ return map
+}
+
+// Style MapLibre (thème Protomaps) : 'dark' pour le board Dispatch (exception mode sombre), 'light' pour les Tournées.
+export function basemapStyle (theme = 'light') {
+ const th = theme === 'dark' ? 'dark' : 'light'
+ return {
+ version: 8,
+ glyphs: GLYPHS,
+ sprite: SPRITE(th),
+ sources: { protomaps: { type: 'vector', url: 'pmtiles://' + TILES_URL, attribution: '© OpenStreetMap' } },
+ layers: layers('protomaps', namedTheme(th), { lang: 'fr' }), // namedTheme() = objet flavor (v4) ; la string seule laisse les couleurs undefined
+ }
+}
+
+// ── Couche SATELLITE (ESRI World Imagery — libre, sans jeton). Réutilisable par toutes les cartes createPlanMap. ──
+const SAT_TILES = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
+// Préférence satellite PARTAGÉE app-wide (même clé que PlanificationPage : 'plan_sat') → cohérente entre toutes les cartes.
+export const satellitePref = ref((() => { try { return localStorage.getItem('plan_sat') === '1' } catch (e) { return false } })())
+export function setSatellitePref (on) { satellitePref.value = !!on; try { localStorage.setItem('plan_sat', on ? '1' : '0') } catch (e) {} }
+// Ajoute la couche raster satellite (sous les marqueurs, ajoutés APRÈS) + applique la visibilité. Idempotent.
+export function addSatelliteLayer (map, visible) {
+ try {
+ if (!map.getSource('sat')) map.addSource('sat', { type: 'raster', tiles: [SAT_TILES], tileSize: 256, attribution: '© Esri, Maxar, Earthstar Geographics' })
+ if (!map.getLayer('sat')) map.addLayer({ id: 'sat', type: 'raster', source: 'sat', layout: { visibility: visible ? 'visible' : 'none' } })
+ else map.setLayoutProperty('sat', 'visibility', visible ? 'visible' : 'none')
+ } catch (e) { /* style pas prêt */ }
+}
+export function setSatelliteVisible (map, on) { try { if (map && map.getLayer && map.getLayer('sat')) map.setLayoutProperty('sat', 'visibility', on ? 'visible' : 'none') } catch (e) {} }
diff --git a/apps/ops/src/data/wizard-constants.js b/apps/ops/src/data/wizard-constants.js
index be0888b..3efeaf5 100644
--- a/apps/ops/src/data/wizard-constants.js
+++ b/apps/ops/src/data/wizard-constants.js
@@ -40,6 +40,8 @@ export const CATALOG_CATEGORIES = ['Tous', 'Internet', 'Téléphonie', 'Bundle',
// -40). Higher tiers have no loyalty rebate — shown at base.
// Combo rebates (RAB2X/RAB3X/RAB4X) are added manually from the catalog.
export const INTERNET_HERO_TIERS = [
+ // Prix EFFECTIFS après promo 24 mois (rabais récurrent RAB-LOYAUTE, rate overridé). Stratégie marketing :
+ // 500 et 1 Gbps au MÊME prix (49.95) → le 1 Gbps devient le choix évident (« no brainer »). 80 = entrée de gamme.
{
id: 'tier_m80',
code: 'FTTH80I',
@@ -48,39 +50,41 @@ export const INTERNET_HERO_TIERS = [
badge: 'Économique',
price_effective: 39.95,
price_base: 79.95,
- desc: 'Fibre symétrique · Rabais fidélité -40$/mois',
+ desc: 'Fibre symétrique · promo −40$/mois (24 mois)',
icon: 'wifi',
- default: true,
items: [
{ item_code: 'FTTH80I', item_name: 'Internet Megafibre 80 Mbps', rate: 79.95, billing: 'recurring', billing_interval: 'Month', contract_months: 24, project_template_id: 'fiber_install' },
- { item_code: 'RAB-LOYAUTE', item_name: 'Rabais Fidélité', rate: -40.00, billing: 'recurring', billing_interval: 'Month', contract_months: 24, is_rebate: true, applies_to_item: 'FTTH80I' },
+ { item_code: 'RAB-LOYAUTE', item_name: 'Rabais promotionnel (24 mois)', rate: -40.00, billing: 'recurring', billing_interval: 'Month', contract_months: 24, is_rebate: true, applies_to_item: 'FTTH80I' },
],
},
{
- id: 'tier_m150',
- code: 'FTTH150I',
- label: 'Megafibre 150',
- speed: '150 / 150 Mbps',
- price_effective: 99.95,
- price_base: 99.95,
+ id: 'tier_500',
+ code: 'INT-500',
+ label: 'Fibre 500',
+ speed: '500 / 500 Mbps',
+ price_effective: 49.95,
+ price_base: 89.95,
desc: 'Fibre symétrique · streaming 4K, télétravail',
icon: 'wifi',
items: [
- { item_code: 'FTTH150I', item_name: 'Internet Megafibre 150 Mbps', rate: 99.95, billing: 'recurring', billing_interval: 'Month', contract_months: 24, project_template_id: 'fiber_install' },
+ { item_code: 'INT-500', item_name: 'Internet 500 Mbps', rate: 89.95, billing: 'recurring', billing_interval: 'Month', contract_months: 24, project_template_id: 'fiber_install' },
+ { item_code: 'RAB-LOYAUTE', item_name: 'Rabais promotionnel (24 mois)', rate: -40.00, billing: 'recurring', billing_interval: 'Month', contract_months: 24, is_rebate: true, applies_to_item: 'INT-500' },
],
},
{
- id: 'tier_g1500',
- code: 'FTTH1500I',
- label: 'Gigafibre 1500',
- speed: '1500 / 940 Mbps',
+ id: 'tier_1g',
+ code: 'INT-1G',
+ label: 'Gigafibre 1 Gbps',
+ speed: '1000 / 1000 Mbps',
badge: 'Populaire',
- price_effective: 109.95,
+ price_effective: 49.95,
price_base: 109.95,
- desc: 'Ultra-rapide · foyers connectés, gaming, cloud',
+ desc: 'Même prix que le 500 — le choix évident',
icon: 'speed',
+ default: true,
items: [
- { item_code: 'FTTH1500I', item_name: 'Internet Gigafibre 1500 Mbps', rate: 109.95, billing: 'recurring', billing_interval: 'Month', contract_months: 24, project_template_id: 'fiber_install' },
+ { item_code: 'INT-1G', item_name: 'Internet 1 Gbps', rate: 109.95, billing: 'recurring', billing_interval: 'Month', contract_months: 24, project_template_id: 'fiber_install' },
+ { item_code: 'RAB-LOYAUTE', item_name: 'Rabais promotionnel (24 mois)', rate: -60.00, billing: 'recurring', billing_interval: 'Month', contract_months: 24, is_rebate: true, applies_to_item: 'INT-1G' },
],
},
]
diff --git a/apps/ops/src/layouts/MainLayout.vue b/apps/ops/src/layouts/MainLayout.vue
index b336e7a..c75a7b3 100644
--- a/apps/ops/src/layouts/MainLayout.vue
+++ b/apps/ops/src/layouts/MainLayout.vue
@@ -130,25 +130,24 @@
-
-
-
-
-
-
-
-
-
-
-
-
- Créer rapidement (ticket · texto · courriel · dicter)
-
-
-
- Voir les communications
-
-
+
+
+
+ Créer rapidement (ticket · texto · courriel · dicter)
+
+
+ Dicter une commande
+ Statut du service
+ Créertâche · intervention · soumission
+ Nouveau texto
+ Nouveau courriel
+
+
+
+
+
+ Ouvrir les communications (tous les fils · courriel · SMS · chat)
+
@@ -187,6 +186,7 @@ const PhoneModal = defineAsyncComponent(() => import('src/components/customer/Ph
import { useSoftphone } from 'src/composables/useSoftphone'
import NotificationBell from 'src/components/shared/NotificationBell.vue'
import { useConversations } from 'src/composables/useConversations'
+import { useCreateSignal } from 'src/composables/useCreateSignal'
const FlowEditorDialog = defineAsyncComponent(() => import('src/components/flow-editor/FlowEditorDialog.vue'))
const NewTicketDialog = defineAsyncComponent(() => import('src/components/shared/NewTicketDialog.vue'))
const OrchestratorDialog = defineAsyncComponent(() => import('src/components/shared/OrchestratorDialog.vue'))
@@ -196,10 +196,14 @@ const OutboxPanel = defineAsyncComponent(() => import('src/components/shared/Out
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper }
const { panelOpen, newDialogOpen, newDialogChannel, activeCount: convCount, openComposeDraft } = useConversations()
+const { requestCreate } = useCreateSignal()
const { open: spOpen, number: spNumber, customer: spCustomer, customerName: spCustomerName, provider: spProvider, handleCallEnded: spHandleCallEnded } = useSoftphone()
function toggleConvPanel () { panelOpen.value = !panelOpen.value }
+// FAB « forum » → le MODULE communications complet (page qui défile, tous les fils/SMS/chat) plutôt que le tiroir étroit qui ne scrollait pas.
+function openComms () { panelOpen.value = false; if (!route.path.startsWith('/communications')) router.push('/communications') }
+// FAB « Créer » → le chooser unifié de la Planification (tâche · intervention · soumission), où vivent les flux de création.
+function goCreate () { requestCreate(); if (!route.path.startsWith('/planification')) router.push('/planification') }
// FAB messagerie : clic « forum » = ouvrir les communications ; bouton « + » (clic, pas survol) = menu créer rapide.
-const quickOpen = ref(false)
const newTicketOpen = ref(false)
const orchestratorOpen = ref(false)
const serviceStatusOpen = ref(false)
diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue
index f5a1ce0..901e0ab 100644
--- a/apps/ops/src/pages/PlanificationPage.vue
+++ b/apps/ops/src/pages/PlanificationPage.vue
@@ -54,31 +54,20 @@
{{ assignPanel.jobs.length }}
Afficher / masquer les jobs à répartir (liste · carte · lasso · suggestion auto)
-
-
Créer un job / une installation (apparaît dans « À assigner »)
-
Trouver le meilleur créneau (tech + date + heure) puis créer le job réservé
+
+
Créer une tâche ou une intervention (sur site · à distance)
-
-
-
- Demande de personnel
-
-
+
+
+ ⚙️ Réglages planification
Point de départ (dépôt){{ depot && depot.address ? depot.address : 'non défini — cliquer pour situer' }}
-
-
- Jobs prévus en en-têteliste par jour — glisser sur un tech
-
-
-
-
- Jobs Legacy sur les timelinesdurées estimées des jobs osTicket datés (fenêtre affichée)
-
-
+ Gérer les compétences (tags)
+ Cadence équipe
+ Durées par caractéristique
Modèles de semaine
@@ -96,16 +85,33 @@
+ Rotation de garde
+
+
+ 🗓 Horaires & personnel
Générer l'horaire (semaine)solveur de quarts — la semaine affichée
Générer les horaires (lot)modèle → plusieurs techs, N semaines
- Rotation de garde
-
- Cadence équipe
- Gérer les compétences (tags)
- Synchroniser les techniciens
- Durées par caractéristique
- Jobs à assigner (glisser-déposer)
Congés / absences
+ Synchroniser les techniciens
+
+
+ 👁 Affichage
+ Jobs à assigner (glisser-déposer)
+
+
+ Jobs prévus en en-têteliste par jour — glisser sur un tech
+
+
+
+
+ Jobs Legacy sur les timelinesdurées estimées des jobs osTicket datés (fenêtre affichée)
+
+
+
+
+ Demande de personnelpanneau besoins vs capacité
+
+
Appliquer le modèle par défaut (consciente des absences)
@@ -432,7 +438,7 @@
{{ isPaused(t) ? 'Réactiver' : 'Pause' }}
{{ techRank(t) }}Priorité {{ techRank(t) }} — combine maîtrise (niveau) + vitesse (efficacité) + coût.
-
Compétences & horaire de {{ t.name }}{{ t.name }}
+
Réglages de {{ t.name }} — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée{{ t.name }}
{{ t.group }}
{{ hoursOf(t.id) }}h
Actions — {{ t.name }}
@@ -562,7 +568,7 @@
-
{{ t.name }}⚠ Surcharge
{{ overWhy(t.id, kanbanDay.iso) }}
+
{{ t.name }}Réglages de {{ t.name }} — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée⚠ Surcharge
{{ overWhy(t.id, kanbanDay.iso) }}Réglages du tech (compétences · GPS · domicile · tournée)
@@ -782,7 +788,7 @@
-
onTagsChange(skillDialog, items)"
@create="onCreateRosterTag" />
↔ Glisse les chips pour l'ordre de priorité — la 1re (★) = fonction principale : le dispatch auto envoie ces jobs à ce tech en premier (épargne les moins spécialisés).
@@ -790,7 +796,7 @@
Compétence
Score
-
Efficacité
+
Cadence
@@ -813,15 +819,16 @@
- setSkillEffPct(skillDialog, sk, v)" suffix="%" placeholder="glob." input-class="text-right" />
+ { effBuf[effBufKey(skillDialog, sk)] = v }" @blur="commitSkillEff(skillDialog, sk)" @keyup.enter="commitSkillEff(skillDialog, sk)" suffix="%" placeholder="glob." input-class="text-right" />
- Score ★ = maîtrise · Efficacité : + plus vite / − plus lent pour CETTE compétence (ex. +80), vide = globale · × sur le chip = retirer.
+ Score ★ = maîtrise · Cadence : 100 % = normale · 200 % = deux fois plus de jobs · < 100 % = plus lent · vide = hérite du global · × sur le chip = retirer.
+ Enregistré automatiquement — pas de bouton à cliquer.
🗓 Horaire
- Modèles (5×8h · 4×10h · 3×12h) + heures par jour + N semaines → publie les quarts
+ Ouvre l'assistant de quarts (modèles 5×8h · 4×10h · 3×12h + heures/jour + N semaines) → publie les quarts. Ce n'est PAS un bouton « enregistrer ».
Applique 5×8h à la semaine affichée (à publier)
@@ -830,6 +837,30 @@
{{ techHomes[skillDialog.id] && techHomes[skillDialog.id].address ? techHomes[skillDialog.id].address : 'non défini' }}
+
+
+
📍 Appareil GPS (Traccar — suivi live + tracé réel)
+
+
+
+ {{ deviceOf(skillDialog).label }}
+ {{ deviceOf(skillDialog).online ? 'En ligne' : 'Hors ligne' }}{{ deviceOf(skillDialog).uniqueId ? ' · ' + deviceOf(skillDialog).uniqueId : '' }}
+
+ Relier à un autre véhicule
+ {{ o.label }}{{ o.uniqueId }}{{ devicesLoading ? 'Chargement…' : 'Aucun appareil' }}
+
+ Dissocier l'appareil GPS
+
+
+ non associé
+ Appareil probable (nom similaire au tech)
+ Relier un véhicule Traccar
+ {{ o.label }}{{ o.uniqueId }}{{ devicesLoading ? 'Chargement…' : 'Aucun appareil' }}
+
+
+
+
+
Passe en vue Tournées et isole la tournée de ce tech sur la carte
@@ -1094,7 +1125,7 @@
Rapide (efficacité {{ t.efficiency }})
Plus lent (efficacité {{ t.efficiency }})
Aucun quart publié cette période
- Modifier compétences & niveaux (★) + efficacité
+ Réglages du tech — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée
@@ -1453,16 +1484,17 @@
- {{ jobDetail.detail }}
- Commentaires / fil du billet
+ Commentaires / fil du billet· plus récent en haut
-
- {{ jobDetail.thread.messages.length }} message(s){{ jobDetail.thread.status ? ' · ' + jobDetail.thread.status : '' }}
-
-
{{ m.author }} · {{ fmtDT(m.at) }}
+
+ {{ jdMessages.length }} message(s){{ jobDetail.thread.status ? ' · ' + jobDetail.thread.status : '' }}
+
+
{{ m.author }} · {{ fmtDT(m.at) }}dernier
{{ m.text }}
+
+
{{ jobDetail.detail }}
{{ jobDetail.lid ? ((jobDetail.thread && jobDetail.thread.error) ? 'Détail indisponible' : 'Aucun commentaire.') : 'Pas de billet Legacy lié à ce job.' }}
@@ -1848,10 +1880,36 @@
-
+
+
+
+ Créer…
+
+
+ Tâche
Travail interne — sans client ni déplacement
+
+ Intervention
+
+ Sur site — trouver un créneau
Meilleur tech + date + heure (selon priorité & compétences)
+
+ …ou ajouter au pool (planifier plus tard)
+
+ À distance — ticket
Support ou vente (selon priorité & compétences), sans déplacement
+
+
+
+ Soumission
Vérifier l'adresse (fibre) → prospect → services & devis — même pour un non-client
+
+
+
+
+
+
+
@@ -1885,7 +1943,7 @@ import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
-import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API Mapbox Matrix), déjà utilisé par le Dispatch
+import { installMaplibre, basemapStyle, createPlanMap } from 'src/config/basemap' // fond de carte MapLibre/OSM auto-hébergé (plus de Mapbox, plus de logo) + init commune des cartes
import { legacyDeptColor, heatColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé) + heatColor (source unique, aussi
)
import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur)
import { relTime, initials } from 'src/composables/useFormatters' // temps relatif + initiales (source unique, ex-locales dé-dupliquées)
@@ -1899,6 +1957,9 @@ import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' // bande d'occupation réutilisable (sélecteur de jour Tournées + tableau de bord)
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) — Dispatch déprécié, tout ici
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » → pré-remplit la création (tech+date+heure+adresse)
+import QuoteWizard from 'src/components/shared/QuoteWizard.vue' // soumission : adresse+fibre+prospect (lead-first)
+import { useCreateSignal } from 'src/composables/useCreateSignal' // FAB global « Créer » → ouvre le chooser ici
+import ProjectWizard from 'src/components/shared/ProjectWizard.vue' // moteur soumission existant (panier + rabais + devis + acceptation) — réutilisé
import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) — repris/amélioré depuis Dispatch
const $q = useQuasar()
@@ -2150,9 +2211,9 @@ function kanbanLaneBlocks (techId) {
}
return out
}
-// Matrice Mapbox RÉELLE par tournée (tech×jour) : 1 SEUL appel /tournée (≤25 coords) → temps de route réels pour tous les legs. Cache par signature (set de jobs) → pas de refetch inutile.
+// Matrice OSRM RÉELLE par tournée (tech×jour) : 1 SEUL appel /tournée (≤25 coords) → temps de route réels pour tous les legs. Cache par signature (set de jobs) → pas de refetch inutile.
async function fetchKbMatrix (techId) {
- const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso || !MAPBOX_TOKEN) return
+ const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return
const key = techId + '|' + iso
const o = occByTechDay.value[key]
const jobs = (o && o.jobs ? o.jobs : []).filter(j => isFinite(+j.lat) && isFinite(+j.lon) && (+j.lat || +j.lon))
@@ -2180,7 +2241,32 @@ async function reloadPool () { try { assignPanel.jobs = capPickups((await roster
const createJobOpen = ref(false)
const createJobCtx = ref({})
const showSuggestSlots = ref(false)
-function openCreateJob () { createJobCtx.value = { scheduled_date: '' }; createJobOpen.value = true } // pool (non planifié) par défaut
+const createChooser = ref(false) // chooser « Créer » : Tâche / Intervention (sur site / à distance)
+const createMode = ref('work-order') // mode passé à UnifiedCreateModal : 'task' | 'work-order' | 'ticket'
+function openCreateJob () { createMode.value = 'work-order'; createJobCtx.value = { scheduled_date: '' }; createJobOpen.value = true } // pool (non planifié) par défaut
+// Point d'entrée unifié « Créer ». startCreate ouvre le formulaire dans le bon mode ; startCreneau ouvre plutôt le solveur de créneau (sur site).
+function startCreate (mode) { createMode.value = mode; createJobCtx.value = { scheduled_date: '' }; createChooser.value = false; createJobOpen.value = true }
+function startCreneau () { createMode.value = 'work-order'; createChooser.value = false; showSuggestSlots.value = true }
+// Soumission : QuoteWizard qualifie (adresse+fibre+prospect) → on ouvre ProjectWizard pré-rempli avec le client.
+const quoteWizardOpen = ref(false)
+const projectWizardOpen = ref(false)
+const quoteCustomer = ref(null)
+function onQuoteQualified (payload) {
+ const cust = (payload && payload.customer) || null
+ // Rattache l'adresse qualifiée (+ zone tarifaire) au client passé à ProjectWizard → portée sur le devis (le prix dépend de l'adresse).
+ quoteCustomer.value = cust ? { ...cust, qualified_address: (payload && payload.address) || null } : null
+ if (quoteCustomer.value) projectWizardOpen.value = true
+}
+function onQuoteCreated (job, meta) {
+ projectWizardOpen.value = false
+ // Pas de contrat signé requis (JWT/DocuSeal) → on enchaîne direct sur « Trouver un créneau » d'installation. Sinon : attendre l'approbation.
+ if (meta && !meta.needsAcceptance) {
+ $q.dialog({ title: 'Soumission créée', message: 'Planifier l\'installation maintenant (trouver un créneau) ?', cancel: 'Plus tard', ok: { label: 'Trouver un créneau', color: 'teal-8', noCaps: true }, persistent: false })
+ .onOk(() => { showSuggestSlots.value = true })
+ } else {
+ $q.notify({ type: 'positive', icon: 'request_quote', message: 'Soumission créée — en attente d\'approbation client avant de planifier', timeout: 3200 })
+ }
+}
function onSlotSelected (slot) { // un créneau choisi → pré-remplit tech+date+heure+adresse+coords+compétence
createJobCtx.value = { assigned_tech: slot.tech_id, scheduled_date: slot.date, start_time: slot.start_time, address: slot._address || '', duration_h: slot._duration || 1, _latitude: slot._latitude, _longitude: slot._longitude, tags: slot._skill ? [slot._skill] : [] }
createJobOpen.value = true
@@ -2297,6 +2383,8 @@ const jdTeamOptions = computed(() => {
.sort((a, b) => (b.capable - a.capable) || a.label.localeCompare(b.label))
.map(({ label, value }) => ({ label, value }))
})
+// Fil du billet, PLUS RÉCENT EN HAUT (le dernier commentaire porte souvent l'info la plus importante).
+const jdMessages = computed(() => { const m = (jobDetail.thread && jobDetail.thread.messages) || []; return m.slice().reverse() })
async function jdAddAssistant () {
const id = jobDetail.teamAdd; if (!id || !jobDetail.name) return
const tech = (techs.value || []).find(t => t.id === id); if (!tech) return
@@ -2431,12 +2519,11 @@ function jdNearby () {
return out.slice(0, 80)
}
async function initJdMap () {
- if (!MAPBOX_TOKEN || !jdMapEl.value) return
+ if (!jdMapEl.value) return
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !jdMapEl.value) return
if (_jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null }
const lat = jobDetail.lat, lon = jobDetail.lon; const hasLL = isFinite(lat) && isFinite(lon) && (lat || lon)
- _jdMap = new mapboxgl.Map({ container: jdMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: hasLL ? [lon, lat] : [-73.6756, 45.1599], zoom: hasLL ? 14 : 9 })
- _jdMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
+ _jdMap = createPlanMap(jdMapEl.value, { center: hasLL ? [lon, lat] : [-73.6756, 45.1599], zoom: hasLL ? 14 : 9 })
_jdMap.on('load', () => {
_jdMap.resize()
// Autres jobs à proximité = points PÂLES (contexte), ajoutés AVANT le marqueur du job → celui-ci reste au-dessus.
@@ -2526,7 +2613,15 @@ const skillByType = ref({}) // type de job → compétence requise déduite (pol
const skillDialog = ref(null) // tech dont on édite les compétences
const skillMenuTarget = ref(null) // élément cliqué = ancre du popover (près de la souris, sur la rangée)
const skillMenuShown = ref(false)
-function openSkillEditor (t, ev) { skillDialog.value = t; skillMenuTarget.value = (ev && ev.currentTarget) || null; skillMenuShown.value = true }
+function openSkillEditor (t, ev) { skillDialog.value = t; skillMenuTarget.value = (ev && ev.currentTarget) || null; skillMenuShown.value = true; loadTraccarDevices() } // charge les appareils GPS pour la section « Appareil GPS » du volet
+// « Voir sa tournée » depuis le volet réglages du tech : bascule en vue Tournées + isole ce tech sur la carte.
+function viewTechTournee (tech) {
+ if (!tech || !tech.id) return
+ skillMenuShown.value = false
+ if (!routesDay.value) routesDay.value = todayISO()
+ boardView.value = 'routes'
+ nextTick(() => { const others = allDayRoutes.value.map(r => r.id).filter(x => x !== tech.id); hiddenRouteTechs.value = new Set(others) })
+}
function openSkillEditorFor (techId, ev) { const t = (visibleTechs.value || []).find(x => x.id === techId); if (t) openSkillEditor(t, ev) } // depuis la revue de l'optimiseur
// TagEditor (showLevel) ↔ tech : skills = libellés (CSV) · skill_levels = {compétence: niveau 1–5} (JSON).
// Par compétence : SCORE (maîtrise 1–5, qualité) + EFFICACITÉ (facteur vitesse ; défaut = efficacité globale).
@@ -2579,14 +2674,19 @@ async function onScheduleApply ({ schedule, weeks, techIds, mode }) {
}
function skillEffOf (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? Number(e) : (Number(t.efficiency) || 1) } // facteur (pour le calcul de priorité)
// Efficacité saisie en % de PERFORMANCE : + = plus VITE (moins de temps) · − = plus LENT. Conversion ↔ facteur.
-function effPctOf (factor) { return Math.round((1 - (Number(factor) || 1)) * 100) }
-function factorFromPct (pct) { const f = 1 - (Number(pct) || 0) / 100; return Math.max(0.1, Math.min(3, Math.round(f * 100) / 100)) }
+// CADENCE en % : 100 = normal · 200 = deux fois plus de jobs (moitié du temps) · <100 = plus lent. Interne = FACTEUR de durée (200 %→0.5).
+function effPctOf (factor) { const f = Number(factor) || 1; return Math.round(100 / (f > 0 ? f : 1)) }
+function factorFromPct (pct) { const p = Number(pct) || 100; const f = 100 / (p > 0 ? p : 100); return Math.max(0.1, Math.min(5, Math.round(f * 100) / 100)) } // cap 20 %–1000 %
function skillEffPct (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? effPctOf(e) : '' } // '' = hérite de la globale
function setSkillEffPct (t, sk, pct) { const m = { ...(t.skill_eff || {}) }; if (pct === '' || pct == null) delete m[sk]; else m[sk] = factorFromPct(pct); t.skill_eff = m; queueSkillSave(t) } // libre (ex. +80)
+// Saisie efficacité : tampon local pendant l'édition (pas de debounce qui perdait la valeur au blur) → commit sur blur/Entrée.
+const effBuf = reactive({})
+function effBufKey (t, sk) { return (t && t.id) + '::' + sk }
+function commitSkillEff (t, sk) { const k = effBufKey(t, sk); if (!(k in effBuf)) return; const v = effBuf[k]; delete effBuf[k]; setSkillEffPct(t, sk, v) }
function setSkillLevel (t, sk, v) { if (!t.skill_levels) t.skill_levels = {}; t.skill_levels = { ...t.skill_levels, [sk]: v }; queueSkillSave(t) }
// Efficacité GLOBALE du tech, éditable/réinitialisable depuis le popover (débouncée via setTechEfficiency).
// Couleur du cercle (vitesse PAR compétence) : vite → vert · normal → bleu-gris · lent → rouge. Intensité ∝ écart.
-function effColor (factor) { const p = effPctOf(factor); if (!p) return '#607d8b'; const a = Math.min(Math.abs(p), 30) / 30; const hue = p > 0 ? 122 : 4; return 'hsl(' + hue + ',' + Math.round(50 + a * 28) + '%,' + (44 - Math.round(a * 8)) + '%)' }
+function effColor (factor) { const p = effPctOf(factor); if (p === 100) return '#607d8b'; const a = Math.min(Math.abs(p - 100), 200) / 200; const hue = p > 100 ? 122 : 4; return 'hsl(' + hue + ',' + Math.round(50 + a * 28) + '%,' + (44 - Math.round(a * 8)) + '%)' }
function skillEffColor (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e == null || e === '') ? '#607d8b' : effColor(Number(e)) } // neutre si pas d'override (pure par-compétence)
// Icône Material (offline, jeu Quasar par défaut) représentant une compétence/type — par mots-clés, repli 'bolt'.
// Icône de compétence pour q-icon (accepte les SYMBOLES SVG importés) : échelle=installation, casque=support,
@@ -2911,13 +3011,13 @@ function groupLabel (j) { return assignLocations.value.byName[j.name] || '' } //
// ── Carte des jobs à assigner (Mapbox GL) : 1 pin par adresse, lettre = groupe ; clic = sélectionne les jobs co-localisés ──
const assignMapEl = ref(null); let _assignMap = null, _assignMapRO = null
-// ── Bascule vue SATELLITE (couche raster Mapbox superposée SOUS les pins ; pas de setStyle = pins conservés) ──
+// ── Bascule vue SATELLITE (couche raster ESRI World Imagery — libre, sans jeton — superposée SOUS les pins) ──
// Préférence partagée par les 2 cartes (panneau + journée), persistée. addSatLayer() est appelé dans chaque on('load')
// AVANT les couches de pins → le raster reste dessous. toggleSat() bascule la visibilité sur les cartes vivantes.
const satView = ref((() => { try { return localStorage.getItem('plan_sat') === '1' } catch (e) { return false } })())
function addSatLayer (map) {
try {
- if (!map.getSource('sat')) map.addSource('sat', { type: 'raster', url: 'mapbox://mapbox.satellite', tileSize: 256 })
+ if (!map.getSource('sat')) map.addSource('sat', { type: 'raster', tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'], tileSize: 256, attribution: '© Esri, Maxar, Earthstar Geographics' })
if (!map.getLayer('sat')) map.addLayer({ id: 'sat', type: 'raster', source: 'sat', layout: { visibility: satView.value ? 'visible' : 'none' } })
} catch (e) { /* style pas prêt — ignoré */ }
}
@@ -2963,8 +3063,7 @@ async function setHomeFromLiveGps (t) {
try {
const r = await roster.techLivePosition(t.id)
if (!r || !r.ok || r.lat == null) { $q.notify({ type: 'warning', message: (r && r.error) || 'Aucune position GPS live (appareil Traccar non associé ?)' }); return }
- let address = ''
- if (MAPBOX_TOKEN) { try { const g = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${r.lon},${r.lat}.json?access_token=${MAPBOX_TOKEN}&language=fr&country=ca&types=address,poi`); const jj = await g.json(); const f = (jj.features || [])[0]; if (f && f.place_name) address = f.place_name.replace(/, Canada$/, '') } catch (_) { /* géocodage inverse non bloquant */ } }
+ const address = await reverseGeocodeOSM(r.lon, r.lat)
const homes = { ...techHomes.value, [t.id]: { address, lat: +(+r.lat).toFixed(6), lon: +(+r.lon).toFixed(6) } }
await roster.savePolicy({ tech_homes: homes }); techHomes.value = homes
if (_dayMap) refreshDayMap()
@@ -2997,20 +3096,20 @@ const deviceOptions = computed(() => {
.filter(o => !q || o.label.toLowerCase().includes(q) || (o.uniqueId && o.uniqueId.toLowerCase().includes(q)))
.sort((a, b) => (b.online ? 1 : 0) - (a.online ? 1 : 0) || a.label.localeCompare(b.label))
})
-// Appareil ACTUELLEMENT relié au tech de l'éditeur (résout l'id → nom réel, ex. « 24 - Stephane M »). missing = id enregistré mais introuvable dans Traccar.
-const jdDevice = computed(() => {
- const id = dayEditor.tech && dayEditor.tech.traccar_device_id
+// Appareil GPS relié à N'IMPORTE QUEL tech (résout l'id → nom réel). Réutilisé par l'éditeur de jour ET le volet réglages du tech.
+function deviceOf (tech) {
+ const id = tech && tech.traccar_device_id
if (!id) return null
const d = traccarDevices.value.find(x => String(x.id) === String(id))
if (d) return { value: String(d.id), label: d.name || d.uniqueId || ('#' + d.id), online: d.status === 'online', uniqueId: d.uniqueId || '' }
return { value: String(id), label: devicesLoading.value ? ('#' + id + '…') : ('#' + id + ' — introuvable'), online: false, missing: !devicesLoading.value }
-})
+}
+const jdDevice = computed(() => deviceOf(dayEditor.tech))
// Suggestion AUTO quand non relié : appareil dont le nom/uniqueId contient un mot du nom du tech (≥3 lettres). Facilite « relier ».
const _normDev = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '')
-const jdDeviceSuggest = computed(() => {
- if (jdDevice.value) return null
- const t = dayEditor.tech; if (!t) return null
- const parts = _normDev(t.name).split(/[^a-z]+/).filter(p => p.length >= 3)
+function deviceSuggestOf (tech) {
+ if (!tech || deviceOf(tech)) return null
+ const parts = _normDev(tech.name).split(/[^a-z]+/).filter(p => p.length >= 3)
if (!parts.length) return null
let best = null
for (const d of traccarDevices.value) {
@@ -3020,7 +3119,8 @@ const jdDeviceSuggest = computed(() => {
}
if (best && best.hit >= 1) { const d = best.d; return { value: String(d.id), label: d.name || ('#' + d.id), online: d.status === 'online' } }
return null
-})
+}
+const jdDeviceSuggest = computed(() => deviceSuggestOf(dayEditor.tech))
async function onPickDevice (tech, deviceId) {
if (!tech || !tech.id) return
const prev = tech.traccar_device_id || ''; const v = deviceId == null ? '' : String(deviceId)
@@ -3032,10 +3132,7 @@ async function onPickDevice (tech, deviceId) {
async function initLocMap () {
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !locMapEl.value) return
if (_locMap) { _locMap.resize(); return }
- mapboxgl.accessToken = MAPBOX_TOKEN
- _locMap = new mapboxgl.Map({ container: locMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [locPicker.lon || -73.95, locPicker.lat || 45.18], zoom: locPicker.lat != null ? 14 : 8.5 })
- _locMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
- _locMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
+ _locMap = createPlanMap(locMapEl.value, { center: [locPicker.lon || -73.95, locPicker.lat || 45.18], zoom: locPicker.lat != null ? 14 : 8.5, streetView: openStreetView })
_locMap.on('load', () => {
_locMap.resize(); addSatLayer(_locMap)
if (locPicker.lat == null) _locMap.fitBounds(TERR_BOUNDS, { padding: 18, duration: 0 }) // zoom sur la région au sud de Montréal
@@ -3051,14 +3148,19 @@ function placeLocMarker (lon, lat, doReverse) {
} else _locMarker.setLngLat([lon, lat])
if (doReverse) reverseGeocode(lon, lat)
}
-async function reverseGeocode (lon, lat) {
- if (!MAPBOX_TOKEN) return
+// Géocodage INVERSE (coord → adresse). 1) NOTRE RQA locale (hub /address/reverse — pas de dépendance externe). 2) repli OSM/Nominatim.
+async function reverseGeocodeOSM (lon, lat) {
+ try { const r = await addressApi.reverse(lat, lon); if (r && r.found && r.address) return r.address } catch (e) { /* RQA reverse indispo → repli */ }
try {
- const r = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${lon},${lat}.json?access_token=${MAPBOX_TOKEN}&language=fr&country=ca&types=address,poi`)
- const j = await r.json(); const f = (j.features || [])[0]
- if (f && f.place_name) locPicker.address = f.place_name.replace(/, Canada$/, '')
- } catch (e) { /* non bloquant */ }
+ const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&accept-language=fr&zoom=18`)
+ const j = await r.json(); const a = j.address || {}
+ const line1 = [a.house_number, a.road].filter(Boolean).join(' ')
+ const city = a.city || a.town || a.village || a.municipality || a.hamlet || ''
+ const parts = [line1, city, a.postcode].filter(Boolean)
+ return parts.length ? parts.join(', ') : (j.display_name ? String(j.display_name).replace(/, Canada$/, '') : '')
+ } catch (e) { return '' }
}
+async function reverseGeocode (lon, lat) { const a = await reverseGeocodeOSM(lon, lat); if (a) locPicker.address = a }
async function locSearch () {
const q = (locPicker.search || '').trim(); if (q.length < 3) return
locPicker.searching = true; locPicker.autoMatched = false
@@ -3301,12 +3403,9 @@ function focusAssignJobInList (name) {
nextTick(() => { const el = document.querySelector('.assign-body [data-jobname="' + name + '"]'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center', behavior: 'smooth' }) })
}
async function initAssignMap () {
- if (!MAPBOX_TOKEN || !assignMapEl.value || _assignMap) return
+ if (!assignMapEl.value || _assignMap) return
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !assignMapEl.value) return
- mapboxgl.accessToken = MAPBOX_TOKEN
- _assignMap = new mapboxgl.Map({ container: assignMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.55, 45.2], zoom: 8 })
- _assignMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
- _assignMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
+ _assignMap = createPlanMap(assignMapEl.value, { center: [-73.55, 45.2], zoom: 8, streetView: openStreetView })
_assignMapRO = new ResizeObserver(() => { if (_assignMap) _assignMap.resize() }); _assignMapRO.observe(assignMapEl.value)
_assignMap.on('load', () => {
_assignMap.resize()
@@ -3589,16 +3688,7 @@ function setDayOrigin (kind) { dayOriginChoice.value = kind; if (_dayMap) refres
// numérotés dans l'ordre de tournée. Navigable : zoom molette + boutons (NavigationControl), déplacement. ──
const dayMapEl = ref(null)
let _dayMap = null; let _dayMapRO = null; let _dirTimer = null; let _dayStopMarkers = []
-function ensureMapbox () {
- return new Promise((resolve) => {
- 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) }
- if (window.mapboxgl) return resolve(window.mapboxgl)
- let s = document.getElementById('mapbox-js')
- if (!s) { s = document.createElement('script'); s.id = 'mapbox-js'; s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'; document.head.appendChild(s) }
- s.addEventListener('load', () => resolve(window.mapboxgl))
- const iv = setInterval(() => { if (window.mapboxgl) { clearInterval(iv); resolve(window.mapboxgl) } }, 150)
- })
-}
+function ensureMapbox () { return Promise.resolve(installMaplibre()) } // MapLibre bundlé + protocole pmtiles:// (alias window.mapboxgl pour compat)
const _esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
function dayStops () { // arrêts géolocalisés, dans l'ordre de tournée (packedDay) + infos pour le popup
return packedDay.value.filter(hasLL).map((j, i) => ({
@@ -3648,12 +3738,9 @@ function bulkCloseSelected () {
}
function fmtDT (iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString('fr-CA', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' } }
async function initDayMap () {
- if (!MAPBOX_TOKEN || !dayMapEl.value || _dayMap) return
+ if (!dayMapEl.value || _dayMap) return
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !dayMapEl.value) return
- mapboxgl.accessToken = MAPBOX_TOKEN
- _dayMap = new mapboxgl.Map({ container: dayMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.6756, 45.1599], zoom: 9 })
- _dayMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right') // boutons zoom +/-
- _dayMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
+ _dayMap = createPlanMap(dayMapEl.value, { center: [-73.6756, 45.1599], zoom: 9, streetView: openStreetView })
_dayMapRO = new ResizeObserver(() => { if (_dayMap) _dayMap.resize() }); _dayMapRO.observe(dayMapEl.value)
_dayMap.on('load', () => {
_dayMap.resize()
@@ -4310,11 +4397,18 @@ function selectAllSector (city) {
// ── D : SUGGESTION de répartition (glouton proximité + charge + compétence), REVUE en surcouche avant d'appliquer ──
const suggestDlg = reactive({ open: false, mode: 'config', plan: [], building: false, applying: false, fromSel: false, techSel: {}, makeShifts: true, strategy: 'optimize', view: 'list', jobTime: {}, includeAssigned: false }) // défaut = solveur VRP · strategy: optimize|smart|best|balance|enough · includeAssigned = simulation re-optimisant AUSSI les jobs déjà assignés
// Noms des jobs DÉJÀ assignés sur la fenêtre (pour la simulation « ré-optimiser l'existant ») — depuis l'occupation.
-function assignedNamesForWindow () {
- const win = new Set(suggestWindow.value); const out = new Set()
- for (const key in occByTechDay.value) { if (!win.has(key.split('|')[1])) continue; for (const n of (occByTechDay.value[key].jobs || [])) if (n) out.add(n) }
- return [...out]
+// Jobs DÉJÀ assignés dans la fenêtre (objets complets : subject/detail → détection AM/PM). occ.jobs = objets, pas des noms.
+function assignedJobsForWindow () {
+ const win = new Set(suggestWindow.value); const out = []; const seen = new Set()
+ for (const key in occByTechDay.value) {
+ if (!win.has(key.split('|')[1])) continue
+ for (const j of (occByTechDay.value[key].jobs || [])) { const n = j && j.name; if (n && !seen.has(n)) { seen.add(n); out.push(j) } }
+ }
+ return out
}
+function assignedNamesForWindow () { return assignedJobsForWindow().map(j => j.name).filter(Boolean) }
+// AUTO AM/PM sur les jobs déjà assignés (ré-optimiser l'existant) : sinon la « Note AM » d'un job dispatché serait ignorée → placé en fin de tournée.
+function seedAssignedAmpm () { for (const j of assignedJobsForWindow()) { if (!suggestDlg.jobTime[j.name]) { const ap = detectAmpm(j); if (ap) suggestDlg.jobTime[j.name] = { ampm: ap, auto: true } } } }
const prevOpen = reactive({}) // revue : sections « déjà assigné(s) » dépliées (clé techId|iso)
// ── Revue : détail du ticket au clic + temps révisable + temps de route ENTRE arrêts (OSRM) ──
const durOverride = reactive({}) // jobName → heures révisées par le dispatcher (survit aux Ré-optimiser de la session)
@@ -4748,6 +4842,7 @@ function shiftWindowMin (techId, iso) {
async function optimizeSuggestion () {
suggestDlg.makeShifts = true // quarts 8-16 assumés (hub assumed_shift) → créés au Publier
const selTechs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id])
+ if (suggestDlg.includeAssigned) seedAssignedAmpm() // ré-optimiser l'existant : détecte AM/PM des jobs déjà dispatchés (fenêtre dure 8-12 / 12-16)
const job_windows = {}
for (const n in suggestDlg.jobTime) { const w = AMPM_WIN[(suggestDlg.jobTime[n] || {}).ampm]; if (w) job_windows[n] = { tw_start_min: w[0], tw_end_min: w[1] } }
try {
@@ -4990,6 +5085,10 @@ const routeUnassignedPins = computed(() => {
// ── Deep-link (?day=YYYY-MM-DD & ?skill=…) + action « planifier des quarts » du slot-finder ──
const route = useRoute()
+const createSignal = useCreateSignal()
+// FAB global « Créer » → signal partagé (useCreateSignal) → ouvre le chooser unifié. Ref module-level = survit à la nav
+// (fresh mount ET même page). nextTick → posé après le rendu (fiable, contrairement au router.replace-au-setup).
+watch(() => createSignal.pending.value, (v) => { if (v) { createSignal.pending.value = false; nextTick(() => { createChooser.value = true }) } }, { immediate: true })
function matchSkill (skill) { return skill ? (allSkills.value || []).find(s => String(s).toLowerCase() === String(skill).toLowerCase()) : null }
function focusDay (iso, skill) { // cadre la semaine sur le jour, le sélectionne (grille/mobile/tournées), filtre compétence si connue
if (iso && /^\d{4}-\d{2}-\d{2}$/.test(iso)) { start.value = mondayISO(iso); mobileSelIso.value = iso; routesDay.value = iso }
@@ -5419,7 +5518,7 @@ async function refreshTemplates () { const tp = await roster.listTemplates(); te
// cadence / efficacité par tech
// Libellé orienté PERFORMANCE/vitesse (≠ libellé "facteur temps") : facteur 0,9 = plus rapide, 1,2 = plus lent.
-function effSuffix (e) { const d = Math.round((1 - Number(e)) * 100); if (!d) return 'normal'; return d > 0 ? ('+' + d + '% vite') : ('−' + Math.abs(d) + '% lent') }
+function effSuffix (e) { const p = Math.round(100 / (Number(e) || 1)); if (p === 100) return 'cadence normale'; return 'cadence ' + p + '% (' + (p > 100 ? 'plus de jobs' : 'moins de jobs') + ')' }
// RÔLE dérivé des compétences (tags) : support→casque · installation→échelle · réparation/terrain→handyman.
const ROLE_ICON = { support: symOutlinedHeadsetMic, install: symOutlinedToolsLadder, repair: symOutlinedHandyman }
const ROLE_LABEL = { support: 'Support / service client', install: 'Installation', repair: 'Réparation / terrain' }
@@ -6176,6 +6275,13 @@ tr.res-hidden .hide-eye { opacity: 1; }
.de-msg:first-of-type { border-top: none; }
.de-msg-hdr { font-size: 11px; font-weight: 700; color: #5e35b1; }
.de-msg-txt { font-size: 11px; white-space: pre-wrap; color: #37474f; }
+/* Dernier commentaire (le plus récent) mis en avant — info potentiellement la plus importante. */
+.de-msg-latest { background: #f3f0fb; border-radius: 6px; padding: 6px 8px; margin-bottom: 4px; }
+.de-msg-badge { display: inline-block; margin-left: 6px; font-size: 9px; font-weight: 700; color: #fff; background: #7e57c2; border-radius: 4px; padding: 0 5px; vertical-align: middle; }
+/* Chooser « Créer » : options pleine largeur, contenu aligné à gauche (icône + titre + sous-titre). */
+.cc-opt { width: 100%; border-radius: 10px; padding: 12px 14px; }
+.cc-opt :deep(.q-btn__content) { flex-wrap: nowrap; justify-content: flex-start; }
+.cc-sub { align-self: flex-start; margin: -4px 0 2px 8px; }
.assign-thread { background: #f5f5f5; border-radius: 5px; padding: 4px 7px; margin-top: 4px; max-height: 220px; overflow: auto; }
.assign-msg { font-size: 11px; border-top: 1px solid #e0e0e0; padding: 3px 0; }
.assign-msg:first-child { border-top: none; }