Laravel access controll pour les utilisateurs

This commit is contained in:
kevin 2026-04-28 09:33:07 +03:00
parent d5916d96a2
commit d275c460b6
64 changed files with 5802 additions and 4633 deletions

View File

@ -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",

Binary file not shown.

View File

@ -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);
}
}
}

View File

@ -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);

View File

@ -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()];
}
} }

View File

@ -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) {

View File

@ -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)],
]; ];
} }

View File

@ -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)],
]; ];
} }

View File

@ -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);

View File

@ -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);

View 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();
}
}

View File

@ -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;
}

View File

@ -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;
}
}
} }

View File

@ -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": {

View File

@ -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"
} }

View 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',
],
];

View File

@ -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']);
}
};

View File

@ -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

View 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', []);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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([

View File

@ -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" }),
] ]
), ),

View File

@ -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>

View File

@ -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");

View File

@ -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>

View File

@ -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);
}; };

View File

@ -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>

View File

@ -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>

View File

@ -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 () => {

View File

@ -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;

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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";
}; };

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,

View File

@ -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);

View File

@ -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;

View File

@ -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"
> >

View File

@ -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>

View File

@ -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: {

View File

@ -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: {

View File

@ -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: {

View File

@ -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";
}; };

View File

@ -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",

View File

@ -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);

View File

@ -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;

View File

@ -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;
} }

View File

@ -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;
} }

View File

@ -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);
} }

View File

@ -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>

View File

@ -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;
} }

View File

@ -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>

View File

@ -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();
}; };