From 042c5545f7f58040f85c23ba68a9723ecce8afab Mon Sep 17 00:00:00 2001 From: louispaulb Date: Mon, 6 Jul 2026 21:52:14 -0400 Subject: [PATCH] =?UTF-8?q?ops:=20MapLibre/OSM=20basemap=20(drop=20Mapbox)?= =?UTF-8?q?=20+=20satellite=20toggle,=20unified=20Cr=C3=A9er=20flow,=20wiz?= =?UTF-8?q?ard=20pricing,=20infra-first=20reverse=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Maps: MapLibre GL + self-hosted Protomaps tiles + ESRI satellite toggle - FAB 'Créer' chooser (incl. Soumission wizard) via useCreateSignal - Quote wizard: new default tiers (80/500/1500 @ 39.95/49.95/49.95), competitor price-match, mini-map pin w/ green check, carry qualified address - api/address.reverse() + reverseGeocodeOSM tries RQA-backed hub, Nominatim fallback Co-Authored-By: Claude Opus 4.8 --- apps/ops/.gitignore | 1 + apps/ops/package-lock.json | 254 ++++++++++++++- apps/ops/package.json | 7 +- apps/ops/quasar.config.js | 2 +- apps/ops/src/api/address.js | 3 + .../src/components/shared/ProjectWizard.vue | 33 +- .../ops/src/components/shared/QuoteWizard.vue | 200 ++++++++++++ apps/ops/src/components/shared/RouteMap.vue | 29 +- apps/ops/src/composables/useCreateSignal.js | 9 + apps/ops/src/composables/useMap.js | 24 +- apps/ops/src/composables/useWizardCatalog.js | 23 ++ apps/ops/src/composables/useWizardPublish.js | 9 +- apps/ops/src/config/basemap.js | 65 ++++ apps/ops/src/data/wizard-constants.js | 38 ++- apps/ops/src/layouts/MainLayout.vue | 44 +-- apps/ops/src/pages/PlanificationPage.vue | 306 ++++++++++++------ 16 files changed, 865 insertions(+), 182 deletions(-) create mode 100644 apps/ops/.gitignore create mode 100644 apps/ops/src/components/shared/QuoteWizard.vue create mode 100644 apps/ops/src/composables/useCreateSignal.js create mode 100644 apps/ops/src/config/basemap.js 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 @@ + +
+ {{ 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 }} + +
+
+ +
+ +
+ + + + + +
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 }} + + + +
+
+ + + + + + + + + + + + + + + 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) }) 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 @@
🏷 {{ skillDialog.name }}
-
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 @@
- +
-
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)
+
+ + +
+ + 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
Lecture du ticket…
-