ops: MapLibre/OSM basemap (drop Mapbox) + satellite toggle, unified Créer flow, wizard pricing, infra-first reverse client
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
3c7f04870c
commit
042c5545f7
1
apps/ops/.gitignore
vendored
Normal file
1
apps/ops/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
public/tiles/
|
||||
254
apps/ops/package-lock.json
generated
254
apps/ops/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@
|
|||
<template v-if="issue?.name">{{ issue.name }} · {{ issue.subject }}</template>
|
||||
<template v-else-if="customer?.name">{{ customer.customer_name || customer.name }}</template>
|
||||
</div>
|
||||
<!-- Adresse qualifiée (wizard soumission) : le prix dépend de l'adresse → visible pendant toute la soumission -->
|
||||
<div v-if="customer?.qualified_address" class="text-caption row items-center no-wrap q-mt-xs" style="gap:4px;color:#0f766e">
|
||||
<q-icon name="place" size="14px" />{{ customer.qualified_address.address }}
|
||||
<q-chip v-if="customer.qualified_address.zone != null" dense square color="teal-1" text-color="teal-9" size="sm" class="q-ml-xs">Zone {{ customer.qualified_address.zone }}</q-chip>
|
||||
<q-icon :name="customer.qualified_address.fiber ? 'wifi' : 'wifi_off'" size="13px" :color="customer.qualified_address.fiber ? 'positive' : 'grey-5'" />
|
||||
</div>
|
||||
</div>
|
||||
<q-btn flat round dense icon="close" @click="cancel" />
|
||||
</q-card-section>
|
||||
|
|
@ -190,6 +196,21 @@
|
|||
<q-icon v-if="orderItems.length" name="arrow_forward" size="18px" color="primary" />
|
||||
</div>
|
||||
|
||||
<!-- Égaler un concurrent (terrain) : total mensuel du concurrent → rabais promo récurrent (rab-x) sur l'Internet, 24 mois -->
|
||||
<div v-if="orderItems.length" class="cw-compet q-mt-sm q-pa-sm">
|
||||
<div class="row items-center no-wrap q-gutter-sm">
|
||||
<q-icon name="sell" color="deep-orange-6" size="20px" />
|
||||
<div class="text-caption text-weight-medium col">Égaler un concurrent<div class="text-caption text-grey-6" style="font-weight:400">Total mensuel du concurrent → rabais 24 mois sur l'Internet</div></div>
|
||||
<q-input dense outlined v-model.number="competitorPrice" type="number" step="0.01" min="0" suffix="$/mois" style="width:118px" placeholder="total" @keyup.enter="applyCompetitor" />
|
||||
<q-btn dense unelevated no-caps color="deep-orange-6" label="Égaler" :disable="!(competitorPrice > 0)" @click="applyCompetitor" />
|
||||
</div>
|
||||
<div v-if="competitorInfo" class="row items-center no-wrap q-mt-xs" :class="competitorInfo.ok ? 'text-positive' : 'text-grey-7'">
|
||||
<q-icon :name="competitorInfo.ok ? 'check_circle' : 'info'" size="15px" class="q-mr-xs" />
|
||||
<span class="text-caption col">{{ competitorInfo.msg }}</span>
|
||||
<q-btn v-if="competitorInfo.ok" flat dense no-caps size="sm" color="grey-7" label="Retirer" @click="removeCompetitor" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ──────────────────────────────────────────────────────────────
|
||||
ÉTAPE 1 — Internet (obligatoire, accordion list)
|
||||
Same accordion pattern as TV/Phone: clicking the header toggles
|
||||
|
|
@ -1338,8 +1359,17 @@ function confirmAddStep () {
|
|||
|
||||
const templateLoadedFor = ref(new Set())
|
||||
|
||||
const { catalogProducts, catalogLoading, catalogFilter, catalogCategories, residentialPresets, internetHeroTiers, tvHeroTiers, selectedHeroTier, selectHeroTier, selectedTvTier, selectTvTier, filteredCatalog, loadCatalog, addCatalogItem, applyPreset, loadTemplateFromItem, toggleTemplate, removeStepFromBundle, addStepToBundle, loadedBundles, lastMergeCount, mergedTemplateLabels } =
|
||||
const { catalogProducts, catalogLoading, catalogFilter, catalogCategories, residentialPresets, internetHeroTiers, tvHeroTiers, selectedHeroTier, selectHeroTier, selectedTvTier, selectTvTier, filteredCatalog, loadCatalog, addCatalogItem, matchCompetitor, clearCompetitor, applyPreset, loadTemplateFromItem, toggleTemplate, removeStepFromBundle, addStepToBundle, loadedBundles, lastMergeCount, mergedTemplateLabels } =
|
||||
useWizardCatalog({ orderItems, wizardSteps, templates, templateLoadedFor })
|
||||
// Égaler un concurrent (terrain) : saisir le total mensuel du concurrent → rabais promo (rab-x) sur l'Internet, 24 mois.
|
||||
const competitorPrice = ref(null)
|
||||
const competitorInfo = ref(null)
|
||||
function applyCompetitor () {
|
||||
const r = matchCompetitor(competitorPrice.value, 24)
|
||||
if (r.ok) competitorInfo.value = { ok: true, msg: `Rabais −${r.rebate.toFixed(2)}$/mois sur l'Internet (24 mois) → total ${r.target.toFixed(2)}$/mois` }
|
||||
else competitorInfo.value = { ok: false, msg: (r.target >= r.base && r.base > 0) ? `Concurrent (${(r.target || 0).toFixed(2)}$) ≥ notre prix (${r.base.toFixed(2)}$) — aucun rabais requis.` : 'Ajoutez d\'abord des services au panier.' }
|
||||
}
|
||||
function removeCompetitor () { clearCompetitor(); competitorInfo.value = null; competitorPrice.value = null }
|
||||
|
||||
// Internet accordion defaults to open — Internet is required so we want
|
||||
// the tier list visible on first landing. Clicking a tier closes it.
|
||||
|
|
@ -2786,6 +2816,7 @@ watch(() => props.modelValue, (v) => {
|
|||
Sticky summary at the top of the Items/Devis scroll area. Combines
|
||||
item count + running totals + taxes into a single strip; details live
|
||||
on the Sommaire step (click → navigate). */
|
||||
.cw-compet { border: 1px solid #fde3d3; background: #fff8f4; border-radius: 10px; }
|
||||
.cart-pill {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 14px;
|
||||
|
|
|
|||
200
apps/ops/src/components/shared/QuoteWizard.vue
Normal file
200
apps/ops/src/components/shared/QuoteWizard.vue
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<template>
|
||||
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :maximized="$q.screen.lt.md" persistent>
|
||||
<q-card class="qw-card column no-wrap">
|
||||
<q-card-section class="row items-center no-wrap q-py-sm">
|
||||
<q-icon name="request_quote" size="22px" color="teal-7" class="q-mr-sm" />
|
||||
<div class="text-h6 col ellipsis">Nouvelle soumission</div>
|
||||
<q-btn flat round dense icon="close" @click="$emit('update:modelValue', false)" />
|
||||
</q-card-section>
|
||||
|
||||
<!-- Progression (slides) -->
|
||||
<div class="qw-steps row items-center no-wrap q-px-md q-pb-sm">
|
||||
<template v-for="(s, i) in steps" :key="s.key">
|
||||
<div class="qw-step row items-center no-wrap" :class="{ done: i < stepIdx, current: i === stepIdx }">
|
||||
<div class="qw-dot row items-center justify-center"><q-icon v-if="i < stepIdx" name="check" size="14px" /><span v-else>{{ i + 1 }}</span></div>
|
||||
<div class="qw-step-lbl gt-xs">{{ s.label }}</div>
|
||||
</div>
|
||||
<div v-if="i < steps.length - 1" class="qw-line col" :class="{ done: i < stepIdx }"></div>
|
||||
</template>
|
||||
</div>
|
||||
<q-separator />
|
||||
|
||||
<q-carousel v-model="step" animated swipeable transition-prev="slide-right" transition-next="slide-left" class="qw-body col" :navigation="false" :arrows="false" keep-alive>
|
||||
<!-- 1 · ADRESSE + qualification fibre -->
|
||||
<q-carousel-slide name="adresse" class="q-pa-md column no-wrap">
|
||||
<div class="text-subtitle1 text-weight-medium">Adresse de service</div>
|
||||
<div class="text-caption text-grey-7 q-mb-sm">Cherchez l'adresse civique — on vérifie la disponibilité de la fibre avant tout (base RQA + réseau Targo).</div>
|
||||
<q-input dense outlined v-model="addrQuery" placeholder="Ex. 123 rue Principale, Ville…" :loading="addrLoading" clearable autofocus @update:model-value="searchAddr">
|
||||
<template #prepend><q-icon name="place" /></template>
|
||||
</q-input>
|
||||
<q-list v-if="addrResults.length" bordered class="qw-results q-mt-xs">
|
||||
<q-item v-for="(a, i) in addrResults" :key="i" clickable @click="pickAddress(a)">
|
||||
<q-item-section avatar style="min-width:30px"><q-icon :name="a.fiber_available ? 'check_circle' : 'cancel'" :color="a.fiber_available ? 'positive' : 'grey-5'" /></q-item-section>
|
||||
<q-item-section><q-item-label>{{ a.address_full }}</q-item-label><q-item-label caption>{{ a.fiber_available ? ('Fibre disponible' + (a.max_speed ? ' · jusqu\'à ' + a.max_speed + ' Mbps' : '')) : 'Fibre non disponible à cette adresse' }}</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<q-banner v-if="chosen.address" class="qw-chosen q-mt-md" :class="chosen.fiber ? 'bg-green-1' : 'bg-orange-1'">
|
||||
<template #avatar><q-icon :name="chosen.fiber ? 'wifi' : 'wifi_off'" :color="chosen.fiber ? 'positive' : 'orange-8'" /></template>
|
||||
<div class="text-weight-medium">{{ chosen.address }}</div>
|
||||
<div class="text-caption">{{ chosen.fiber ? ('✓ Fibre disponible' + (chosen.maxSpeed ? ' — jusqu\'à ' + chosen.maxSpeed + ' Mbps' : '')) : '⚠ Fibre non desservie ici — soumission possible mais à valider (hors zone / à déployer).' }}</div>
|
||||
</q-banner>
|
||||
<!-- Mini-carte : repère (✓ vert) sur l'adresse trouvée → confirmation visuelle ; bouton satellite (voir le bâtiment) -->
|
||||
<div v-show="chosen.address && chosen.lat != null" class="qw-map-wrap q-mt-sm">
|
||||
<div ref="qwMapEl" class="qw-map"></div>
|
||||
<q-btn round dense size="sm" class="qw-sat-btn" :color="satellitePref ? 'primary' : 'grey-9'" text-color="white" :icon="satellitePref ? 'map' : 'satellite_alt'" @click="toggleMiniSat"><q-tooltip>{{ satellitePref ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
|
||||
</div>
|
||||
</q-carousel-slide>
|
||||
|
||||
<!-- 2 · CONTACT (fiche existante ou nouveau prospect) -->
|
||||
<q-carousel-slide name="contact" class="q-pa-md column no-wrap">
|
||||
<div class="text-subtitle1 text-weight-medium">Contact</div>
|
||||
<div class="text-caption text-grey-7 q-mb-sm">Associez une fiche existante, ou créez un prospect (courriel <b>ou</b> mobile suffit pour la confirmation).</div>
|
||||
<q-input dense outlined v-model="custQuery" placeholder="Chercher par nom · téléphone · courriel…" :loading="custLoading" clearable @update:model-value="searchCust" class="q-mb-xs">
|
||||
<template #prepend><q-icon name="search" /></template>
|
||||
</q-input>
|
||||
<q-list v-if="custResults.length" bordered class="qw-results q-mb-sm">
|
||||
<q-item v-for="c in custResults" :key="c.name" clickable @click="pickCustomer(c)" :active="chosen.customer && chosen.customer.name === c.name" active-class="bg-teal-1">
|
||||
<q-item-section avatar style="min-width:30px"><q-icon name="badge" color="teal-7" /></q-item-section>
|
||||
<q-item-section><q-item-label>{{ c.customer_name || c.name }}</q-item-label><q-item-label caption>{{ [c.cell_phone || c.phone, c.email_id].filter(Boolean).join(' · ') || c.name }}</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<q-banner v-if="chosen.customer" class="bg-teal-1 q-mb-sm">
|
||||
<template #avatar><q-icon name="how_to_reg" color="teal-8" /></template>
|
||||
Fiche existante : <b>{{ chosen.customer.customer_name || chosen.customer.name }}</b>
|
||||
<template #action><q-btn flat dense no-caps label="Changer" @click="chosen.customer = null" /></template>
|
||||
</q-banner>
|
||||
<template v-else>
|
||||
<div class="text-caption text-grey-6 q-mb-xs">…ou nouveau prospect :</div>
|
||||
<q-btn-toggle v-model="lead.type" :options="[{label:'Résidentiel',value:'Individual'},{label:'Commercial',value:'Company'}]" unelevated dense no-caps toggle-color="teal-7" color="grey-3" text-color="grey-8" class="q-mb-sm" />
|
||||
<div class="row q-col-gutter-sm">
|
||||
<q-input class="col-6" dense outlined v-model="lead.first" label="Prénom" />
|
||||
<q-input class="col-6" dense outlined v-model="lead.last" :label="lead.type === 'Company' ? 'Entreprise' : 'Nom'" />
|
||||
<q-input class="col-6" dense outlined v-model="lead.email" label="Courriel" type="email" :error="showLeadErr && !leadContactOk" />
|
||||
<q-input class="col-6" dense outlined v-model="lead.mobile" label="Mobile" mask="(###) ###-####" :error="showLeadErr && !leadContactOk" />
|
||||
</div>
|
||||
<div v-if="showLeadErr && !leadContactOk" class="text-caption text-negative q-mt-xs">Au moins un courriel ou un mobile est requis.</div>
|
||||
</template>
|
||||
</q-carousel-slide>
|
||||
</q-carousel>
|
||||
|
||||
<q-separator />
|
||||
<q-card-actions class="row items-center q-px-md q-py-sm">
|
||||
<q-btn flat no-caps icon="chevron_left" label="Retour" :disable="stepIdx === 0" @click="go(stepIdx - 1)" />
|
||||
<q-space />
|
||||
<q-btn v-if="step === 'adresse'" unelevated no-caps color="teal-7" icon-right="chevron_right" label="Continuer" :disable="!chosen.address" @click="go(1)" />
|
||||
<q-btn v-else unelevated no-caps color="teal-8" icon-right="request_quote" label="Continuer vers les services" :loading="submitting" @click="finish" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { useAddressSearch } from 'src/composables/useAddressSearch'
|
||||
import { createPlanMap, addSatelliteLayer, satellitePref, setSatellitePref, setSatelliteVisible } from 'src/config/basemap' // init carte MapLibre/OSM commune + satellite ESRI
|
||||
import { listDocs, createDoc } from 'src/api/erp'
|
||||
|
||||
const props = defineProps({ modelValue: Boolean })
|
||||
// « qualified » : émis avec le customer (doc ERPNext) prêt pour ProjectWizard, + l'adresse/fibre pour info.
|
||||
const emit = defineEmits(['update:modelValue', 'qualified'])
|
||||
const $q = useQuasar()
|
||||
|
||||
const steps = [{ key: 'adresse', label: 'Adresse' }, { key: 'contact', label: 'Contact' }]
|
||||
const step = ref('adresse')
|
||||
const stepIdx = computed(() => Math.max(0, steps.findIndex(s => s.key === step.value)))
|
||||
function go (i) { const n = Math.min(steps.length - 1, Math.max(0, i)); step.value = steps[n].key }
|
||||
|
||||
// ── Étape 1 : adresse + fibre ──
|
||||
const { addrResults, addrLoading, searchAddr } = useAddressSearch()
|
||||
const addrQuery = ref('')
|
||||
const chosen = reactive({ address: '', ville: '', code_postal: '', lat: null, lon: null, fiber: false, maxSpeed: null, zone: null, customer: null })
|
||||
function pickAddress (a) {
|
||||
chosen.address = a.address_full; chosen.ville = a.ville || ''; chosen.code_postal = a.code_postal || ''
|
||||
chosen.lat = a.latitude != null ? +a.latitude : null; chosen.lon = a.longitude != null ? +a.longitude : null
|
||||
chosen.fiber = !!a.fiber_available; chosen.maxSpeed = a.max_speed || null; chosen.zone = a.zone_tarifaire != null ? a.zone_tarifaire : null // zone tarifaire → le prix dépend de l'adresse
|
||||
addrResults.value = []; addrQuery.value = a.address_full
|
||||
}
|
||||
|
||||
// ── Mini-carte : repère (✓ vert) sur l'adresse trouvée (MapLibre/OSM auto-hébergé) ──
|
||||
const qwMapEl = ref(null); let _qwMap = null; let _qwMarker = null
|
||||
function initMiniMap () {
|
||||
if (!qwMapEl.value || chosen.lat == null || chosen.lon == null) return
|
||||
if (_qwMap) { _qwMap.setCenter([chosen.lon, chosen.lat]); if (_qwMarker) _qwMarker.setLngLat([chosen.lon, chosen.lat]); setTimeout(() => { try { _qwMap.resize() } catch (e) {} }, 60); return }
|
||||
_qwMap = createPlanMap(qwMapEl.value, { center: [chosen.lon, chosen.lat], zoom: 15 })
|
||||
_qwMap.on('load', () => {
|
||||
try { _qwMap.resize() } catch (e) {}
|
||||
addSatelliteLayer(_qwMap, satellitePref.value) // satellite (voir le bâtiment) — masqué par défaut, bouton pour basculer
|
||||
const el = document.createElement('div'); el.className = 'qw-pin'; el.innerHTML = '<span class="material-icons">check</span>'
|
||||
_qwMarker = new window.mapboxgl.Marker({ element: el, anchor: 'bottom' }).setLngLat([chosen.lon, chosen.lat]).addTo(_qwMap)
|
||||
})
|
||||
}
|
||||
function toggleMiniSat () { setSatellitePref(!satellitePref.value); if (_qwMap) setSatelliteVisible(_qwMap, satellitePref.value) }
|
||||
watch(satellitePref, (v) => { if (_qwMap) setSatelliteVisible(_qwMap, v) })
|
||||
function destroyMiniMap () { if (_qwMap) { try { _qwMap.remove() } catch (e) {} _qwMap = null; _qwMarker = null } }
|
||||
watch(() => [props.modelValue, step.value, chosen.lat, chosen.lon], () => {
|
||||
if (props.modelValue && step.value === 'adresse' && chosen.lat != null) nextTick(() => setTimeout(initMiniMap, 80))
|
||||
})
|
||||
watch(() => props.modelValue, (open) => { if (!open) destroyMiniMap() })
|
||||
onBeforeUnmount(destroyMiniMap)
|
||||
|
||||
// ── Étape 2 : contact (recherche fiche + création prospect) ──
|
||||
const custQuery = ref(''); const custResults = ref([]); const custLoading = ref(false); let custT = null
|
||||
const lead = reactive({ type: 'Individual', first: '', last: '', email: '', mobile: '' })
|
||||
const showLeadErr = ref(false)
|
||||
const leadContactOk = computed(() => !!(lead.email.trim() || lead.mobile.replace(/\D/g, '').length >= 10))
|
||||
function searchCust (q) {
|
||||
clearTimeout(custT); if (!q || q.trim().length < 3) { custResults.value = []; return }
|
||||
custLoading.value = true
|
||||
custT = setTimeout(async () => {
|
||||
try {
|
||||
const digits = q.replace(/\D/g, '')
|
||||
const or = [['customer_name', 'like', '%' + q + '%'], ['email_id', 'like', '%' + q + '%']]
|
||||
if (digits.length >= 4) or.push(['cell_phone', 'like', '%' + digits.slice(-10) + '%'], ['phone', 'like', '%' + digits.slice(-10) + '%'])
|
||||
const r = await listDocs('Customer', { or_filters: or, fields: ['name', 'customer_name', 'cell_phone', 'phone', 'email_id'], limit: 8 })
|
||||
custResults.value = Array.isArray(r) ? r : (r.data || [])
|
||||
} catch (e) { custResults.value = [] } finally { custLoading.value = false }
|
||||
}, 320)
|
||||
}
|
||||
function pickCustomer (c) { chosen.customer = c; custResults.value = [] }
|
||||
|
||||
const submitting = ref(false)
|
||||
async function finish () {
|
||||
if (!chosen.customer) {
|
||||
if (!leadContactOk.value) { showLeadErr.value = true; return }
|
||||
const name = [lead.first.trim(), lead.last.trim()].filter(Boolean).join(' ') || (lead.email || lead.mobile)
|
||||
submitting.value = true
|
||||
try {
|
||||
const doc = await createDoc('Customer', {
|
||||
customer_name: name, customer_type: lead.type, customer_group: lead.type === 'Company' ? 'Commercial' : 'Individual', territory: 'Canada',
|
||||
...(lead.email.trim() ? { email_id: lead.email.trim() } : {}), ...(lead.mobile.replace(/\D/g, '') ? { cell_phone: lead.mobile } : {}),
|
||||
})
|
||||
chosen.customer = doc && (doc.data || doc)
|
||||
$q.notify({ type: 'positive', icon: 'person_add', message: 'Prospect créé — ' + name, timeout: 2000 })
|
||||
} catch (e) { $q.notify({ type: 'negative', message: 'Création du prospect impossible : ' + (e.message || e) }); submitting.value = false; return }
|
||||
submitting.value = false
|
||||
}
|
||||
// Handoff : ProjectWizard (panier + rabais rab-x + devis + acceptation) prend le relais, pré-rempli.
|
||||
emit('qualified', { customer: chosen.customer, address: { address: chosen.address, ville: chosen.ville, code_postal: chosen.code_postal, lat: chosen.lat, lon: chosen.lon, fiber: chosen.fiber, zone: chosen.zone } })
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qw-card { width: 620px; max-width: 96vw; height: 70vh; max-height: 720px; }
|
||||
.q-dialog--maximized .qw-card { width: 100%; height: 100%; max-height: none; }
|
||||
.qw-steps { gap: 4px; }
|
||||
.qw-step { gap: 6px; } .qw-dot { width: 24px; height: 24px; border-radius: 50%; background: #e2e8f0; color: #64748b; font-size: 12px; font-weight: 700; flex: 0 0 auto; transition: all .2s; }
|
||||
.qw-step.current .qw-dot { background: #0f766e; color: #fff; } .qw-step.done .qw-dot { background: #14b8a6; color: #fff; }
|
||||
.qw-step-lbl { font-size: 12px; color: #64748b; font-weight: 600; } .qw-step.current .qw-step-lbl { color: #0f766e; }
|
||||
.qw-line { height: 2px; background: #e2e8f0; margin: 0 6px; border-radius: 2px; } .qw-line.done { background: #14b8a6; }
|
||||
.qw-body { min-height: 0; } .qw-body :deep(.q-carousel__slide) { overflow-y: auto; }
|
||||
.qw-results { border-radius: 8px; max-height: 260px; overflow-y: auto; }
|
||||
.qw-chosen { border-radius: 8px; }
|
||||
.qw-map-wrap { position: relative; }
|
||||
.qw-map { height: 180px; border-radius: 10px; overflow: hidden; border: 1px solid #e2e8f0; }
|
||||
.qw-sat-btn { position: absolute; top: 6px; left: 6px; z-index: 3; }
|
||||
/* Repère « adresse trouvée » : goutte verte + ✓ blanc */
|
||||
:deep(.qw-pin) { width: 30px; height: 30px; border-radius: 50% 50% 50% 0; background: #16a34a; transform: rotate(-45deg); display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 6px rgba(0,0,0,.3); border: 2px solid #fff; }
|
||||
:deep(.qw-pin .material-icons) { transform: rotate(45deg); color: #fff; font-size: 16px; }
|
||||
</style>
|
||||
|
|
@ -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) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :ref="setEl" class="route-map" :style="{ height }" />
|
||||
<div class="rm-wrap" :style="{ height }">
|
||||
<div :ref="setEl" class="route-map" style="height:100%" />
|
||||
<q-btn round dense size="sm" class="rm-sat-btn" :color="satellitePref ? 'primary' : 'grey-9'" text-color="white" :icon="satellitePref ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satellitePref ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rm-wrap { position: relative; }
|
||||
.route-map { border-radius: 8px; overflow: hidden; border: 1px solid #cfd8dc; }
|
||||
.rm-sat-btn { position: absolute; top: 8px; left: 8px; z-index: 3; }
|
||||
</style>
|
||||
|
||||
<!-- NON scoped : les marqueurs sont créés en JS et injectés dans le conteneur Mapbox (hors de l'arbre scoped). Préfixe rm- = pas de collision. -->
|
||||
|
|
|
|||
9
apps/ops/src/composables/useCreateSignal.js
Normal file
9
apps/ops/src/composables/useCreateSignal.js
Normal file
|
|
@ -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 } }
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
65
apps/ops/src/config/basemap.js
Normal file
65
apps/ops/src/config/basemap.js
Normal file
|
|
@ -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) {} }
|
||||
|
|
@ -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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -130,26 +130,25 @@
|
|||
|
||||
<!-- FAB messagerie : clic « forum » = ouvrir les communications (action directe et fiable) ; bouton « + » (clic) = menu créer rapide. -->
|
||||
<q-page-sticky v-if="can('view_clients')" position="bottom-right" :offset="[18, 18]">
|
||||
<div class="column items-end q-gutter-sm">
|
||||
<q-slide-transition>
|
||||
<div v-show="quickOpen" class="column items-end q-gutter-xs">
|
||||
<q-btn rounded unelevated color="deep-purple-6" icon="bolt" label="Dicter une commande" no-caps size="sm" @click="quickOpen = false; orchestratorOpen = true" />
|
||||
<q-btn rounded unelevated color="info" icon="wifi_find" label="Statut du service" no-caps size="sm" @click="quickOpen = false; serviceStatusOpen = true" />
|
||||
<q-btn rounded unelevated color="positive" icon="confirmation_number" label="Créer un ticket" no-caps size="sm" @click="quickOpen = false; newTicketOpen = true" />
|
||||
<q-btn rounded unelevated color="green-6" icon="sms" label="Nouveau texto" no-caps size="sm" @click="quickOpen = false; openNew('sms')" />
|
||||
<q-btn rounded unelevated color="negative" icon="mail" label="Nouveau courriel" no-caps size="sm" @click="quickOpen = false; openNew('email')" />
|
||||
</div>
|
||||
</q-slide-transition>
|
||||
<div class="row items-center q-gutter-xs">
|
||||
<q-btn round unelevated :color="quickOpen ? 'blue-grey-7' : 'blue-grey-4'" :icon="quickOpen ? 'close' : 'add'" size="sm" @click="quickOpen = !quickOpen">
|
||||
<!-- Menu « créer rapide » : POPUP composité (q-menu) au lieu d'une colonne à hauteur animée (q-slide-transition) → plus de saccade / reflow du page-sticky. -->
|
||||
<q-btn round unelevated color="blue-grey-5" icon="add" size="sm">
|
||||
<q-tooltip>Créer rapidement (ticket · texto · courriel · dicter)</q-tooltip>
|
||||
<q-menu anchor="top right" self="bottom right" auto-close>
|
||||
<q-list dense style="min-width:212px">
|
||||
<q-item clickable v-close-popup @click="orchestratorOpen = true"><q-item-section avatar><q-icon name="bolt" color="deep-purple-6" /></q-item-section><q-item-section>Dicter une commande</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="serviceStatusOpen = true"><q-item-section avatar><q-icon name="wifi_find" color="info" /></q-item-section><q-item-section>Statut du service</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="goCreate"><q-item-section avatar><q-icon name="add_box" color="positive" /></q-item-section><q-item-section>Créer<q-item-label caption>tâche · intervention · soumission</q-item-label></q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openNew('sms')"><q-item-section avatar><q-icon name="sms" color="green-6" /></q-item-section><q-item-section>Nouveau texto</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openNew('email')"><q-item-section avatar><q-icon name="mail" color="negative" /></q-item-section><q-item-section>Nouveau courriel</q-item-section></q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
<q-btn fab icon="forum" color="primary" @click="toggleConvPanel">
|
||||
<q-btn fab icon="forum" color="primary" @click="openComms">
|
||||
<q-badge v-if="convCount > 0" color="red" floating rounded :label="convCount" />
|
||||
<q-tooltip>Voir les communications</q-tooltip>
|
||||
<q-tooltip>Ouvrir les communications (tous les fils · courriel · SMS · chat)</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-page-sticky>
|
||||
|
||||
<!-- Création rapide d'un ticket depuis le FAB -->
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -54,31 +54,20 @@
|
|||
<q-badge v-if="assignPanel.jobs.length" color="red" floating>{{ assignPanel.jobs.length }}</q-badge>
|
||||
<q-tooltip>Afficher / masquer les jobs à répartir (liste · carte · lasso · suggestion auto)</q-tooltip>
|
||||
</q-btn>
|
||||
<!-- Création de job NATIVE OPS (#17) — Dispatch déprécié → tout ici. « Créer » = pool · « Créneau » = choisir tech+date+heure d'abord -->
|
||||
<q-btn dense unelevated no-caps color="teal-7" icon="add_task" label="Créer un job" @click="openCreateJob"><q-tooltip>Créer un job / une installation (apparaît dans « À assigner »)</q-tooltip></q-btn>
|
||||
<q-btn dense outline no-caps color="teal-8" icon="event_available" label="Créneau" @click="showSuggestSlots = true"><q-tooltip>Trouver le meilleur créneau (tech + date + heure) puis créer le job réservé</q-tooltip></q-btn>
|
||||
<!-- Création UNIFIÉE : Tâche · Intervention (sur site → créneau / à distance → ticket support·vente). Remplace « Créer un job » + « Créneau ». -->
|
||||
<q-btn dense unelevated no-caps color="teal-7" icon="add" label="Créer" @click="createChooser = true"><q-tooltip>Créer une tâche ou une intervention (sur site · à distance)</q-tooltip></q-btn>
|
||||
<!-- Menu OUTILS : regroupe la config secondaire pour désencombrer -->
|
||||
<q-btn-dropdown dense outline color="grey-8" icon="build" label="Outils" no-caps>
|
||||
<q-list dense style="min-width:230px">
|
||||
<q-item clickable v-close-popup @click="showDemand = !showDemand">
|
||||
<q-item-section avatar><q-icon name="tune" :color="showDemand ? 'indigo' : 'grey-7'" /></q-item-section>
|
||||
<q-item-section>Demande de personnel</q-item-section>
|
||||
<q-item-section side><q-icon v-if="showDemand" name="check" color="indigo" /></q-item-section>
|
||||
</q-item>
|
||||
<q-list dense style="min-width:250px">
|
||||
<!-- ⚙️ RÉGLAGES : config de planification (dépôt, compétences, cadence, durées, modèles, garde) -->
|
||||
<q-item-label header class="q-py-xs text-weight-bold text-grey-8">⚙️ Réglages planification</q-item-label>
|
||||
<q-item clickable v-close-popup @click="openDepotPicker">
|
||||
<q-item-section avatar><q-icon name="warehouse" color="blue-grey-7" /></q-item-section>
|
||||
<q-item-section>Point de départ (dépôt)<q-item-label caption class="ellipsis" style="max-width:210px">{{ depot && depot.address ? depot.address : 'non défini — cliquer pour situer' }}</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showDayJobs = !showDayJobs">
|
||||
<q-item-section avatar><q-icon name="event_note" :color="showDayJobs ? 'indigo' : 'grey-7'" /></q-item-section>
|
||||
<q-item-section>Jobs prévus en en-tête<q-item-label caption>liste par jour — glisser sur un tech</q-item-label></q-item-section>
|
||||
<q-item-section side><q-icon v-if="showDayJobs" name="check" color="indigo" /></q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showLegacyLoad = !showLegacyLoad">
|
||||
<q-item-section avatar><q-icon name="assignment_ind" :color="showLegacyLoad ? 'brown' : 'grey-7'" /></q-item-section>
|
||||
<q-item-section>Jobs Legacy sur les timelines<q-item-label caption>durées estimées des jobs osTicket datés (fenêtre affichée)</q-item-label></q-item-section>
|
||||
<q-item-section side><q-icon v-if="showLegacyLoad" name="check" color="brown" /></q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showTagManager = true"><q-item-section avatar><q-icon name="sell" color="teal" /></q-item-section><q-item-section>Gérer les compétences (tags)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="speed" /></q-item-section><q-item-section>Cadence équipe</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openJobChar"><q-item-section avatar><q-icon name="timer" color="warning" /></q-item-section><q-item-section>Durées par caractéristique</q-item-section></q-item>
|
||||
<q-item clickable>
|
||||
<q-item-section avatar><q-icon name="bookmark" color="brown" /></q-item-section>
|
||||
<q-item-section>Modèles de semaine</q-item-section>
|
||||
|
|
@ -96,16 +85,33 @@
|
|||
</q-list>
|
||||
</q-menu>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="openGarde"><q-item-section avatar><q-icon name="shield" color="brown" /></q-item-section><q-item-section>Rotation de garde</q-item-section></q-item>
|
||||
<q-separator class="q-my-xs" />
|
||||
<!-- 🗓 HORAIRES & PERSONNEL : génération de quarts, absences, synchro -->
|
||||
<q-item-label header class="q-py-xs text-weight-bold text-grey-8">🗓 Horaires & personnel</q-item-label>
|
||||
<q-item clickable v-close-popup @click="doGenerate"><q-item-section avatar><q-icon name="auto_awesome" color="indigo" /></q-item-section><q-item-section>Générer l'horaire (semaine)<q-item-label caption>solveur de quarts — la semaine affichée</q-item-label></q-item-section><q-item-section side><q-spinner v-if="generating" size="16px" color="indigo" /></q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openSchedGenBulk"><q-item-section avatar><q-icon name="event_note" color="primary" /></q-item-section><q-item-section>Générer les horaires (lot)<q-item-label caption>modèle → plusieurs techs, N semaines</q-item-label></q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openGarde"><q-item-section avatar><q-icon name="shield" color="brown" /></q-item-section><q-item-section>Rotation de garde</q-item-section></q-item>
|
||||
<q-separator />
|
||||
<q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="speed" /></q-item-section><q-item-section>Cadence équipe</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="showTagManager = true"><q-item-section avatar><q-icon name="sell" color="teal" /></q-item-section><q-item-section>Gérer les compétences (tags)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openTechSync"><q-item-section avatar><q-icon name="group_add" color="green-7" /></q-item-section><q-item-section>Synchroniser les techniciens</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openJobChar"><q-item-section avatar><q-icon name="timer" color="warning" /></q-item-section><q-item-section>Durées par caractéristique</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openAssignPanel"><q-item-section avatar><q-icon name="drag_indicator" color="deep-purple" /></q-item-section><q-item-section>Jobs à assigner (glisser-déposer)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openLeave"><q-item-section avatar><q-icon name="beach_access" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openTechSync"><q-item-section avatar><q-icon name="group_add" color="green-7" /></q-item-section><q-item-section>Synchroniser les techniciens</q-item-section></q-item>
|
||||
<q-separator class="q-my-xs" />
|
||||
<!-- 👁 AFFICHAGE : bascules de ce qui s'affiche sur le board -->
|
||||
<q-item-label header class="q-py-xs text-weight-bold text-grey-8">👁 Affichage</q-item-label>
|
||||
<q-item clickable v-close-popup @click="openAssignPanel"><q-item-section avatar><q-icon name="drag_indicator" color="deep-purple" /></q-item-section><q-item-section>Jobs à assigner (glisser-déposer)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="showDayJobs = !showDayJobs">
|
||||
<q-item-section avatar><q-icon name="event_note" :color="showDayJobs ? 'indigo' : 'grey-7'" /></q-item-section>
|
||||
<q-item-section>Jobs prévus en en-tête<q-item-label caption>liste par jour — glisser sur un tech</q-item-label></q-item-section>
|
||||
<q-item-section side><q-icon v-if="showDayJobs" name="check" color="indigo" /></q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showLegacyLoad = !showLegacyLoad">
|
||||
<q-item-section avatar><q-icon name="assignment_ind" :color="showLegacyLoad ? 'brown' : 'grey-7'" /></q-item-section>
|
||||
<q-item-section>Jobs Legacy sur les timelines<q-item-label caption>durées estimées des jobs osTicket datés (fenêtre affichée)</q-item-label></q-item-section>
|
||||
<q-item-section side><q-icon v-if="showLegacyLoad" name="check" color="brown" /></q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showDemand = !showDemand">
|
||||
<q-item-section avatar><q-icon name="tune" :color="showDemand ? 'indigo' : 'grey-7'" /></q-item-section>
|
||||
<q-item-section>Demande de personnel<q-item-label caption>panneau besoins vs capacité</q-item-label></q-item-section>
|
||||
<q-item-section side><q-icon v-if="showDemand" name="check" color="indigo" /></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
<q-btn v-if="defaultTemplate" dense flat color="warning" icon="star" :label="defaultTemplate.name" @click="applyDefault"><q-tooltip>Appliquer le modèle par défaut (consciente des absences)</q-tooltip></q-btn>
|
||||
|
|
@ -432,7 +438,7 @@
|
|||
<q-btn flat round dense size="9px" :icon="isPaused(t) ? 'play_arrow' : 'pause'" :color="isPaused(t) ? 'grey' : 'primary'" @click.stop="togglePause(t)"><q-tooltip>{{ isPaused(t) ? 'Réactiver' : 'Pause' }}</q-tooltip></q-btn>
|
||||
<q-badge v-if="techRank(t)" color="teal">{{ techRank(t) }}<q-tooltip>Priorité {{ techRank(t) }} — combine maîtrise (niveau) + vitesse (efficacité) + coût.</q-tooltip></q-badge>
|
||||
<!-- Clic sur la RESSOURCE (nom ou chips) → propriétés de l'employé (compétences, horaire) -->
|
||||
<span class="tech-name clk" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Compétences & horaire de {{ t.name }}</q-tooltip>{{ t.name }}</span>
|
||||
<span class="tech-name clk" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Réglages de {{ t.name }} — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée</q-tooltip>{{ t.name }}</span>
|
||||
<span v-if="t.group" class="grp">{{ t.group }}</span>
|
||||
<span v-if="hoursOf(t.id)" class="th" :class="hoursOf(t.id) > maxHours ? 'text-red text-weight-bold' : 'text-grey-6'">{{ hoursOf(t.id) }}h<q-icon v-if="hoursOf(t.id) > maxHours" name="warning" color="red" size="12px" /></span>
|
||||
<q-btn flat dense round size="9px" icon="more_vert" color="grey-6" @click.stop><q-tooltip>Actions — {{ t.name }}</q-tooltip>
|
||||
|
|
@ -562,7 +568,7 @@
|
|||
<!-- Trait « maintenant » CONTINU : 1 seul élément sur toute la hauteur des lanes -->
|
||||
<div v-if="nowLineStyle" class="kbb-now-full" :style="nowLineStyle"></div>
|
||||
<div v-for="t in visibleTechs" :key="t.id" class="kbb-row">
|
||||
<div class="kbb-name"><span class="ellipsis">{{ t.name }}</span><q-icon v-if="kanbanDay && cellOcc(t.id, kanbanDay.iso) && cellOcc(t.id, kanbanDay.iso).over" name="warning" color="warning" size="15px"><q-tooltip class="bg-grey-9" style="font-size:11.5px; max-width:260px"><b>⚠ Surcharge</b><br>{{ overWhy(t.id, kanbanDay.iso) }}</q-tooltip></q-icon></div>
|
||||
<div class="kbb-name"><span class="ellipsis clk" style="cursor:pointer" @click.stop="openSkillEditor(t, $event)">{{ t.name }}<q-tooltip>Réglages de {{ t.name }} — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée</q-tooltip></span><q-icon v-if="kanbanDay && cellOcc(t.id, kanbanDay.iso) && cellOcc(t.id, kanbanDay.iso).over" name="warning" color="warning" size="15px"><q-tooltip class="bg-grey-9" style="font-size:11.5px; max-width:260px"><b>⚠ Surcharge</b><br>{{ overWhy(t.id, kanbanDay.iso) }}</q-tooltip></q-icon><q-icon name="tune" size="14px" color="grey-5" class="cursor-pointer" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Réglages du tech (compétences · GPS · domicile · tournée)</q-tooltip></q-icon></div>
|
||||
<div class="kbb-lane" :style="kbLaneStyle" :class="{ 'drop-hover': kanbanDay && dropCell === t.id + '|' + kanbanDay.iso }"
|
||||
@dragover.prevent="kanbanDay && onCellDragOver(t, kanbanDay)" @dragleave="dropCell = null" @drop="kanbanDay && onKbDrop($event, t)"
|
||||
@click.self="kanbanDay && openDayFromCell(t, kanbanDay)" @contextmenu.self.prevent="onKbLaneMenu(t, $event)">
|
||||
|
|
@ -782,7 +788,7 @@
|
|||
<q-menu v-model="skillMenuShown" :target="skillMenuTarget" anchor="bottom left" self="top left" no-focus max-height="80vh">
|
||||
<div v-if="skillDialog" class="q-pa-sm" style="width:368px;min-height:300px" @click.stop @mousedown.stop>
|
||||
<div class="row items-center q-mb-xs"><div class="text-subtitle2 text-weight-bold col ellipsis">🏷 {{ skillDialog.name }}</div><q-btn flat dense round size="sm" icon="close" v-close-popup /></div>
|
||||
<TagEditor :model-value="skillDialog.skills" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" sortable autofocus placeholder="Chercher ou créer une compétence…"
|
||||
<TagEditor :model-value="skillDialog.skills" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" sortable placeholder="Cliquez pour ajouter une compétence…"
|
||||
@update:model-value="items => onTagsChange(skillDialog, items)"
|
||||
@create="onCreateRosterTag" />
|
||||
<div v-if="(skillDialog.skills || []).length > 1" class="text-caption text-grey-6 q-mt-xs" style="line-height:1.3">↔ <b>Glisse les chips</b> pour l'ordre de priorité — la <b>1<sup>re</sup> (★)</b> = fonction principale : le dispatch auto envoie ces jobs à ce tech <b>en premier</b> (épargne les moins spécialisés).</div>
|
||||
|
|
@ -790,7 +796,7 @@
|
|||
<div class="row items-center text-caption text-grey-6 q-pb-xs">
|
||||
<div class="col">Compétence</div>
|
||||
<div style="width:90px" class="text-center">Score</div>
|
||||
<div style="width:88px" class="text-center">Efficacité</div>
|
||||
<div style="width:88px" class="text-center">Cadence</div>
|
||||
</div>
|
||||
<div v-for="(sk, si) in skillDialog.skills" :key="sk" class="row items-center no-wrap q-py-xs" style="border-top:1px solid #eee">
|
||||
<div class="col row items-center no-wrap">
|
||||
|
|
@ -813,15 +819,16 @@
|
|||
<q-icon v-for="n in 5" :key="n" :name="skillLevelOf(skillDialog, sk) >= n ? 'star' : 'star_outline'" :color="skillLevelOf(skillDialog, sk) >= n ? 'indigo' : 'grey-4'" size="16px" class="cursor-pointer" @click="setSkillLevel(skillDialog, sk, skillLevelOf(skillDialog, sk) === n ? 0 : n)" />
|
||||
</div>
|
||||
<div style="width:88px">
|
||||
<q-input dense outlined type="number" step="5" debounce="600" :model-value="skillEffPct(skillDialog, sk)" @update:model-value="v => setSkillEffPct(skillDialog, sk, v)" suffix="%" placeholder="glob." input-class="text-right" />
|
||||
<q-input dense outlined type="number" step="25" min="20" max="1000" :model-value="(effBufKey(skillDialog, sk) in effBuf) ? effBuf[effBufKey(skillDialog, sk)] : skillEffPct(skillDialog, sk)" @update:model-value="v => { effBuf[effBufKey(skillDialog, sk)] = v }" @blur="commitSkillEff(skillDialog, sk)" @keyup.enter="commitSkillEff(skillDialog, sk)" suffix="%" placeholder="glob." input-class="text-right" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-caption text-grey-6 q-mt-xs"><b>Score</b> ★ = maîtrise · <b>Efficacité</b> : <b>+</b> plus vite / <b>−</b> plus lent pour CETTE compétence (ex. <b>+80</b>), vide = globale · × sur le chip = retirer.</div>
|
||||
<div class="text-caption text-grey-6 q-mt-xs"><b>Score</b> ★ = maîtrise · <b>Cadence</b> : <b>100 %</b> = normale · <b>200 %</b> = deux fois plus de jobs · < 100 % = plus lent · vide = hérite du global · × sur le chip = retirer.</div>
|
||||
<div class="text-caption text-green-7 q-mt-xs row items-center no-wrap" style="gap:3px"><q-icon name="cloud_done" size="13px" />Enregistré automatiquement — pas de bouton à cliquer.</div>
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🗓 Horaire</div>
|
||||
<div class="row items-center q-gutter-xs">
|
||||
<q-btn dense unelevated size="sm" no-caps color="primary" icon="event_note" label="Générer l'horaire…" @click="openSchedGen(skillDialog)"><q-tooltip>Modèles (5×8h · 4×10h · 3×12h) + heures par jour + N semaines → publie les quarts</q-tooltip></q-btn>
|
||||
<q-btn dense outline size="sm" no-caps color="indigo-7" icon="event_note" label="Générer l'horaire…" @click="openSchedGen(skillDialog)"><q-tooltip>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 ».</q-tooltip></q-btn>
|
||||
<q-btn dense outline size="sm" no-caps color="primary" label="5×8h rapide" @click="applyWeekPreset(skillDialog, [1,2,3,4,5], 8, 16)"><q-tooltip>Applique 5×8h à la semaine affichée (à publier)</q-tooltip></q-btn>
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
|
|
@ -830,6 +837,30 @@
|
|||
<q-btn dense outline size="sm" no-caps color="teal" :icon="techHomes[skillDialog.id] ? 'edit_location_alt' : 'add_location_alt'" :label="techHomes[skillDialog.id] ? 'Modifier' : 'Définir'" @click="openHomePicker(skillDialog)" />
|
||||
<span class="text-caption text-grey-6 ellipsis col">{{ techHomes[skillDialog.id] && techHomes[skillDialog.id].address ? techHomes[skillDialog.id].address : 'non défini' }}</span>
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<!-- 📍 Appareil GPS (Traccar) — même contrôle que l'éditeur de jour, ici accessible directement depuis le tech -->
|
||||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">📍 Appareil GPS <span class="text-grey-5 text-weight-regular">(Traccar — suivi live + tracé réel)</span></div>
|
||||
<div class="row items-center no-wrap q-gutter-xs">
|
||||
<template v-if="deviceOf(skillDialog)">
|
||||
<q-chip dense :color="deviceOf(skillDialog).missing ? 'orange-1' : 'green-1'" :text-color="deviceOf(skillDialog).missing ? 'orange-9' : 'green-9'" icon="place" class="text-weight-medium">
|
||||
<q-icon name="circle" size="8px" :color="deviceOf(skillDialog).online ? 'positive' : 'grey-5'" class="q-mr-xs" />{{ deviceOf(skillDialog).label }}
|
||||
<q-tooltip>{{ deviceOf(skillDialog).online ? 'En ligne' : 'Hors ligne' }}{{ deviceOf(skillDialog).uniqueId ? ' · ' + deviceOf(skillDialog).uniqueId : '' }}</q-tooltip>
|
||||
</q-chip>
|
||||
<q-btn flat dense no-caps size="sm" color="indigo" icon="swap_horiz" label="Changer"><q-tooltip>Relier à un autre véhicule</q-tooltip>
|
||||
<q-menu anchor="bottom left" self="top left"><div class="q-pa-xs" style="min-width:250px"><q-input dense outlined v-model="deviceFilter" placeholder="Filtrer les véhicules…" clearable autofocus class="q-mb-xs" /><q-list dense style="max-height:40vh;overflow:auto"><q-item v-for="o in deviceOptions" :key="o.value" clickable v-close-popup @click="onPickDevice(skillDialog, o.value)" :active="deviceOf(skillDialog) && deviceOf(skillDialog).value === o.value" active-class="bg-green-1"><q-item-section avatar style="min-width:22px"><q-icon name="circle" size="9px" :color="o.online ? 'positive' : 'grey-4'" /></q-item-section><q-item-section><q-item-label>{{ o.label }}</q-item-label><q-item-label v-if="o.uniqueId" caption>{{ o.uniqueId }}</q-item-label></q-item-section></q-item><q-item v-if="!deviceOptions.length"><q-item-section class="text-grey-6">{{ devicesLoading ? 'Chargement…' : 'Aucun appareil' }}</q-item-section></q-item></q-list></div></q-menu>
|
||||
</q-btn>
|
||||
<q-btn flat dense round size="sm" icon="link_off" color="grey-6" @click="onPickDevice(skillDialog, null)"><q-tooltip>Dissocier l'appareil GPS</q-tooltip></q-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-caption text-grey-6">non associé</span>
|
||||
<q-btn v-if="deviceSuggestOf(skillDialog)" dense unelevated size="sm" no-caps color="primary" icon="link" :label="'Associer ' + deviceSuggestOf(skillDialog).label" @click="onPickDevice(skillDialog, deviceSuggestOf(skillDialog).value)"><q-tooltip>Appareil probable (nom similaire au tech)</q-tooltip></q-btn>
|
||||
<q-btn flat dense no-caps size="sm" color="indigo" icon="add_location_alt" label="Choisir"><q-tooltip>Relier un véhicule Traccar</q-tooltip>
|
||||
<q-menu anchor="bottom left" self="top left"><div class="q-pa-xs" style="min-width:250px"><q-input dense outlined v-model="deviceFilter" placeholder="Filtrer les véhicules…" clearable autofocus class="q-mb-xs" /><q-list dense style="max-height:40vh;overflow:auto"><q-item v-for="o in deviceOptions" :key="o.value" clickable v-close-popup @click="onPickDevice(skillDialog, o.value)"><q-item-section avatar style="min-width:22px"><q-icon name="circle" size="9px" :color="o.online ? 'positive' : 'grey-4'" /></q-item-section><q-item-section><q-item-label>{{ o.label }}</q-item-label><q-item-label v-if="o.uniqueId" caption>{{ o.uniqueId }}</q-item-label></q-item-section></q-item><q-item v-if="!deviceOptions.length"><q-item-section class="text-grey-6">{{ devicesLoading ? 'Chargement…' : 'Aucun appareil' }}</q-item-section></q-item></q-list></div></q-menu>
|
||||
</q-btn>
|
||||
</template>
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<q-btn dense unelevated size="sm" no-caps color="deep-purple" icon="route" label="Voir sa tournée" class="full-width" @click="viewTechTournee(skillDialog)"><q-tooltip>Passe en vue Tournées et isole la tournée de ce tech sur la carte</q-tooltip></q-btn>
|
||||
</div>
|
||||
</q-menu>
|
||||
|
||||
|
|
@ -1094,7 +1125,7 @@
|
|||
<q-icon v-if="(Number(t.efficiency) || 1) < 0.95" name="bolt" size="14px" color="green-6"><q-tooltip>Rapide (efficacité {{ t.efficiency }})</q-tooltip></q-icon>
|
||||
<q-icon v-else-if="(Number(t.efficiency) || 1) > 1.05" name="hourglass_bottom" size="13px" color="orange-6"><q-tooltip>Plus lent (efficacité {{ t.efficiency }})</q-tooltip></q-icon>
|
||||
<q-icon v-if="!(dayList || []).some(d => hasShiftDay(t.id, d.iso))" name="event_busy" size="13px" color="amber-7"><q-tooltip>Aucun quart publié cette période</q-tooltip></q-icon>
|
||||
<q-icon name="edit" size="14px" color="grey-6" class="cursor-pointer" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Modifier compétences & niveaux (★) + efficacité</q-tooltip></q-icon>
|
||||
<q-icon name="tune" size="14px" color="grey-6" class="cursor-pointer" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Réglages du tech — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée</q-tooltip></q-icon>
|
||||
</div>
|
||||
</div>
|
||||
<q-toggle v-model="suggestDlg.makeShifts" dense size="sm" color="primary" class="q-mt-sm" label="Créer un quart 8–16 pour les techs sans quart (à l'application)" />
|
||||
|
|
@ -1453,16 +1484,17 @@
|
|||
<q-btn dense unelevated color="deep-purple" icon="group_add" label="Ajouter" :disable="!jobDetail.teamAdd" @click="jdAddAssistant" no-caps />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="jobDetail.detail" class="jd-detail">{{ jobDetail.detail }}</div>
|
||||
<div class="text-subtitle2 q-mt-md q-mb-xs">Commentaires / fil du billet</div>
|
||||
<div class="text-subtitle2 q-mt-md q-mb-xs row items-center">Commentaires / fil du billet<span v-if="jdMessages.length" class="text-grey-5 q-ml-sm" style="font-size:10px">· plus récent en haut</span></div>
|
||||
<div v-if="jobDetail.loading" class="text-center q-pa-md"><q-spinner size="26px" color="primary" /><div class="text-caption text-grey-6 q-mt-sm">Lecture du ticket…</div></div>
|
||||
<template v-else-if="jobDetail.thread && jobDetail.thread.messages && jobDetail.thread.messages.length">
|
||||
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ jobDetail.thread.messages.length }} message(s){{ jobDetail.thread.status ? ' · ' + jobDetail.thread.status : '' }}</div>
|
||||
<div v-for="(m, mi) in jobDetail.thread.messages" :key="mi" class="de-msg">
|
||||
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span></div>
|
||||
<template v-else-if="jdMessages.length">
|
||||
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ jdMessages.length }} message(s){{ jobDetail.thread.status ? ' · ' + jobDetail.thread.status : '' }}</div>
|
||||
<div v-for="(m, mi) in jdMessages" :key="mi" class="de-msg" :class="{ 'de-msg-latest': mi === 0 }">
|
||||
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span><span v-if="mi === 0 && jdMessages.length > 1" class="de-msg-badge">dernier</span></div>
|
||||
<div class="de-msg-txt">{{ m.text }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Repli : corps brut du billet UNIQUEMENT s'il n'y a aucun message dans le fil (sinon = doublon du 1er commentaire) -->
|
||||
<div v-else-if="jobDetail.detail" class="jd-detail">{{ jobDetail.detail }}</div>
|
||||
<div v-else class="text-grey-6 q-pa-md text-center">{{ jobDetail.lid ? ((jobDetail.thread && jobDetail.thread.error) ? 'Détail indisponible' : 'Aucun commentaire.') : 'Pas de billet Legacy lié à ce job.' }}</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
|
@ -1848,10 +1880,36 @@
|
|||
</q-dialog>
|
||||
|
||||
<!-- #17 — Création de job NATIVE OPS (work-order) : réutilise le modal partagé. @created → rafraîchit le pool. -->
|
||||
<UnifiedCreateModal v-model="createJobOpen" mode="work-order" :context="createJobCtx"
|
||||
<UnifiedCreateModal v-model="createJobOpen" :mode="createMode" :context="createJobCtx"
|
||||
:technicians="techs" :external-tags="tagCatalog" :external-get-color="getTagColor" @created="onJobCreated" />
|
||||
<!-- Chooser « Créer » : Tâche · Intervention (sur site → créneau / à distance → ticket) — un seul point d'entrée -->
|
||||
<q-dialog v-model="createChooser">
|
||||
<q-card style="min-width:340px;max-width:440px">
|
||||
<q-card-section class="row items-center q-pb-sm"><div class="text-h6">Créer…</div><q-space /><q-btn flat round dense icon="close" v-close-popup /></q-card-section>
|
||||
<q-card-section class="q-pt-none column q-gutter-sm">
|
||||
<q-btn no-caps unelevated align="left" color="grey-2" text-color="grey-9" class="cc-opt" @click="startCreate('task')">
|
||||
<q-icon name="task_alt" color="indigo" size="24px" class="q-mr-md" /><div class="text-left"><div class="text-weight-medium">Tâche</div><div class="text-caption text-grey-7">Travail interne — sans client ni déplacement</div></div>
|
||||
</q-btn>
|
||||
<div class="text-caption text-weight-medium text-grey-7 q-mt-xs">Intervention</div>
|
||||
<q-btn no-caps unelevated align="left" color="teal-1" text-color="teal-10" class="cc-opt" @click="startCreneau">
|
||||
<q-icon name="construction" color="teal-8" size="24px" class="q-mr-md" /><div class="text-left"><div class="text-weight-medium">Sur site — trouver un créneau</div><div class="text-caption text-grey-7">Meilleur tech + date + heure (selon priorité & compétences)</div></div>
|
||||
</q-btn>
|
||||
<q-btn no-caps flat dense align="left" color="teal-9" class="cc-sub" @click="startCreate('work-order')"><q-icon name="inbox" size="16px" class="q-mr-sm" />…ou ajouter au pool (planifier plus tard)</q-btn>
|
||||
<q-btn no-caps unelevated align="left" color="blue-1" text-color="blue-10" class="cc-opt" @click="startCreate('ticket')">
|
||||
<q-icon name="headset_mic" color="blue-8" size="24px" class="q-mr-md" /><div class="text-left"><div class="text-weight-medium">À distance — ticket</div><div class="text-caption text-grey-7">Support ou vente (selon priorité & compétences), sans déplacement</div></div>
|
||||
</q-btn>
|
||||
<q-separator class="q-my-xs" />
|
||||
<q-btn no-caps unelevated align="left" color="amber-1" text-color="amber-10" class="cc-opt" @click="createChooser = false; quoteWizardOpen = true">
|
||||
<q-icon name="request_quote" color="amber-8" size="24px" class="q-mr-md" /><div class="text-left"><div class="text-weight-medium">Soumission</div><div class="text-caption text-grey-7">Vérifier l'adresse (fibre) → prospect → services & devis — même pour un non-client</div></div>
|
||||
</q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- « Trouver un créneau » → chips compétence (durée par défaut) + adresse → choisit tech+date+heure puis pré-remplit la création -->
|
||||
<SuggestSlotsDialog v-model="showSuggestSlots" :skills="slotSkills" @select="onSlotSelected" @plan-shifts="onPlanShifts" />
|
||||
<!-- Soumission : wizard adresse+fibre+prospect → passe le relais à ProjectWizard (panier + rabais + devis + acceptation) -->
|
||||
<QuoteWizard v-model="quoteWizardOpen" @qualified="onQuoteQualified" />
|
||||
<ProjectWizard v-model="projectWizardOpen" :customer="quoteCustomer" @created="onQuoteCreated" />
|
||||
<!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) → écrit les Shift Assignment -->
|
||||
<WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" />
|
||||
</q-page>
|
||||
|
|
@ -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 <OccupancyStrip>)
|
||||
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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user