Laravel access controll pour les utilisateurs
This commit is contained in:
parent
d5916d96a2
commit
d275c460b6
281
.opencode/package-lock.json
generated
281
.opencode/package-lock.json
generated
@ -5,22 +5,23 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kilocode/plugin": "7.2.10",
|
"@kilocode/plugin": "7.2.22",
|
||||||
"@opencode-ai/plugin": "1.1.31"
|
"@opencode-ai/plugin": "1.1.31"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@kilocode/plugin": {
|
"node_modules/@kilocode/plugin": {
|
||||||
"version": "7.2.10",
|
"version": "7.2.22",
|
||||||
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.22.tgz",
|
||||||
"integrity": "sha512-VJPhJC+E5WWu7XgEJzrVOxKJlwJ+OATwxEzgjqEPj8KN5N38YxUPBY/rzUTjv90x7nkzyk1rFGfCVqXdA/Koug==",
|
"integrity": "sha512-uS8tnoLzXAyDHHgSOvP/GhrvkKpus6i6tmWb57E4+YfgHBOO7HqF+LzV4MiC1cuGIifbMtyUEi3kZWmWdIuhhw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kilocode/sdk": "7.2.10",
|
"@kilocode/sdk": "7.2.22",
|
||||||
|
"effect": "4.0.0-beta.48",
|
||||||
"zod": "4.1.8"
|
"zod": "4.1.8"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.1.97",
|
"@opentui/core": ">=0.1.100",
|
||||||
"@opentui/solid": ">=0.1.97"
|
"@opentui/solid": ">=0.1.100"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@opentui/core": {
|
"@opentui/core": {
|
||||||
@ -32,14 +33,92 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@kilocode/sdk": {
|
"node_modules/@kilocode/sdk": {
|
||||||
"version": "7.2.10",
|
"version": "7.2.22",
|
||||||
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.22.tgz",
|
||||||
"integrity": "sha512-H6jGXYAhN/yjOGX3MRZ0OxyEAuRGY3VOwDbLTh4O6ljpgutFHaLvomDZ82qNVy7gl7AjJgi3SAQAt9UQpeGl/w==",
|
"integrity": "sha512-2t4VuK5rVY9o/Pck/oRJ+CxAAqnwLhRAD/i91uSabWw4POGlOHHsq2etQKFAX8kJ5zdTk/I1DLvffh7bFPPXZw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "7.0.6"
|
"cross-spawn": "7.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@opencode-ai/plugin": {
|
"node_modules/@opencode-ai/plugin": {
|
||||||
"version": "1.1.31",
|
"version": "1.1.31",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -52,6 +131,12 @@
|
|||||||
"version": "1.1.31",
|
"version": "1.1.31",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@ -66,12 +151,135 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/effect": {
|
||||||
|
"version": "4.0.0-beta.48",
|
||||||
|
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
|
||||||
|
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"fast-check": "^4.6.0",
|
||||||
|
"find-my-way-ts": "^0.1.6",
|
||||||
|
"ini": "^6.0.0",
|
||||||
|
"kubernetes-types": "^1.30.0",
|
||||||
|
"msgpackr": "^1.11.9",
|
||||||
|
"multipasta": "^0.2.7",
|
||||||
|
"toml": "^4.1.1",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
|
"yaml": "^2.8.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-check": {
|
||||||
|
"version": "4.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
|
||||||
|
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pure-rand": "^8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/find-my-way-ts": {
|
||||||
|
"version": "0.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
||||||
|
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/ini": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.17.0 || >=22.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/kubernetes-types": {
|
||||||
|
"version": "1.30.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
||||||
|
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/msgpackr": {
|
||||||
|
"version": "1.11.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz",
|
||||||
|
"integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"msgpackr-extract": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/msgpackr-extract": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"node-gyp-build-optional-packages": "5.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/multipasta": {
|
||||||
|
"version": "0.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
||||||
|
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/node-gyp-build-optional-packages": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build-optional-packages": "bin.js",
|
||||||
|
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||||
|
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-key": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
@ -81,6 +289,22 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pure-rand": {
|
||||||
|
"version": "8.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
|
||||||
|
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@ -102,6 +326,28 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/toml": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist-node/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@ -117,6 +363,21 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||||
|
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.8",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
BIN
New-Thanasoft_MVP_Tracking.xlsx
Normal file
BIN
New-Thanasoft_MVP_Tracking.xlsx
Normal file
Binary file not shown.
@ -0,0 +1,252 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Repositories\AccessControlRepositoryInterface;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class AccessControlController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccessControlRepositoryInterface $accessControlRepository
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->accessControlRepository->index(),
|
||||||
|
'message' => 'Roles et permissions recuperes avec succes.',
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error fetching access control data: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la recuperation des roles et permissions.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeRole(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:100', 'unique:roles,name'],
|
||||||
|
'guard_name' => ['nullable', 'string', 'max:50'],
|
||||||
|
'permissions' => ['nullable', 'array'],
|
||||||
|
'permissions.*' => ['string', 'max:150'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$role = $this->accessControlRepository->createRole($validated);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $role,
|
||||||
|
'message' => 'Role cree avec succes.',
|
||||||
|
], 201);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error creating role: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'data' => $validated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la creation du role.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateRole(Request $request, string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['sometimes', 'string', 'max:100', 'unique:roles,name,' . $id],
|
||||||
|
'guard_name' => ['nullable', 'string', 'max:50'],
|
||||||
|
'permissions' => ['nullable', 'array'],
|
||||||
|
'permissions.*' => ['string', 'max:150'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$role = $this->accessControlRepository->updateRole((int) $id, $validated);
|
||||||
|
|
||||||
|
if (! $role) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Role non trouve.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $role,
|
||||||
|
'message' => 'Role mis a jour avec succes.',
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error updating role: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'role_id' => $id,
|
||||||
|
'data' => $validated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la mise a jour du role.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroyRole(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$deleted = $this->accessControlRepository->deleteRole((int) $id);
|
||||||
|
|
||||||
|
if (! $deleted) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Role non trouve.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Role supprime avec succes.',
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error deleting role: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'role_id' => $id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la suppression du role.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncRolePermissions(Request $request, string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'permissions' => ['required', 'array'],
|
||||||
|
'permissions.*' => ['string', 'max:150'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$role = $this->accessControlRepository->syncRolePermissions((int) $id, $validated['permissions']);
|
||||||
|
|
||||||
|
if (! $role) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Role non trouve.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $role,
|
||||||
|
'message' => 'Permissions du role synchronisees avec succes.',
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error syncing role permissions: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'role_id' => $id,
|
||||||
|
'data' => $validated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la synchronisation des permissions du role.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storePermission(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:150', 'unique:permissions,name'],
|
||||||
|
'guard_name' => ['nullable', 'string', 'max:50'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$permission = $this->accessControlRepository->createPermission($validated);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $permission,
|
||||||
|
'message' => 'Permission creee avec succes.',
|
||||||
|
], 201);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error creating permission: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'data' => $validated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la creation de la permission.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePermission(Request $request, string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['sometimes', 'string', 'max:150', 'unique:permissions,name,' . $id],
|
||||||
|
'guard_name' => ['nullable', 'string', 'max:50'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$permission = $this->accessControlRepository->updatePermission((int) $id, $validated);
|
||||||
|
|
||||||
|
if (! $permission) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Permission non trouvee.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $permission,
|
||||||
|
'message' => 'Permission mise a jour avec succes.',
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error updating permission: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'permission_id' => $id,
|
||||||
|
'data' => $validated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la mise a jour de la permission.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroyPermission(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$deleted = $this->accessControlRepository->deletePermission((int) $id);
|
||||||
|
|
||||||
|
if (! $deleted) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Permission non trouvee.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Permission supprimee avec succes.',
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error deleting permission: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'permission_id' => $id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la suppression de la permission.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,6 +19,10 @@ class AuthController extends BaseController
|
|||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
|
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
|
||||||
|
'roles' => ['nullable', 'array'],
|
||||||
|
'roles.*' => ['string', 'max:100'],
|
||||||
|
'permissions' => ['nullable', 'array'],
|
||||||
|
'permissions.*' => ['string', 'max:150'],
|
||||||
'password' => ['required', Password::min(8)],
|
'password' => ['required', Password::min(8)],
|
||||||
|
|
||||||
]);
|
]);
|
||||||
@ -29,10 +33,18 @@ class AuthController extends BaseController
|
|||||||
'password' => $data['password'], // hashed via User model cast
|
'password' => $data['password'], // hashed via User model cast
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (! empty($data['roles'])) {
|
||||||
|
$user->syncRoles($data['roles']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($data['permissions'])) {
|
||||||
|
$user->syncPermissions($data['permissions']);
|
||||||
|
}
|
||||||
|
|
||||||
$token = $user->createToken('api')->plainTextToken;
|
$token = $user->createToken('api')->plainTextToken;
|
||||||
|
|
||||||
return $this->sendResponse([
|
return $this->sendResponse([
|
||||||
'user' => $user,
|
'user' => $user->load('roles', 'permissions'),
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
], 'User registered successfully.');
|
], 'User registered successfully.');
|
||||||
|
|
||||||
@ -150,7 +162,7 @@ class AuthController extends BaseController
|
|||||||
return $this->sendError('Unauthenticated.', [], 401);
|
return $this->sendError('Unauthenticated.', [], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->sendResponse($user, 'User retrieved successfully.');
|
return $this->sendResponse($user->load('roles', 'permissions'), 'User retrieved successfully.');
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return $this->sendError('Failed to retrieve user.', ['error' => $e->getMessage()], 500);
|
return $this->sendError('Failed to retrieve user.', ['error' => $e->getMessage()], 500);
|
||||||
|
|||||||
@ -9,9 +9,12 @@ use App\Http\Requests\StoreEmployeeRequest;
|
|||||||
use App\Http\Requests\UpdateEmployeeRequest;
|
use App\Http\Requests\UpdateEmployeeRequest;
|
||||||
use App\Http\Resources\Employee\EmployeeResource;
|
use App\Http\Resources\Employee\EmployeeResource;
|
||||||
use App\Http\Resources\Employee\EmployeeCollection;
|
use App\Http\Resources\Employee\EmployeeCollection;
|
||||||
|
use App\Http\Resources\Intervention\InterventionResource;
|
||||||
use App\Repositories\EmployeeRepositoryInterface;
|
use App\Repositories\EmployeeRepositoryInterface;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class EmployeeController extends Controller
|
class EmployeeController extends Controller
|
||||||
@ -208,6 +211,129 @@ class EmployeeController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the intervention agenda for a specific employee.
|
||||||
|
*/
|
||||||
|
public function agenda(Request $request, string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'month' => ['nullable', 'date_format:Y-m'],
|
||||||
|
'start_date' => ['nullable', 'date'],
|
||||||
|
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
|
||||||
|
'status' => ['nullable', 'string'],
|
||||||
|
'type' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Paramètres d\'agenda invalides.',
|
||||||
|
'errors' => $validator->errors(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$employee = $this->employeeRepository->find($id);
|
||||||
|
|
||||||
|
if (!$employee) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Employé non trouvé.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$employee->load('thanatopractitioner');
|
||||||
|
|
||||||
|
if (!$employee->thanatopractitioner) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Aucun agenda disponible pour cet employé.',
|
||||||
|
'employee' => [
|
||||||
|
'id' => $employee->id,
|
||||||
|
'full_name' => $employee->full_name,
|
||||||
|
'job_title' => $employee->job_title,
|
||||||
|
],
|
||||||
|
'filters' => [
|
||||||
|
'month' => $request->input('month'),
|
||||||
|
'start_date' => $request->input('start_date'),
|
||||||
|
'end_date' => $request->input('end_date'),
|
||||||
|
'status' => $request->input('status'),
|
||||||
|
'type' => $request->input('type'),
|
||||||
|
],
|
||||||
|
'data' => [],
|
||||||
|
'meta' => [
|
||||||
|
'total' => 0,
|
||||||
|
'status_summary' => [],
|
||||||
|
],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$startDate, $endDate] = $this->resolveAgendaPeriod(
|
||||||
|
$request->input('month'),
|
||||||
|
$request->input('start_date'),
|
||||||
|
$request->input('end_date')
|
||||||
|
);
|
||||||
|
|
||||||
|
$interventions = $employee->thanatopractitioner
|
||||||
|
->interventions()
|
||||||
|
->with([
|
||||||
|
'client',
|
||||||
|
'deceased',
|
||||||
|
'location',
|
||||||
|
'quote',
|
||||||
|
'practitioners.employee',
|
||||||
|
])
|
||||||
|
->when($startDate, function ($query) use ($startDate) {
|
||||||
|
$query->where('scheduled_at', '>=', $startDate);
|
||||||
|
})
|
||||||
|
->when($endDate, function ($query) use ($endDate) {
|
||||||
|
$query->where('scheduled_at', '<=', $endDate);
|
||||||
|
})
|
||||||
|
->when($request->filled('status'), function ($query) use ($request) {
|
||||||
|
$query->where('status', $request->string('status'));
|
||||||
|
})
|
||||||
|
->when($request->filled('type'), function ($query) use ($request) {
|
||||||
|
$query->where('type', $request->string('type'));
|
||||||
|
})
|
||||||
|
->orderBy('scheduled_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Agenda employé récupéré avec succès.',
|
||||||
|
'employee' => [
|
||||||
|
'id' => $employee->id,
|
||||||
|
'full_name' => $employee->full_name,
|
||||||
|
'job_title' => $employee->job_title,
|
||||||
|
'thanatopractitioner_id' => $employee->thanatopractitioner->id,
|
||||||
|
],
|
||||||
|
'filters' => [
|
||||||
|
'month' => $request->input('month'),
|
||||||
|
'start_date' => $startDate?->toDateTimeString(),
|
||||||
|
'end_date' => $endDate?->toDateTimeString(),
|
||||||
|
'status' => $request->input('status'),
|
||||||
|
'type' => $request->input('type'),
|
||||||
|
],
|
||||||
|
'data' => InterventionResource::collection($interventions)->resolve(),
|
||||||
|
'meta' => [
|
||||||
|
'total' => $interventions->count(),
|
||||||
|
'status_summary' => $interventions
|
||||||
|
->groupBy('status')
|
||||||
|
->map(fn ($group) => $group->count())
|
||||||
|
->toArray(),
|
||||||
|
],
|
||||||
|
], 200);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error fetching employee agenda: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
'employee_id' => $id,
|
||||||
|
'filters' => $request->all(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la récupération de l\'agenda employé.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the specified employee.
|
* Update the specified employee.
|
||||||
*/
|
*/
|
||||||
@ -269,4 +395,29 @@ class EmployeeController extends Controller
|
|||||||
], 500);
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the requested agenda period.
|
||||||
|
*
|
||||||
|
* Defaults to the current month when no explicit range is provided.
|
||||||
|
*
|
||||||
|
* @return array{0: \Carbon\Carbon, 1: \Carbon\Carbon}
|
||||||
|
*/
|
||||||
|
private function resolveAgendaPeriod(?string $month, ?string $startDate, ?string $endDate): array
|
||||||
|
{
|
||||||
|
if ($month) {
|
||||||
|
$reference = Carbon::createFromFormat('Y-m', $month)->startOfMonth();
|
||||||
|
|
||||||
|
return [$reference->copy()->startOfMonth(), $reference->copy()->endOfMonth()];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($startDate || $endDate) {
|
||||||
|
return [
|
||||||
|
$startDate ? Carbon::parse($startDate)->startOfDay() : Carbon::now()->startOfMonth(),
|
||||||
|
$endDate ? Carbon::parse($endDate)->endOfDay() : Carbon::now()->endOfMonth(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [Carbon::now()->startOfMonth(), Carbon::now()->endOfMonth()];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,7 @@ class UserController extends Controller
|
|||||||
$email = request()->query('email');
|
$email = request()->query('email');
|
||||||
|
|
||||||
if ($email) {
|
if ($email) {
|
||||||
$user = User::query()->where('email', $email)->first();
|
$user = User::query()->with(['roles', 'permissions'])->where('email', $email)->first();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $user,
|
'data' => $user,
|
||||||
@ -36,7 +36,7 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $this->userRepository->all()->sortBy('name')->values(),
|
'data' => $this->userRepository->all()->load(['roles', 'permissions'])->sortBy('name')->values(),
|
||||||
'message' => 'Utilisateurs recuperes avec succes.',
|
'message' => 'Utilisateurs recuperes avec succes.',
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@ -57,7 +57,7 @@ class UserController extends Controller
|
|||||||
$user = $this->userRepository->create($request->validated());
|
$user = $this->userRepository->create($request->validated());
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $user,
|
'data' => $user->load('roles', 'permissions'),
|
||||||
'message' => 'Utilisateur cree avec succes.',
|
'message' => 'Utilisateur cree avec succes.',
|
||||||
], 201);
|
], 201);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@ -85,7 +85,7 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $user,
|
'data' => $user->load('roles', 'permissions'),
|
||||||
'message' => 'Utilisateur recupere avec succes.',
|
'message' => 'Utilisateur recupere avec succes.',
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@ -118,7 +118,7 @@ class UserController extends Controller
|
|||||||
unset($validated['password']);
|
unset($validated['password']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$updated = $user->fill($validated)->save();
|
$updated = $this->userRepository->update($id, $validated);
|
||||||
|
|
||||||
if (! $updated) {
|
if (! $updated) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@ -127,7 +127,7 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $user->fresh(),
|
'data' => $user->fresh()->load('roles', 'permissions'),
|
||||||
'message' => 'Utilisateur mis a jour avec succes.',
|
'message' => 'Utilisateur mis a jour avec succes.',
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|||||||
@ -22,6 +22,10 @@ class StoreUserRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
|
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
|
||||||
|
'roles' => ['nullable', 'array'],
|
||||||
|
'roles.*' => ['string', 'max:100'],
|
||||||
|
'permissions' => ['nullable', 'array'],
|
||||||
|
'permissions.*' => ['string', 'max:150'],
|
||||||
'password' => ['nullable', 'string', Password::min(8)],
|
'password' => ['nullable', 'string', Password::min(8)],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,10 @@ class UpdateUserRequest extends FormRequest
|
|||||||
'max:255',
|
'max:255',
|
||||||
Rule::unique('users', 'email')->ignore($this->route('user')),
|
Rule::unique('users', 'email')->ignore($this->route('user')),
|
||||||
],
|
],
|
||||||
|
'roles' => ['nullable', 'array'],
|
||||||
|
'roles.*' => ['string', 'max:100'],
|
||||||
|
'permissions' => ['nullable', 'array'],
|
||||||
|
'permissions.*' => ['string', 'max:150'],
|
||||||
'password' => ['nullable', 'string', Password::min(8)],
|
'password' => ['nullable', 'string', Password::min(8)],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,11 +8,14 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
|||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
use HasApiTokens, HasFactory, HasRoles, Notifiable;
|
||||||
|
|
||||||
|
protected string $guard_name = 'sanctum';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
@ -48,6 +51,11 @@ class User extends Authenticatable
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function canViewAgenda(): bool
|
||||||
|
{
|
||||||
|
return $this->can('employee_agenda.view');
|
||||||
|
}
|
||||||
|
|
||||||
public function employee(): HasOne
|
public function employee(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasOne(Employee::class);
|
return $this->hasOne(Employee::class);
|
||||||
|
|||||||
@ -6,6 +6,8 @@ use App\Repositories\DeceasedRepositoryInterface;
|
|||||||
use App\Repositories\DeceasedRepository;
|
use App\Repositories\DeceasedRepository;
|
||||||
use App\Repositories\DeceasedDocumentRepositoryInterface;
|
use App\Repositories\DeceasedDocumentRepositoryInterface;
|
||||||
use App\Repositories\DeceasedDocumentRepository;
|
use App\Repositories\DeceasedDocumentRepository;
|
||||||
|
use App\Repositories\AccessControlRepository;
|
||||||
|
use App\Repositories\AccessControlRepositoryInterface;
|
||||||
use App\Repositories\InterventionRepositoryInterface;
|
use App\Repositories\InterventionRepositoryInterface;
|
||||||
use App\Repositories\InterventionRepository;
|
use App\Repositories\InterventionRepository;
|
||||||
use App\Repositories\FileRepositoryInterface;
|
use App\Repositories\FileRepositoryInterface;
|
||||||
@ -19,6 +21,7 @@ class RepositoryServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
|
$this->app->bind(AccessControlRepositoryInterface::class, AccessControlRepository::class);
|
||||||
$this->app->bind(DeceasedRepositoryInterface::class, DeceasedRepository::class);
|
$this->app->bind(DeceasedRepositoryInterface::class, DeceasedRepository::class);
|
||||||
$this->app->bind(DeceasedDocumentRepositoryInterface::class, DeceasedDocumentRepository::class);
|
$this->app->bind(DeceasedDocumentRepositoryInterface::class, DeceasedDocumentRepository::class);
|
||||||
$this->app->bind(InterventionRepositoryInterface::class, InterventionRepository::class);
|
$this->app->bind(InterventionRepositoryInterface::class, InterventionRepository::class);
|
||||||
|
|||||||
146
thanasoft-back/app/Repositories/AccessControlRepository.php
Normal file
146
thanasoft-back/app/Repositories/AccessControlRepository.php
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
|
||||||
|
class AccessControlRepository implements AccessControlRepositoryInterface
|
||||||
|
{
|
||||||
|
public function index(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'roles' => Role::query()
|
||||||
|
->with('permissions:id,name')
|
||||||
|
->withCount('users')
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
'permissions' => Permission::query()
|
||||||
|
->with('roles:id,name')
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createRole(array $attributes): Role
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($attributes): Role {
|
||||||
|
$permissions = $attributes['permissions'] ?? [];
|
||||||
|
unset($attributes['permissions']);
|
||||||
|
|
||||||
|
$role = Role::query()->create([
|
||||||
|
'name' => $attributes['name'],
|
||||||
|
'guard_name' => $attributes['guard_name'] ?? 'sanctum',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (is_array($permissions) && $permissions !== []) {
|
||||||
|
$role->syncPermissions($permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $role->load('permissions:id,name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateRole(int $id, array $attributes): ?Role
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($id, $attributes): ?Role {
|
||||||
|
$role = Role::query()->find($id);
|
||||||
|
|
||||||
|
if (! $role) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissions = $attributes['permissions'] ?? null;
|
||||||
|
unset($attributes['permissions']);
|
||||||
|
|
||||||
|
if (array_key_exists('name', $attributes)) {
|
||||||
|
$role->name = $attributes['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('guard_name', $attributes) && is_string($attributes['guard_name'])) {
|
||||||
|
$role->guard_name = $attributes['guard_name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$role->save();
|
||||||
|
|
||||||
|
if (is_array($permissions)) {
|
||||||
|
$role->syncPermissions($permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $role->load('permissions:id,name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteRole(int $id): bool
|
||||||
|
{
|
||||||
|
return (bool) DB::transaction(function () use ($id): bool {
|
||||||
|
$role = Role::query()->find($id);
|
||||||
|
|
||||||
|
if (! $role) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$role->delete();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncRolePermissions(int $id, array $permissions): ?Role
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($id, $permissions): ?Role {
|
||||||
|
$role = Role::query()->find($id);
|
||||||
|
|
||||||
|
if (! $role) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$role->syncPermissions($permissions);
|
||||||
|
|
||||||
|
return $role->load('permissions:id,name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createPermission(array $attributes): Permission
|
||||||
|
{
|
||||||
|
return Permission::query()->create([
|
||||||
|
'name' => $attributes['name'],
|
||||||
|
'guard_name' => $attributes['guard_name'] ?? 'sanctum',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePermission(int $id, array $attributes): ?Permission
|
||||||
|
{
|
||||||
|
$permission = Permission::query()->find($id);
|
||||||
|
|
||||||
|
if (! $permission) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('name', $attributes)) {
|
||||||
|
$permission->name = $attributes['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('guard_name', $attributes) && is_string($attributes['guard_name'])) {
|
||||||
|
$permission->guard_name = $attributes['guard_name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$permission->save();
|
||||||
|
|
||||||
|
return $permission->load('roles:id,name');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deletePermission(int $id): bool
|
||||||
|
{
|
||||||
|
$permission = Permission::query()->find($id);
|
||||||
|
|
||||||
|
if (! $permission) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) $permission->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
|
||||||
|
interface AccessControlRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{roles: \Illuminate\Support\Collection<int, Role>, permissions: \Illuminate\Support\Collection<int, Permission>}
|
||||||
|
*/
|
||||||
|
public function index(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
*/
|
||||||
|
public function createRole(array $attributes): Role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
*/
|
||||||
|
public function updateRole(int $id, array $attributes): ?Role;
|
||||||
|
|
||||||
|
public function deleteRole(int $id): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $permissions
|
||||||
|
*/
|
||||||
|
public function syncRolePermissions(int $id, array $permissions): ?Role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
*/
|
||||||
|
public function createPermission(array $attributes): Permission;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
*/
|
||||||
|
public function updatePermission(int $id, array $attributes): ?Permission;
|
||||||
|
|
||||||
|
public function deletePermission(int $id): bool;
|
||||||
|
}
|
||||||
@ -5,6 +5,9 @@ declare(strict_types=1);
|
|||||||
namespace App\Repositories;
|
namespace App\Repositories;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class UserRepository extends BaseRepository implements UserRepositoryInterface
|
class UserRepository extends BaseRepository implements UserRepositoryInterface
|
||||||
{
|
{
|
||||||
@ -12,4 +15,92 @@ class UserRepository extends BaseRepository implements UserRepositoryInterface
|
|||||||
{
|
{
|
||||||
parent::__construct($model);
|
parent::__construct($model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
*/
|
||||||
|
public function create(array $attributes): Model
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
DB::beginTransaction();
|
||||||
|
|
||||||
|
$roles = $attributes['roles'] ?? [];
|
||||||
|
$permissions = $attributes['permissions'] ?? [];
|
||||||
|
|
||||||
|
unset($attributes['roles'], $attributes['permissions']);
|
||||||
|
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->model->newQuery()->create($attributes);
|
||||||
|
|
||||||
|
if (! empty($roles)) {
|
||||||
|
$user->syncRoles($roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($permissions)) {
|
||||||
|
$user->syncPermissions($permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::commit();
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
Log::error('Error creating user with roles/permissions: ' . $e->getMessage(), [
|
||||||
|
'attributes' => $attributes,
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int|string $id
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
*/
|
||||||
|
public function update(int|string $id, array $attributes): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
DB::beginTransaction();
|
||||||
|
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = $this->find($id);
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
DB::rollBack();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = $attributes['roles'] ?? null;
|
||||||
|
$permissions = $attributes['permissions'] ?? null;
|
||||||
|
|
||||||
|
unset($attributes['roles'], $attributes['permissions']);
|
||||||
|
|
||||||
|
$updated = $user->fill($attributes)->save();
|
||||||
|
|
||||||
|
if (! $updated) {
|
||||||
|
DB::rollBack();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($roles)) {
|
||||||
|
$user->syncRoles($roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($permissions)) {
|
||||||
|
$user->syncPermissions($permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::commit();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
Log::error('Error updating user with roles/permissions: ' . $e->getMessage(), [
|
||||||
|
'id' => $id,
|
||||||
|
'attributes' => $attributes,
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
"barryvdh/laravel-dompdf": "^3.1",
|
"barryvdh/laravel-dompdf": "^3.1",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "^4.2",
|
"laravel/sanctum": "^4.2",
|
||||||
|
"spatie/laravel-permission": "^6.18",
|
||||||
"laravel/tinker": "^2.10.1"
|
"laravel/tinker": "^2.10.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|||||||
92
thanasoft-back/composer.lock
generated
92
thanasoft-back/composer.lock
generated
@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "343ecaac4a8b061c5430a046847047e7",
|
"content-hash": "39694481426b03a733a81beaf6531e56",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "barryvdh/laravel-dompdf",
|
"name": "barryvdh/laravel-dompdf",
|
||||||
@ -3785,6 +3785,90 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-09-14T07:37:21+00:00"
|
"time": "2025-09-14T07:37:21+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "spatie/laravel-permission",
|
||||||
|
"version": "6.25.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/spatie/laravel-permission.git",
|
||||||
|
"reference": "d7d4cb0d58616722f1afc90e0484e4825155b9b3"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/spatie/laravel-permission/zipball/d7d4cb0d58616722f1afc90e0484e4825155b9b3",
|
||||||
|
"reference": "d7d4cb0d58616722f1afc90e0484e4825155b9b3",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/auth": "^8.12|^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/container": "^8.12|^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/contracts": "^8.12|^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/database": "^8.12|^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"php": "^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"laravel/passport": "^11.0|^12.0|^13.0",
|
||||||
|
"laravel/pint": "^1.0",
|
||||||
|
"orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0|^11.0",
|
||||||
|
"pestphp/pest": "^2.0|^3.0|^4.0",
|
||||||
|
"pestphp/pest-plugin-laravel": "^2.0|^3.0|^4.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Spatie\\Permission\\PermissionServiceProvider"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "6.x-dev",
|
||||||
|
"dev-master": "6.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/helpers.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Spatie\\Permission\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Freek Van der Herten",
|
||||||
|
"email": "freek@spatie.be",
|
||||||
|
"homepage": "https://spatie.be",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Permission handling for Laravel 8.0 and up",
|
||||||
|
"homepage": "https://github.com/spatie/laravel-permission",
|
||||||
|
"keywords": [
|
||||||
|
"acl",
|
||||||
|
"laravel",
|
||||||
|
"permission",
|
||||||
|
"permissions",
|
||||||
|
"rbac",
|
||||||
|
"roles",
|
||||||
|
"security",
|
||||||
|
"spatie"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/spatie/laravel-permission/issues",
|
||||||
|
"source": "https://github.com/spatie/laravel-permission/tree/6.25.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/spatie",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-03-17T22:46:46+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/clock",
|
"name": "symfony/clock",
|
||||||
"version": "v7.3.0",
|
"version": "v7.3.0",
|
||||||
@ -9039,12 +9123,12 @@
|
|||||||
],
|
],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"stability-flags": {},
|
"stability-flags": [],
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.2"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": [],
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
206
thanasoft-back/config/permission.php
Normal file
206
thanasoft-back/config/permission.php
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Spatie\Permission\DefaultTeamResolver;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'models' => [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasPermissions" trait from this package, we need to know which
|
||||||
|
* Eloquent model should be used to retrieve your permissions. Of course, it
|
||||||
|
* is often just the "Permission" model but you may use whatever you like.
|
||||||
|
*
|
||||||
|
* The model you want to use as a Permission model needs to implement the
|
||||||
|
* `Spatie\Permission\Contracts\Permission` contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'permission' => Permission::class,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasRoles" trait from this package, we need to know which
|
||||||
|
* Eloquent model should be used to retrieve your roles. Of course, it
|
||||||
|
* is often just the "Role" model but you may use whatever you like.
|
||||||
|
*
|
||||||
|
* The model you want to use as a Role model needs to implement the
|
||||||
|
* `Spatie\Permission\Contracts\Role` contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'role' => Role::class,
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
'table_names' => [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasRoles" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your roles. We have chosen a basic
|
||||||
|
* default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'roles' => 'roles',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasPermissions" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your permissions. We have chosen a basic
|
||||||
|
* default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'permissions' => 'permissions',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasPermissions" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your models permissions. We have chosen a
|
||||||
|
* basic default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'model_has_permissions' => 'model_has_permissions',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasRoles" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your models roles. We have chosen a
|
||||||
|
* basic default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'model_has_roles' => 'model_has_roles',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasRoles" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your roles permissions. We have chosen a
|
||||||
|
* basic default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'role_has_permissions' => 'role_has_permissions',
|
||||||
|
],
|
||||||
|
|
||||||
|
'column_names' => [
|
||||||
|
/*
|
||||||
|
* Change this if you want to name the related pivots other than defaults
|
||||||
|
*/
|
||||||
|
'role_pivot_key' => null, // default 'role_id',
|
||||||
|
'permission_pivot_key' => null, // default 'permission_id',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Change this if you want to name the related model primary key other than
|
||||||
|
* `model_id`.
|
||||||
|
*
|
||||||
|
* For example, this would be nice if your primary keys are all UUIDs. In
|
||||||
|
* that case, name this `model_uuid`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'model_morph_key' => 'model_id',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Change this if you want to use the teams feature and your related model's
|
||||||
|
* foreign key is other than `team_id`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'team_foreign_key' => 'team_id',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When set to true, the method for checking permissions will be registered on the gate.
|
||||||
|
* Set this to false if you want to implement custom logic for checking permissions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'register_permission_check_method' => true,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
|
||||||
|
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
|
||||||
|
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
|
||||||
|
*/
|
||||||
|
'register_octane_reset_listener' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Events will fire when a role or permission is assigned/unassigned:
|
||||||
|
* \Spatie\Permission\Events\RoleAttached
|
||||||
|
* \Spatie\Permission\Events\RoleDetached
|
||||||
|
* \Spatie\Permission\Events\PermissionAttached
|
||||||
|
* \Spatie\Permission\Events\PermissionDetached
|
||||||
|
*
|
||||||
|
* To enable, set to true, and then create listeners to watch these events.
|
||||||
|
*/
|
||||||
|
'events_enabled' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Teams Feature.
|
||||||
|
* When set to true the package implements teams using the 'team_foreign_key'.
|
||||||
|
* If you want the migrations to register the 'team_foreign_key', you must
|
||||||
|
* set this to true before doing the migration.
|
||||||
|
* If you already did the migration then you must make a new migration to also
|
||||||
|
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
|
||||||
|
* (view the latest version of this package's migration file)
|
||||||
|
*/
|
||||||
|
|
||||||
|
'teams' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The class to use to resolve the permissions team id
|
||||||
|
*/
|
||||||
|
'team_resolver' => DefaultTeamResolver::class,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Passport Client Credentials Grant
|
||||||
|
* When set to true the package will use Passports Client to check permissions
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use_passport_client_credentials' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When set to true, the required permission names are added to exception messages.
|
||||||
|
* This could be considered an information leak in some contexts, so the default
|
||||||
|
* setting is false here for optimum safety.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'display_permission_in_exception' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When set to true, the required role names are added to exception messages.
|
||||||
|
* This could be considered an information leak in some contexts, so the default
|
||||||
|
* setting is false here for optimum safety.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'display_role_in_exception' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* By default wildcard permission lookups are disabled.
|
||||||
|
* See documentation to understand supported syntax.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'enable_wildcard_permission' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The class to use for interpreting wildcard permissions.
|
||||||
|
* If you need to modify delimiters, override the class and specify its name here.
|
||||||
|
*/
|
||||||
|
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
|
||||||
|
|
||||||
|
/* Cache-specific settings */
|
||||||
|
|
||||||
|
'cache' => [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* By default all permissions are cached for 24 hours to speed up performance.
|
||||||
|
* When permissions or roles are updated the cache is flushed automatically.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'expiration_time' => DateInterval::createFromDateString('24 hours'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The cache key used to store all permissions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'key' => 'spatie.permission.cache',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* You may optionally indicate a specific cache driver to use for permission and
|
||||||
|
* role caching using any of the `store` drivers listed in the cache.php config
|
||||||
|
* file. Using 'default' here means to use the `default` set in cache.php.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'store' => 'default',
|
||||||
|
],
|
||||||
|
];
|
||||||
@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$teams = config('permission.teams');
|
||||||
|
$tableNames = config('permission.table_names');
|
||||||
|
$columnNames = config('permission.column_names');
|
||||||
|
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
|
||||||
|
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
|
||||||
|
|
||||||
|
throw_if(empty($tableNames), Exception::class, 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||||
|
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), Exception::class, 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||||
|
|
||||||
|
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
|
||||||
|
// $table->engine('InnoDB');
|
||||||
|
$table->bigIncrements('id'); // permission id
|
||||||
|
$table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format)
|
||||||
|
$table->string('guard_name'); // For MyISAM use string('guard_name', 25);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['name', 'guard_name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
|
||||||
|
// $table->engine('InnoDB');
|
||||||
|
$table->bigIncrements('id'); // role id
|
||||||
|
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
|
||||||
|
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
|
||||||
|
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
|
||||||
|
}
|
||||||
|
$table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format)
|
||||||
|
$table->string('guard_name'); // For MyISAM use string('guard_name', 25);
|
||||||
|
$table->timestamps();
|
||||||
|
if ($teams || config('permission.testing')) {
|
||||||
|
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
|
||||||
|
} else {
|
||||||
|
$table->unique(['name', 'guard_name']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
|
||||||
|
$table->unsignedBigInteger($pivotPermission);
|
||||||
|
|
||||||
|
$table->string('model_type');
|
||||||
|
$table->unsignedBigInteger($columnNames['model_morph_key']);
|
||||||
|
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
|
||||||
|
|
||||||
|
$table->foreign($pivotPermission)
|
||||||
|
->references('id') // permission id
|
||||||
|
->on($tableNames['permissions'])
|
||||||
|
->onDelete('cascade');
|
||||||
|
if ($teams) {
|
||||||
|
$table->unsignedBigInteger($columnNames['team_foreign_key']);
|
||||||
|
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
|
||||||
|
|
||||||
|
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
|
||||||
|
'model_has_permissions_permission_model_type_primary');
|
||||||
|
} else {
|
||||||
|
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
|
||||||
|
'model_has_permissions_permission_model_type_primary');
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
|
||||||
|
$table->unsignedBigInteger($pivotRole);
|
||||||
|
|
||||||
|
$table->string('model_type');
|
||||||
|
$table->unsignedBigInteger($columnNames['model_morph_key']);
|
||||||
|
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
|
||||||
|
|
||||||
|
$table->foreign($pivotRole)
|
||||||
|
->references('id') // role id
|
||||||
|
->on($tableNames['roles'])
|
||||||
|
->onDelete('cascade');
|
||||||
|
if ($teams) {
|
||||||
|
$table->unsignedBigInteger($columnNames['team_foreign_key']);
|
||||||
|
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
|
||||||
|
|
||||||
|
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
|
||||||
|
'model_has_roles_role_model_type_primary');
|
||||||
|
} else {
|
||||||
|
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
|
||||||
|
'model_has_roles_role_model_type_primary');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
|
||||||
|
$table->unsignedBigInteger($pivotPermission);
|
||||||
|
$table->unsignedBigInteger($pivotRole);
|
||||||
|
|
||||||
|
$table->foreign($pivotPermission)
|
||||||
|
->references('id') // permission id
|
||||||
|
->on($tableNames['permissions'])
|
||||||
|
->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->foreign($pivotRole)
|
||||||
|
->references('id') // role id
|
||||||
|
->on($tableNames['roles'])
|
||||||
|
->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
app('cache')
|
||||||
|
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
|
||||||
|
->forget(config('permission.cache.key'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$tableNames = config('permission.table_names');
|
||||||
|
|
||||||
|
throw_if(empty($tableNames), Exception::class, 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
|
||||||
|
|
||||||
|
Schema::drop($tableNames['role_has_permissions']);
|
||||||
|
Schema::drop($tableNames['model_has_roles']);
|
||||||
|
Schema::drop($tableNames['model_has_permissions']);
|
||||||
|
Schema::drop($tableNames['roles']);
|
||||||
|
Schema::drop($tableNames['permissions']);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -20,6 +20,7 @@ use App\Http\Controllers\Api\FileController;
|
|||||||
use App\Http\Controllers\Api\FileAttachmentController;
|
use App\Http\Controllers\Api\FileAttachmentController;
|
||||||
use App\Http\Controllers\Api\QuoteController;
|
use App\Http\Controllers\Api\QuoteController;
|
||||||
use App\Http\Controllers\Api\ClientActivityTimelineController;
|
use App\Http\Controllers\Api\ClientActivityTimelineController;
|
||||||
|
use App\Http\Controllers\Api\AccessControlController;
|
||||||
use App\Http\Controllers\Api\PurchaseOrderController;
|
use App\Http\Controllers\Api\PurchaseOrderController;
|
||||||
use App\Http\Controllers\Api\PriceListController;
|
use App\Http\Controllers\Api\PriceListController;
|
||||||
use App\Http\Controllers\Api\TvaRateController;
|
use App\Http\Controllers\Api\TvaRateController;
|
||||||
@ -65,6 +66,14 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
Route::apiResource('client-groups', ClientGroupController::class);
|
Route::apiResource('client-groups', ClientGroupController::class);
|
||||||
Route::apiResource('price-lists', PriceListController::class);
|
Route::apiResource('price-lists', PriceListController::class);
|
||||||
Route::apiResource('users', UserController::class);
|
Route::apiResource('users', UserController::class);
|
||||||
|
Route::get('access-control', [AccessControlController::class, 'index']);
|
||||||
|
Route::post('access-control/roles', [AccessControlController::class, 'storeRole']);
|
||||||
|
Route::put('access-control/roles/{id}', [AccessControlController::class, 'updateRole']);
|
||||||
|
Route::delete('access-control/roles/{id}', [AccessControlController::class, 'destroyRole']);
|
||||||
|
Route::put('access-control/roles/{id}/permissions', [AccessControlController::class, 'syncRolePermissions']);
|
||||||
|
Route::post('access-control/permissions', [AccessControlController::class, 'storePermission']);
|
||||||
|
Route::put('access-control/permissions/{id}', [AccessControlController::class, 'updatePermission']);
|
||||||
|
Route::delete('access-control/permissions/{id}', [AccessControlController::class, 'destroyPermission']);
|
||||||
|
|
||||||
Route::apiResource('client-locations', ClientLocationController::class);
|
Route::apiResource('client-locations', ClientLocationController::class);
|
||||||
Route::apiResource('client-locations', ClientLocationController::class);
|
Route::apiResource('client-locations', ClientLocationController::class);
|
||||||
@ -138,6 +147,7 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
// Employee management
|
// Employee management
|
||||||
Route::get('/employees/searchBy', [EmployeeController::class, 'searchBy']);
|
Route::get('/employees/searchBy', [EmployeeController::class, 'searchBy']);
|
||||||
Route::get('/employees/thanatopractitioners', [EmployeeController::class, 'getThanatopractitioners']);
|
Route::get('/employees/thanatopractitioners', [EmployeeController::class, 'getThanatopractitioners']);
|
||||||
|
Route::get('/employees/{id}/agenda', [EmployeeController::class, 'agenda']);
|
||||||
Route::apiResource('employees', EmployeeController::class);
|
Route::apiResource('employees', EmployeeController::class);
|
||||||
|
|
||||||
// Thanatopractitioner management
|
// Thanatopractitioner management
|
||||||
|
|||||||
148
thanasoft-back/tests/Feature/EmployeeAgendaApiTest.php
Normal file
148
thanasoft-back/tests/Feature/EmployeeAgendaApiTest.php
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Employee;
|
||||||
|
use App\Models\Intervention;
|
||||||
|
use App\Models\Thanatopractitioner;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class EmployeeAgendaApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $authenticatedUser;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->authenticatedUser = User::factory()->create();
|
||||||
|
Sanctum::actingAs($this->authenticatedUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_get_employee_agenda_filtered_by_month_and_status(): void
|
||||||
|
{
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
|
||||||
|
$employee = Employee::create([
|
||||||
|
'first_name' => 'Jean',
|
||||||
|
'last_name' => 'Martin',
|
||||||
|
'email' => 'jean.martin@example.test',
|
||||||
|
'job_title' => 'Thanatopracteur',
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$otherEmployee = Employee::create([
|
||||||
|
'first_name' => 'Paul',
|
||||||
|
'last_name' => 'Durand',
|
||||||
|
'email' => 'paul.durand@example.test',
|
||||||
|
'job_title' => 'Thanatopracteur',
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$thanatopractitioner = Thanatopractitioner::create([
|
||||||
|
'employee_id' => $employee->id,
|
||||||
|
'authorization_number' => 'AUTH-001',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$otherThanatopractitioner = Thanatopractitioner::create([
|
||||||
|
'employee_id' => $otherEmployee->id,
|
||||||
|
'authorization_number' => 'AUTH-002',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$matchingIntervention = Intervention::create([
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'type' => 'thanatopraxie',
|
||||||
|
'scheduled_at' => '2026-04-15 09:00:00',
|
||||||
|
'duration_min' => 90,
|
||||||
|
'status' => 'planifie',
|
||||||
|
'created_by' => $this->authenticatedUser->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sameEmployeeOutsideMonth = Intervention::create([
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'type' => 'thanatopraxie',
|
||||||
|
'scheduled_at' => '2026-05-03 10:00:00',
|
||||||
|
'duration_min' => 60,
|
||||||
|
'status' => 'planifie',
|
||||||
|
'created_by' => $this->authenticatedUser->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sameMonthDifferentStatus = Intervention::create([
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'type' => 'thanatopraxie',
|
||||||
|
'scheduled_at' => '2026-04-18 11:00:00',
|
||||||
|
'duration_min' => 45,
|
||||||
|
'status' => 'termine',
|
||||||
|
'created_by' => $this->authenticatedUser->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$otherEmployeeIntervention = Intervention::create([
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'type' => 'toilette_mortuaire',
|
||||||
|
'scheduled_at' => '2026-04-20 14:00:00',
|
||||||
|
'duration_min' => 30,
|
||||||
|
'status' => 'planifie',
|
||||||
|
'created_by' => $this->authenticatedUser->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$matchingIntervention->practitioners()->attach($thanatopractitioner->id, [
|
||||||
|
'role' => 'principal',
|
||||||
|
'assigned_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sameEmployeeOutsideMonth->practitioners()->attach($thanatopractitioner->id, [
|
||||||
|
'role' => 'assistant',
|
||||||
|
'assigned_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sameMonthDifferentStatus->practitioners()->attach($thanatopractitioner->id, [
|
||||||
|
'role' => 'assistant',
|
||||||
|
'assigned_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$otherEmployeeIntervention->practitioners()->attach($otherThanatopractitioner->id, [
|
||||||
|
'role' => 'principal',
|
||||||
|
'assigned_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson(sprintf(
|
||||||
|
'/api/employees/%d/agenda?month=2026-04&status=planifie',
|
||||||
|
$employee->id
|
||||||
|
));
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJsonPath('employee.id', $employee->id)
|
||||||
|
->assertJsonPath('employee.thanatopractitioner_id', $thanatopractitioner->id)
|
||||||
|
->assertJsonPath('filters.month', '2026-04')
|
||||||
|
->assertJsonPath('meta.total', 1)
|
||||||
|
->assertJsonPath('meta.status_summary.planifie', 1)
|
||||||
|
->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.id', $matchingIntervention->id)
|
||||||
|
->assertJsonPath('data.0.status', 'planifie');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_returns_empty_agenda_when_employee_has_no_thanatopractitioner(): void
|
||||||
|
{
|
||||||
|
$employee = Employee::create([
|
||||||
|
'first_name' => 'Luc',
|
||||||
|
'last_name' => 'Bernard',
|
||||||
|
'email' => 'luc.bernard@example.test',
|
||||||
|
'job_title' => 'Assistant',
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/employees/' . $employee->id . '/agenda');
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJsonPath('employee.id', $employee->id)
|
||||||
|
->assertJsonPath('employee.full_name', 'Luc Bernard')
|
||||||
|
->assertJsonPath('meta.total', 0)
|
||||||
|
->assertJsonPath('data', []);
|
||||||
|
}
|
||||||
|
}
|
||||||
2726
thanasoft-front/lint_output.txt
Normal file
2726
thanasoft-front/lint_output.txt
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,268 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!-- Backdrop -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<Transition name="modal-fade">
|
|
||||||
<div v-if="isOpen" class="modal-backdrop" @mousedown.self="$emit('close')">
|
|
||||||
<div class="modal-box" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="modal-title-wrap">
|
|
||||||
<div class="modal-icon">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
|
||||||
<circle cx="8.5" cy="7" r="4"/>
|
|
||||||
<line x1="20" y1="8" x2="20" y2="14"/>
|
|
||||||
<line x1="23" y1="11" x2="17" y2="11"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 id="modal-title" class="modal-title">Assigner un praticien</h2>
|
|
||||||
<p class="modal-sub">Sélectionnez un praticien et son rôle</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="close-btn" @click="$emit('close')">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body -->
|
|
||||||
<div class="modal-body">
|
|
||||||
<!-- Practitioner ID -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Identifiant du praticien</label>
|
|
||||||
<input
|
|
||||||
v-model="form.practitionerId"
|
|
||||||
type="number"
|
|
||||||
class="form-input"
|
|
||||||
placeholder="ex: 42"
|
|
||||||
min="1"
|
|
||||||
/>
|
|
||||||
<p class="form-hint">Entrez l'ID du praticien à assigner à cette intervention.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Role -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Rôle</label>
|
|
||||||
<div class="role-grid">
|
|
||||||
<button
|
|
||||||
class="role-option"
|
|
||||||
:class="{ selected: form.role === 'principal' }"
|
|
||||||
@click="form.role = 'principal'"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<div class="role-radio">
|
|
||||||
<div v-if="form.role === 'principal'" class="role-radio-dot"></div>
|
|
||||||
</div>
|
|
||||||
<div class="role-info">
|
|
||||||
<div class="role-name">Principal</div>
|
|
||||||
<div class="role-desc">Responsable de l'intervention</div>
|
|
||||||
</div>
|
|
||||||
<span class="role-chip chip-principal">Principal</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="role-option"
|
|
||||||
:class="{ selected: form.role === 'assistant' }"
|
|
||||||
@click="form.role = 'assistant'"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<div class="role-radio">
|
|
||||||
<div v-if="form.role === 'assistant'" class="role-radio-dot"></div>
|
|
||||||
</div>
|
|
||||||
<div class="role-info">
|
|
||||||
<div class="role-name">Assistant</div>
|
|
||||||
<div class="role-desc">Rôle de soutien et assistance</div>
|
|
||||||
</div>
|
|
||||||
<span class="role-chip chip-assistant">Assistant</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Validation error -->
|
|
||||||
<Transition name="slide-error">
|
|
||||||
<div v-if="error" class="error-banner">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn-ghost" @click="$emit('close')">Annuler</button>
|
|
||||||
<button class="btn-primary" @click="handleSubmit">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
|
||||||
Confirmer l'assignation
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, watch, defineProps, defineEmits } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
isOpen: { type: Boolean, default: false },
|
|
||||||
});
|
|
||||||
const emit = defineEmits(['close', 'assign']);
|
|
||||||
|
|
||||||
const form = ref({ practitionerId: '', role: 'principal' });
|
|
||||||
const error = ref('');
|
|
||||||
|
|
||||||
// Reset form when modal opens
|
|
||||||
watch(() => props.isOpen, open => {
|
|
||||||
if (open) { form.value = { practitionerId: '', role: 'principal' }; error.value = ''; }
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
error.value = '';
|
|
||||||
if (!form.value.practitionerId) {
|
|
||||||
error.value = 'Veuillez entrer un identifiant de praticien.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (parseInt(form.value.practitionerId) <= 0) {
|
|
||||||
error.value = 'L\'identifiant doit être un nombre positif.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
emit('assign', {
|
|
||||||
practitionerId: parseInt(form.value.practitionerId),
|
|
||||||
role: form.value.role,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* ── Tokens ── */
|
|
||||||
.modal-backdrop {
|
|
||||||
--brand: #4f46e5;
|
|
||||||
--brand-lt: #eef2ff;
|
|
||||||
--brand-dk: #3730a3;
|
|
||||||
--surface: #ffffff;
|
|
||||||
--surface-2:#f8fafc;
|
|
||||||
--border: #e2e8f0;
|
|
||||||
--text-1: #0f172a;
|
|
||||||
--text-2: #64748b;
|
|
||||||
--text-3: #94a3b8;
|
|
||||||
--r-sm: 8px;
|
|
||||||
--r-md: 12px;
|
|
||||||
position: fixed; inset: 0;
|
|
||||||
background: rgba(15,23,42,.45);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
z-index: 1000; padding: 20px;
|
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Modal box ── */
|
|
||||||
.modal-box {
|
|
||||||
background: var(--surface);
|
|
||||||
border-radius: 16px;
|
|
||||||
width: 100%; max-width: 460px;
|
|
||||||
box-shadow: 0 24px 64px rgba(0,0,0,.18), 0 0 0 1px rgba(0,0,0,.05);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.modal-header {
|
|
||||||
display: flex; align-items: flex-start; justify-content: space-between;
|
|
||||||
padding: 22px 24px 18px; border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.modal-title-wrap { display: flex; align-items: center; gap: 12px; }
|
|
||||||
.modal-icon {
|
|
||||||
width: 38px; height: 38px; border-radius: 10px;
|
|
||||||
background: var(--brand-lt); color: var(--brand);
|
|
||||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.modal-title { font-size: 16px; font-weight: 700; color: var(--text-1); margin: 0; }
|
|
||||||
.modal-sub { font-size: 12.5px; color: var(--text-3); margin: 2px 0 0; }
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border);
|
|
||||||
background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
|
||||||
color: var(--text-3); transition: all .15s; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.close-btn:hover { background: var(--surface-2); color: var(--text-1); }
|
|
||||||
|
|
||||||
/* Body */
|
|
||||||
.modal-body { padding: 20px 24px; display: flex; flex-direction: column; gap: 18px; }
|
|
||||||
|
|
||||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.form-label { font-size: 12px; font-weight: 700; color: var(--text-2); text-transform: uppercase; letter-spacing: .5px; }
|
|
||||||
.form-hint { font-size: 11.5px; color: var(--text-3); margin: 2px 0 0; }
|
|
||||||
|
|
||||||
.form-input {
|
|
||||||
padding: 9px 12px; border: 1px solid var(--border); border-radius: var(--r-sm);
|
|
||||||
font-size: 13.5px; color: var(--text-1); background: var(--surface); outline: none;
|
|
||||||
transition: border-color .15s, box-shadow .15s; font-family: inherit; width: 100%;
|
|
||||||
}
|
|
||||||
.form-input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(79,70,229,.1); }
|
|
||||||
|
|
||||||
/* Role grid */
|
|
||||||
.role-grid { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.role-option {
|
|
||||||
display: flex; align-items: center; gap: 12px; padding: 12px 14px;
|
|
||||||
border: 1.5px solid var(--border); border-radius: var(--r-sm);
|
|
||||||
background: transparent; cursor: pointer; text-align: left; width: 100%;
|
|
||||||
transition: all .15s;
|
|
||||||
}
|
|
||||||
.role-option:hover { border-color: #a5b4fc; background: var(--brand-lt); }
|
|
||||||
.role-option.selected { border-color: var(--brand); background: var(--brand-lt); }
|
|
||||||
|
|
||||||
.role-radio {
|
|
||||||
width: 17px; height: 17px; border-radius: 50%; border: 2px solid var(--border);
|
|
||||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
|
||||||
transition: border-color .15s;
|
|
||||||
}
|
|
||||||
.role-option.selected .role-radio { border-color: var(--brand); }
|
|
||||||
.role-radio-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--brand); }
|
|
||||||
|
|
||||||
.role-info { flex: 1; }
|
|
||||||
.role-name { font-size: 13.5px; font-weight: 600; color: var(--text-1); }
|
|
||||||
.role-desc { font-size: 11.5px; color: var(--text-3); margin-top: 1px; }
|
|
||||||
|
|
||||||
.role-chip { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 10.5px; font-weight: 600; }
|
|
||||||
.chip-principal { background: #eef2ff; color: #4f46e5; }
|
|
||||||
.chip-assistant { background: #f0fdf4; color: #16a34a; }
|
|
||||||
|
|
||||||
/* Error */
|
|
||||||
.error-banner {
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
padding: 10px 13px; background: #fef2f2; border: 1px solid #fecaca;
|
|
||||||
border-radius: var(--r-sm); font-size: 13px; color: #dc2626; font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
.modal-footer {
|
|
||||||
display: flex; gap: 10px; justify-content: flex-end;
|
|
||||||
padding: 16px 24px; background: var(--surface-2); border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
display: inline-flex; align-items: center; gap: 7px;
|
|
||||||
padding: 9px 18px; border-radius: var(--r-sm); border: none; cursor: pointer;
|
|
||||||
font-size: 13px; font-weight: 600; color: white; background: var(--brand);
|
|
||||||
transition: all .15s;
|
|
||||||
}
|
|
||||||
.btn-primary:hover { background: var(--brand-dk); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(79,70,229,.3); }
|
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
display: inline-flex; align-items: center; gap: 7px;
|
|
||||||
padding: 9px 16px; border-radius: var(--r-sm); border: 1px solid var(--border);
|
|
||||||
cursor: pointer; font-size: 13px; font-weight: 500; color: var(--text-2); background: transparent;
|
|
||||||
transition: all .15s;
|
|
||||||
}
|
|
||||||
.btn-ghost:hover { background: var(--border); color: var(--text-1); }
|
|
||||||
|
|
||||||
/* ── Transitions ── */
|
|
||||||
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity .2s ease; }
|
|
||||||
.modal-fade-enter-active .modal-box, .modal-fade-leave-active .modal-box { transition: transform .2s ease, opacity .2s ease; }
|
|
||||||
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
|
|
||||||
.modal-fade-enter-from .modal-box, .modal-fade-leave-to .modal-box { transform: scale(.96) translateY(8px); opacity: 0; }
|
|
||||||
|
|
||||||
.slide-error-enter-active, .slide-error-leave-active { transition: all .2s ease; }
|
|
||||||
.slide-error-enter-from, .slide-error-leave-to { opacity: 0; transform: translateY(-6px); }
|
|
||||||
</style>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,230 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="sidebar-wrap">
|
|
||||||
<!-- Hero Card -->
|
|
||||||
<div class="hero-card">
|
|
||||||
<div class="hero-avatar">
|
|
||||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
|
||||||
<circle cx="9" cy="7" r="4"/>
|
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 class="hero-name">{{ intervention.defuntName || 'Personne inconnue' }}</h2>
|
|
||||||
<p class="hero-type">{{ intervention.title || 'Type non défini' }}</p>
|
|
||||||
<div class="status-badge" :class="'sb-' + (intervention.status?.color || 'secondary')">
|
|
||||||
{{ intervention.status?.label || 'En attente' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<!-- Quick Stats -->
|
|
||||||
<div class="quick-stats">
|
|
||||||
<div class="qs-row">
|
|
||||||
<div class="qs-icon" style="background:#eef2ff;color:#4f46e5">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="qs-text">
|
|
||||||
<div class="qs-label">Date</div>
|
|
||||||
<div class="qs-value">{{ intervention.date || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="qs-row">
|
|
||||||
<div class="qs-icon" style="background:#ecfdf5;color:#059669">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="qs-text">
|
|
||||||
<div class="qs-label">Lieu</div>
|
|
||||||
<div class="qs-value">{{ intervention.lieux || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="qs-row">
|
|
||||||
<div class="qs-icon" style="background:#fff7ed;color:#d97706">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="qs-text">
|
|
||||||
<div class="qs-label">Durée</div>
|
|
||||||
<div class="qs-value">{{ intervention.duree || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<!-- Team preview -->
|
|
||||||
<div v-if="intervention.members?.length" class="team-preview">
|
|
||||||
<div class="tp-label">Équipe</div>
|
|
||||||
<div class="tp-avatars">
|
|
||||||
<div
|
|
||||||
v-for="(m, i) in intervention.members.slice(0, 5)"
|
|
||||||
:key="i"
|
|
||||||
class="tp-avatar"
|
|
||||||
:title="m.name"
|
|
||||||
:style="{ zIndex: 10 - i }"
|
|
||||||
>
|
|
||||||
{{ getInitials(m.name) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="intervention.members.length > 5" class="tp-avatar tp-more">
|
|
||||||
+{{ intervention.members.length - 5 }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<!-- Tab Navigation -->
|
|
||||||
<nav class="tab-nav">
|
|
||||||
<button
|
|
||||||
v-for="tab in tabs"
|
|
||||||
:key="tab.id"
|
|
||||||
class="tab-item"
|
|
||||||
:class="{ active: activeTab === tab.id }"
|
|
||||||
@click="$emit('change-tab', tab.id)"
|
|
||||||
>
|
|
||||||
<span class="tab-icon" v-html="tab.icon"></span>
|
|
||||||
<span class="tab-label">{{ tab.label }}</span>
|
|
||||||
<span v-if="tab.id === 'team' && teamCount > 0" class="tab-badge">{{ teamCount }}</span>
|
|
||||||
<span v-if="tab.id === 'documents' && documentsCount > 0" class="tab-badge">{{ documentsCount }}</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Assign Button -->
|
|
||||||
<div class="assign-wrap">
|
|
||||||
<button class="assign-btn" @click="$emit('assign-practitioner')">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
|
||||||
<circle cx="8.5" cy="7" r="4"/>
|
|
||||||
<line x1="20" y1="8" x2="20" y2="14"/>
|
|
||||||
<line x1="23" y1="11" x2="17" y2="11"/>
|
|
||||||
</svg>
|
|
||||||
Assigner un praticien
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { defineProps, defineEmits } from 'vue';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
intervention: { type: Object, required: true },
|
|
||||||
activeTab: { type: String, default: 'overview' },
|
|
||||||
practitioners:{ type: Array, default: () => [] },
|
|
||||||
teamCount: { type: Number, default: 0 },
|
|
||||||
documentsCount:{ type: Number, default: 0 },
|
|
||||||
});
|
|
||||||
defineEmits(['change-tab', 'assign-practitioner']);
|
|
||||||
|
|
||||||
const getInitials = n => n ? n.split(' ').map(w => w[0]).join('').toUpperCase().substring(0,2) : '?';
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id:'overview', label:"Vue d'ensemble", icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>` },
|
|
||||||
{ id:'details', label:'Détails', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>` },
|
|
||||||
{ id:'team', label:'Équipe', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>` },
|
|
||||||
{ id:'documents', label:'Documents', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>` },
|
|
||||||
{ id:'quote', label:'Devis', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>` },
|
|
||||||
{ id:'history', label:'Historique', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.5"/></svg>` },
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.sidebar-wrap {
|
|
||||||
--brand: #4f46e5;
|
|
||||||
--brand-lt: #eef2ff;
|
|
||||||
--brand-dk: #3730a3;
|
|
||||||
--surface: #ffffff;
|
|
||||||
--surface-2:#f8fafc;
|
|
||||||
--border: #e2e8f0;
|
|
||||||
--border-lt:#f1f5f9;
|
|
||||||
--text-1: #0f172a;
|
|
||||||
--text-2: #64748b;
|
|
||||||
--text-3: #94a3b8;
|
|
||||||
--r-sm: 8px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,.06);
|
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hero */
|
|
||||||
.hero-card {
|
|
||||||
padding: 24px 20px 18px;
|
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center;
|
|
||||||
}
|
|
||||||
.hero-avatar {
|
|
||||||
width: 58px; height: 58px; border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
color: white; margin-bottom: 2px;
|
|
||||||
box-shadow: 0 4px 14px rgba(79,70,229,.28);
|
|
||||||
}
|
|
||||||
.hero-name { font-size: 15px; font-weight: 700; color: var(--text-1); margin: 0; line-height: 1.3; }
|
|
||||||
.hero-type { font-size: 12px; color: var(--text-2); margin: 0; font-weight: 500; }
|
|
||||||
|
|
||||||
/* Status badge */
|
|
||||||
.status-badge { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 20px; font-size: 11.5px; font-weight: 600; }
|
|
||||||
.sb-success { background:#dcfce7; color:#16a34a; }
|
|
||||||
.sb-warning { background:#fef9c3; color:#ca8a04; }
|
|
||||||
.sb-danger { background:#fee2e2; color:#dc2626; }
|
|
||||||
.sb-info { background:#dbeafe; color:#2563eb; }
|
|
||||||
.sb-primary { background:#eef2ff; color:#4f46e5; }
|
|
||||||
.sb-secondary{ background:#f1f5f9; color:#64748b; }
|
|
||||||
|
|
||||||
.divider { height: 1px; background: var(--border-lt); }
|
|
||||||
|
|
||||||
/* Quick stats */
|
|
||||||
.quick-stats { padding: 14px 18px; display: flex; flex-direction: column; gap: 10px; }
|
|
||||||
.qs-row { display: flex; align-items: flex-start; gap: 10px; }
|
|
||||||
.qs-icon { width: 28px; height: 28px; border-radius: 7px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
|
||||||
.qs-label { font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-3); font-weight: 600; }
|
|
||||||
.qs-value { font-size: 12.5px; color: var(--text-1); font-weight: 500; margin-top: 1px; }
|
|
||||||
|
|
||||||
/* Team preview */
|
|
||||||
.team-preview { padding: 12px 18px; display: flex; align-items: center; gap: 12px; }
|
|
||||||
.tp-label { font-size: 11.5px; font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: .4px; }
|
|
||||||
.tp-avatars { display: flex; }
|
|
||||||
.tp-avatar {
|
|
||||||
width: 30px; height: 30px; border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
|
||||||
border: 2px solid var(--surface);
|
|
||||||
color: white; font-size: 10px; font-weight: 700;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
margin-left: -6px; cursor: default;
|
|
||||||
transition: transform .15s;
|
|
||||||
}
|
|
||||||
.tp-avatar:first-child { margin-left: 0; }
|
|
||||||
.tp-avatar:hover { transform: translateY(-3px); }
|
|
||||||
.tp-more { background: var(--surface-2); color: var(--text-2); font-size: 9px; }
|
|
||||||
|
|
||||||
/* Tab nav */
|
|
||||||
.tab-nav { padding: 8px 10px; display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.tab-item {
|
|
||||||
display: flex; align-items: center; gap: 9px; padding: 8px 11px;
|
|
||||||
border-radius: var(--r-sm); border: none; background: transparent; cursor: pointer;
|
|
||||||
width: 100%; text-align: left; font-size: 13px; font-weight: 500; color: var(--text-2);
|
|
||||||
transition: all .12s;
|
|
||||||
}
|
|
||||||
.tab-item:hover { background: var(--surface-2); color: var(--text-1); }
|
|
||||||
.tab-item.active { background: var(--brand-lt); color: var(--brand); font-weight: 600; }
|
|
||||||
.tab-icon { flex-shrink: 0; display: flex; color: var(--text-3); }
|
|
||||||
.tab-item.active .tab-icon { color: var(--brand); }
|
|
||||||
.tab-label { flex: 1; }
|
|
||||||
.tab-badge {
|
|
||||||
min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px;
|
|
||||||
background: var(--brand); color: white; font-size: 10px; font-weight: 700;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Assign */
|
|
||||||
.assign-wrap { padding: 0 10px 12px; }
|
|
||||||
.assign-btn {
|
|
||||||
width: 100%; display: flex; align-items: center; justify-content: center; gap: 7px;
|
|
||||||
padding: 9px; border: 1.5px dashed var(--border); border-radius: var(--r-sm);
|
|
||||||
background: transparent; cursor: pointer; font-size: 12.5px; font-weight: 500; color: var(--text-2);
|
|
||||||
transition: all .15s;
|
|
||||||
}
|
|
||||||
.assign-btn:hover { border-color: var(--brand); color: var(--brand); background: var(--brand-lt); }
|
|
||||||
</style>
|
|
||||||
@ -1,803 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="intervention-page">
|
|
||||||
|
|
||||||
<!-- ── Top Navigation Bar ── -->
|
|
||||||
<div class="page-topbar">
|
|
||||||
<router-link to="/interventions" class="back-btn">
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
|
|
||||||
Interventions
|
|
||||||
</router-link>
|
|
||||||
|
|
||||||
<div v-if="intervention" class="topbar-center">
|
|
||||||
<span class="topbar-id">#{{ intervention.id }}</span>
|
|
||||||
<span class="topbar-divider">·</span>
|
|
||||||
<span class="topbar-name">{{ getDeceasedName(intervention) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="topbar-right">
|
|
||||||
<div v-if="interventionStore.isLoading" class="topbar-loading">
|
|
||||||
<div class="mini-spinner"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Loading ── -->
|
|
||||||
<div v-if="interventionStore.isLoading && !intervention" class="fullpage-center">
|
|
||||||
<div class="loading-orb"></div>
|
|
||||||
<p class="loading-text">Chargement de l'intervention…</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Error ── -->
|
|
||||||
<div v-else-if="interventionStore.getError && !intervention" class="fullpage-center">
|
|
||||||
<div class="state-icon error-icon">
|
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="state-title">Erreur de chargement</h3>
|
|
||||||
<p class="state-desc">{{ interventionStore.getError }}</p>
|
|
||||||
<router-link to="/interventions" class="btn-primary">Retour à la liste</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Main Layout ── -->
|
|
||||||
<div v-else-if="intervention" class="page-layout">
|
|
||||||
|
|
||||||
<!-- LEFT SIDEBAR -->
|
|
||||||
<aside class="sidebar">
|
|
||||||
<!-- Hero -->
|
|
||||||
<div class="hero-card">
|
|
||||||
<div class="hero-avatar">
|
|
||||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
|
||||||
<circle cx="9" cy="7" r="4"/>
|
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 class="hero-name">{{ getDeceasedName(intervention) }}</h2>
|
|
||||||
<p class="hero-type">{{ getTypeLabel(intervention.type) }}</p>
|
|
||||||
<StatusBadge :status="intervention.status" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Stats -->
|
|
||||||
<div class="quick-stats">
|
|
||||||
<QuickStat color="#4f46e5" :label="'Date'" :value="formatDate(intervention.scheduled_at)">
|
|
||||||
<template #icon><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></template>
|
|
||||||
</QuickStat>
|
|
||||||
<QuickStat color="#059669" :label="'Lieu'" :value="intervention.location?.name || 'Non défini'">
|
|
||||||
<template #icon><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></template>
|
|
||||||
</QuickStat>
|
|
||||||
<QuickStat color="#d97706" :label="'Durée'" :value="intervention.duration_min ? intervention.duration_min + ' min' : 'Non définie'">
|
|
||||||
<template #icon><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></template>
|
|
||||||
</QuickStat>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab Nav -->
|
|
||||||
<nav class="tab-nav">
|
|
||||||
<button
|
|
||||||
v-for="tab in tabs"
|
|
||||||
:key="tab.id"
|
|
||||||
class="tab-item"
|
|
||||||
:class="{ active: activeTab === tab.id }"
|
|
||||||
@click="activeTab = tab.id"
|
|
||||||
>
|
|
||||||
<span class="tab-icon" v-html="tab.icon"></span>
|
|
||||||
<span class="tab-label">{{ tab.label }}</span>
|
|
||||||
<span v-if="tab.badge" class="tab-badge">{{ tab.badge }}</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Assign Button -->
|
|
||||||
<button class="assign-cta" @click="openAssignModal">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
|
|
||||||
Assigner un praticien
|
|
||||||
</button>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- MAIN CONTENT -->
|
|
||||||
<main class="main-content">
|
|
||||||
|
|
||||||
<!-- OVERVIEW -->
|
|
||||||
<section v-if="activeTab === 'overview'" class="tab-section">
|
|
||||||
<SectionHeader title="Vue d'ensemble" />
|
|
||||||
<div class="info-grid">
|
|
||||||
<InfoCard title="Informations générales" accent="#4f46e5">
|
|
||||||
<template #icon><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M6 20v-2a6 6 0 0 1 12 0v2"/></svg></template>
|
|
||||||
<DataRow label="Nom du défunt" :value="getDeceasedName(intervention)" />
|
|
||||||
<DataRow label="Date prévue" :value="formatDate(intervention.scheduled_at)" />
|
|
||||||
<DataRow label="Lieu" :value="intervention.location?.name || 'Non défini'" />
|
|
||||||
<DataRow label="Durée" :value="intervention.duration_min ? intervention.duration_min + ' min' : 'Non définie'" />
|
|
||||||
</InfoCard>
|
|
||||||
|
|
||||||
<InfoCard title="Contact & Communication" accent="#10b981">
|
|
||||||
<template #icon><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 13a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 9.91a16 16 0 0 0 6.16 6.16l.91-.91a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 17z"/></svg></template>
|
|
||||||
<DataRow label="Contact familial" :value="intervention.order_giver || 'Non renseigné'" />
|
|
||||||
<DataRow label="Email / Tél." :value="intervention.client ? (intervention.client.email || intervention.client.phone || '-') : '-'" />
|
|
||||||
<DataRow label="Type intervention" :value="getTypeLabel(intervention.type)" />
|
|
||||||
</InfoCard>
|
|
||||||
|
|
||||||
<InfoCard title="Notes & Description" accent="#8b5cf6" class="full-col">
|
|
||||||
<template #icon><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></template>
|
|
||||||
<p class="notes-text">{{ intervention.notes || 'Aucune description disponible.' }}</p>
|
|
||||||
</InfoCard>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- DETAILS -->
|
|
||||||
<section v-if="activeTab === 'details'" class="tab-section">
|
|
||||||
<SectionHeader title="Détails de l'intervention" />
|
|
||||||
<div class="card-wrap">
|
|
||||||
<!-- Editable form fields -->
|
|
||||||
<div class="edit-form">
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Type d'intervention</label>
|
|
||||||
<select v-model="editForm.type" class="form-select">
|
|
||||||
<option value="thanatopraxie">Thanatopraxie</option>
|
|
||||||
<option value="toilette_mortuaire">Toilette mortuaire</option>
|
|
||||||
<option value="exhumation">Exhumation</option>
|
|
||||||
<option value="retrait_pacemaker">Retrait pacemaker</option>
|
|
||||||
<option value="retrait_bijoux">Retrait bijoux</option>
|
|
||||||
<option value="autre">Autre</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Statut</label>
|
|
||||||
<select v-model="editForm.status" class="form-select">
|
|
||||||
<option value="demande">Demande</option>
|
|
||||||
<option value="planifie">Planifié</option>
|
|
||||||
<option value="en_cours">En cours</option>
|
|
||||||
<option value="termine">Terminé</option>
|
|
||||||
<option value="annule">Annulé</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Date prévue</label>
|
|
||||||
<input type="datetime-local" v-model="editForm.scheduled_at" class="form-input" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Durée (minutes)</label>
|
|
||||||
<input type="number" v-model="editForm.duration_min" class="form-input" min="0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Contact familial (donneur d'ordre)</label>
|
|
||||||
<input type="text" v-model="editForm.order_giver" class="form-input" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Notes</label>
|
|
||||||
<textarea v-model="editForm.notes" class="form-textarea" rows="4" placeholder="Ajouter des notes…"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button class="btn-ghost" @click="resetForm">Annuler</button>
|
|
||||||
<button class="btn-primary" :disabled="interventionStore.isLoading" @click="submitUpdate">
|
|
||||||
<span v-if="interventionStore.isLoading" class="mini-spinner white"></span>
|
|
||||||
Enregistrer les modifications
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- TEAM -->
|
|
||||||
<section v-if="activeTab === 'team'" class="tab-section">
|
|
||||||
<SectionHeader title="Équipe assignée">
|
|
||||||
<button class="btn-primary sm" @click="openAssignModal">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
||||||
Ajouter
|
|
||||||
</button>
|
|
||||||
</SectionHeader>
|
|
||||||
|
|
||||||
<div v-if="intervention.practitioners?.length" class="practitioner-grid">
|
|
||||||
<div v-for="(p, i) in intervention.practitioners" :key="i" class="practitioner-card">
|
|
||||||
<div class="pract-avatar">{{ getInitials(getPractName(p)) }}</div>
|
|
||||||
<div class="pract-info">
|
|
||||||
<div class="pract-name">{{ getPractName(p) }}</div>
|
|
||||||
<span class="role-chip" :class="p.pivot?.role === 'principal' ? 'chip-principal' : 'chip-assistant'">
|
|
||||||
{{ p.pivot?.role === 'principal' ? 'Principal' : 'Assistant' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button class="unassign-btn" title="Désassigner" @click="handleUnassign(p)">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EmptyState v-else icon="team" message="Aucun praticien assigné">
|
|
||||||
<button class="btn-primary sm" @click="openAssignModal">Assigner maintenant</button>
|
|
||||||
</EmptyState>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- DOCUMENTS -->
|
|
||||||
<section v-if="activeTab === 'documents'" class="tab-section">
|
|
||||||
<SectionHeader title="Documents" />
|
|
||||||
<DocumentManagement
|
|
||||||
:documents="documentAttachments"
|
|
||||||
:loading="documentStore.isLoading"
|
|
||||||
:error="documentStore.getError"
|
|
||||||
@files-selected="() => {}"
|
|
||||||
@upload-files="handleUploadFiles"
|
|
||||||
@delete-document="handleDeleteDocument"
|
|
||||||
@delete-documents="handleDeleteDocuments"
|
|
||||||
@update-document-label="handleUpdateDocumentLabel"
|
|
||||||
@retry="loadDocuments"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- QUOTE -->
|
|
||||||
<section v-if="activeTab === 'quote'" class="tab-section">
|
|
||||||
<SectionHeader title="Devis associé">
|
|
||||||
<router-link v-if="intervention.quote?.id" :to="`/ventes/devis/${intervention.quote.id}`" class="btn-primary sm">
|
|
||||||
Ouvrir le devis
|
|
||||||
</router-link>
|
|
||||||
</SectionHeader>
|
|
||||||
|
|
||||||
<div v-if="intervention.quote">
|
|
||||||
<div class="info-grid">
|
|
||||||
<InfoCard title="Informations" accent="#3b82f6">
|
|
||||||
<template #icon><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></template>
|
|
||||||
<DataRow label="Référence" :value="intervention.quote.reference" />
|
|
||||||
<DataRow label="Date" :value="intervention.quote.quote_date" />
|
|
||||||
<DataRow label="Validité" :value="intervention.quote.valid_until" />
|
|
||||||
<div class="data-row">
|
|
||||||
<span class="data-label">Statut</span>
|
|
||||||
<span class="status-chip" :class="'sc-' + getQuoteColor(intervention.quote.status)">{{ getQuoteLabel(intervention.quote.status) }}</span>
|
|
||||||
</div>
|
|
||||||
</InfoCard>
|
|
||||||
<InfoCard title="Montants" accent="#10b981">
|
|
||||||
<template #icon><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg></template>
|
|
||||||
<DataRow label="Total HT" :value="fmtCurrency(intervention.quote.total_ht)" />
|
|
||||||
<DataRow label="Total TVA" :value="fmtCurrency(intervention.quote.total_tva)" />
|
|
||||||
<DataRow label="Total TTC" :value="fmtCurrency(intervention.quote.total_ttc)" :bold="true" />
|
|
||||||
</InfoCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="intervention.quote.lines?.length" class="quote-lines">
|
|
||||||
<div class="lines-title">Lignes du devis</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="data-table">
|
|
||||||
<thead><tr><th>Description</th><th class="tc">Qté</th><th class="tr">PU HT</th><th class="tr">Total HT</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="l in intervention.quote.lines" :key="l.id">
|
|
||||||
<td>{{ l.description || '-' }}</td>
|
|
||||||
<td class="tc">{{ l.units_qty || 0 }}</td>
|
|
||||||
<td class="tr">{{ fmtCurrency(l.unit_price) }}</td>
|
|
||||||
<td class="tr fw6">{{ fmtCurrency(l.total_ht) }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EmptyState v-else icon="quote" message="Aucun devis associé à cette intervention" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- HISTORY -->
|
|
||||||
<section v-if="activeTab === 'history'" class="tab-section">
|
|
||||||
<SectionHeader title="Historique" />
|
|
||||||
<EmptyState icon="history" message="Historique des modifications">
|
|
||||||
<span class="coming-soon-chip">Fonctionnalité à venir</span>
|
|
||||||
</EmptyState>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Assign Modal -->
|
|
||||||
<AssignPractitionerModal
|
|
||||||
:is-open="isModalOpen"
|
|
||||||
@close="closeAssignModal"
|
|
||||||
@assign="handleAssignPractitioner"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted, watch, defineComponent, h } from 'vue';
|
|
||||||
import { useRoute, RouterLink } from 'vue-router';
|
|
||||||
import { useInterventionStore } from '@/stores/interventionStore';
|
|
||||||
import { useNotificationStore } from '@/stores/notification';
|
|
||||||
import { useDocumentAttachmentStore } from '@/stores/documentAttachmentStore';
|
|
||||||
import DocumentManagement from '@/components/molecules/Interventions/DocumentManagement.vue';
|
|
||||||
import AssignPractitionerModal from '@/components/molecules/intervention/AssignPractitionerModal.vue';
|
|
||||||
|
|
||||||
// ── Inline sub-components ──────────────────────────────────────────────────
|
|
||||||
const StatusBadge = {
|
|
||||||
props: { status: String },
|
|
||||||
setup(props) {
|
|
||||||
const map = { demande: ['warning','Demande'], planifie: ['info','Planifié'], en_cours: ['primary','En cours'], termine: ['success','Terminé'], annule: ['danger','Annulé'] };
|
|
||||||
const [color, label] = map[props.status] || ['secondary', props.status || 'En attente'];
|
|
||||||
return () => h('span', { class: `status-badge sb-${color}` }, label);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const QuickStat = {
|
|
||||||
props: { label: String, value: String, color: String },
|
|
||||||
template: `
|
|
||||||
<div class="qs-item">
|
|
||||||
<div class="qs-icon" :style="{ background: color + '18', color }"><slot name="icon"/></div>
|
|
||||||
<div><div class="qs-label">{{ label }}</div><div class="qs-value">{{ value || '-' }}</div></div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
};
|
|
||||||
|
|
||||||
const InfoCard = {
|
|
||||||
props: { title: String, accent: String },
|
|
||||||
template: `
|
|
||||||
<div class="info-card">
|
|
||||||
<div class="info-card-header" :style="{ '--a': accent }">
|
|
||||||
<span class="ic-icon"><slot name="icon"/></span>
|
|
||||||
<span class="ic-title">{{ title }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-card-body"><slot/></div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
};
|
|
||||||
|
|
||||||
const DataRow = {
|
|
||||||
props: { label: String, value: String, bold: Boolean },
|
|
||||||
template: `
|
|
||||||
<div class="data-row">
|
|
||||||
<span class="data-label">{{ label }}</span>
|
|
||||||
<span class="data-value" :class="bold ? 'fw6' : ''">{{ value || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
};
|
|
||||||
|
|
||||||
const SectionHeader = {
|
|
||||||
props: { title: String },
|
|
||||||
template: `<div class="section-header"><h3 class="section-title">{{ title }}</h3><slot/></div>`
|
|
||||||
};
|
|
||||||
|
|
||||||
const EmptyState = {
|
|
||||||
props: { icon: String, message: String },
|
|
||||||
setup(props, { slots }) {
|
|
||||||
const icons = {
|
|
||||||
team: `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="23" y1="11" x2="17" y2="11"/><line x1="20" y1="8" x2="20" y2="14"/></svg>`,
|
|
||||||
quote: `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
|
|
||||||
history: `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.5"/></svg>`,
|
|
||||||
};
|
|
||||||
return () => h('div', { class: 'empty-state' }, [
|
|
||||||
h('div', { class: 'empty-icon', innerHTML: icons[props.icon] || icons.team }),
|
|
||||||
h('p', { class: 'empty-msg' }, props.message),
|
|
||||||
slots.default?.(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Setup ──────────────────────────────────────────────────────────────────
|
|
||||||
const route = useRoute();
|
|
||||||
const interventionStore = useInterventionStore();
|
|
||||||
const notifStore = useNotificationStore();
|
|
||||||
const documentStore = useDocumentAttachmentStore();
|
|
||||||
|
|
||||||
const intervention = ref(null);
|
|
||||||
const activeTab = ref('overview');
|
|
||||||
const isModalOpen = ref(false);
|
|
||||||
const editForm = ref({});
|
|
||||||
|
|
||||||
const documentAttachments = computed(() =>
|
|
||||||
documentStore.getInterventionAttachments(intervention.value?.id || 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const tabs = computed(() => [
|
|
||||||
{ id: 'overview', label: "Vue d'ensemble", icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>` },
|
|
||||||
{ id: 'details', label: 'Détails', icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>` },
|
|
||||||
{ id: 'team', label: 'Équipe', icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`, badge: intervention.value?.practitioners?.length || null },
|
|
||||||
{ id: 'documents', label: 'Documents', icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>` },
|
|
||||||
{ id: 'quote', label: 'Devis', icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>` },
|
|
||||||
{ id: 'history', label: 'Historique', icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.5"/></svg>` },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
||||||
const getDeceasedName = i => i?.deceased
|
|
||||||
? `${i.deceased.last_name || ''} ${i.deceased.first_name || ''}`.trim()
|
|
||||||
: `Personne ${i?.deceased_id || 'inconnue'}`;
|
|
||||||
|
|
||||||
const formatDate = v => v ? new Date(v).toLocaleString('fr-FR') : 'Non définie';
|
|
||||||
const getTypeLabel = t => ({ thanatopraxie:'Thanatopraxie', toilette_mortuaire:'Toilette mortuaire', exhumation:'Exhumation', retrait_pacemaker:'Retrait pacemaker', retrait_bijoux:'Retrait bijoux', autre:'Autre' }[t] || t || 'Type non défini');
|
|
||||||
const getQuoteLabel = s => ({ brouillon:'Brouillon', envoye:'Envoyé', accepte:'Accepté', refuse:'Refusé', expire:'Expiré' }[s] || s || 'Inconnu');
|
|
||||||
const getQuoteColor = s => ({ brouillon:'secondary', envoye:'info', accepte:'success', refuse:'danger', expire:'warning' }[s] || 'secondary');
|
|
||||||
const fmtCurrency = v => new Intl.NumberFormat('fr-FR', { style:'currency', currency:'EUR' }).format(Number(v || 0));
|
|
||||||
const getPractName = p => p.employee ? `${p.employee.first_name || ''} ${p.employee.last_name || ''}`.trim() : `${p.first_name || ''} ${p.last_name || ''}`.trim();
|
|
||||||
const getInitials = n => n ? n.split(' ').map(w => w[0]).join('').toUpperCase().substring(0,2) : '?';
|
|
||||||
|
|
||||||
// ── Edit form ──────────────────────────────────────────────────────────────
|
|
||||||
const resetForm = () => {
|
|
||||||
if (!intervention.value) return;
|
|
||||||
editForm.value = {
|
|
||||||
type: intervention.value.type || '',
|
|
||||||
status: intervention.value.status || '',
|
|
||||||
scheduled_at: intervention.value.scheduled_at ? intervention.value.scheduled_at.substring(0,16) : '',
|
|
||||||
duration_min: intervention.value.duration_min || '',
|
|
||||||
order_giver: intervention.value.order_giver || '',
|
|
||||||
notes: intervention.value.notes || '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitUpdate = async () => {
|
|
||||||
try {
|
|
||||||
const result = await interventionStore.updateIntervention({ id: intervention.value.id, ...editForm.value });
|
|
||||||
intervention.value = result;
|
|
||||||
notifStore.updated('Intervention');
|
|
||||||
} catch (e) {
|
|
||||||
notifStore.error('Erreur', 'Impossible de mettre à jour');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Data fetch ─────────────────────────────────────────────────────────────
|
|
||||||
const fetchIntervention = async () => {
|
|
||||||
try {
|
|
||||||
const id = parseInt(route.params.id);
|
|
||||||
if (id) {
|
|
||||||
intervention.value = await interventionStore.fetchInterventionById(id);
|
|
||||||
resetForm();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
notifStore.error('Erreur', 'Impossible de charger l\'intervention');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Modal & assignment ─────────────────────────────────────────────────────
|
|
||||||
const openAssignModal = () => { isModalOpen.value = true; };
|
|
||||||
const closeAssignModal = () => { isModalOpen.value = false; };
|
|
||||||
|
|
||||||
const handleAssignPractitioner = async (data) => {
|
|
||||||
try {
|
|
||||||
const payload = data.role === 'principal'
|
|
||||||
? { principal_practitioner_id: data.practitionerId }
|
|
||||||
: { assistant_practitioner_ids: [data.practitionerId] };
|
|
||||||
await interventionStore.assignPractitioner(intervention.value.id, payload);
|
|
||||||
await fetchIntervention();
|
|
||||||
notifStore.created('Praticien assigné');
|
|
||||||
closeAssignModal();
|
|
||||||
} catch (e) {
|
|
||||||
notifStore.error('Erreur', 'Impossible d\'assigner');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnassign = async (p) => {
|
|
||||||
try {
|
|
||||||
await interventionStore.unassignPractitioner(intervention.value.id, p.id);
|
|
||||||
await fetchIntervention();
|
|
||||||
notifStore.updated('Praticien désassigné');
|
|
||||||
} catch (e) {
|
|
||||||
notifStore.error('Erreur', 'Impossible de désassigner');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Documents ──────────────────────────────────────────────────────────────
|
|
||||||
const loadDocuments = async () => {
|
|
||||||
if (!intervention.value?.id) return;
|
|
||||||
try { await documentStore.fetchInterventionFiles(intervention.value.id); }
|
|
||||||
catch (e) { documentStore.clearError(); }
|
|
||||||
};
|
|
||||||
const handleUploadFiles = async files => {
|
|
||||||
if (!intervention.value?.id || !files.length) return;
|
|
||||||
try { await documentStore.uploadAndAttachFiles(files, 'App\\Models\\Intervention', intervention.value.id); }
|
|
||||||
catch { documentStore.clearError(); }
|
|
||||||
};
|
|
||||||
const handleDeleteDocument = async id => { try { await documentStore.detachFile(id); } catch { documentStore.clearError(); } };
|
|
||||||
const handleDeleteDocuments = async ids => { try { await documentStore.detachMultipleFiles({ attachment_ids: ids }); } catch { documentStore.clearError(); } };
|
|
||||||
const handleUpdateDocumentLabel = async ({ id, label }) => { try { await documentStore.updateAttachmentMetadata(id, { label }); } catch { documentStore.clearError(); } };
|
|
||||||
|
|
||||||
// ── Watchers & lifecycle ───────────────────────────────────────────────────
|
|
||||||
watch(() => interventionStore.currentIntervention, v => { if (v) intervention.value = v; }, { deep: true });
|
|
||||||
watch(activeTab, tab => { if (tab === 'documents' && intervention.value?.id) loadDocuments(); });
|
|
||||||
onMounted(fetchIntervention);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* ── Design tokens ─────────────────────────────────────────────────────── */
|
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
|
||||||
|
|
||||||
.intervention-page {
|
|
||||||
--brand: #4f46e5;
|
|
||||||
--brand-lt: #eef2ff;
|
|
||||||
--brand-dk: #3730a3;
|
|
||||||
--surface: #ffffff;
|
|
||||||
--surface-2: #f8fafc;
|
|
||||||
--surface-3: #f1f5f9;
|
|
||||||
--border: #e2e8f0;
|
|
||||||
--border-lt: #f1f5f9;
|
|
||||||
--text-1: #0f172a;
|
|
||||||
--text-2: #64748b;
|
|
||||||
--text-3: #94a3b8;
|
|
||||||
--r-sm: 8px;
|
|
||||||
--r-md: 12px;
|
|
||||||
--shadow-sm: 0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
|
|
||||||
--shadow-md: 0 4px 16px rgba(0,0,0,.08);
|
|
||||||
min-height: 100vh;
|
|
||||||
|
|
||||||
color: var(--text-1);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Top bar ───────────────────────────────────────────────────────────── */
|
|
||||||
.page-topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 0 24px;
|
|
||||||
height: 56px;
|
|
||||||
background: var(--surface);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
.back-btn {
|
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
|
||||||
font-size: 13px; font-weight: 500; color: var(--text-2);
|
|
||||||
text-decoration: none; padding: 5px 10px; border-radius: var(--r-sm);
|
|
||||||
transition: background .15s, color .15s;
|
|
||||||
}
|
|
||||||
.back-btn:hover { background: var(--surface-3); color: var(--text-1); }
|
|
||||||
.topbar-center { display: flex; align-items: center; gap: 8px; margin: 0 auto; }
|
|
||||||
.topbar-id { font-size: 13px; font-weight: 600; color: var(--text-2); }
|
|
||||||
.topbar-divider { color: var(--text-3); }
|
|
||||||
.topbar-name { font-size: 14px; font-weight: 600; color: var(--text-1); }
|
|
||||||
.topbar-right { margin-left: auto; }
|
|
||||||
.topbar-loading { display: flex; align-items: center; }
|
|
||||||
|
|
||||||
/* ── Layout ────────────────────────────────────────────────────────────── */
|
|
||||||
.page-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 272px 1fr;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Sidebar ───────────────────────────────────────────────────────────── */
|
|
||||||
.sidebar {
|
|
||||||
background: var(--surface);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: sticky;
|
|
||||||
top: 56px;
|
|
||||||
height: calc(100vh - 56px);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hero */
|
|
||||||
.hero-card {
|
|
||||||
padding: 24px 20px 18px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
text-align: center;
|
|
||||||
border-bottom: 1px solid var(--border-lt);
|
|
||||||
}
|
|
||||||
.hero-avatar {
|
|
||||||
width: 60px; height: 60px; border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
color: white; margin-bottom: 4px;
|
|
||||||
box-shadow: 0 4px 14px rgba(79,70,229,.3);
|
|
||||||
}
|
|
||||||
.hero-name { font-size: 15px; font-weight: 700; color: var(--text-1); margin: 0; line-height: 1.3; }
|
|
||||||
.hero-type { font-size: 12px; color: var(--text-2); margin: 0; font-weight: 500; }
|
|
||||||
|
|
||||||
/* Quick stats */
|
|
||||||
.quick-stats {
|
|
||||||
padding: 14px 18px;
|
|
||||||
display: flex; flex-direction: column; gap: 10px;
|
|
||||||
border-bottom: 1px solid var(--border-lt);
|
|
||||||
}
|
|
||||||
.qs-item { display: flex; align-items: flex-start; gap: 10px; }
|
|
||||||
.qs-icon { width: 28px; height: 28px; border-radius: 7px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
|
||||||
.qs-label { font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-3); font-weight: 600; }
|
|
||||||
.qs-value { font-size: 12.5px; color: var(--text-1); font-weight: 500; margin-top: 1px; }
|
|
||||||
|
|
||||||
/* Tab nav */
|
|
||||||
.tab-nav { padding: 10px 10px; display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
|
||||||
.tab-item {
|
|
||||||
display: flex; align-items: center; gap: 9px; padding: 8px 11px;
|
|
||||||
border-radius: var(--r-sm); border: none; background: transparent; cursor: pointer;
|
|
||||||
width: 100%; text-align: left; font-size: 13.5px; font-weight: 500; color: var(--text-2);
|
|
||||||
transition: all .12s;
|
|
||||||
}
|
|
||||||
.tab-item:hover { background: var(--surface-3); color: var(--text-1); }
|
|
||||||
.tab-item.active { background: var(--brand-lt); color: var(--brand); font-weight: 600; }
|
|
||||||
.tab-icon { flex-shrink: 0; display: flex; color: var(--text-3); }
|
|
||||||
.tab-item.active .tab-icon { color: var(--brand); }
|
|
||||||
.tab-label { flex: 1; }
|
|
||||||
.tab-badge {
|
|
||||||
min-width: 19px; height: 19px; padding: 0 5px; border-radius: 10px;
|
|
||||||
background: var(--brand); color: white; font-size: 10.5px; font-weight: 700;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.tab-item.active .tab-badge { background: var(--brand-dk); }
|
|
||||||
|
|
||||||
/* Assign CTA */
|
|
||||||
.assign-cta {
|
|
||||||
margin: 0 10px 14px; display: flex; align-items: center; justify-content: center; gap: 7px;
|
|
||||||
padding: 9px; border: 1.5px dashed var(--border); border-radius: var(--r-sm);
|
|
||||||
background: transparent; cursor: pointer; font-size: 12.5px; font-weight: 500; color: var(--text-2);
|
|
||||||
transition: all .15s;
|
|
||||||
}
|
|
||||||
.assign-cta:hover { border-color: var(--brand); color: var(--brand); background: var(--brand-lt); }
|
|
||||||
|
|
||||||
/* ── Main content ──────────────────────────────────────────────────────── */
|
|
||||||
.main-content { padding: 24px 28px; overflow-y: auto; }
|
|
||||||
.tab-section { animation: fadeUp .2s ease; }
|
|
||||||
@keyframes fadeUp { from { opacity:0; transform:translateY(5px); } to { opacity:1; transform:translateY(0); } }
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
.section-title { font-size: 17px; font-weight: 700; color: var(--text-1); margin: 0; }
|
|
||||||
|
|
||||||
/* ── Info grid ─────────────────────────────────────────────────────────── */
|
|
||||||
.info-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
.full-col { grid-column: 1 / -1; }
|
|
||||||
|
|
||||||
/* Info card */
|
|
||||||
.info-card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
.info-card-header {
|
|
||||||
display: flex; align-items: center; gap: 9px;
|
|
||||||
padding: 12px 16px; border-bottom: 1px solid var(--border-lt);
|
|
||||||
background: var(--surface-2);
|
|
||||||
}
|
|
||||||
.ic-icon {
|
|
||||||
width: 26px; height: 26px; border-radius: 7px; display: flex; align-items: center; justify-content: center;
|
|
||||||
background: color-mix(in srgb, var(--a, #4f46e5) 14%, transparent);
|
|
||||||
color: var(--a, #4f46e5);
|
|
||||||
}
|
|
||||||
.ic-title {
|
|
||||||
font-size: 11.5px; font-weight: 700; color: var(--text-1);
|
|
||||||
text-transform: uppercase; letter-spacing: .6px;
|
|
||||||
}
|
|
||||||
.info-card-body { padding: 4px 16px 12px; }
|
|
||||||
|
|
||||||
/* Data row */
|
|
||||||
.data-row { display: flex; justify-content: space-between; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--border-lt); gap: 12px; }
|
|
||||||
.data-row:last-child { border-bottom: none; }
|
|
||||||
.data-label { font-size: 12px; color: var(--text-3); font-weight: 500; flex-shrink: 0; }
|
|
||||||
.data-value { font-size: 13px; color: var(--text-1); text-align: right; }
|
|
||||||
.fw6 { font-weight: 600; }
|
|
||||||
|
|
||||||
.notes-text { font-size: 13.5px; color: var(--text-2); line-height: 1.7; margin: 8px 0 0; }
|
|
||||||
|
|
||||||
/* ── Edit form ─────────────────────────────────────────────────────────── */
|
|
||||||
.card-wrap {
|
|
||||||
background: var(--surface); border: 1px solid var(--border);
|
|
||||||
border-radius: var(--r-md); padding: 24px; box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
.edit-form { display: flex; flex-direction: column; gap: 18px; }
|
|
||||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
||||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.form-group label { font-size: 12px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: .4px; }
|
|
||||||
.form-input, .form-select, .form-textarea {
|
|
||||||
padding: 9px 12px; border: 1px solid var(--border); border-radius: var(--r-sm);
|
|
||||||
font-size: 13.5px; color: var(--text-1); background: var(--surface); outline: none;
|
|
||||||
transition: border-color .15s, box-shadow .15s; font-family: inherit;
|
|
||||||
}
|
|
||||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
|
||||||
border-color: var(--brand); box-shadow: 0 0 0 3px rgba(79,70,229,.1);
|
|
||||||
}
|
|
||||||
.form-textarea { resize: vertical; }
|
|
||||||
.form-actions { display: flex; gap: 10px; justify-content: flex-end; padding-top: 4px; }
|
|
||||||
|
|
||||||
/* ── Team ──────────────────────────────────────────────────────────────── */
|
|
||||||
.practitioner-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
|
||||||
.practitioner-card {
|
|
||||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md);
|
|
||||||
padding: 14px 16px; display: flex; align-items: center; gap: 12px;
|
|
||||||
box-shadow: var(--shadow-sm); transition: box-shadow .15s;
|
|
||||||
}
|
|
||||||
.practitioner-card:hover { box-shadow: var(--shadow-md); }
|
|
||||||
.pract-avatar {
|
|
||||||
width: 42px; height: 42px; border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
|
||||||
color: white; display: flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 13px; font-weight: 700; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.pract-info { flex: 1; }
|
|
||||||
.pract-name { font-size: 13.5px; font-weight: 600; color: var(--text-1); }
|
|
||||||
.role-chip { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 10.5px; font-weight: 600; margin-top: 3px; }
|
|
||||||
.chip-principal { background: #eef2ff; color: #4f46e5; }
|
|
||||||
.chip-assistant { background: #f0fdf4; color: #16a34a; }
|
|
||||||
.unassign-btn {
|
|
||||||
width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--border);
|
|
||||||
background: var(--surface); cursor: pointer; display: flex; align-items: center; justify-content: center;
|
|
||||||
color: var(--text-3); transition: all .15s; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.unassign-btn:hover { background: #fee2e2; border-color: #fca5a5; color: #dc2626; }
|
|
||||||
|
|
||||||
/* ── Status badge ──────────────────────────────────────────────────────── */
|
|
||||||
.status-badge {
|
|
||||||
display: inline-flex; align-items: center; padding: 3px 10px;
|
|
||||||
border-radius: 20px; font-size: 11.5px; font-weight: 600; letter-spacing: .2px;
|
|
||||||
}
|
|
||||||
.sb-success { background:#dcfce7; color:#16a34a; }
|
|
||||||
.sb-warning { background:#fef9c3; color:#ca8a04; }
|
|
||||||
.sb-danger { background:#fee2e2; color:#dc2626; }
|
|
||||||
.sb-info { background:#dbeafe; color:#2563eb; }
|
|
||||||
.sb-primary { background:#eef2ff; color:#4f46e5; }
|
|
||||||
.sb-secondary{ background:#f1f5f9; color:#64748b; }
|
|
||||||
|
|
||||||
/* Status chip (quote) */
|
|
||||||
.status-chip { display:inline-block; padding:2px 9px; border-radius:10px; font-size:11.5px; font-weight:600; }
|
|
||||||
.sc-success { background:#dcfce7; color:#16a34a; }
|
|
||||||
.sc-info { background:#dbeafe; color:#2563eb; }
|
|
||||||
.sc-warning { background:#fef9c3; color:#ca8a04; }
|
|
||||||
.sc-danger { background:#fee2e2; color:#dc2626; }
|
|
||||||
.sc-secondary{ background:#f1f5f9; color:#64748b; }
|
|
||||||
|
|
||||||
/* ── Buttons ───────────────────────────────────────────────────────────── */
|
|
||||||
.btn-primary {
|
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
|
||||||
padding: 9px 18px; border-radius: var(--r-sm); border: none; cursor: pointer;
|
|
||||||
font-size: 13px; font-weight: 600; color: white; background: var(--brand);
|
|
||||||
text-decoration: none; transition: all .15s;
|
|
||||||
}
|
|
||||||
.btn-primary:hover { background: var(--brand-dk); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(79,70,229,.3); }
|
|
||||||
.btn-primary.sm { padding: 6px 13px; font-size: 12px; }
|
|
||||||
.btn-primary:disabled { opacity: .6; cursor: not-allowed; transform: none; }
|
|
||||||
.btn-ghost {
|
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
|
||||||
padding: 9px 18px; border-radius: var(--r-sm); border: 1px solid var(--border);
|
|
||||||
cursor: pointer; font-size: 13px; font-weight: 500; color: var(--text-2); background: transparent;
|
|
||||||
transition: all .15s;
|
|
||||||
}
|
|
||||||
.btn-ghost:hover { background: var(--surface-3); color: var(--text-1); }
|
|
||||||
|
|
||||||
/* ── Quote ─────────────────────────────────────────────────────────────── */
|
|
||||||
.quote-lines { margin-top: 18px; }
|
|
||||||
.lines-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; color: var(--text-2); margin-bottom: 10px; }
|
|
||||||
.table-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden; box-shadow: var(--shadow-sm); }
|
|
||||||
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
||||||
.data-table thead { background: var(--surface-2); }
|
|
||||||
.data-table th { padding: 10px 16px; font-size: 11px; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid var(--border); text-align: left; }
|
|
||||||
.data-table td { padding: 11px 16px; border-bottom: 1px solid var(--border-lt); color: var(--text-1); }
|
|
||||||
.data-table tbody tr:last-child td { border-bottom: none; }
|
|
||||||
.data-table tbody tr:hover td { background: var(--surface-2); }
|
|
||||||
.tc { text-align: center; }
|
|
||||||
.tr { text-align: right; }
|
|
||||||
|
|
||||||
/* ── Empty state ───────────────────────────────────────────────────────── */
|
|
||||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 56px 24px; text-align: center; gap: 10px; }
|
|
||||||
.empty-icon { width: 60px; height: 60px; border-radius: 50%; background: var(--surface-3); display: flex; align-items: center; justify-content: center; color: var(--text-3); margin-bottom: 4px; }
|
|
||||||
.empty-msg { font-size: 14px; color: var(--text-2); margin: 0; font-weight: 500; }
|
|
||||||
.coming-soon-chip { font-size: 11px; color: var(--text-3); background: var(--surface-3); padding: 3px 10px; border-radius: 20px; }
|
|
||||||
|
|
||||||
/* ── Loading / error ───────────────────────────────────────────────────── */
|
|
||||||
.fullpage-center { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; gap: 14px; padding: 80px; text-align: center; }
|
|
||||||
.loading-orb { width: 40px; height: 40px; border-radius: 50%; border: 3px solid var(--border); border-top-color: var(--brand); animation: spin .75s linear infinite; }
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
.loading-text { font-size: 14px; color: var(--text-2); margin: 0; }
|
|
||||||
.state-icon { width: 60px; height: 60px; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
|
||||||
.error-icon { background: #fee2e2; color: #dc2626; }
|
|
||||||
.state-title { font-size: 18px; font-weight: 700; margin: 0; }
|
|
||||||
.state-desc { font-size: 14px; color: var(--text-2); margin: 0; }
|
|
||||||
|
|
||||||
.mini-spinner { width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgba(79,70,229,.3); border-top-color: var(--brand); animation: spin .6s linear infinite; display: inline-block; }
|
|
||||||
.mini-spinner.white { border-color: rgba(255,255,255,.3); border-top-color: white; }
|
|
||||||
|
|
||||||
/* ── Responsive ────────────────────────────────────────────────────────── */
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.page-layout { grid-template-columns: 1fr; }
|
|
||||||
.sidebar { position: static; height: auto; border-right: none; border-bottom: 1px solid var(--border); }
|
|
||||||
.tab-nav { flex-direction: row; flex-wrap: wrap; }
|
|
||||||
.tab-item { flex: none; }
|
|
||||||
.info-grid { grid-template-columns: 1fr; }
|
|
||||||
.full-col { grid-column: 1; }
|
|
||||||
.form-row { grid-template-columns: 1fr; }
|
|
||||||
.main-content { padding: 16px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
<template>
|
|
||||||
<nav class="tab-nav">
|
|
||||||
<button
|
|
||||||
v-for="tab in tabs"
|
|
||||||
:key="tab.id"
|
|
||||||
class="tab-item"
|
|
||||||
:class="{ active: activeTab === tab.id }"
|
|
||||||
@click="$emit('change-tab', tab.id)"
|
|
||||||
>
|
|
||||||
<span class="tab-icon" v-html="tab.icon"></span>
|
|
||||||
<span class="tab-label">{{ tab.label }}</span>
|
|
||||||
<span v-if="tab.id === 'team' && teamCount > 0" class="tab-badge">{{ teamCount }}</span>
|
|
||||||
<span v-if="tab.id === 'documents' && documentsCount > 0" class="tab-badge">{{ documentsCount }}</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { defineProps, defineEmits } from 'vue';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
activeTab: { type: String, required: true },
|
|
||||||
teamCount: { type: Number, default: 0 },
|
|
||||||
documentsCount:{ type: Number, default: 0 },
|
|
||||||
});
|
|
||||||
defineEmits(['change-tab']);
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id:'overview', label:"Vue d'ensemble", icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>` },
|
|
||||||
{ id:'details', label:'Détails', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>` },
|
|
||||||
{ id:'team', label:'Équipe', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>` },
|
|
||||||
{ id:'documents', label:'Documents', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>` },
|
|
||||||
{ id:'quote', label:'Devis', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>` },
|
|
||||||
{ id:'history', label:'Historique', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.5"/></svg>` },
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tab-nav {
|
|
||||||
--brand: #4f46e5;
|
|
||||||
--brand-lt: #eef2ff;
|
|
||||||
--brand-dk: #3730a3;
|
|
||||||
--surface-2:#f8fafc;
|
|
||||||
--border-lt:#f1f5f9;
|
|
||||||
--text-1: #0f172a;
|
|
||||||
--text-2: #64748b;
|
|
||||||
--text-3: #94a3b8;
|
|
||||||
--r-sm: 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-item {
|
|
||||||
display: flex; align-items: center; gap: 9px;
|
|
||||||
padding: 8px 11px; border-radius: var(--r-sm);
|
|
||||||
border: none; background: transparent; cursor: pointer;
|
|
||||||
width: 100%; text-align: left;
|
|
||||||
font-size: 13.5px; font-weight: 500; color: var(--text-2);
|
|
||||||
transition: background .12s, color .12s;
|
|
||||||
}
|
|
||||||
.tab-item:hover { background: var(--surface-2); color: var(--text-1); }
|
|
||||||
.tab-item.active { background: var(--brand-lt); color: var(--brand); font-weight: 600; }
|
|
||||||
|
|
||||||
.tab-icon { flex-shrink: 0; display: flex; color: var(--text-3); }
|
|
||||||
.tab-item.active .tab-icon { color: var(--brand); }
|
|
||||||
.tab-label { flex: 1; }
|
|
||||||
|
|
||||||
.tab-badge {
|
|
||||||
min-width: 19px; height: 19px; padding: 0 5px; border-radius: 10px;
|
|
||||||
background: var(--brand); color: white;
|
|
||||||
font-size: 10.5px; font-weight: 700;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.tab-item.active .tab-badge { background: var(--brand-dk); }
|
|
||||||
</style>
|
|
||||||
@ -10,7 +10,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="activeTab === 'info'" class="ed-pane">
|
<div v-show="activeTab === 'info'" class="ed-pane">
|
||||||
<EmployeeInfoTab :employee="employee" @employee-updated="updateEmployee" />
|
<EmployeeInfoTab
|
||||||
|
:employee="employee"
|
||||||
|
@employee-updated="updateEmployee"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="activeTab === 'user'" class="ed-pane">
|
<div v-show="activeTab === 'user'" class="ed-pane">
|
||||||
@ -41,15 +44,21 @@
|
|||||||
<div class="ed-grid">
|
<div class="ed-grid">
|
||||||
<div class="ed-data">
|
<div class="ed-data">
|
||||||
<span class="ed-data__label">Numero de licence</span>
|
<span class="ed-data__label">Numero de licence</span>
|
||||||
<strong class="ed-data__value">{{ practitioner?.license_number || 'Non renseigne' }}</strong>
|
<strong class="ed-data__value">{{
|
||||||
|
practitioner?.license_number || "Non renseigne"
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="ed-data">
|
<div class="ed-data">
|
||||||
<span class="ed-data__label">Numero d'autorisation</span>
|
<span class="ed-data__label">Numero d'autorisation</span>
|
||||||
<strong class="ed-data__value">{{ practitioner?.authorization_number || 'Non renseigne' }}</strong>
|
<strong class="ed-data__value">{{
|
||||||
|
practitioner?.authorization_number || "Non renseigne"
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="ed-data">
|
<div class="ed-data">
|
||||||
<span class="ed-data__label">Validite</span>
|
<span class="ed-data__label">Validite</span>
|
||||||
<strong class="ed-data__value">{{ formatDate(practitioner?.authorization_valid_until) }}</strong>
|
<strong class="ed-data__value">{{
|
||||||
|
formatDate(practitioner?.authorization_valid_until)
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -98,7 +107,8 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const practitioner = computed(
|
const practitioner = computed(
|
||||||
() => props.thanatopractitionerData || props.employee?.thanatopractitioner || null
|
() =>
|
||||||
|
props.thanatopractitionerData || props.employee?.thanatopractitioner || null
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
|
|||||||
@ -7,7 +7,10 @@
|
|||||||
:alt="employeeName"
|
:alt="employeeName"
|
||||||
class="employee-sidebar__avatar"
|
class="employee-sidebar__avatar"
|
||||||
/>
|
/>
|
||||||
<div v-else class="employee-sidebar__avatar employee-sidebar__avatar--fallback">
|
<div
|
||||||
|
v-else
|
||||||
|
class="employee-sidebar__avatar employee-sidebar__avatar--fallback"
|
||||||
|
>
|
||||||
{{ initials }}
|
{{ initials }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -18,11 +21,18 @@
|
|||||||
<div class="product-sidebar__badges employee-sidebar__badges">
|
<div class="product-sidebar__badges employee-sidebar__badges">
|
||||||
<span
|
<span
|
||||||
class="employee-sidebar__badge"
|
class="employee-sidebar__badge"
|
||||||
:class="isActive ? 'employee-sidebar__badge--success' : 'employee-sidebar__badge--muted'"
|
:class="
|
||||||
|
isActive
|
||||||
|
? 'employee-sidebar__badge--success'
|
||||||
|
: 'employee-sidebar__badge--muted'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ isActive ? "Actif" : "Inactif" }}
|
{{ isActive ? "Actif" : "Inactif" }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="isThanatopractitioner" class="employee-sidebar__badge employee-sidebar__badge--info">
|
<span
|
||||||
|
v-if="isThanatopractitioner"
|
||||||
|
class="employee-sidebar__badge employee-sidebar__badge--info"
|
||||||
|
>
|
||||||
Thanatopracteur
|
Thanatopracteur
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -35,7 +45,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="employee-sidebar__detail-item">
|
<div class="employee-sidebar__detail-item">
|
||||||
<span class="employee-sidebar__detail-label">Contact</span>
|
<span class="employee-sidebar__detail-label">Contact</span>
|
||||||
<span class="employee-sidebar__detail-value">{{ employee.email || employee.phone || "Non renseigne" }}</span>
|
<span class="employee-sidebar__detail-value">{{
|
||||||
|
employee.email || employee.phone || "Non renseigne"
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -147,7 +159,10 @@ const IconDocument = defineComponent({
|
|||||||
"stroke-width": "1.5",
|
"stroke-width": "1.5",
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
h("path", { d: "M5 2.5h4l2.5 2.5v7A1.5 1.5 0 0 1 10 13.5H5A1.5 1.5 0 0 1 3.5 12V4A1.5 1.5 0 0 1 5 2.5z" }),
|
h("path", {
|
||||||
|
d:
|
||||||
|
"M5 2.5h4l2.5 2.5v7A1.5 1.5 0 0 1 10 13.5H5A1.5 1.5 0 0 1 3.5 12V4A1.5 1.5 0 0 1 5 2.5z",
|
||||||
|
}),
|
||||||
h("path", { d: "M9 2.5V5h2.5" }),
|
h("path", { d: "M9 2.5V5h2.5" }),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|||||||
@ -58,9 +58,15 @@
|
|||||||
<p class="text-sm">{{ formatDate(clientGroup.updated_at) }}</p>
|
<p class="text-sm">{{ formatDate(clientGroup.updated_at) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-12 mb-3">
|
<div class="col-md-12 mb-3">
|
||||||
<h6 class="text-sm text-uppercase text-muted">Clients du groupe</h6>
|
<h6 class="text-sm text-uppercase text-muted">
|
||||||
|
Clients du groupe
|
||||||
|
</h6>
|
||||||
<p class="text-sm mb-2">
|
<p class="text-sm mb-2">
|
||||||
{{ clientGroup.clients_count || clientGroup.clients?.length || 0 }}
|
{{
|
||||||
|
clientGroup.clients_count ||
|
||||||
|
clientGroup.clients?.length ||
|
||||||
|
0
|
||||||
|
}}
|
||||||
client(s)
|
client(s)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -74,7 +80,9 @@
|
|||||||
class="d-flex align-items-center justify-content-between p-3 border rounded bg-light"
|
class="d-flex align-items-center justify-content-between p-3 border rounded bg-light"
|
||||||
>
|
>
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<span class="font-weight-bold text-sm">{{ client.name }}</span>
|
<span class="font-weight-bold text-sm">{{
|
||||||
|
client.name
|
||||||
|
}}</span>
|
||||||
<span class="text-xs text-muted">
|
<span class="text-xs text-muted">
|
||||||
{{ client.email || "Pas d'email" }}
|
{{ client.email || "Pas d'email" }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -54,11 +54,7 @@ onMounted(async () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notificationStore.error(
|
notificationStore.error("Erreur", "Impossible de charger le groupe", 3000);
|
||||||
"Erreur",
|
|
||||||
"Impossible de charger le groupe",
|
|
||||||
3000
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
router.push("/clients/groups");
|
router.push("/clients/groups");
|
||||||
|
|||||||
@ -2,7 +2,10 @@
|
|||||||
<new-convoy-template>
|
<new-convoy-template>
|
||||||
<template #multi-step></template>
|
<template #multi-step></template>
|
||||||
<template #convoy-form>
|
<template #convoy-form>
|
||||||
<new-convoy-form :loading="loading" @create-convoy="$emit('create-convoy', $event)" />
|
<new-convoy-form
|
||||||
|
:loading="loading"
|
||||||
|
@create-convoy="$emit('create-convoy', $event)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</new-convoy-template>
|
</new-convoy-template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -7,9 +7,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #header-pagination>
|
<template #header-pagination>
|
||||||
<div v-if="pagination && pagination.last_page > 1" class="d-flex justify-content-center">
|
<div
|
||||||
|
v-if="pagination && pagination.last_page > 1"
|
||||||
|
class="d-flex justify-content-center"
|
||||||
|
>
|
||||||
<soft-pagination color="success" size="sm">
|
<soft-pagination color="success" size="sm">
|
||||||
<soft-pagination-item prev :disabled="pagination.current_page <= 1" @click="changePage(pagination.current_page - 1)" />
|
<soft-pagination-item
|
||||||
|
prev
|
||||||
|
:disabled="pagination.current_page <= 1"
|
||||||
|
@click="changePage(pagination.current_page - 1)"
|
||||||
|
/>
|
||||||
<soft-pagination-item
|
<soft-pagination-item
|
||||||
v-for="page in visiblePages"
|
v-for="page in visiblePages"
|
||||||
:key="page"
|
:key="page"
|
||||||
@ -17,43 +24,86 @@
|
|||||||
:active="pagination.current_page === page"
|
:active="pagination.current_page === page"
|
||||||
@click="typeof page === 'number' && changePage(page)"
|
@click="typeof page === 'number' && changePage(page)"
|
||||||
/>
|
/>
|
||||||
<soft-pagination-item next :disabled="pagination.current_page >= pagination.last_page" @click="changePage(pagination.current_page + 1)" />
|
<soft-pagination-item
|
||||||
|
next
|
||||||
|
:disabled="pagination.current_page >= pagination.last_page"
|
||||||
|
@click="changePage(pagination.current_page + 1)"
|
||||||
|
/>
|
||||||
</soft-pagination>
|
</soft-pagination>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #select-filter>
|
<template #select-filter>
|
||||||
<soft-button color="dark" variant="outline" class="dropdown-toggle" data-bs-toggle="dropdown">
|
<soft-button
|
||||||
|
color="dark"
|
||||||
|
variant="outline"
|
||||||
|
class="dropdown-toggle"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
>
|
||||||
<i class="fas fa-filter me-2"></i> Filtrer
|
<i class="fas fa-filter me-2"></i> Filtrer
|
||||||
</soft-button>
|
</soft-button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end px-2 py-3">
|
<ul class="dropdown-menu dropdown-menu-end px-2 py-3">
|
||||||
<li><button class="dropdown-item border-radius-md" @click="$emit('filter-status', 'planned')">Planifiés</button></li>
|
<li>
|
||||||
<li><button class="dropdown-item border-radius-md" @click="$emit('filter-status', 'in_progress')">En cours</button></li>
|
<button
|
||||||
<li><button class="dropdown-item border-radius-md" @click="$emit('filter-status', 'completed')">Terminés</button></li>
|
class="dropdown-item border-radius-md"
|
||||||
|
@click="$emit('filter-status', 'planned')"
|
||||||
|
>
|
||||||
|
Planifiés
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="dropdown-item border-radius-md"
|
||||||
|
@click="$emit('filter-status', 'in_progress')"
|
||||||
|
>
|
||||||
|
En cours
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="dropdown-item border-radius-md"
|
||||||
|
@click="$emit('filter-status', 'completed')"
|
||||||
|
>
|
||||||
|
Terminés
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #convoy-list>
|
<template #convoy-list>
|
||||||
<div v-if="loading" class="text-center py-5">
|
<div v-if="loading" class="text-center py-5">
|
||||||
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Chargement...</span></div>
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Chargement...</span>
|
||||||
|
</div>
|
||||||
<p class="mt-2">Chargement des convois...</p>
|
<p class="mt-2">Chargement des convois...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="error" class="alert alert-danger text-center py-4">
|
<div v-else-if="error" class="alert alert-danger text-center py-4">
|
||||||
<p>{{ error }}</p>
|
<p>{{ error }}</p>
|
||||||
<button class="btn btn-outline-danger" @click="$emit('retry')">Réessayer</button>
|
<button class="btn btn-outline-danger" @click="$emit('retry')">
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!convoys.length" class="card border-0 shadow-sm text-center py-5">
|
<div
|
||||||
|
v-else-if="!convoys.length"
|
||||||
|
class="card border-0 shadow-sm text-center py-5"
|
||||||
|
>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<i class="fas fa-road fa-3x text-secondary mb-3"></i>
|
<i class="fas fa-road fa-3x text-secondary mb-3"></i>
|
||||||
<h5>Aucun convoi trouvé</h5>
|
<h5>Aucun convoi trouvé</h5>
|
||||||
<p class="text-sm text-secondary mb-0">Créez votre premier convoi pour commencer.</p>
|
<p class="text-sm text-secondary mb-0">
|
||||||
|
Créez votre premier convoi pour commencer.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="row g-4">
|
<div v-else class="row g-4">
|
||||||
<div v-for="convoy in convoys" :key="convoy.id" class="col-12 col-md-6 col-xl-4">
|
<div
|
||||||
|
v-for="convoy in convoys"
|
||||||
|
:key="convoy.id"
|
||||||
|
class="col-12 col-md-6 col-xl-4"
|
||||||
|
>
|
||||||
<convoy-event-card :convoy="convoy" @view="$emit('view', $event)" />
|
<convoy-event-card :convoy="convoy" @view="$emit('view', $event)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -77,13 +127,13 @@ const props = defineProps({
|
|||||||
pagination: { type: Object, default: null },
|
pagination: { type: Object, default: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const goToCreate = () => router.push({ name: "Ajouter convoi" });
|
const goToCreate = () => router.push({ name: "Ajouter convoi" });
|
||||||
|
|
||||||
const changePage = (page) => {
|
const changePage = (page) => {
|
||||||
if (typeof page !== "number") return;
|
if (typeof page !== "number") return;
|
||||||
if (page < 1 || (props.pagination && page > props.pagination.last_page)) return;
|
if (page < 1 || (props.pagination && page > props.pagination.last_page))
|
||||||
|
return;
|
||||||
if (page === props.pagination.current_page) return;
|
if (page === props.pagination.current_page) return;
|
||||||
emit("page-change", page);
|
emit("page-change", page);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,7 +8,12 @@
|
|||||||
<div v-else-if="error" class="vdp__state">
|
<div v-else-if="error" class="vdp__state">
|
||||||
<h5>Erreur de chargement</h5>
|
<h5>Erreur de chargement</h5>
|
||||||
<p>{{ error }}</p>
|
<p>{{ error }}</p>
|
||||||
<SoftButton color="primary" variant="outline" size="sm" @click="emit('reload')">
|
<SoftButton
|
||||||
|
color="primary"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="emit('reload')"
|
||||||
|
>
|
||||||
Réessayer
|
Réessayer
|
||||||
</SoftButton>
|
</SoftButton>
|
||||||
</div>
|
</div>
|
||||||
@ -17,7 +22,9 @@
|
|||||||
<h5>Véhicule introuvable</h5>
|
<h5>Véhicule introuvable</h5>
|
||||||
<p>Ce véhicule n'existe pas ou a été supprimé.</p>
|
<p>Ce véhicule n'existe pas ou a été supprimé.</p>
|
||||||
<RouterLink to="/employes/vehicules">
|
<RouterLink to="/employes/vehicules">
|
||||||
<SoftButton color="primary" variant="outline" size="sm">Retour à la liste</SoftButton>
|
<SoftButton color="primary" variant="outline" size="sm"
|
||||||
|
>Retour à la liste</SoftButton
|
||||||
|
>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -26,7 +33,14 @@
|
|||||||
<div class="vdp__topbar-left">
|
<div class="vdp__topbar-left">
|
||||||
<RouterLink to="/employes/vehicules">
|
<RouterLink to="/employes/vehicules">
|
||||||
<SoftButton color="secondary" variant="outline" size="sm">
|
<SoftButton color="secondary" variant="outline" size="sm">
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
<path d="M10 3L5 8l5 5" />
|
<path d="M10 3L5 8l5 5" />
|
||||||
</svg>
|
</svg>
|
||||||
Retour
|
Retour
|
||||||
@ -37,21 +51,40 @@
|
|||||||
<span class="vdp__breadcrumb-sep">/</span>
|
<span class="vdp__breadcrumb-sep">/</span>
|
||||||
<span>Véhicules</span>
|
<span>Véhicules</span>
|
||||||
<span class="vdp__breadcrumb-sep">/</span>
|
<span class="vdp__breadcrumb-sep">/</span>
|
||||||
<span class="vdp__breadcrumb-current">{{ vehicle.brand }} {{ vehicle.model }}</span>
|
<span class="vdp__breadcrumb-current"
|
||||||
|
>{{ vehicle.brand }} {{ vehicle.model }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="vdp__topbar-actions">
|
<div class="vdp__topbar-actions">
|
||||||
<template v-if="!isEditMode">
|
<template v-if="!isEditMode">
|
||||||
<SoftButton color="primary" variant="outline" size="sm" @click="startEdit">
|
<SoftButton
|
||||||
|
color="primary"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="startEdit"
|
||||||
|
>
|
||||||
Modifier
|
Modifier
|
||||||
</SoftButton>
|
</SoftButton>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SoftButton color="secondary" variant="outline" size="sm" :disabled="saving" @click="cancelEdit">
|
<SoftButton
|
||||||
|
color="secondary"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="cancelEdit"
|
||||||
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</SoftButton>
|
</SoftButton>
|
||||||
<SoftButton color="primary" variant="gradient" size="sm" :disabled="saving" @click="saveVehicle">
|
<SoftButton
|
||||||
|
color="primary"
|
||||||
|
variant="gradient"
|
||||||
|
size="sm"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="saveVehicle"
|
||||||
|
>
|
||||||
{{ saving ? "Sauvegarde..." : "Sauvegarder" }}
|
{{ saving ? "Sauvegarde..." : "Sauvegarder" }}
|
||||||
</SoftButton>
|
</SoftButton>
|
||||||
</template>
|
</template>
|
||||||
@ -78,7 +111,9 @@
|
|||||||
:key="tab.id"
|
:key="tab.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="vdp-sidebar__nav-item"
|
class="vdp-sidebar__nav-item"
|
||||||
:class="{ 'vdp-sidebar__nav-item--active': activeTab === tab.id }"
|
:class="{
|
||||||
|
'vdp-sidebar__nav-item--active': activeTab === tab.id,
|
||||||
|
}"
|
||||||
@click="activeTab = tab.id"
|
@click="activeTab = tab.id"
|
||||||
>
|
>
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
@ -104,15 +139,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Immatriculation</label>
|
<label class="form-label">Immatriculation</label>
|
||||||
<soft-input v-model="form.registration_number" :disabled="!isEditMode" />
|
<soft-input
|
||||||
|
v-model="form.registration_number"
|
||||||
|
:disabled="!isEditMode"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Année</label>
|
<label class="form-label">Année</label>
|
||||||
<soft-input v-model="form.year" type="number" :disabled="!isEditMode" />
|
<soft-input
|
||||||
|
v-model="form.year"
|
||||||
|
type="number"
|
||||||
|
:disabled="!isEditMode"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Type</label>
|
<label class="form-label">Type</label>
|
||||||
<select v-model="form.vehicle_type" class="form-control" :disabled="!isEditMode">
|
<select
|
||||||
|
v-model="form.vehicle_type"
|
||||||
|
class="form-control"
|
||||||
|
:disabled="!isEditMode"
|
||||||
|
>
|
||||||
<option value="utility">Utilitaire</option>
|
<option value="utility">Utilitaire</option>
|
||||||
<option value="hearse">Corbillard</option>
|
<option value="hearse">Corbillard</option>
|
||||||
<option value="transport_vehicle">Transport</option>
|
<option value="transport_vehicle">Transport</option>
|
||||||
@ -121,7 +167,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Carburant</label>
|
<label class="form-label">Carburant</label>
|
||||||
<select v-model="form.fuel_type" class="form-control" :disabled="!isEditMode">
|
<select
|
||||||
|
v-model="form.fuel_type"
|
||||||
|
class="form-control"
|
||||||
|
:disabled="!isEditMode"
|
||||||
|
>
|
||||||
<option value="diesel">Diesel</option>
|
<option value="diesel">Diesel</option>
|
||||||
<option value="petrol">Essence</option>
|
<option value="petrol">Essence</option>
|
||||||
<option value="electric">Électrique</option>
|
<option value="electric">Électrique</option>
|
||||||
@ -130,7 +180,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Statut</label>
|
<label class="form-label">Statut</label>
|
||||||
<select v-model="form.status" class="form-control" :disabled="!isEditMode">
|
<select
|
||||||
|
v-model="form.status"
|
||||||
|
class="form-control"
|
||||||
|
:disabled="!isEditMode"
|
||||||
|
>
|
||||||
<option value="active">Actif</option>
|
<option value="active">Actif</option>
|
||||||
<option value="maintenance">Maintenance</option>
|
<option value="maintenance">Maintenance</option>
|
||||||
<option value="out_of_service">Hors service</option>
|
<option value="out_of_service">Hors service</option>
|
||||||
@ -143,7 +197,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="position-relative">
|
<div v-else class="position-relative">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
<span class="input-group-text"
|
||||||
|
><i class="fas fa-search"></i
|
||||||
|
></span>
|
||||||
<input
|
<input
|
||||||
v-model="employeeSearch"
|
v-model="employeeSearch"
|
||||||
type="text"
|
type="text"
|
||||||
@ -166,13 +222,18 @@
|
|||||||
class="text-center py-2 position-absolute w-100 bg-white border rounded shadow-sm"
|
class="text-center py-2 position-absolute w-100 bg-white border rounded shadow-sm"
|
||||||
style="z-index: 1000; top: 100%"
|
style="z-index: 1000; top: 100%"
|
||||||
>
|
>
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
<div
|
||||||
|
class="spinner-border spinner-border-sm text-primary"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
<span class="visually-hidden">Chargement...</span>
|
<span class="visually-hidden">Chargement...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="employeeResults.length > 0 && showEmployeeResults"
|
v-else-if="
|
||||||
|
employeeResults.length > 0 && showEmployeeResults
|
||||||
|
"
|
||||||
class="list-group position-absolute w-100 mt-1 shadow-lg"
|
class="list-group position-absolute w-100 mt-1 shadow-lg"
|
||||||
style="z-index: 1000; max-height: 280px; overflow-y: auto"
|
style="z-index: 1000; max-height: 280px; overflow-y: auto"
|
||||||
>
|
>
|
||||||
@ -184,8 +245,14 @@
|
|||||||
@click="selectEmployee(employee)"
|
@click="selectEmployee(employee)"
|
||||||
>
|
>
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<span class="font-weight-bold text-sm">{{ employee.full_name }}</span>
|
<span class="font-weight-bold text-sm">{{
|
||||||
<span class="text-xs text-muted">{{ employee.email || employee.job_title || 'Aucune information' }}</span>
|
employee.full_name
|
||||||
|
}}</span>
|
||||||
|
<span class="text-xs text-muted">{{
|
||||||
|
employee.email ||
|
||||||
|
employee.job_title ||
|
||||||
|
"Aucune information"
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -193,7 +260,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label">Notes</label>
|
<label class="form-label">Notes</label>
|
||||||
<textarea v-model="form.notes" class="form-control" rows="4" :disabled="!isEditMode"></textarea>
|
<textarea
|
||||||
|
v-model="form.notes"
|
||||||
|
class="form-control"
|
||||||
|
rows="4"
|
||||||
|
:disabled="!isEditMode"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -209,21 +281,27 @@
|
|||||||
<div class="vdp-timeline__dot"></div>
|
<div class="vdp-timeline__dot"></div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Création du véhicule</strong>
|
<strong>Création du véhicule</strong>
|
||||||
<p class="mb-0 text-sm text-secondary">{{ formatDate(vehicle.created_at) }}</p>
|
<p class="mb-0 text-sm text-secondary">
|
||||||
|
{{ formatDate(vehicle.created_at) }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="vdp-timeline__item">
|
<div class="vdp-timeline__item">
|
||||||
<div class="vdp-timeline__dot"></div>
|
<div class="vdp-timeline__dot"></div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Dernière mise à jour</strong>
|
<strong>Dernière mise à jour</strong>
|
||||||
<p class="mb-0 text-sm text-secondary">{{ formatDate(vehicle.updated_at) }}</p>
|
<p class="mb-0 text-sm text-secondary">
|
||||||
|
{{ formatDate(vehicle.updated_at) }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="vdp-timeline__item" v-if="vehicle.primary_user">
|
<div v-if="vehicle.primary_user" class="vdp-timeline__item">
|
||||||
<div class="vdp-timeline__dot"></div>
|
<div class="vdp-timeline__dot"></div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Utilisateur actuellement assigné</strong>
|
<strong>Utilisateur actuellement assigné</strong>
|
||||||
<p class="mb-0 text-sm text-secondary">{{ vehicle.primary_user.full_name }}</p>
|
<p class="mb-0 text-sm text-secondary">
|
||||||
|
{{ vehicle.primary_user.full_name }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -337,7 +415,9 @@ const handleEmployeeSearch = () => {
|
|||||||
employeeLoading.value = true;
|
employeeLoading.value = true;
|
||||||
debounceTimeout = setTimeout(async () => {
|
debounceTimeout = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
employeeResults.value = await employeeStore.searchEmployees(employeeSearch.value);
|
employeeResults.value = await employeeStore.searchEmployees(
|
||||||
|
employeeSearch.value
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error searching employees:", error);
|
console.error("Error searching employees:", error);
|
||||||
employeeResults.value = [];
|
employeeResults.value = [];
|
||||||
@ -384,11 +464,13 @@ const formatStatus = (status) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusClass = (status) => {
|
const statusClass = (status) => {
|
||||||
return {
|
return (
|
||||||
active: "bg-gradient-success",
|
{
|
||||||
maintenance: "bg-gradient-warning",
|
active: "bg-gradient-success",
|
||||||
out_of_service: "bg-gradient-danger",
|
maintenance: "bg-gradient-warning",
|
||||||
}[status] || "bg-gradient-secondary";
|
out_of_service: "bg-gradient-danger",
|
||||||
|
}[status] || "bg-gradient-secondary"
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (value) => {
|
const formatDate = (value) => {
|
||||||
@ -421,11 +503,24 @@ const formatDate = (value) => {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.vdp__topbar-left { gap: 0.75rem; }
|
.vdp__topbar-left {
|
||||||
.vdp__topbar-actions { gap: 0.5rem; }
|
gap: 0.75rem;
|
||||||
.vdp__breadcrumb { gap: 5px; font-size: 13px; color: #9ca3af; }
|
}
|
||||||
.vdp__breadcrumb-sep { color: #d1d5db; }
|
.vdp__topbar-actions {
|
||||||
.vdp__breadcrumb-current { color: #374151; font-weight: 500; }
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.vdp__breadcrumb {
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.vdp__breadcrumb-sep {
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
.vdp__breadcrumb-current {
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
.vdp__body {
|
.vdp__body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 260px 1fr;
|
grid-template-columns: 260px 1fr;
|
||||||
@ -433,7 +528,9 @@ const formatDate = (value) => {
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
.vdp__sidebar,
|
.vdp__sidebar,
|
||||||
.vdp__panel { min-width: 0; }
|
.vdp__panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.vdp__state {
|
.vdp__state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -470,8 +567,13 @@ const formatDate = (value) => {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #8392ab;
|
color: #8392ab;
|
||||||
}
|
}
|
||||||
.vdp-card__body { padding: 1.25rem; }
|
.vdp-card__body {
|
||||||
.vdp-card--sidebar { position: sticky; top: 1rem; }
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.vdp-card--sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
.vdp-sidebar__hero {
|
.vdp-sidebar__hero {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -489,9 +591,18 @@ const formatDate = (value) => {
|
|||||||
background: linear-gradient(310deg, #5e72e4 0%, #825ee4 100%);
|
background: linear-gradient(310deg, #5e72e4 0%, #825ee4 100%);
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
.vdp-sidebar__hero h4 { margin-bottom: 0.25rem; }
|
.vdp-sidebar__hero h4 {
|
||||||
.vdp-sidebar__hero p { color: #8392ab; margin-bottom: 0.75rem; }
|
margin-bottom: 0.25rem;
|
||||||
.vdp-sidebar__nav { padding: 0.75rem; display: grid; gap: 0.5rem; }
|
}
|
||||||
|
.vdp-sidebar__hero p {
|
||||||
|
color: #8392ab;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.vdp-sidebar__nav {
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
.vdp-sidebar__nav-item {
|
.vdp-sidebar__nav-item {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@ -514,8 +625,15 @@ const formatDate = (value) => {
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
}
|
}
|
||||||
.vdp-timeline { display: grid; gap: 1rem; }
|
.vdp-timeline {
|
||||||
.vdp-timeline__item { display: flex; gap: 0.9rem; align-items: flex-start; }
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.vdp-timeline__item {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.9rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
.vdp-timeline__dot {
|
.vdp-timeline__dot {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
@ -530,9 +648,17 @@ input.form-control,
|
|||||||
.input-group-text {
|
.input-group-text {
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
}
|
}
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.vdp { padding: 1rem; }
|
.vdp {
|
||||||
.vdp__body { grid-template-columns: 1fr; }
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.vdp__body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,160 +1,86 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="sidebar-wrap">
|
<div class="card position-sticky top-1">
|
||||||
<!-- Hero Card -->
|
<div class="card-body text-center">
|
||||||
<div class="hero-card">
|
<ClientAvatar
|
||||||
<div class="hero-avatar">
|
:initials="getInitials(intervention.defuntName || '?')"
|
||||||
<svg
|
:alt="intervention.defuntName || 'Intervention'"
|
||||||
width="26"
|
/>
|
||||||
height="26"
|
|
||||||
viewBox="0 0 24 24"
|
<h5 class="font-weight-bolder mb-0">
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
||||||
<circle cx="9" cy="7" r="4" />
|
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 class="hero-name">
|
|
||||||
{{ intervention.defuntName || "Personne inconnue" }}
|
{{ intervention.defuntName || "Personne inconnue" }}
|
||||||
</h2>
|
</h5>
|
||||||
<p class="hero-type">{{ intervention.title || "Type non défini" }}</p>
|
<p class="text-sm text-secondary mb-2">
|
||||||
<div
|
{{ intervention.title || "Type non défini" }}
|
||||||
class="status-badge"
|
</p>
|
||||||
:class="'sb-' + (intervention.status?.color || 'secondary')"
|
|
||||||
>
|
<span class="badge badge-sm" :class="statusBadgeClass">
|
||||||
{{ intervention.status?.label || "En attente" }}
|
{{ intervention.status?.label || "En attente" }}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="row text-center mt-3">
|
||||||
|
<div class="col-4 border-end">
|
||||||
|
<h6 class="text-sm font-weight-bolder mb-0">
|
||||||
|
{{ intervention.date || "-" }}
|
||||||
|
</h6>
|
||||||
|
<p class="text-xs text-secondary mb-0">Date</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-4 border-end">
|
||||||
|
<h6 class="text-sm font-weight-bolder mb-0">
|
||||||
|
{{ intervention.lieux || "-" }}
|
||||||
|
</h6>
|
||||||
|
<p class="text-xs text-secondary mb-0">Lieu</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<h6 class="text-sm font-weight-bolder mb-0">
|
||||||
|
{{ intervention.duree || "-" }}
|
||||||
|
</h6>
|
||||||
|
<p class="text-xs text-secondary mb-0">Durée</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Quick Stats -->
|
<div v-if="intervention.members?.length" class="mt-3">
|
||||||
<div class="quick-stats">
|
<span class="text-xs text-secondary d-block mb-2">Équipe</span>
|
||||||
<div class="qs-row">
|
<div class="d-flex flex-wrap justify-content-center gap-2">
|
||||||
<div class="qs-icon" style="background: #eef2ff; color: #4f46e5">
|
<span
|
||||||
<svg
|
v-for="(member, index) in intervention.members.slice(0, 5)"
|
||||||
width="13"
|
:key="index"
|
||||||
height="13"
|
class="badge bg-gradient-light text-dark"
|
||||||
viewBox="0 0 24 24"
|
:title="member.name"
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
>
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
{{ getInitials(member.name) }}
|
||||||
<line x1="16" y1="2" x2="16" y2="6" />
|
</span>
|
||||||
<line x1="8" y1="2" x2="8" y2="6" />
|
<span
|
||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
v-if="intervention.members.length > 5"
|
||||||
</svg>
|
class="badge bg-gradient-secondary"
|
||||||
</div>
|
|
||||||
<div class="qs-text">
|
|
||||||
<div class="qs-label">Date</div>
|
|
||||||
<div class="qs-value">{{ intervention.date || "—" }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="qs-row">
|
|
||||||
<div class="qs-icon" style="background: #ecfdf5; color: #059669">
|
|
||||||
<svg
|
|
||||||
width="13"
|
|
||||||
height="13"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
>
|
||||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
+{{ intervention.members.length - 5 }}
|
||||||
<circle cx="12" cy="10" r="3" />
|
</span>
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="qs-text">
|
|
||||||
<div class="qs-label">Lieu</div>
|
|
||||||
<div class="qs-value">{{ intervention.lieux || "—" }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="qs-row">
|
|
||||||
<div class="qs-icon" style="background: #fff7ed; color: #d97706">
|
|
||||||
<svg
|
|
||||||
width="13"
|
|
||||||
height="13"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<polyline points="12 6 12 12 16 14" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="qs-text">
|
|
||||||
<div class="qs-label">Durée</div>
|
|
||||||
<div class="qs-value">{{ intervention.duree || "—" }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<hr class="horizontal dark my-3 mx-3" />
|
||||||
|
|
||||||
<!-- Team preview -->
|
<div class="card-body pt-0">
|
||||||
<div v-if="intervention.members?.length" class="team-preview">
|
<ul class="nav nav-pills flex-column">
|
||||||
<div class="tp-label">Équipe</div>
|
<TabNavigationItem
|
||||||
<div class="tp-avatars">
|
v-for="tab in tabs"
|
||||||
<div
|
:key="tab.id"
|
||||||
v-for="(m, i) in intervention.members.slice(0, 5)"
|
:icon="tab.icon"
|
||||||
:key="i"
|
:label="tab.label"
|
||||||
class="tp-avatar"
|
:is-active="activeTab === tab.id"
|
||||||
:title="m.name"
|
:badge="getTabBadge(tab.id)"
|
||||||
:style="{ zIndex: 10 - i }"
|
:spacing="tab.spacing || ''"
|
||||||
>
|
@click="$emit('change-tab', tab.id)"
|
||||||
{{ getInitials(m.name) }}
|
/>
|
||||||
</div>
|
</ul>
|
||||||
<div v-if="intervention.members.length > 5" class="tp-avatar tp-more">
|
|
||||||
+{{ intervention.members.length - 5 }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<!-- Tab Navigation -->
|
|
||||||
<nav class="tab-nav">
|
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
type="button"
|
||||||
:key="tab.id"
|
class="btn btn-outline-secondary btn-sm w-100 mt-3"
|
||||||
class="tab-item"
|
@click="$emit('assign-practitioner')"
|
||||||
:class="{ active: activeTab === tab.id }"
|
|
||||||
@click="$emit('change-tab', tab.id)"
|
|
||||||
>
|
>
|
||||||
<span class="tab-icon" v-html="tab.icon"></span>
|
<i class="fas fa-user-plus me-2"></i>
|
||||||
<span class="tab-label">{{ tab.label }}</span>
|
|
||||||
<span v-if="tab.id === 'team' && teamCount > 0" class="tab-badge">{{
|
|
||||||
teamCount
|
|
||||||
}}</span>
|
|
||||||
<span
|
|
||||||
v-if="tab.id === 'documents' && documentsCount > 0"
|
|
||||||
class="tab-badge"
|
|
||||||
>{{ documentsCount }}</span
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Assign Button -->
|
|
||||||
<div class="assign-wrap">
|
|
||||||
<button class="assign-btn" @click="$emit('assign-practitioner')">
|
|
||||||
<svg
|
|
||||||
width="13"
|
|
||||||
height="13"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2.5"
|
|
||||||
>
|
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
||||||
<circle cx="8.5" cy="7" r="4" />
|
|
||||||
<line x1="20" y1="8" x2="20" y2="14" />
|
|
||||||
<line x1="23" y1="11" x2="17" y2="11" />
|
|
||||||
</svg>
|
|
||||||
Assigner un praticien
|
Assigner un praticien
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -162,9 +88,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, defineEmits } from "vue";
|
import { computed, defineProps, defineEmits } from "vue";
|
||||||
|
import ClientAvatar from "@/components/atoms/client/ClientAvatar.vue";
|
||||||
|
import TabNavigationItem from "@/components/atoms/client/TabNavigationItem.vue";
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
intervention: { type: Object, required: true },
|
intervention: { type: Object, required: true },
|
||||||
activeTab: { type: String, default: "overview" },
|
activeTab: { type: String, default: "overview" },
|
||||||
practitioners: { type: Array, default: () => [] },
|
practitioners: { type: Array, default: () => [] },
|
||||||
@ -173,6 +101,19 @@ defineProps({
|
|||||||
});
|
});
|
||||||
defineEmits(["change-tab", "assign-practitioner"]);
|
defineEmits(["change-tab", "assign-practitioner"]);
|
||||||
|
|
||||||
|
const statusBadgeClass = computed(() => {
|
||||||
|
const color = props.intervention.status?.color || "secondary";
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: "bg-gradient-success",
|
||||||
|
warning: "bg-gradient-warning",
|
||||||
|
danger: "bg-gradient-danger",
|
||||||
|
info: "bg-gradient-info",
|
||||||
|
primary: "bg-gradient-primary",
|
||||||
|
secondary: "bg-gradient-secondary",
|
||||||
|
}[color];
|
||||||
|
});
|
||||||
|
|
||||||
const getInitials = (n) =>
|
const getInitials = (n) =>
|
||||||
n
|
n
|
||||||
? n
|
? n
|
||||||
@ -183,295 +124,61 @@ const getInitials = (n) =>
|
|||||||
.substring(0, 2)
|
.substring(0, 2)
|
||||||
: "?";
|
: "?";
|
||||||
|
|
||||||
|
const getTabBadge = (tabId) => {
|
||||||
|
if (tabId === "team") {
|
||||||
|
return props.teamCount > 0 ? props.teamCount : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabId === "documents") {
|
||||||
|
return props.documentsCount > 0 ? props.documentsCount : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
id: "overview",
|
id: "overview",
|
||||||
label: "Vue d'ensemble",
|
label: "Vue d'ensemble",
|
||||||
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`,
|
icon: "fas fa-eye",
|
||||||
|
spacing: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "details",
|
id: "details",
|
||||||
label: "Détails",
|
label: "Détails",
|
||||||
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`,
|
icon: "fas fa-info-circle",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "team",
|
id: "team",
|
||||||
label: "Équipe",
|
label: "Équipe",
|
||||||
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
|
icon: "fas fa-users",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "documents",
|
id: "documents",
|
||||||
label: "Documents",
|
label: "Documents",
|
||||||
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
|
icon: "fas fa-file-alt",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "quote",
|
id: "quote",
|
||||||
label: "Devis",
|
label: "Devis",
|
||||||
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
|
icon: "fas fa-file-invoice",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "history",
|
id: "history",
|
||||||
label: "Historique",
|
label: "Historique",
|
||||||
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.5"/></svg>`,
|
icon: "fas fa-history",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.sidebar-wrap {
|
.position-sticky {
|
||||||
--brand: #4f46e5;
|
top: 1rem;
|
||||||
--brand-lt: #eef2ff;
|
|
||||||
--brand-dk: #3730a3;
|
|
||||||
--surface: #ffffff;
|
|
||||||
--surface-2: #f8fafc;
|
|
||||||
--border: #e2e8f0;
|
|
||||||
--border-lt: #f1f5f9;
|
|
||||||
--text-1: #0f172a;
|
|
||||||
--text-2: #64748b;
|
|
||||||
--text-3: #94a3b8;
|
|
||||||
--r-sm: 8px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hero */
|
.card {
|
||||||
.hero-card {
|
border: 0;
|
||||||
padding: 24px 20px 18px;
|
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.hero-avatar {
|
|
||||||
width: 58px;
|
|
||||||
height: 58px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.28);
|
|
||||||
}
|
|
||||||
.hero-name {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-1);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
.hero-type {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-2);
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status badge */
|
|
||||||
.status-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.sb-success {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #16a34a;
|
|
||||||
}
|
|
||||||
.sb-warning {
|
|
||||||
background: #fef9c3;
|
|
||||||
color: #ca8a04;
|
|
||||||
}
|
|
||||||
.sb-danger {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
.sb-info {
|
|
||||||
background: #dbeafe;
|
|
||||||
color: #2563eb;
|
|
||||||
}
|
|
||||||
.sb-primary {
|
|
||||||
background: #eef2ff;
|
|
||||||
color: #4f46e5;
|
|
||||||
}
|
|
||||||
.sb-secondary {
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--border-lt);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Quick stats */
|
|
||||||
.quick-stats {
|
|
||||||
padding: 14px 18px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.qs-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.qs-icon {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 7px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.qs-label {
|
|
||||||
font-size: 10.5px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-3);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.qs-value {
|
|
||||||
font-size: 12.5px;
|
|
||||||
color: var(--text-1);
|
|
||||||
font-weight: 500;
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Team preview */
|
|
||||||
.team-preview {
|
|
||||||
padding: 12px 18px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.tp-label {
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-3);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.4px;
|
|
||||||
}
|
|
||||||
.tp-avatars {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
.tp-avatar {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
|
||||||
border: 2px solid var(--surface);
|
|
||||||
color: white;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-left: -6px;
|
|
||||||
cursor: default;
|
|
||||||
transition: transform 0.15s;
|
|
||||||
}
|
|
||||||
.tp-avatar:first-child {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
.tp-avatar:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
}
|
|
||||||
.tp-more {
|
|
||||||
background: var(--surface-2);
|
|
||||||
color: var(--text-2);
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tab nav */
|
|
||||||
.tab-nav {
|
|
||||||
padding: 8px 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
.tab-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 9px;
|
|
||||||
padding: 8px 11px;
|
|
||||||
border-radius: var(--r-sm);
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-2);
|
|
||||||
transition: all 0.12s;
|
|
||||||
}
|
|
||||||
.tab-item:hover {
|
|
||||||
background: var(--surface-2);
|
|
||||||
color: var(--text-1);
|
|
||||||
}
|
|
||||||
.tab-item.active {
|
|
||||||
background: var(--brand-lt);
|
|
||||||
color: var(--brand);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.tab-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
color: var(--text-3);
|
|
||||||
}
|
|
||||||
.tab-item.active .tab-icon {
|
|
||||||
color: var(--brand);
|
|
||||||
}
|
|
||||||
.tab-label {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.tab-badge {
|
|
||||||
min-width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
padding: 0 5px;
|
|
||||||
border-radius: 9px;
|
|
||||||
background: var(--brand);
|
|
||||||
color: white;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Assign */
|
|
||||||
.assign-wrap {
|
|
||||||
padding: 0 10px 12px;
|
|
||||||
}
|
|
||||||
.assign-btn {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 7px;
|
|
||||||
padding: 9px;
|
|
||||||
border: 1.5px dashed var(--border);
|
|
||||||
border-radius: var(--r-sm);
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-2);
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.assign-btn:hover {
|
|
||||||
border-color: var(--brand);
|
|
||||||
color: var(--brand);
|
|
||||||
background: var(--brand-lt);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@ -192,7 +192,9 @@ const recipientName = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const groupDetailsFallback = computed(() => {
|
const groupDetailsFallback = computed(() => {
|
||||||
return invoice.value?.group?.name ? `Groupe: ${invoice.value.group.name}` : "—";
|
return invoice.value?.group?.name
|
||||||
|
? `Groupe: ${invoice.value.group.name}`
|
||||||
|
: "—";
|
||||||
});
|
});
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
|
|||||||
@ -31,7 +31,9 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="recipient-toggle__btn"
|
class="recipient-toggle__btn"
|
||||||
:class="{ 'recipient-toggle__btn--active': form.recipient_type === 'client' }"
|
:class="{
|
||||||
|
'recipient-toggle__btn--active': form.recipient_type === 'client',
|
||||||
|
}"
|
||||||
@click="setRecipientType('client')"
|
@click="setRecipientType('client')"
|
||||||
>
|
>
|
||||||
Client
|
Client
|
||||||
@ -39,38 +41,59 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="recipient-toggle__btn"
|
class="recipient-toggle__btn"
|
||||||
:class="{ 'recipient-toggle__btn--active': form.recipient_type === 'group' }"
|
:class="{
|
||||||
|
'recipient-toggle__btn--active': form.recipient_type === 'group',
|
||||||
|
}"
|
||||||
@click="setRecipientType('group')"
|
@click="setRecipientType('group')"
|
||||||
>
|
>
|
||||||
Groupe client
|
Groupe client
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select
|
<div ref="recipientSearchRef" class="recipient-search">
|
||||||
v-if="form.recipient_type === 'client'"
|
<div class="recipient-search__input-wrap">
|
||||||
v-model="form.client_id"
|
<input
|
||||||
class="form-select field-select"
|
v-model="recipientQuery"
|
||||||
>
|
type="text"
|
||||||
<option :value="null" disabled>— Sélectionner un client —</option>
|
class="form-control field-select recipient-search__input"
|
||||||
<option v-for="client in clients" :key="client.id" :value="client.id">
|
:placeholder="recipientPlaceholder"
|
||||||
{{ client.name }}
|
@focus="openRecipientDropdown"
|
||||||
</option>
|
@input="handleRecipientInput"
|
||||||
</select>
|
/>
|
||||||
|
<button
|
||||||
|
v-if="selectedRecipient"
|
||||||
|
type="button"
|
||||||
|
class="recipient-search__clear"
|
||||||
|
@click="clearRecipientSelection"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<select
|
<div v-if="showRecipientDropdown" class="recipient-search__dropdown">
|
||||||
v-else
|
<button
|
||||||
v-model="form.group_id"
|
v-for="option in recipientOptions"
|
||||||
class="form-select field-select"
|
:key="`${form.recipient_type}-${option.id}`"
|
||||||
>
|
type="button"
|
||||||
<option :value="null" disabled>— Sélectionner un groupe —</option>
|
class="recipient-search__option"
|
||||||
<option
|
@click="selectRecipient(option)"
|
||||||
v-for="group in clientGroups"
|
>
|
||||||
:key="group.id"
|
<span class="recipient-search__option-name">{{
|
||||||
:value="group.id"
|
option.name
|
||||||
>
|
}}</span>
|
||||||
{{ group.name }}
|
<span class="recipient-search__option-meta">
|
||||||
</option>
|
{{ getRecipientOptionMeta(option) }}
|
||||||
</select>
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="recipientOptions.length === 0"
|
||||||
|
class="recipient-search__empty"
|
||||||
|
>
|
||||||
|
{{ recipientEmptyText }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p v-if="recipientError" class="field-error">
|
<p v-if="recipientError" class="field-error">
|
||||||
<i class="fas fa-exclamation-circle me-1"></i>{{ recipientError }}
|
<i class="fas fa-exclamation-circle me-1"></i>{{ recipientError }}
|
||||||
@ -164,7 +187,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import CreateQuoteTemplate from "@/components/templates/Quote/CreateQuoteTemplate.vue";
|
import CreateQuoteTemplate from "@/components/templates/Quote/CreateQuoteTemplate.vue";
|
||||||
import ProductLineItem from "@/components/molecules/Quote/ProductLineItem.vue";
|
import ProductLineItem from "@/components/molecules/Quote/ProductLineItem.vue";
|
||||||
@ -184,6 +207,9 @@ const { clientGroups } = storeToRefs(clientGroupStore);
|
|||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const attempted = ref(false);
|
const attempted = ref(false);
|
||||||
|
const recipientQuery = ref("");
|
||||||
|
const showRecipientDropdown = ref(false);
|
||||||
|
const recipientSearchRef = ref(null);
|
||||||
|
|
||||||
const statuses = [
|
const statuses = [
|
||||||
{
|
{
|
||||||
@ -226,6 +252,36 @@ const form = ref({
|
|||||||
lines: [defaultLine()],
|
lines: [defaultLine()],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedRecipient = computed(() => {
|
||||||
|
if (form.value.recipient_type === "client") {
|
||||||
|
return (
|
||||||
|
clients.value.find((client) => client.id === form.value.client_id) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
clientGroups.value.find((group) => group.id === form.value.group_id) || null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipientOptions = computed(() => {
|
||||||
|
return form.value.recipient_type === "client"
|
||||||
|
? clients.value
|
||||||
|
: clientGroups.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipientPlaceholder = computed(() => {
|
||||||
|
return form.value.recipient_type === "client"
|
||||||
|
? "Rechercher un client..."
|
||||||
|
: "Rechercher un groupe client...";
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipientEmptyText = computed(() => {
|
||||||
|
return form.value.recipient_type === "client"
|
||||||
|
? "Aucun client trouve"
|
||||||
|
: "Aucun groupe client trouve";
|
||||||
|
});
|
||||||
|
|
||||||
const recipientError = computed(() => {
|
const recipientError = computed(() => {
|
||||||
if (!attempted.value) return "";
|
if (!attempted.value) return "";
|
||||||
|
|
||||||
@ -263,6 +319,83 @@ const setRecipientType = (type) => {
|
|||||||
} else {
|
} else {
|
||||||
form.value.client_id = null;
|
form.value.client_id = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recipientQuery.value = "";
|
||||||
|
showRecipientDropdown.value = false;
|
||||||
|
fetchRecipientOptions("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRecipientOptionMeta = (option) => {
|
||||||
|
if (form.value.recipient_type === "client") {
|
||||||
|
return option.email || option.phone || `Client #${option.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return option.description || `Groupe #${option.id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRecipientOptions = async (search = "") => {
|
||||||
|
if (form.value.recipient_type === "client") {
|
||||||
|
await clientStore.fetchClients({
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
search: search || undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await clientGroupStore.fetchClientGroups({
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
search: search || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRecipientDropdown = async () => {
|
||||||
|
showRecipientDropdown.value = true;
|
||||||
|
await fetchRecipientOptions(recipientQuery.value.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecipientInput = async () => {
|
||||||
|
if (form.value.recipient_type === "client") {
|
||||||
|
form.value.client_id = null;
|
||||||
|
} else {
|
||||||
|
form.value.group_id = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
showRecipientDropdown.value = true;
|
||||||
|
await fetchRecipientOptions(recipientQuery.value.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectRecipient = (option) => {
|
||||||
|
if (form.value.recipient_type === "client") {
|
||||||
|
form.value.client_id = option.id;
|
||||||
|
} else {
|
||||||
|
form.value.group_id = option.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
recipientQuery.value = option.name;
|
||||||
|
showRecipientDropdown.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearRecipientSelection = async () => {
|
||||||
|
if (form.value.recipient_type === "client") {
|
||||||
|
form.value.client_id = null;
|
||||||
|
} else {
|
||||||
|
form.value.group_id = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
recipientQuery.value = "";
|
||||||
|
showRecipientDropdown.value = true;
|
||||||
|
await fetchRecipientOptions("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (!recipientSearchRef.value?.contains(event.target)) {
|
||||||
|
showRecipientDropdown.value = false;
|
||||||
|
if (selectedRecipient.value) {
|
||||||
|
recipientQuery.value = selectedRecipient.value.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (value) =>
|
const formatCurrency = (value) =>
|
||||||
@ -310,8 +443,18 @@ const saveQuote = async () => {
|
|||||||
const cancel = () => router.back();
|
const cancel = () => router.back();
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
clientStore.fetchClients();
|
fetchRecipientOptions("");
|
||||||
clientGroupStore.fetchClientGroups({ per_page: 100 });
|
document.addEventListener("click", handleClickOutside);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener("click", handleClickOutside);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(selectedRecipient, (value) => {
|
||||||
|
if (value) {
|
||||||
|
recipientQuery.value = value.name;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -360,6 +503,80 @@ onMounted(() => {
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recipient-search {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-search__input-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-search__input {
|
||||||
|
padding-right: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-search__clear {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0.75rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #8898aa;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-search__dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.35rem);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-search__option {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-search__option:hover {
|
||||||
|
background: #f8f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-search__option + .recipient-search__option {
|
||||||
|
border-top: 1px solid #f0f2f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-search__option-name {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #344767;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-search__option-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #8898aa;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-search__empty {
|
||||||
|
padding: 0.85rem 0.9rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #8898aa;
|
||||||
|
}
|
||||||
|
|
||||||
.field-error {
|
.field-error {
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@ -339,11 +339,7 @@ const changeStatus = (id, newStatus) => {
|
|||||||
e?.response?.data?.message ||
|
e?.response?.data?.message ||
|
||||||
e?.response?.data?.error ||
|
e?.response?.data?.error ||
|
||||||
"Impossible de mettre à jour le statut";
|
"Impossible de mettre à jour le statut";
|
||||||
notificationStore.error(
|
notificationStore.error("Erreur", message, 3000);
|
||||||
"Erreur",
|
|
||||||
message,
|
|
||||||
3000
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
updating.value = false;
|
updating.value = false;
|
||||||
|
|||||||
@ -47,7 +47,9 @@
|
|||||||
class="d-flex align-items-center justify-content-between p-2 border rounded bg-light"
|
class="d-flex align-items-center justify-content-between p-2 border rounded bg-light"
|
||||||
>
|
>
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<span class="font-weight-bold text-sm">{{ client.name }}</span>
|
<span class="font-weight-bold text-sm">{{
|
||||||
|
client.name
|
||||||
|
}}</span>
|
||||||
<span class="text-xs text-muted">
|
<span class="text-xs text-muted">
|
||||||
{{ client.email || "Pas d'email" }}
|
{{ client.email || "Pas d'email" }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -31,13 +31,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mt-4 pt-2 border-top">
|
<div
|
||||||
|
class="d-flex justify-content-between align-items-center mt-4 pt-2 border-top"
|
||||||
|
>
|
||||||
<div class="d-flex gap-2 text-xs text-secondary">
|
<div class="d-flex gap-2 text-xs text-secondary">
|
||||||
<span>{{ convoyTypeLabel }}</span>
|
<span>{{ convoyTypeLabel }}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{{ notificationLabel }}</span>
|
<span>{{ notificationLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<soft-button color="info" variant="outline" size="sm" @click="$emit('view', convoy.id)">
|
<soft-button
|
||||||
|
color="info"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="$emit('view', convoy.id)"
|
||||||
|
>
|
||||||
Voir
|
Voir
|
||||||
</soft-button>
|
</soft-button>
|
||||||
</div>
|
</div>
|
||||||
@ -62,7 +69,11 @@ defineEmits(["view"]);
|
|||||||
const deceasedName = computed(() => {
|
const deceasedName = computed(() => {
|
||||||
const deceased = props.convoy.deceased;
|
const deceased = props.convoy.deceased;
|
||||||
if (!deceased) return "Défunt non renseigné";
|
if (!deceased) return "Défunt non renseigné";
|
||||||
return deceased.full_name || [deceased.first_name, deceased.last_name].filter(Boolean).join(" ") || "Défunt";
|
return (
|
||||||
|
deceased.full_name ||
|
||||||
|
[deceased.first_name, deceased.last_name].filter(Boolean).join(" ") ||
|
||||||
|
"Défunt"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultTitle = computed(() => `Convoi #${props.convoy.id}`);
|
const defaultTitle = computed(() => `Convoi #${props.convoy.id}`);
|
||||||
@ -77,13 +88,52 @@ const formattedDate = computed(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const transportLabel = computed(() => ({ road: "Route", air: "Aérien", sea: "Maritime", rail: "Ferroviaire" }[props.convoy.transport_mode] || props.convoy.transport_mode));
|
const transportLabel = computed(
|
||||||
const convoyTypeLabel = computed(() => ({ local: "Local", national: "National", international: "International" }[props.convoy.convoy_type] || props.convoy.convoy_type));
|
() =>
|
||||||
const statusLabel = computed(() => ({ planned: "Planifié", in_progress: "En cours", completed: "Terminé", cancelled: "Annulé" }[props.convoy.status] || props.convoy.status));
|
({ road: "Route", air: "Aérien", sea: "Maritime", rail: "Ferroviaire" }[
|
||||||
const statusClass = computed(() => ({ planned: "bg-gradient-secondary", in_progress: "bg-gradient-info", completed: "bg-gradient-success", cancelled: "bg-gradient-danger" }[props.convoy.status] || "bg-gradient-secondary"));
|
props.convoy.transport_mode
|
||||||
const departureLabel = computed(() => props.convoy.departure?.city || props.convoy.departure?.name || "Départ non défini");
|
] || props.convoy.transport_mode)
|
||||||
const vehicleLabel = computed(() => props.convoy.vehicle ? `${props.convoy.vehicle.brand} ${props.convoy.vehicle.model}` : "Aucun véhicule");
|
);
|
||||||
const notificationLabel = computed(() => props.convoy.automatic_notifications ? "Notifications actives" : "Notifications inactives");
|
const convoyTypeLabel = computed(
|
||||||
|
() =>
|
||||||
|
({ local: "Local", national: "National", international: "International" }[
|
||||||
|
props.convoy.convoy_type
|
||||||
|
] || props.convoy.convoy_type)
|
||||||
|
);
|
||||||
|
const statusLabel = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
planned: "Planifié",
|
||||||
|
in_progress: "En cours",
|
||||||
|
completed: "Terminé",
|
||||||
|
cancelled: "Annulé",
|
||||||
|
}[props.convoy.status] || props.convoy.status)
|
||||||
|
);
|
||||||
|
const statusClass = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
planned: "bg-gradient-secondary",
|
||||||
|
in_progress: "bg-gradient-info",
|
||||||
|
completed: "bg-gradient-success",
|
||||||
|
cancelled: "bg-gradient-danger",
|
||||||
|
}[props.convoy.status] || "bg-gradient-secondary")
|
||||||
|
);
|
||||||
|
const departureLabel = computed(
|
||||||
|
() =>
|
||||||
|
props.convoy.departure?.city ||
|
||||||
|
props.convoy.departure?.name ||
|
||||||
|
"Départ non défini"
|
||||||
|
);
|
||||||
|
const vehicleLabel = computed(() =>
|
||||||
|
props.convoy.vehicle
|
||||||
|
? `${props.convoy.vehicle.brand} ${props.convoy.vehicle.model}`
|
||||||
|
: "Aucun véhicule"
|
||||||
|
);
|
||||||
|
const notificationLabel = computed(() =>
|
||||||
|
props.convoy.automatic_notifications
|
||||||
|
? "Notifications actives"
|
||||||
|
: "Notifications inactives"
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -31,7 +31,11 @@
|
|||||||
<!-- En-tête avec titre et badge de statut -->
|
<!-- En-tête avec titre et badge de statut -->
|
||||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
<h5 class="mb-0">Détails de l'Intervention</h5>
|
<h5 class="mb-0">Détails de l'Intervention</h5>
|
||||||
<SoftBadge :color="statusObject.color" :variant="statusObject.variant" size="sm">
|
<SoftBadge
|
||||||
|
:color="statusObject.color"
|
||||||
|
:variant="statusObject.variant"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
{{ statusObject.label }}
|
{{ statusObject.label }}
|
||||||
</SoftBadge>
|
</SoftBadge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,7 +2,10 @@
|
|||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<div v-if="loading" class="loading-container">
|
<div v-if="loading" class="loading-container">
|
||||||
<div class="loading-spinner">
|
<div class="loading-spinner">
|
||||||
<div class="spinner-border text-success loading-spinner-circle" role="status">
|
<div
|
||||||
|
class="spinner-border text-success loading-spinner-circle"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
<span class="visually-hidden">Chargement...</span>
|
<span class="visually-hidden">Chargement...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -126,7 +129,9 @@
|
|||||||
|
|
||||||
<td class="text-xs font-weight-bold">
|
<td class="text-xs font-weight-bold">
|
||||||
<div class="contact-info">
|
<div class="contact-info">
|
||||||
<div class="text-xs text-secondary">{{ client.email || "N/A" }}</div>
|
<div class="text-xs text-secondary">
|
||||||
|
{{ client.email || "N/A" }}
|
||||||
|
</div>
|
||||||
<div class="text-xs">{{ client.phone || "N/A" }}</div>
|
<div class="text-xs">{{ client.phone || "N/A" }}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -215,7 +220,7 @@
|
|||||||
class="page-item"
|
class="page-item"
|
||||||
:class="{
|
:class="{
|
||||||
active: (pagination.current_page || 1) === page,
|
active: (pagination.current_page || 1) === page,
|
||||||
disabled: page === '...'
|
disabled: page === '...',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<a class="page-link" href="#" @click.prevent="changePage(page)">
|
<a class="page-link" href="#" @click.prevent="changePage(page)">
|
||||||
@ -226,7 +231,8 @@
|
|||||||
<li
|
<li
|
||||||
class="page-item"
|
class="page-item"
|
||||||
:class="{
|
:class="{
|
||||||
disabled: (pagination.current_page || 1) === (pagination.last_page || 1)
|
disabled:
|
||||||
|
(pagination.current_page || 1) === (pagination.last_page || 1),
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
@ -396,9 +402,11 @@ const getAddressLine = (address) => {
|
|||||||
const getShortAddress = (address) => {
|
const getShortAddress = (address) => {
|
||||||
if (!address) return "N/A";
|
if (!address) return "N/A";
|
||||||
|
|
||||||
const parts = [address.postal_code, address.city, address.country_code].filter(
|
const parts = [
|
||||||
Boolean
|
address.postal_code,
|
||||||
);
|
address.city,
|
||||||
|
address.country_code,
|
||||||
|
].filter(Boolean);
|
||||||
return parts.length > 0 ? parts.join(" ") : "N/A";
|
return parts.length > 0 ? parts.join(" ") : "N/A";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,10 @@
|
|||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<div v-if="loading" class="loading-container">
|
<div v-if="loading" class="loading-container">
|
||||||
<div class="loading-spinner">
|
<div class="loading-spinner">
|
||||||
<div class="spinner-border text-success loading-spinner-circle" role="status">
|
<div
|
||||||
|
class="spinner-border text-success loading-spinner-circle"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
<span class="visually-hidden">Chargement...</span>
|
<span class="visually-hidden">Chargement...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -78,7 +81,9 @@
|
|||||||
<div class="text-xs text-secondary">
|
<div class="text-xs text-secondary">
|
||||||
{{ getDescriptionLine(group.description) }}
|
{{ getDescriptionLine(group.description) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs">{{ getDescriptionMeta(group.description) }}</div>
|
<div class="text-xs">
|
||||||
|
{{ getDescriptionMeta(group.description) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@ -88,11 +93,7 @@
|
|||||||
|
|
||||||
<td class="text-xs font-weight-bold">
|
<td class="text-xs font-weight-bold">
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<soft-button
|
<soft-button color="success" variant="outline" class="btn-sm">
|
||||||
color="success"
|
|
||||||
variant="outline"
|
|
||||||
class="btn-sm"
|
|
||||||
>
|
|
||||||
<i class="fas fa-check me-1"></i>
|
<i class="fas fa-check me-1"></i>
|
||||||
Actif
|
Actif
|
||||||
</soft-button>
|
</soft-button>
|
||||||
@ -170,7 +171,7 @@
|
|||||||
class="page-item"
|
class="page-item"
|
||||||
:class="{
|
:class="{
|
||||||
active: (pagination.current_page || 1) === page,
|
active: (pagination.current_page || 1) === page,
|
||||||
disabled: page === '...'
|
disabled: page === '...',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<a class="page-link" href="#" @click.prevent="changePage(page)">
|
<a class="page-link" href="#" @click.prevent="changePage(page)">
|
||||||
@ -181,7 +182,8 @@
|
|||||||
<li
|
<li
|
||||||
class="page-item"
|
class="page-item"
|
||||||
:class="{
|
:class="{
|
||||||
disabled: (pagination.current_page || 1) === (pagination.last_page || 1)
|
disabled:
|
||||||
|
(pagination.current_page || 1) === (pagination.last_page || 1),
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@ -2,7 +2,10 @@
|
|||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<div v-if="loading" class="loading-container">
|
<div v-if="loading" class="loading-container">
|
||||||
<div class="loading-spinner">
|
<div class="loading-spinner">
|
||||||
<div class="spinner-border text-success loading-spinner-circle" role="status">
|
<div
|
||||||
|
class="spinner-border text-success loading-spinner-circle"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
<span class="visually-hidden">Chargement...</span>
|
<span class="visually-hidden">Chargement...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -56,14 +59,24 @@
|
|||||||
<soft-checkbox />
|
<soft-checkbox />
|
||||||
<div class="ms-2">
|
<div class="ms-2">
|
||||||
<span>{{ vehicle.brand }} {{ vehicle.model }}</span>
|
<span>{{ vehicle.brand }} {{ vehicle.model }}</span>
|
||||||
<div class="text-xs text-muted">{{ vehicle.year || "N/A" }}</div>
|
<div class="text-xs text-muted">
|
||||||
|
{{ vehicle.year || "N/A" }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-xs font-weight-bold">{{ vehicle.registration_number }}</td>
|
<td class="text-xs font-weight-bold">
|
||||||
<td class="text-xs font-weight-bold">{{ formatVehicleType(vehicle.vehicle_type) }}</td>
|
{{ vehicle.registration_number }}
|
||||||
<td class="text-xs font-weight-bold">{{ formatFuelType(vehicle.fuel_type) }}</td>
|
</td>
|
||||||
<td class="text-xs font-weight-bold">{{ vehicle.primary_user?.full_name || "Non attribué" }}</td>
|
<td class="text-xs font-weight-bold">
|
||||||
|
{{ formatVehicleType(vehicle.vehicle_type) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-xs font-weight-bold">
|
||||||
|
{{ formatFuelType(vehicle.fuel_type) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-xs font-weight-bold">
|
||||||
|
{{ vehicle.primary_user?.full_name || "Non attribué" }}
|
||||||
|
</td>
|
||||||
<td class="text-xs font-weight-bold">
|
<td class="text-xs font-weight-bold">
|
||||||
<soft-button
|
<soft-button
|
||||||
:color="getStatusColor(vehicle.status)"
|
:color="getStatusColor(vehicle.status)"
|
||||||
@ -114,7 +127,9 @@
|
|||||||
<i class="fas fa-truck fa-3x text-muted"></i>
|
<i class="fas fa-truck fa-3x text-muted"></i>
|
||||||
</div>
|
</div>
|
||||||
<h5 class="empty-title">Aucun véhicule trouvé</h5>
|
<h5 class="empty-title">Aucun véhicule trouvé</h5>
|
||||||
<p class="empty-text text-muted">Aucun véhicule à afficher pour le moment.</p>
|
<p class="empty-text text-muted">
|
||||||
|
Aucun véhicule à afficher pour le moment.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -3,7 +3,10 @@
|
|||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loading" class="loading-container">
|
<div v-if="loading" class="loading-container">
|
||||||
<div class="loading-spinner">
|
<div class="loading-spinner">
|
||||||
<div class="spinner-border text-success loading-spinner-circle" role="status">
|
<div
|
||||||
|
class="spinner-border text-success loading-spinner-circle"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
<span class="visually-hidden">Chargement...</span>
|
<span class="visually-hidden">Chargement...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -203,13 +206,13 @@
|
|||||||
Expire bientôt
|
Expire bientôt
|
||||||
</soft-button>
|
</soft-button>
|
||||||
<!-- Normal Status -->
|
<!-- Normal Status -->
|
||||||
<soft-button
|
<soft-button
|
||||||
v-if="!product.is_low_stock && !isExpiringSoon(product)"
|
v-if="!product.is_low_stock && !isExpiringSoon(product)"
|
||||||
color="success"
|
color="success"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="btn-sm"
|
class="btn-sm"
|
||||||
>
|
>
|
||||||
<i class="fas fa-check me-1"></i>
|
<i class="fas fa-check me-1"></i>
|
||||||
Stock Normal
|
Stock Normal
|
||||||
</soft-button>
|
</soft-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -11,7 +11,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="eo-action" type="button" @click="$emit('view-info-tab')">
|
<button
|
||||||
|
class="eo-action"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('view-info-tab')"
|
||||||
|
>
|
||||||
<i class="fas fa-pen"></i>
|
<i class="fas fa-pen"></i>
|
||||||
Modifier la fiche
|
Modifier la fiche
|
||||||
</button>
|
</button>
|
||||||
@ -20,15 +24,21 @@
|
|||||||
<div class="eo-highlights">
|
<div class="eo-highlights">
|
||||||
<div class="eo-highlight">
|
<div class="eo-highlight">
|
||||||
<span class="eo-highlight__label">Email</span>
|
<span class="eo-highlight__label">Email</span>
|
||||||
<strong class="eo-highlight__value">{{ employee.email || "Non renseigne" }}</strong>
|
<strong class="eo-highlight__value">{{
|
||||||
|
employee.email || "Non renseigne"
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="eo-highlight">
|
<div class="eo-highlight">
|
||||||
<span class="eo-highlight__label">Telephone</span>
|
<span class="eo-highlight__label">Telephone</span>
|
||||||
<strong class="eo-highlight__value">{{ employee.phone || "Non renseigne" }}</strong>
|
<strong class="eo-highlight__value">{{
|
||||||
|
employee.phone || "Non renseigne"
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="eo-highlight">
|
<div class="eo-highlight">
|
||||||
<span class="eo-highlight__label">Statut</span>
|
<span class="eo-highlight__label">Statut</span>
|
||||||
<strong class="eo-highlight__value">{{ employee.active ? "Actif" : "Inactif" }}</strong>
|
<strong class="eo-highlight__value">{{
|
||||||
|
employee.active ? "Actif" : "Inactif"
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -43,19 +53,27 @@
|
|||||||
<div class="eo-list">
|
<div class="eo-list">
|
||||||
<div class="eo-list__item">
|
<div class="eo-list__item">
|
||||||
<span class="eo-list__label">Prenom</span>
|
<span class="eo-list__label">Prenom</span>
|
||||||
<strong class="eo-list__value">{{ employee.first_name || "Non renseigne" }}</strong>
|
<strong class="eo-list__value">{{
|
||||||
|
employee.first_name || "Non renseigne"
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="eo-list__item">
|
<div class="eo-list__item">
|
||||||
<span class="eo-list__label">Nom</span>
|
<span class="eo-list__label">Nom</span>
|
||||||
<strong class="eo-list__value">{{ employee.last_name || "Non renseigne" }}</strong>
|
<strong class="eo-list__value">{{
|
||||||
|
employee.last_name || "Non renseigne"
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="eo-list__item">
|
<div class="eo-list__item">
|
||||||
<span class="eo-list__label">Email</span>
|
<span class="eo-list__label">Email</span>
|
||||||
<strong class="eo-list__value">{{ employee.email || "Non renseigne" }}</strong>
|
<strong class="eo-list__value">{{
|
||||||
|
employee.email || "Non renseigne"
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="eo-list__item">
|
<div class="eo-list__item">
|
||||||
<span class="eo-list__label">Telephone</span>
|
<span class="eo-list__label">Telephone</span>
|
||||||
<strong class="eo-list__value">{{ employee.phone || "Non renseigne" }}</strong>
|
<strong class="eo-list__value">{{
|
||||||
|
employee.phone || "Non renseigne"
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -73,15 +91,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="eo-list__item">
|
<div class="eo-list__item">
|
||||||
<span class="eo-list__label">Poste</span>
|
<span class="eo-list__label">Poste</span>
|
||||||
<strong class="eo-list__value">{{ employee.job_title || "Non renseigne" }}</strong>
|
<strong class="eo-list__value">{{
|
||||||
|
employee.job_title || "Non renseigne"
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="eo-list__item">
|
<div class="eo-list__item">
|
||||||
<span class="eo-list__label">Salaire</span>
|
<span class="eo-list__label">Salaire</span>
|
||||||
<strong class="eo-list__value">{{ employee.salary ? `${employee.salary} EUR` : "Non renseigne" }}</strong>
|
<strong class="eo-list__value">{{
|
||||||
|
employee.salary ? `${employee.salary} EUR` : "Non renseigne"
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="eo-list__item">
|
<div class="eo-list__item">
|
||||||
<span class="eo-list__label">Type</span>
|
<span class="eo-list__label">Type</span>
|
||||||
<strong class="eo-list__value">{{ employee.thanatopractitioner ? "Thanatopracteur" : "Employe" }}</strong>
|
<strong class="eo-list__value">{{
|
||||||
|
employee.thanatopractitioner ? "Thanatopracteur" : "Employe"
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -97,19 +121,28 @@
|
|||||||
<div class="eo-list__item">
|
<div class="eo-list__item">
|
||||||
<span class="eo-list__label">Numero de licence</span>
|
<span class="eo-list__label">Numero de licence</span>
|
||||||
<strong class="eo-list__value">
|
<strong class="eo-list__value">
|
||||||
{{ employee.thanatopractitioner.license_number || "Non renseigne" }}
|
{{
|
||||||
|
employee.thanatopractitioner.license_number || "Non renseigne"
|
||||||
|
}}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="eo-list__item">
|
<div class="eo-list__item">
|
||||||
<span class="eo-list__label">Numero d'autorisation</span>
|
<span class="eo-list__label">Numero d'autorisation</span>
|
||||||
<strong class="eo-list__value">
|
<strong class="eo-list__value">
|
||||||
{{ employee.thanatopractitioner.authorization_number || "Non renseigne" }}
|
{{
|
||||||
|
employee.thanatopractitioner.authorization_number ||
|
||||||
|
"Non renseigne"
|
||||||
|
}}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="eo-list__item">
|
<div class="eo-list__item">
|
||||||
<span class="eo-list__label">Validite</span>
|
<span class="eo-list__label">Validite</span>
|
||||||
<strong class="eo-list__value">
|
<strong class="eo-list__value">
|
||||||
{{ formatDate(employee.thanatopractitioner.authorization_valid_until) }}
|
{{
|
||||||
|
formatDate(
|
||||||
|
employee.thanatopractitioner.authorization_valid_until
|
||||||
|
)
|
||||||
|
}}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -124,11 +157,15 @@
|
|||||||
<div class="eo-grid">
|
<div class="eo-grid">
|
||||||
<div class="eo-list__item">
|
<div class="eo-list__item">
|
||||||
<span class="eo-list__label">Date de creation</span>
|
<span class="eo-list__label">Date de creation</span>
|
||||||
<strong class="eo-list__value">{{ formatDate(employee.created_at) }}</strong>
|
<strong class="eo-list__value">{{
|
||||||
|
formatDate(employee.created_at)
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="eo-list__item">
|
<div class="eo-list__item">
|
||||||
<span class="eo-list__label">Derniere modification</span>
|
<span class="eo-list__label">Derniere modification</span>
|
||||||
<strong class="eo-list__value">{{ formatDate(employee.updated_at) }}</strong>
|
<strong class="eo-list__value">{{
|
||||||
|
formatDate(employee.updated_at)
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -21,10 +21,16 @@
|
|||||||
|
|
||||||
<div class="epc__identity">
|
<div class="epc__identity">
|
||||||
<div class="epc__badges">
|
<div class="epc__badges">
|
||||||
<span class="epc__badge" :class="isActive ? 'epc__badge--success' : 'epc__badge--muted'">
|
<span
|
||||||
|
class="epc__badge"
|
||||||
|
:class="isActive ? 'epc__badge--success' : 'epc__badge--muted'"
|
||||||
|
>
|
||||||
{{ isActive ? "Actif" : "Inactif" }}
|
{{ isActive ? "Actif" : "Inactif" }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="isThanatopractitioner" class="epc__badge epc__badge--info">
|
<span
|
||||||
|
v-if="isThanatopractitioner"
|
||||||
|
class="epc__badge epc__badge--info"
|
||||||
|
>
|
||||||
Thanatopracteur
|
Thanatopracteur
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -40,7 +46,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="epc__meta-item">
|
<div class="epc__meta-item">
|
||||||
<span class="epc__meta-label">Contact</span>
|
<span class="epc__meta-label">Contact</span>
|
||||||
<strong>{{ employee.email || employee.phone || "Non renseigne" }}</strong>
|
<strong>{{
|
||||||
|
employee.email || employee.phone || "Non renseigne"
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -48,7 +56,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, defineEmits } from 'vue';
|
import { defineProps, defineEmits } from "vue";
|
||||||
defineProps({
|
defineProps({
|
||||||
avatarUrl: {
|
avatarUrl: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@ -56,7 +56,9 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="eut-btn eut-btn--primary"
|
class="eut-btn eut-btn--primary"
|
||||||
:disabled="isLoading || !createForm.name.trim() || !createForm.email.trim()"
|
:disabled="
|
||||||
|
isLoading || !createForm.name.trim() || !createForm.email.trim()
|
||||||
|
"
|
||||||
@click="createAndAttachUser"
|
@click="createAndAttachUser"
|
||||||
>
|
>
|
||||||
{{ isLoading ? "Creation..." : "Creer" }}
|
{{ isLoading ? "Creation..." : "Creer" }}
|
||||||
@ -103,7 +105,11 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["employee-updated", "notify-success", "notify-error"]);
|
const emit = defineEmits([
|
||||||
|
"employee-updated",
|
||||||
|
"notify-success",
|
||||||
|
"notify-error",
|
||||||
|
]);
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const showCreateForm = ref(false);
|
const showCreateForm = ref(false);
|
||||||
|
|||||||
@ -6,7 +6,9 @@
|
|||||||
<div class="multisteps-form__content">
|
<div class="multisteps-form__content">
|
||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<div class="col-12 col-sm-6 position-relative">
|
<div class="col-12 col-sm-6 position-relative">
|
||||||
<label class="form-label">Défunt <span class="text-danger">*</span></label>
|
<label class="form-label"
|
||||||
|
>Défunt <span class="text-danger">*</span></label
|
||||||
|
>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||||||
<input
|
<input
|
||||||
@ -31,7 +33,10 @@
|
|||||||
class="text-center py-2 position-absolute w-100 bg-white border rounded shadow-sm"
|
class="text-center py-2 position-absolute w-100 bg-white border rounded shadow-sm"
|
||||||
style="z-index: 1000; top: 100%"
|
style="z-index: 1000; top: 100%"
|
||||||
>
|
>
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
<div
|
||||||
|
class="spinner-border spinner-border-sm text-primary"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
<span class="visually-hidden">Chargement...</span>
|
<span class="visually-hidden">Chargement...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -50,17 +55,27 @@
|
|||||||
>
|
>
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<span class="font-weight-bold text-sm">
|
<span class="font-weight-bold text-sm">
|
||||||
{{ [deceased.first_name, deceased.last_name].filter(Boolean).join(' ') || 'Défunt' }}
|
{{
|
||||||
|
[deceased.first_name, deceased.last_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ") || "Défunt"
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-muted">
|
<span class="text-xs text-muted">
|
||||||
{{ deceased.death_date || deceased.birth_date || 'Aucune date renseignée' }}
|
{{
|
||||||
|
deceased.death_date ||
|
||||||
|
deceased.birth_date ||
|
||||||
|
"Aucune date renseignée"
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="deceasedSearch && !deceasedLoading && showDeceasedResults"
|
v-else-if="
|
||||||
|
deceasedSearch && !deceasedLoading && showDeceasedResults
|
||||||
|
"
|
||||||
class="position-absolute w-100 mt-1 p-3 bg-white border rounded shadow-sm text-center text-sm text-muted"
|
class="position-absolute w-100 mt-1 p-3 bg-white border rounded shadow-sm text-center text-sm text-muted"
|
||||||
style="z-index: 1000"
|
style="z-index: 1000"
|
||||||
>
|
>
|
||||||
@ -68,12 +83,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedDeceased" class="mt-2 small text-success">
|
<div v-if="selectedDeceased" class="mt-2 small text-success">
|
||||||
Sélectionné: {{ [selectedDeceased.first_name, selectedDeceased.last_name].filter(Boolean).join(' ') }}
|
Sélectionné:
|
||||||
|
{{
|
||||||
|
[selectedDeceased.first_name, selectedDeceased.last_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||||
<label class="form-label">Titre de mission</label>
|
<label class="form-label">Titre de mission</label>
|
||||||
<soft-input v-model="form.mission_title" type="text" placeholder="Ex. Convoi Mme DUPONT" />
|
<soft-input
|
||||||
|
v-model="form.mission_title"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex. Convoi Mme DUPONT"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -99,7 +123,9 @@
|
|||||||
|
|
||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<div class="col-12 col-sm-6">
|
<div class="col-12 col-sm-6">
|
||||||
<label class="form-label">Début prévu <span class="text-danger">*</span></label>
|
<label class="form-label"
|
||||||
|
>Début prévu <span class="text-danger">*</span></label
|
||||||
|
>
|
||||||
<soft-input v-model="form.planned_start_at" type="datetime-local" />
|
<soft-input v-model="form.planned_start_at" type="datetime-local" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||||
@ -135,7 +161,10 @@
|
|||||||
class="text-center py-2 position-absolute w-100 bg-white border rounded shadow-sm"
|
class="text-center py-2 position-absolute w-100 bg-white border rounded shadow-sm"
|
||||||
style="z-index: 1000; top: 100%"
|
style="z-index: 1000; top: 100%"
|
||||||
>
|
>
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
<div
|
||||||
|
class="spinner-border spinner-border-sm text-primary"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
<span class="visually-hidden">Chargement...</span>
|
<span class="visually-hidden">Chargement...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -153,16 +182,28 @@
|
|||||||
@click="selectLocation(location)"
|
@click="selectLocation(location)"
|
||||||
>
|
>
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<span class="font-weight-bold text-sm">{{ location.name || 'Lieu sans nom' }}</span>
|
<span class="font-weight-bold text-sm">{{
|
||||||
|
location.name || "Lieu sans nom"
|
||||||
|
}}</span>
|
||||||
<span class="text-xs text-muted">
|
<span class="text-xs text-muted">
|
||||||
{{ [location.address_line1, location.postal_code, location.city].filter(Boolean).join(', ') }}
|
{{
|
||||||
|
[
|
||||||
|
location.address_line1,
|
||||||
|
location.postal_code,
|
||||||
|
location.city,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="locationSearch && !locationLoading && showLocationResults"
|
v-else-if="
|
||||||
|
locationSearch && !locationLoading && showLocationResults
|
||||||
|
"
|
||||||
class="position-absolute w-100 mt-1 p-3 bg-white border rounded shadow-sm text-center text-sm text-muted"
|
class="position-absolute w-100 mt-1 p-3 bg-white border rounded shadow-sm text-center text-sm text-muted"
|
||||||
style="z-index: 1000"
|
style="z-index: 1000"
|
||||||
>
|
>
|
||||||
@ -170,20 +211,37 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedLocation" class="mt-2 small text-success">
|
<div v-if="selectedLocation" class="mt-2 small text-success">
|
||||||
Sélectionné: {{ selectedLocation.name || 'Lieu' }}
|
Sélectionné: {{ selectedLocation.name || "Lieu" }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||||
<label class="form-label">Email famille</label>
|
<label class="form-label">Email famille</label>
|
||||||
<soft-input v-model="form.family_email" type="email" placeholder="famille@email.fr" />
|
<soft-input
|
||||||
|
v-model="form.family_email"
|
||||||
|
type="email"
|
||||||
|
placeholder="famille@email.fr"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="button-row d-flex mt-4">
|
<div class="button-row d-flex mt-4">
|
||||||
<soft-button type="button" color="secondary" variant="outline" class="me-2 mb-0" @click="resetForm">
|
<soft-button
|
||||||
|
type="button"
|
||||||
|
color="secondary"
|
||||||
|
variant="outline"
|
||||||
|
class="me-2 mb-0"
|
||||||
|
@click="resetForm"
|
||||||
|
>
|
||||||
Réinitialiser
|
Réinitialiser
|
||||||
</soft-button>
|
</soft-button>
|
||||||
<soft-button type="button" color="dark" variant="gradient" class="ms-auto mb-0" :disabled="loading" @click="submitForm">
|
<soft-button
|
||||||
|
type="button"
|
||||||
|
color="dark"
|
||||||
|
variant="gradient"
|
||||||
|
class="ms-auto mb-0"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="submitForm"
|
||||||
|
>
|
||||||
{{ loading ? "Création..." : "Créer le convoi" }}
|
{{ loading ? "Création..." : "Créer le convoi" }}
|
||||||
</soft-button>
|
</soft-button>
|
||||||
</div>
|
</div>
|
||||||
@ -265,7 +323,9 @@ const handleDeceasedSearch = () => {
|
|||||||
|
|
||||||
const selectDeceased = (deceased) => {
|
const selectDeceased = (deceased) => {
|
||||||
selectedDeceased.value = deceased;
|
selectedDeceased.value = deceased;
|
||||||
deceasedSearch.value = [deceased.first_name, deceased.last_name].filter(Boolean).join(" ");
|
deceasedSearch.value = [deceased.first_name, deceased.last_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
form.value.deceased_id = deceased.id;
|
form.value.deceased_id = deceased.id;
|
||||||
deceasedResults.value = [];
|
deceasedResults.value = [];
|
||||||
showDeceasedResults.value = false;
|
showDeceasedResults.value = false;
|
||||||
@ -308,7 +368,9 @@ const handleLocationSearch = () => {
|
|||||||
|
|
||||||
const selectLocation = (location) => {
|
const selectLocation = (location) => {
|
||||||
selectedLocation.value = location;
|
selectedLocation.value = location;
|
||||||
locationSearch.value = location.name || [location.address_line1, location.city].filter(Boolean).join(", ");
|
locationSearch.value =
|
||||||
|
location.name ||
|
||||||
|
[location.address_line1, location.city].filter(Boolean).join(", ");
|
||||||
form.value.departure_location_id = location.id;
|
form.value.departure_location_id = location.id;
|
||||||
form.value.departure_name = location.name || null;
|
form.value.departure_name = location.name || null;
|
||||||
form.value.departure_address = location.address_line1 || null;
|
form.value.departure_address = location.address_line1 || null;
|
||||||
|
|||||||
@ -6,7 +6,9 @@
|
|||||||
<div class="multisteps-form__content">
|
<div class="multisteps-form__content">
|
||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<div class="col-12 col-sm-6">
|
<div class="col-12 col-sm-6">
|
||||||
<label class="form-label">Marque <span class="text-danger">*</span></label>
|
<label class="form-label"
|
||||||
|
>Marque <span class="text-danger">*</span></label
|
||||||
|
>
|
||||||
<soft-input
|
<soft-input
|
||||||
v-model="form.brand"
|
v-model="form.brand"
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
@ -20,7 +22,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||||
<label class="form-label">Modèle <span class="text-danger">*</span></label>
|
<label class="form-label"
|
||||||
|
>Modèle <span class="text-danger">*</span></label
|
||||||
|
>
|
||||||
<soft-input
|
<soft-input
|
||||||
v-model="form.model"
|
v-model="form.model"
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
@ -37,7 +41,9 @@
|
|||||||
|
|
||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<div class="col-12 col-sm-6">
|
<div class="col-12 col-sm-6">
|
||||||
<label class="form-label">Immatriculation <span class="text-danger">*</span></label>
|
<label class="form-label"
|
||||||
|
>Immatriculation <span class="text-danger">*</span></label
|
||||||
|
>
|
||||||
<soft-input
|
<soft-input
|
||||||
v-model="form.registration_number"
|
v-model="form.registration_number"
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
@ -101,7 +107,9 @@
|
|||||||
<label class="form-label">Utilisateur principal</label>
|
<label class="form-label">Utilisateur principal</label>
|
||||||
<div class="position-relative">
|
<div class="position-relative">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
<span class="input-group-text"
|
||||||
|
><i class="fas fa-search"></i
|
||||||
|
></span>
|
||||||
<input
|
<input
|
||||||
v-model="employeeSearch"
|
v-model="employeeSearch"
|
||||||
type="text"
|
type="text"
|
||||||
@ -124,7 +132,10 @@
|
|||||||
class="text-center py-2 position-absolute w-100 bg-white border rounded shadow-sm"
|
class="text-center py-2 position-absolute w-100 bg-white border rounded shadow-sm"
|
||||||
style="z-index: 1000; top: 100%"
|
style="z-index: 1000; top: 100%"
|
||||||
>
|
>
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
<div
|
||||||
|
class="spinner-border spinner-border-sm text-primary"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
<span class="visually-hidden">Chargement...</span>
|
<span class="visually-hidden">Chargement...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -142,16 +153,24 @@
|
|||||||
@click="selectEmployee(employee)"
|
@click="selectEmployee(employee)"
|
||||||
>
|
>
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<span class="font-weight-bold text-sm">{{ employee.full_name }}</span>
|
<span class="font-weight-bold text-sm">{{
|
||||||
|
employee.full_name
|
||||||
|
}}</span>
|
||||||
<span class="text-xs text-muted">
|
<span class="text-xs text-muted">
|
||||||
{{ employee.email || employee.job_title || "Aucune information" }}
|
{{
|
||||||
|
employee.email ||
|
||||||
|
employee.job_title ||
|
||||||
|
"Aucune information"
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="employeeSearch && !employeeLoading && showEmployeeResults"
|
v-else-if="
|
||||||
|
employeeSearch && !employeeLoading && showEmployeeResults
|
||||||
|
"
|
||||||
class="position-absolute w-100 mt-1 p-3 bg-white border rounded shadow-sm text-center text-sm text-muted"
|
class="position-absolute w-100 mt-1 p-3 bg-white border rounded shadow-sm text-center text-sm text-muted"
|
||||||
style="z-index: 1000"
|
style="z-index: 1000"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -55,18 +55,59 @@
|
|||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<!-- Practitioner ID -->
|
<!-- Practitioner Selection -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Identifiant du praticien</label>
|
<label class="form-label">Thanatopracteur</label>
|
||||||
<input
|
<SoftInput
|
||||||
v-model="form.practitionerId"
|
v-model="searchQuery"
|
||||||
type="number"
|
icon="fas fa-search"
|
||||||
class="form-input"
|
icon-dir="left"
|
||||||
placeholder="ex: 42"
|
placeholder="Rechercher un thanato par nom"
|
||||||
min="1"
|
|
||||||
/>
|
/>
|
||||||
<p class="form-hint">
|
<div v-if="hasSearchQuery" class="search-panel">
|
||||||
Entrez l'ID du praticien à assigner à cette intervention.
|
<div v-if="filteredPractitioners.length" class="search-results">
|
||||||
|
<button
|
||||||
|
v-for="practitioner in filteredPractitioners"
|
||||||
|
:key="practitioner.id"
|
||||||
|
type="button"
|
||||||
|
class="search-result"
|
||||||
|
:class="{
|
||||||
|
selected: String(practitioner.id) === form.practitionerId,
|
||||||
|
}"
|
||||||
|
@click="selectPractitioner(practitioner)"
|
||||||
|
>
|
||||||
|
<span class="search-result-name">
|
||||||
|
{{ getPractitionerLabel(practitioner) }}
|
||||||
|
</span>
|
||||||
|
<span class="search-result-meta">
|
||||||
|
#{{ practitioner.id }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="search-empty">
|
||||||
|
Aucun thanatopracteur ne correspond a cette recherche.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="form-hint">
|
||||||
|
Saisissez au moins 2 caracteres pour rechercher un
|
||||||
|
thanatopracteur par nom.
|
||||||
|
</p>
|
||||||
|
<div v-if="selectedPractitioner" class="selected-practitioner">
|
||||||
|
<span class="selected-label">Selectionne</span>
|
||||||
|
<strong>{{
|
||||||
|
getPractitionerLabel(selectedPractitioner)
|
||||||
|
}}</strong>
|
||||||
|
</div>
|
||||||
|
<p v-if="isSearching" class="form-hint">Recherche en cours…</p>
|
||||||
|
<p
|
||||||
|
v-if="hasSearchQuery && availablePractitioners.length"
|
||||||
|
class="form-hint"
|
||||||
|
>
|
||||||
|
Seuls les praticiens actifs non encore assignés sont proposés.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="hasSearchQuery && !isSearching" class="form-hint">
|
||||||
|
Tous les praticiens disponibles sont déjà assignés ou aucun
|
||||||
|
thanatopracteur actif n'est chargé.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -136,8 +177,14 @@
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn-ghost" @click="$emit('close')">Annuler</button>
|
<SoftButton
|
||||||
<button class="btn-primary" @click="handleSubmit">
|
color="secondary"
|
||||||
|
variant="outline"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</SoftButton>
|
||||||
|
<SoftButton color="info" variant="gradient" @click="handleSubmit">
|
||||||
<svg
|
<svg
|
||||||
width="13"
|
width="13"
|
||||||
height="13"
|
height="13"
|
||||||
@ -149,7 +196,7 @@
|
|||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
</svg>
|
</svg>
|
||||||
Confirmer l'assignation
|
Confirmer l'assignation
|
||||||
</button>
|
</SoftButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -158,15 +205,109 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, defineProps, defineEmits } from "vue";
|
import {
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
defineProps,
|
||||||
|
defineEmits,
|
||||||
|
computed,
|
||||||
|
onBeforeUnmount,
|
||||||
|
} from "vue";
|
||||||
|
import SoftInput from "@/components/SoftInput.vue";
|
||||||
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
|
import { useThanatopractitionerStore } from "@/stores/thanatopractitionerStore";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isOpen: { type: Boolean, default: false },
|
isOpen: { type: Boolean, default: false },
|
||||||
|
practitioners: { type: Array, default: () => [] },
|
||||||
|
assignedPractitionerIds: { type: Array, default: () => [] },
|
||||||
});
|
});
|
||||||
const emit = defineEmits(["close", "assign"]);
|
const emit = defineEmits(["close", "assign"]);
|
||||||
|
const thanatopractitionerStore = useThanatopractitionerStore();
|
||||||
|
|
||||||
const form = ref({ practitionerId: "", role: "principal" });
|
const form = ref({ practitionerId: "", role: "principal" });
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
|
const searchQuery = ref("");
|
||||||
|
const isSearching = computed(() => thanatopractitionerStore.loading);
|
||||||
|
const hasSearchQuery = computed(() => searchQuery.value.trim().length >= 2);
|
||||||
|
let searchDebounceTimer = null;
|
||||||
|
|
||||||
|
const availablePractitioners = computed(() => {
|
||||||
|
const assignedIds = new Set(
|
||||||
|
props.assignedPractitionerIds.map((practitionerId) =>
|
||||||
|
Number(practitionerId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return props.practitioners.filter((practitioner) => {
|
||||||
|
const practitionerId = Number(practitioner?.id);
|
||||||
|
return Number.isFinite(practitionerId) && !assignedIds.has(practitionerId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredPractitioners = computed(() => {
|
||||||
|
if (!hasSearchQuery.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return availablePractitioners.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedPractitioner = computed(() =>
|
||||||
|
availablePractitioners.value.find(
|
||||||
|
(practitioner) => String(practitioner.id) === form.value.practitionerId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const getPractitionerLabel = (practitioner) => {
|
||||||
|
const fullName =
|
||||||
|
practitioner?.full_name ||
|
||||||
|
practitioner?.employee?.full_name ||
|
||||||
|
[practitioner?.first_name, practitioner?.last_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ") ||
|
||||||
|
`Praticien #${practitioner?.id}`;
|
||||||
|
|
||||||
|
const secondary =
|
||||||
|
practitioner?.job_title || practitioner?.employee?.job_title;
|
||||||
|
return secondary ? `${fullName} - ${secondary}` : fullName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectPractitioner = (practitioner) => {
|
||||||
|
form.value.practitionerId = String(practitioner.id);
|
||||||
|
error.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPractitioners = async () => {
|
||||||
|
if (!hasSearchQuery.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await thanatopractitionerStore.fetchThanatopractitioners({
|
||||||
|
per_page: 100,
|
||||||
|
active: true,
|
||||||
|
search: searchQuery.value.trim(),
|
||||||
|
});
|
||||||
|
} catch (fetchError) {
|
||||||
|
console.error("Error searching practitioners:", fetchError);
|
||||||
|
error.value = "Impossible de charger les thanatopracteurs.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const queuePractitionerSearch = () => {
|
||||||
|
if (!props.isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchDebounceTimer) {
|
||||||
|
clearTimeout(searchDebounceTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchDebounceTimer = window.setTimeout(() => {
|
||||||
|
fetchPractitioners();
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
// Reset form when modal opens
|
// Reset form when modal opens
|
||||||
watch(
|
watch(
|
||||||
@ -175,14 +316,29 @@ watch(
|
|||||||
if (open) {
|
if (open) {
|
||||||
form.value = { practitionerId: "", role: "principal" };
|
form.value = { practitionerId: "", role: "principal" };
|
||||||
error.value = "";
|
error.value = "";
|
||||||
|
searchQuery.value = "";
|
||||||
|
} else if (searchDebounceTimer) {
|
||||||
|
clearTimeout(searchDebounceTimer);
|
||||||
|
searchDebounceTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(searchQuery, () => {
|
||||||
|
error.value = "";
|
||||||
|
queuePractitionerSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (searchDebounceTimer) {
|
||||||
|
clearTimeout(searchDebounceTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
error.value = "";
|
error.value = "";
|
||||||
if (!form.value.practitionerId) {
|
if (!form.value.practitionerId) {
|
||||||
error.value = "Veuillez entrer un identifiant de praticien.";
|
error.value = "Veuillez sélectionner un praticien.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (parseInt(form.value.practitionerId) <= 0) {
|
if (parseInt(form.value.practitionerId) <= 0) {
|
||||||
@ -329,6 +485,83 @@ const handleSubmit = () => {
|
|||||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-panel {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--surface-2);
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result:hover,
|
||||||
|
.search-result.selected {
|
||||||
|
background: var(--brand-lt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result.selected {
|
||||||
|
box-shadow: inset 3px 0 0 var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-empty {
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-practitioner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--brand-lt);
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
/* Role grid */
|
/* Role grid */
|
||||||
.role-grid {
|
.role-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -499,4 +732,8 @@ const handleSubmit = () => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-6px);
|
transform: translateY(-6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="data-row">
|
<div class="data-row">
|
||||||
<span class="data-label">{{ label }}</span>
|
<span class="data-label">{{ label }}</span>
|
||||||
<span class="data-value" :class="{ 'fw-semibold': bold }">{{ value || "-" }}</span>
|
<span class="data-value" :class="{ 'fw-semibold': bold }">{{
|
||||||
|
value || "-"
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { defineProps } from "vue";
|
||||||
import { defineProps } from 'vue';
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
label: {
|
label: {
|
||||||
|
|||||||
@ -9,8 +9,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { defineProps } from "vue";
|
||||||
import { defineProps } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
title: {
|
title: {
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<span class="status-pill" :class="['sp-' + (status?.color || 'secondary'), large ? 'sp-lg' : '']">
|
<span
|
||||||
|
class="status-pill"
|
||||||
|
:class="['sp-' + (status?.color || 'secondary'), large ? 'sp-lg' : '']"
|
||||||
|
>
|
||||||
{{ status?.label || "En attente" }}
|
{{ status?.label || "En attente" }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { defineProps } from "vue";
|
||||||
import { defineProps } from 'vue';
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
status: {
|
status: {
|
||||||
|
|||||||
@ -2,7 +2,10 @@
|
|||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<div v-if="loading" class="loading-container">
|
<div v-if="loading" class="loading-container">
|
||||||
<div class="loading-spinner">
|
<div class="loading-spinner">
|
||||||
<div class="spinner-border text-success loading-spinner-circle" role="status">
|
<div
|
||||||
|
class="spinner-border text-success loading-spinner-circle"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
<span class="visually-hidden">Chargement...</span>
|
<span class="visually-hidden">Chargement...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -87,14 +90,18 @@
|
|||||||
|
|
||||||
<td class="text-xs font-weight-bold">
|
<td class="text-xs font-weight-bold">
|
||||||
<div class="contact-info">
|
<div class="contact-info">
|
||||||
<div class="text-xs text-secondary">{{ getAddressLine(location) }}</div>
|
<div class="text-xs text-secondary">
|
||||||
|
{{ getAddressLine(location) }}
|
||||||
|
</div>
|
||||||
<div class="text-xs">{{ getAddressMeta(location) }}</div>
|
<div class="text-xs">{{ getAddressMeta(location) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="text-xs font-weight-bold">
|
<td class="text-xs font-weight-bold">
|
||||||
<div class="contact-info">
|
<div class="contact-info">
|
||||||
<div class="text-xs text-secondary">{{ getLatitude(location) }}</div>
|
<div class="text-xs text-secondary">
|
||||||
|
{{ getLatitude(location) }}
|
||||||
|
</div>
|
||||||
<div class="text-xs">{{ getLongitude(location) }}</div>
|
<div class="text-xs">{{ getLongitude(location) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -106,7 +113,13 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
class="btn-sm"
|
class="btn-sm"
|
||||||
>
|
>
|
||||||
<i :class="location.is_default ? 'fas fa-check me-1' : 'fas fa-times me-1'"></i>
|
<i
|
||||||
|
:class="
|
||||||
|
location.is_default
|
||||||
|
? 'fas fa-check me-1'
|
||||||
|
: 'fas fa-times me-1'
|
||||||
|
"
|
||||||
|
></i>
|
||||||
{{ location.is_default ? "Par defaut" : "Secondaire" }}
|
{{ location.is_default ? "Par defaut" : "Secondaire" }}
|
||||||
</soft-button>
|
</soft-button>
|
||||||
</div>
|
</div>
|
||||||
@ -141,7 +154,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!loading && tableData.length > 0 && (pagination?.last_page || 1) > 1"
|
v-if="
|
||||||
|
!loading && tableData.length > 0 && (pagination?.last_page || 1) > 1
|
||||||
|
"
|
||||||
class="d-flex justify-content-between align-items-center mt-3 px-3 flex-wrap gap-3"
|
class="d-flex justify-content-between align-items-center mt-3 px-3 flex-wrap gap-3"
|
||||||
>
|
>
|
||||||
<div class="text-xs text-secondary font-weight-bold">
|
<div class="text-xs text-secondary font-weight-bold">
|
||||||
@ -173,7 +188,7 @@
|
|||||||
class="page-item"
|
class="page-item"
|
||||||
:class="{
|
:class="{
|
||||||
active: (pagination.current_page || 1) === page,
|
active: (pagination.current_page || 1) === page,
|
||||||
disabled: page === '...'
|
disabled: page === '...',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<a class="page-link" href="#" @click.prevent="changePage(page)">
|
<a class="page-link" href="#" @click.prevent="changePage(page)">
|
||||||
@ -184,7 +199,8 @@
|
|||||||
<li
|
<li
|
||||||
class="page-item"
|
class="page-item"
|
||||||
:class="{
|
:class="{
|
||||||
disabled: (pagination.current_page || 1) === (pagination.last_page || 1)
|
disabled:
|
||||||
|
(pagination.current_page || 1) === (pagination.last_page || 1),
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
@ -330,11 +346,17 @@ const safeTo = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getAddressLine = (location) => {
|
const getAddressLine = (location) => {
|
||||||
return location.full_address || location.address_line1 || "Adresse indisponible";
|
return (
|
||||||
|
location.full_address || location.address_line1 || "Adresse indisponible"
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAddressMeta = (location) => {
|
const getAddressMeta = (location) => {
|
||||||
const parts = [location.postal_code, location.city, location.country_code].filter(Boolean);
|
const parts = [
|
||||||
|
location.postal_code,
|
||||||
|
location.city,
|
||||||
|
location.country_code,
|
||||||
|
].filter(Boolean);
|
||||||
return parts.length > 0 ? parts.join(" ") : "N/A";
|
return parts.length > 0 ? parts.join(" ") : "N/A";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -111,7 +111,9 @@ export const VehicleService = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteVehicle(id: number): Promise<{ message: string; status: string }> {
|
async deleteVehicle(
|
||||||
|
id: number
|
||||||
|
): Promise<{ message: string; status: string }> {
|
||||||
return await request<{ message: string; status: string }>({
|
return await request<{ message: string; status: string }>({
|
||||||
url: `/api/vehicles/${id}`,
|
url: `/api/vehicles/${id}`,
|
||||||
method: "delete",
|
method: "delete",
|
||||||
|
|||||||
@ -113,7 +113,9 @@ export const useClientGroupStore = defineStore("clientGroup", () => {
|
|||||||
search?: string;
|
search?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await ClientGroupService.getAllClientGroups(requestParams);
|
const response = await ClientGroupService.getAllClientGroups(
|
||||||
|
requestParams
|
||||||
|
);
|
||||||
setClientGroups(response.data);
|
setClientGroups(response.data);
|
||||||
if (response.meta) {
|
if (response.meta) {
|
||||||
setPagination(response.meta);
|
setPagination(response.meta);
|
||||||
|
|||||||
@ -55,7 +55,8 @@ export const useConvoyStore = defineStore("convoy", () => {
|
|||||||
setPagination(response.meta);
|
setPagination(response.meta);
|
||||||
return response;
|
return response;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.message || err.message || "Failed to fetch convoys";
|
error.value =
|
||||||
|
err.response?.data?.message || err.message || "Failed to fetch convoys";
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@ -70,7 +71,8 @@ export const useConvoyStore = defineStore("convoy", () => {
|
|||||||
currentConvoy.value = response.data;
|
currentConvoy.value = response.data;
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.message || err.message || "Failed to fetch convoy";
|
error.value =
|
||||||
|
err.response?.data?.message || err.message || "Failed to fetch convoy";
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@ -86,7 +88,8 @@ export const useConvoyStore = defineStore("convoy", () => {
|
|||||||
currentConvoy.value = response.data;
|
currentConvoy.value = response.data;
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.message || err.message || "Failed to create convoy";
|
error.value =
|
||||||
|
err.response?.data?.message || err.message || "Failed to create convoy";
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@ -98,12 +101,16 @@ export const useConvoyStore = defineStore("convoy", () => {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
const response = await ConvoyService.updateConvoy(payload);
|
const response = await ConvoyService.updateConvoy(payload);
|
||||||
const index = convoys.value.findIndex((convoy) => convoy.id === response.data.id);
|
const index = convoys.value.findIndex(
|
||||||
|
(convoy) => convoy.id === response.data.id
|
||||||
|
);
|
||||||
if (index !== -1) convoys.value[index] = response.data;
|
if (index !== -1) convoys.value[index] = response.data;
|
||||||
if (currentConvoy.value?.id === response.data.id) currentConvoy.value = response.data;
|
if (currentConvoy.value?.id === response.data.id)
|
||||||
|
currentConvoy.value = response.data;
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.message || err.message || "Failed to update convoy";
|
error.value =
|
||||||
|
err.response?.data?.message || err.message || "Failed to update convoy";
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@ -119,7 +126,8 @@ export const useConvoyStore = defineStore("convoy", () => {
|
|||||||
if (currentConvoy.value?.id === id) currentConvoy.value = null;
|
if (currentConvoy.value?.id === id) currentConvoy.value = null;
|
||||||
return response;
|
return response;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.message || err.message || "Failed to delete convoy";
|
error.value =
|
||||||
|
err.response?.data?.message || err.message || "Failed to delete convoy";
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|||||||
@ -63,7 +63,9 @@ export const useDeceasedStore = defineStore("deceased", () => {
|
|||||||
success.value = false;
|
success.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeDeceased = (entry: Partial<Deceased> | null | undefined): Deceased | null => {
|
const normalizeDeceased = (
|
||||||
|
entry: Partial<Deceased> | null | undefined
|
||||||
|
): Deceased | null => {
|
||||||
if (!entry || typeof entry !== "object") {
|
if (!entry || typeof entry !== "object") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,7 +138,9 @@ export const useQuoteStore = defineStore("quote", () => {
|
|||||||
return await QuoteService.downloadQuotePdf(id);
|
return await QuoteService.downloadQuotePdf(id);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err.response?.data?.message || err.message || "Failed to download quote PDF";
|
err.response?.data?.message ||
|
||||||
|
err.message ||
|
||||||
|
"Failed to download quote PDF";
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<h5 class="mb-1">Ajouter une localisation</h5>
|
<h5 class="mb-1">Ajouter une localisation</h5>
|
||||||
<p class="text-sm text-secondary mb-0">
|
<p class="text-sm text-secondary mb-0">
|
||||||
Le client est obligatoire et au moins un champ d'adresse doit etre renseigne.
|
Le client est obligatoire et au moins un champ d'adresse doit
|
||||||
|
etre renseigne.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -35,7 +36,11 @@
|
|||||||
:key="client.id"
|
:key="client.id"
|
||||||
:value="String(client.id)"
|
:value="String(client.id)"
|
||||||
>
|
>
|
||||||
{{ client.company_name || client.name || `Client #${client.id}` }}
|
{{
|
||||||
|
client.company_name ||
|
||||||
|
client.name ||
|
||||||
|
`Client #${client.id}`
|
||||||
|
}}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-if="fieldErrors.client_id" class="invalid-feedback">
|
<div v-if="fieldErrors.client_id" class="invalid-feedback">
|
||||||
@ -187,7 +192,9 @@
|
|||||||
class="btn bg-gradient-primary mb-0"
|
class="btn bg-gradient-primary mb-0"
|
||||||
:disabled="clientLocationStore.isLoading"
|
:disabled="clientLocationStore.isLoading"
|
||||||
>
|
>
|
||||||
{{ clientLocationStore.isLoading ? "Creation..." : "Ajouter" }}
|
{{
|
||||||
|
clientLocationStore.isLoading ? "Creation..." : "Ajouter"
|
||||||
|
}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -278,8 +285,7 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error.response?.data?.message ||
|
error.response?.data?.message || "Impossible de creer la localisation";
|
||||||
"Impossible de creer la localisation";
|
|
||||||
|
|
||||||
notificationStore.error("Erreur", errorMessage);
|
notificationStore.error("Erreur", errorMessage);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<add-convoy-presentation :loading="convoyStore.isLoading" @create-convoy="handleCreateConvoy" />
|
<add-convoy-presentation
|
||||||
|
:loading="convoyStore.isLoading"
|
||||||
|
@create-convoy="handleCreateConvoy"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|||||||
@ -37,7 +37,10 @@ const handleSave = async (payload) => {
|
|||||||
notificationStore.updated("Véhicule");
|
notificationStore.updated("Véhicule");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating vehicle:", error);
|
console.error("Error updating vehicle:", error);
|
||||||
notificationStore.error("Erreur", "Impossible de mettre à jour le véhicule");
|
notificationStore.error(
|
||||||
|
"Erreur",
|
||||||
|
"Impossible de mettre à jour le véhicule"
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,37 +6,44 @@
|
|||||||
:error="interventionStore.getError"
|
:error="interventionStore.getError"
|
||||||
:active-tab="activeTab"
|
:active-tab="activeTab"
|
||||||
:practitioners="practitioners"
|
:practitioners="practitioners"
|
||||||
|
:is-modal-open="isModalOpen"
|
||||||
|
:assigned-practitioner-ids="assignedPractitionerIds"
|
||||||
@update-intervention="handleUpdate"
|
@update-intervention="handleUpdate"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
@assign-practitioner="openAssignModal"
|
@assign-practitioner="openAssignModal"
|
||||||
/>
|
@assign-practitioner-confirmed="handleAssignPractitioner"
|
||||||
|
@close-modal="closeAssignModal"
|
||||||
<!-- Assign Practitioner Modal -->
|
@unassign-practitioner="handleUnassignPractitioner"
|
||||||
<AssignPractitionerModal
|
|
||||||
:is-open="isModalOpen"
|
|
||||||
@close="closeAssignModal"
|
|
||||||
@assign="handleAssignPractitioner"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch } from "vue";
|
import { ref, onMounted, watch, computed } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import InterventionDetailPresentation from "@/components/Organism/Interventions/InterventionDetailPresentation.vue";
|
import InterventionDetailPresentation from "@/components/Organism/Interventions/InterventionDetailPresentation.vue";
|
||||||
import AssignPractitionerModal from "@/components/molecules/intervention/AssignPractitionerModal.vue";
|
|
||||||
import { useInterventionStore } from "@/stores/interventionStore";
|
import { useInterventionStore } from "@/stores/interventionStore";
|
||||||
|
import { useThanatopractitionerStore } from "@/stores/thanatopractitionerStore";
|
||||||
import { useNotificationStore } from "@/stores/notification";
|
import { useNotificationStore } from "@/stores/notification";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const interventionStore = useInterventionStore();
|
const interventionStore = useInterventionStore();
|
||||||
|
const thanatopractitionerStore = useThanatopractitionerStore();
|
||||||
const notificationStore = useNotificationStore();
|
const notificationStore = useNotificationStore();
|
||||||
|
|
||||||
// Reactive data
|
// Reactive data
|
||||||
const intervention = ref(null);
|
const intervention = ref(null);
|
||||||
const activeTab = ref("overview");
|
const activeTab = ref("overview");
|
||||||
const practitioners = ref([]);
|
|
||||||
const isModalOpen = ref(false);
|
const isModalOpen = ref(false);
|
||||||
|
|
||||||
|
const practitioners = computed(
|
||||||
|
() => thanatopractitionerStore.thanatopractitioners || []
|
||||||
|
);
|
||||||
|
const assignedPractitionerIds = computed(() =>
|
||||||
|
(intervention.value?.practitioners || [])
|
||||||
|
.map((practitioner) => Number(practitioner?.id))
|
||||||
|
.filter((practitionerId) => Number.isFinite(practitionerId))
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch intervention data
|
// Fetch intervention data
|
||||||
const fetchIntervention = async () => {
|
const fetchIntervention = async () => {
|
||||||
try {
|
try {
|
||||||
@ -57,6 +64,21 @@ const fetchIntervention = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchPractitioners = async () => {
|
||||||
|
try {
|
||||||
|
await thanatopractitionerStore.fetchThanatopractitioners({
|
||||||
|
per_page: 100,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading practitioners:", error);
|
||||||
|
notificationStore.error(
|
||||||
|
"Erreur",
|
||||||
|
"Impossible de charger la liste des praticiens"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Open assign modal
|
// Open assign modal
|
||||||
const openAssignModal = () => {
|
const openAssignModal = () => {
|
||||||
isModalOpen.value = true;
|
isModalOpen.value = true;
|
||||||
@ -95,6 +117,28 @@ const handleAssignPractitioner = async (practitionerData) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUnassignPractitioner = async ({
|
||||||
|
practitionerId,
|
||||||
|
practitionerName,
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
if (!intervention.value?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interventionStore.unassignPractitioner(
|
||||||
|
intervention.value.id,
|
||||||
|
practitionerId
|
||||||
|
);
|
||||||
|
|
||||||
|
await fetchIntervention();
|
||||||
|
notificationStore.deleted(practitionerName || "Praticien", "désassigné");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error unassigning practitioner:", error);
|
||||||
|
notificationStore.error("Erreur", "Impossible de désassigner le praticien");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle update from child components
|
// Handle update from child components
|
||||||
const handleUpdate = async (updatedIntervention) => {
|
const handleUpdate = async (updatedIntervention) => {
|
||||||
try {
|
try {
|
||||||
@ -128,6 +172,7 @@ watch(
|
|||||||
|
|
||||||
// Load data on component mount
|
// Load data on component mount
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
fetchPractitioners();
|
||||||
fetchIntervention();
|
fetchIntervention();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -11,9 +11,7 @@
|
|||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="mx-auto text-center col-lg-5">
|
<div class="mx-auto text-center col-lg-5">
|
||||||
<h1 class="mt-5 mb-2 text-white">Bonjour !</h1>
|
<h1 class="mt-5 mb-2 text-white">Bonjour !</h1>
|
||||||
<p class="text-white text-lead">
|
<p class="text-white text-lead">Entrez votre email pour continuer</p>
|
||||||
Entrez votre email pour continuer
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -153,7 +151,8 @@ const errorMessage = ref("");
|
|||||||
|
|
||||||
const stepTitle = computed(() => {
|
const stepTitle = computed(() => {
|
||||||
if (currentStep.value === "password") return "Saisissez votre mot de passe";
|
if (currentStep.value === "password") return "Saisissez votre mot de passe";
|
||||||
if (currentStep.value === "create-password") return "Creez votre mot de passe";
|
if (currentStep.value === "create-password")
|
||||||
|
return "Creez votre mot de passe";
|
||||||
return "Connectez-vous";
|
return "Connectez-vous";
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -189,7 +188,9 @@ const checkEmailStep = async () => {
|
|||||||
|
|
||||||
const response = await AuthService.checkEmail(email.value);
|
const response = await AuthService.checkEmail(email.value);
|
||||||
checkedEmail.value = email.value;
|
checkedEmail.value = email.value;
|
||||||
currentStep.value = response.data.has_password ? "password" : "create-password";
|
currentStep.value = response.data.has_password
|
||||||
|
? "password"
|
||||||
|
: "create-password";
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
resetPasswords();
|
resetPasswords();
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user