feat(auth): add employee user linking and password setup flow

Add user management endpoints and link employees to existing users
through `user_id`, including API resources, validation, repository
support, and database migrations.

Introduce a two-step login flow that checks email first and lets users
without a password create one before signing in.

Update the employee detail UI with a dedicated user tab and refresh the
employee and intervention side navigation to support the new account
management flow.
This commit is contained in:
nyavokevin 2026-04-08 13:31:57 +03:00
parent 8f7019e815
commit 56b0c50111
32 changed files with 2334 additions and 701 deletions

View File

@ -72,6 +72,75 @@ class AuthController extends BaseController
} }
} }
public function checkEmail(Request $request): JsonResponse
{
try {
$data = $request->validate([
'email' => ['required', 'email'],
]);
$user = User::where('email', $data['email'])->first();
if (! $user) {
return $this->sendError('Utilisateur introuvable.', [
'email' => ['Aucun utilisateur ne correspond a cet email.'],
], 404);
}
return $this->sendResponse([
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
],
'has_password' => ! empty($user->getRawOriginal('password')),
], 'Email verifie avec succes.');
} catch (ValidationException $e) {
return $this->sendError('Validation Error.', $e->errors(), 422);
} catch (\Exception $e) {
return $this->sendError('Email check failed.', ['error' => $e->getMessage()], 500);
}
}
public function createPasswordAndLogin(Request $request): JsonResponse
{
try {
$data = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Password::min(8)],
]);
/** @var User|null $user */
$user = User::where('email', $data['email'])->first();
if (! $user) {
return $this->sendError('Utilisateur introuvable.', [
'email' => ['Aucun utilisateur ne correspond a cet email.'],
], 404);
}
if (! empty($user->getRawOriginal('password'))) {
return $this->sendError('Mot de passe deja defini.', [
'password' => ['Cet utilisateur a deja un mot de passe.'],
], 422);
}
$user->password = $data['password'];
$user->save();
$token = $user->createToken('api')->plainTextToken;
return $this->sendResponse([
'user' => $user,
'token' => $token,
], 'Mot de passe cree et connexion reussie.');
} catch (ValidationException $e) {
return $this->sendError('Validation Error.', $e->errors(), 422);
} catch (\Exception $e) {
return $this->sendError('Password creation failed.', ['error' => $e->getMessage()], 500);
}
}
public function me(Request $request): JsonResponse public function me(Request $request): JsonResponse
{ {
try { try {

View File

@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreUserRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Models\User;
use App\Repositories\UserRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
class UserController extends Controller
{
public function __construct(
private readonly UserRepositoryInterface $userRepository
) {
}
public function index(): JsonResponse
{
try {
$email = request()->query('email');
if ($email) {
$user = User::query()->where('email', $email)->first();
return response()->json([
'data' => $user,
'message' => $user
? 'Utilisateur recupere avec succes.'
: 'Aucun utilisateur trouve pour cet email.',
]);
}
return response()->json([
'data' => $this->userRepository->all()->sortBy('name')->values(),
'message' => 'Utilisateurs recuperes avec succes.',
]);
} catch (\Exception $e) {
Log::error('Error fetching users: ' . $e->getMessage(), [
'exception' => $e,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la recuperation des utilisateurs.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
public function store(StoreUserRequest $request): JsonResponse
{
try {
$user = $this->userRepository->create($request->validated());
return response()->json([
'data' => $user,
'message' => 'Utilisateur cree avec succes.',
], 201);
} catch (\Exception $e) {
Log::error('Error creating user: ' . $e->getMessage(), [
'exception' => $e,
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la creation de l\'utilisateur.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
public function show(string $id): JsonResponse
{
try {
$user = $this->userRepository->find($id);
if (! $user) {
return response()->json([
'message' => 'Utilisateur non trouve.',
], 404);
}
return response()->json([
'data' => $user,
'message' => 'Utilisateur recupere avec succes.',
]);
} catch (\Exception $e) {
Log::error('Error fetching user: ' . $e->getMessage(), [
'exception' => $e,
'user_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la recuperation de l\'utilisateur.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
public function update(UpdateUserRequest $request, string $id): JsonResponse
{
try {
$user = $this->userRepository->find($id);
if (! $user) {
return response()->json([
'message' => 'Utilisateur non trouve ou echec de la mise a jour.',
], 404);
}
$validated = $request->validated();
if (empty($validated['password'])) {
unset($validated['password']);
}
$updated = $user->fill($validated)->save();
if (! $updated) {
return response()->json([
'message' => 'Utilisateur non trouve ou echec de la mise a jour.',
], 404);
}
return response()->json([
'data' => $user->fresh(),
'message' => 'Utilisateur mis a jour avec succes.',
]);
} catch (\Exception $e) {
Log::error('Error updating user: ' . $e->getMessage(), [
'exception' => $e,
'user_id' => $id,
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la mise a jour de l\'utilisateur.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
public function destroy(string $id): JsonResponse
{
try {
$deleted = $this->userRepository->delete($id);
if (! $deleted) {
return response()->json([
'message' => 'Utilisateur non trouve ou echec de la suppression.',
], 404);
}
return response()->json([
'message' => 'Utilisateur supprime avec succes.',
]);
} catch (\Exception $e) {
Log::error('Error deleting user: ' . $e->getMessage(), [
'exception' => $e,
'user_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la suppression de l\'utilisateur.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class StoreUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<int, string>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'password' => ['nullable', 'string', Password::min(8)],
];
}
}

View File

@ -23,8 +23,8 @@ class UpdateEmployeeRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'first_name' => 'required|string|max:191', 'first_name' => 'nullable|string|max:191',
'last_name' => 'required|string|max:191', 'last_name' => 'nullable|string|max:191',
'email' => [ 'email' => [
'nullable', 'nullable',
'email', 'email',
@ -32,6 +32,7 @@ class UpdateEmployeeRequest extends FormRequest
Rule::unique('employees', 'email')->ignore($this->route('employee')) Rule::unique('employees', 'email')->ignore($this->route('employee'))
], ],
'phone' => 'nullable|string|max:50', 'phone' => 'nullable|string|max:50',
'user_id' => 'nullable|exists:users,id',
'job_title' => 'nullable|string|max:191', 'job_title' => 'nullable|string|max:191',
'hire_date' => 'nullable|date', 'hire_date' => 'nullable|date',
'active' => 'boolean', 'active' => 'boolean',
@ -56,6 +57,7 @@ class UpdateEmployeeRequest extends FormRequest
'email.unique' => 'Cette adresse email est déjà utilisée.', 'email.unique' => 'Cette adresse email est déjà utilisée.',
'phone.string' => 'Le téléphone doit être une chaîne de caractères.', 'phone.string' => 'Le téléphone doit être une chaîne de caractères.',
'phone.max' => 'Le téléphone ne peut pas dépasser :max caractères.', 'phone.max' => 'Le téléphone ne peut pas dépasser :max caractères.',
'user_id.exists' => 'L\'utilisateur sélectionné est invalide.',
'job_title.string' => 'L\'intitulé du poste doit être une chaîne de caractères.', 'job_title.string' => 'L\'intitulé du poste doit être une chaîne de caractères.',
'job_title.max' => 'L\'intitulé du poste ne peut pas dépasser :max caractères.', 'job_title.max' => 'L\'intitulé du poste ne peut pas dépasser :max caractères.',
'hire_date.date' => 'La date d\'embauche doit être une date valide.', 'hire_date.date' => 'La date d\'embauche doit être une date valide.',

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class UpdateUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<int, mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users', 'email')->ignore($this->route('user')),
],
'password' => ['nullable', 'string', Password::min(8)],
];
}
}

View File

@ -21,6 +21,7 @@ class EmployeeResource extends JsonResource
'full_name' => $this->full_name, 'full_name' => $this->full_name,
'email' => $this->email, 'email' => $this->email,
'phone' => $this->phone, 'phone' => $this->phone,
'user_id' => $this->user_id,
'job_title' => $this->job_title, 'job_title' => $this->job_title,
'hire_date' => $this->hire_date?->format('Y-m-d'), 'hire_date' => $this->hire_date?->format('Y-m-d'),
'active' => $this->active, 'active' => $this->active,
@ -32,6 +33,15 @@ class EmployeeResource extends JsonResource
$this->relationLoaded('thanatopractitioner'), $this->relationLoaded('thanatopractitioner'),
new ThanatopractitionerResource($this->thanatopractitioner) new ThanatopractitionerResource($this->thanatopractitioner)
), ),
'user' => $this->when(
$this->relationLoaded('user') && $this->user,
fn () => [
'id' => $this->user->id,
'name' => $this->user->name,
'email' => $this->user->email,
'employee_id' => $this->id,
]
),
]; ];
} }
} }

View File

@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@ -19,6 +20,7 @@ class Employee extends Model
protected $fillable = [ protected $fillable = [
'first_name', 'first_name',
'last_name', 'last_name',
'user_id',
'email', 'email',
'phone', 'phone',
'job_title', 'job_title',
@ -46,6 +48,11 @@ class Employee extends Model
return $this->hasOne(Thanatopractitioner::class); return $this->hasOne(Thanatopractitioner::class);
} }
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** /**
* Get the full name of the employee. * Get the full name of the employee.
*/ */

View File

@ -4,6 +4,7 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
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;
@ -46,4 +47,9 @@ class User extends Authenticatable
'password' => 'hashed', 'password' => 'hashed',
]; ];
} }
public function employee(): HasOne
{
return $this->hasOne(Employee::class);
}
} }

View File

@ -28,6 +28,10 @@ class AppServiceProvider extends ServiceProvider
return new \App\Repositories\PriceListRepository($app->make(\App\Models\PriceList::class)); return new \App\Repositories\PriceListRepository($app->make(\App\Models\PriceList::class));
}); });
$this->app->bind(\App\Repositories\UserRepositoryInterface::class, function ($app) {
return new \App\Repositories\UserRepository($app->make(\App\Models\User::class));
});
$this->app->bind(\App\Repositories\ClientContactRepositoryInterface::class, function ($app) { $this->app->bind(\App\Repositories\ClientContactRepositoryInterface::class, function ($app) {
return new \App\Repositories\ClientContactRepository($app->make(\App\Models\ClientContact::class)); return new \App\Repositories\ClientContactRepository($app->make(\App\Models\ClientContact::class));
}); });

View File

@ -48,7 +48,7 @@ class EmployeeRepository extends BaseRepository implements EmployeeRepositoryInt
*/ */
public function findById(int $id): ?Employee public function findById(int $id): ?Employee
{ {
return $this->model->newQuery()->find($id); return $this->model->newQuery()->with(['thanatopractitioner', 'user'])->find($id);
} }
/** /**
@ -88,7 +88,7 @@ class EmployeeRepository extends BaseRepository implements EmployeeRepositoryInt
*/ */
public function getPaginated(int $perPage = 10): array public function getPaginated(int $perPage = 10): array
{ {
$paginator = $this->model->newQuery()->paginate($perPage); $paginator = $this->model->newQuery()->with(['thanatopractitioner', 'user'])->paginate($perPage);
return [ return [
'employees' => $paginator->getCollection(), 'employees' => $paginator->getCollection(),
@ -107,11 +107,18 @@ class EmployeeRepository extends BaseRepository implements EmployeeRepositoryInt
public function getWithThanatopractitioner(): Collection public function getWithThanatopractitioner(): Collection
{ {
return $this->model->newQuery() return $this->model->newQuery()
->with('thanatopractitioner') ->with(['thanatopractitioner', 'user'])
->orderBy('last_name') ->orderBy('last_name')
->get(); ->get();
} }
public function find(int|string $id, array $columns = ['*']): ?\Illuminate\Database\Eloquent\Model
{
return $this->model->newQuery()
->with(['thanatopractitioner', 'user'])
->find($id, $columns);
}
/** /**
* Get employee statistics. * Get employee statistics.
*/ */

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\User;
class UserRepository extends BaseRepository implements UserRepositoryInterface
{
public function __construct(User $model)
{
parent::__construct($model);
}
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
interface UserRepositoryInterface extends BaseRepositoryInterface
{
}

View File

@ -0,0 +1,33 @@
<?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
{
Schema::table('employees', function (Blueprint $table) {
$table->foreignId('user_id')
->nullable()
->after('id')
->constrained('users')
->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('employees', function (Blueprint $table) {
$table->dropForeign(['user_id']);
$table->dropColumn('user_id');
});
}
};

View File

@ -0,0 +1,28 @@
<?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
{
Schema::table('users', function (Blueprint $table) {
$table->string('password')->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('password')->nullable(false)->change();
});
}
};

View File

@ -24,6 +24,7 @@ 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;
use App\Http\Controllers\Api\GoodsReceiptController; use App\Http\Controllers\Api\GoodsReceiptController;
use App\Http\Controllers\Api\UserController;
/* /*
@ -39,6 +40,8 @@ use App\Http\Controllers\Api\GoodsReceiptController;
Route::prefix('auth')->group(function () { Route::prefix('auth')->group(function () {
Route::post('/register', [AuthController::class, 'register']); Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']); Route::post('/login', [AuthController::class, 'login']);
Route::post('/check-email', [AuthController::class, 'checkEmail']);
Route::post('/create-password', [AuthController::class, 'createPasswordAndLogin']);
Route::middleware('auth:sanctum')->group(function () { Route::middleware('auth:sanctum')->group(function () {
Route::get('/me', [AuthController::class, 'me']); Route::get('/me', [AuthController::class, 'me']);
@ -59,6 +62,7 @@ Route::middleware('auth:sanctum')->group(function () {
Route::post('client-groups/{id}/assign-clients', [ClientGroupController::class, 'assignClients']); Route::post('client-groups/{id}/assign-clients', [ClientGroupController::class, 'assignClients']);
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('client-locations', ClientLocationController::class); Route::apiResource('client-locations', ClientLocationController::class);
Route::apiResource('client-locations', ClientLocationController::class); Route::apiResource('client-locations', ClientLocationController::class);

View File

@ -1,23 +1,57 @@
<template> <template>
<employee-detail-template> <div class="edp">
<template #button-return> <div class="edp__topbar">
<div class="col-12"> <div class="edp__topbar-left">
<router-link <RouterLink to="/employes">
to="/employes" <SoftButton color="secondary" variant="outline" size="sm">
class="btn btn-outline-secondary btn-sm mb-3" <svg
> width="14"
<i class="fas fa-arrow-left me-2"></i>Retour aux employés height="14"
</router-link> viewBox="0 0 16 16"
</div> fill="none"
</template> stroke="currentColor"
<template #loading-state> stroke-width="1.5"
<div v-if="isLoading" class="text-center p-5"> >
<div class="spinner-border text-primary" role="status"> <path d="M10 3L5 8l5 5" />
<span class="visually-hidden">Chargement...</span> </svg>
Retour
</SoftButton>
</RouterLink>
<div class="edp__breadcrumb">
<span>Employes</span>
<span class="edp__breadcrumb-sep">/</span>
<span class="edp__breadcrumb-current">{{ employee.full_name }}</span>
</div> </div>
</div> </div>
</template>
<template #employee-detail-sidebar> <div class="edp__topbar-actions">
<SoftButton
color="primary"
variant="outline"
size="sm"
@click="activeTab = 'info'"
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M11.5 2.5l2 2L5 13H3v-2L11.5 2.5z" />
</svg>
Modifier
</SoftButton>
</div>
</div>
<div v-if="isLoading" class="edp__state">
<div class="edp__spinner"></div>
<p>Chargement de l'employe...</p>
</div>
<div v-else class="edp__body">
<EmployeeDetailSidebar <EmployeeDetailSidebar
:avatar-url="employeeAvatar" :avatar-url="employeeAvatar"
:initials="getInitials(employee.full_name)" :initials="getInitials(employee.full_name)"
@ -28,43 +62,46 @@
:active-tab="activeTab" :active-tab="activeTab"
:is-active="employee.active" :is-active="employee.active"
:is-thanatopractitioner="!!thanatopractitionerData" :is-thanatopractitioner="!!thanatopractitionerData"
:employee="employee"
@edit-avatar="triggerFileInput" @edit-avatar="triggerFileInput"
@change-tab="activeTab = $event" @change-tab="activeTab = $event"
/> />
</template>
<template #file-input> <div class="edp__panel">
<input <EmployeeDetailContent
:ref="fileInput" :active-tab="activeTab"
type="file" :employee="employee"
class="d-none" :thanatopractitioner-data="thanatopractitionerData"
accept="image/*" :practitioner-documents="practitionerDocuments"
@change="handleAvatarUpload" :formatted-hire-date="formatDate(employee.hire_date)"
/> :employee-id="employee.id"
</template> @change-tab="activeTab = $event"
<template #employee-detail-content> @updating-employee="handleUpdateEmployee"
<EmployeeDetailContent @create-practitioner-document="handleCreatePractitionerDocument"
:active-tab="activeTab" @updating-practitioner-document="handleModifiedPractitionerDocument"
:employee="employee" @remove-practitioner-document="handleRemovePractitionerDocument"
:thanatopractitioner-data="thanatopractitionerData" @notify-success="emit('notify-success', $event)"
:practitioner-documents="practitionerDocuments" @notify-error="emit('notify-error', $event)"
:formatted-hire-date="formatDate(employee.hire_date)" />
:employee-id="employee.id" </div>
@change-tab="activeTab = $event" </div>
@updating-employee="handleUpdateEmployee"
@create-practitioner-document="handleCreatePractitionerDocument" <input
@updating-practitioner-document="handleModifiedPractitionerDocument" ref="fileInput"
@remove-practitioner-document="handleRemovePractitionerDocument" type="file"
/> class="d-none"
</template> accept="image/*"
</employee-detail-template> @change="handleAvatarUpload"
/>
</div>
</template> </template>
<script setup> <script setup>
import { defineProps, defineEmits, ref } from "vue"; import { defineProps, defineEmits, ref } from "vue";
import EmployeeDetailTemplate from "@/components/templates/CRM/EmployeeDetailTemplate.vue"; import { RouterLink } from "vue-router";
import SoftButton from "@/components/SoftButton.vue";
import EmployeeDetailSidebar from "./employee/EmployeeDetailSidebar.vue"; import EmployeeDetailSidebar from "./employee/EmployeeDetailSidebar.vue";
import EmployeeDetailContent from "./employee/EmployeeDetailContent.vue"; import EmployeeDetailContent from "./employee/EmployeeDetailContent.vue";
import { RouterLink } from "vue-router";
const props = defineProps({ const props = defineProps({
employee: { employee: {
@ -83,10 +120,6 @@ const props = defineProps({
type: String, type: String,
default: "overview", default: "overview",
}, },
fileInput: {
type: Object,
required: true,
},
thanatopractitionerData: { thanatopractitionerData: {
type: Object, type: Object,
default: null, default: null,
@ -98,21 +131,30 @@ const props = defineProps({
}); });
const localAvatar = ref(props.employeeAvatar); const localAvatar = ref(props.employeeAvatar);
const fileInput = ref(null);
const emit = defineEmits([ const emit = defineEmits([
"updateTheEmployee", "updateTheEmployee",
"create-practitioner-document", "create-practitioner-document",
"updating-practitioner-document", "updating-practitioner-document",
"remove-practitioner-document", "remove-practitioner-document",
"notify-success",
"notify-error",
"update:activeTab",
]); ]);
const activeTab = ref(props.activeTab);
const triggerFileInput = () => {
fileInput.value?.click();
};
const handleAvatarUpload = (event) => { const handleAvatarUpload = (event) => {
const file = event.target.files[0]; const file = event.target.files[0];
if (file) { if (file) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
localAvatar.value = e.target.result; localAvatar.value = e.target.result;
// TODO: Upload to server
console.log("Upload avatar to server"); console.log("Upload avatar to server");
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
@ -149,11 +191,11 @@ const getEmployeeType = (employee) => {
if (employee.thanatopractitioner) { if (employee.thanatopractitioner) {
return "Thanatopractitioner"; return "Thanatopractitioner";
} }
return "Employé"; return "Employe";
}; };
const formatDate = (dateString) => { const formatDate = (dateString) => {
if (!dateString) return "Non renseignée"; if (!dateString) return "Non renseignee";
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleDateString("fr-FR", { return date.toLocaleDateString("fr-FR", {
year: "numeric", year: "numeric",
@ -162,3 +204,96 @@ const formatDate = (dateString) => {
}); });
}; };
</script> </script>
<style scoped>
.edp {
padding: 1.5rem 1.75rem;
min-height: 100vh;
color: #344767;
}
.edp__topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.edp__topbar-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.edp__topbar-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.edp__breadcrumb {
display: flex;
align-items: center;
gap: 5px;
font-size: 13px;
color: #9ca3af;
}
.edp__breadcrumb-sep {
color: #d1d5db;
}
.edp__breadcrumb-current {
color: #374151;
font-weight: 500;
}
.edp__body {
display: grid;
grid-template-columns: 280px 1fr;
gap: 1.25rem;
align-items: start;
margin-top: 1rem;
}
.edp__panel {
min-width: 0;
}
.edp__state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
min-height: 420px;
text-align: center;
color: #6b7280;
}
.edp__spinner {
width: 24px;
height: 24px;
border: 2px solid #e5e7eb;
border-top-color: #111827;
border-radius: 50%;
animation: edp-spin 0.75s linear infinite;
}
@keyframes edp-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.edp {
padding: 1rem;
}
.edp__body {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,7 +1,6 @@
<template> <template>
<div> <div class="ed-content">
<!-- Overview Tab --> <div v-show="activeTab === 'overview'" class="ed-pane">
<div v-show="activeTab === 'overview'">
<EmployeeOverview <EmployeeOverview
:employee="employee" :employee="employee"
:formatted-hire-date="formattedHireDate" :formatted-hire-date="formattedHireDate"
@ -10,16 +9,20 @@
/> />
</div> </div>
<!-- Information Tab --> <div v-show="activeTab === 'info'" class="ed-pane">
<div v-show="activeTab === 'info'"> <EmployeeInfoTab :employee="employee" @employee-updated="updateEmployee" />
<EmployeeInfoTab </div>
<div v-show="activeTab === 'user'" class="ed-pane">
<EmployeeUserTab
:employee="employee" :employee="employee"
@employee-updated="updateEmployee" @employee-updated="updateEmployee"
@notify-success="$emit('notify-success', $event)"
@notify-error="$emit('notify-error', $event)"
/> />
</div> </div>
<!-- Documents Tab --> <div v-show="activeTab === 'documents'" class="ed-pane">
<div v-show="activeTab === 'documents'">
<EmployeeDocumentsTab <EmployeeDocumentsTab
:documents="practitionerDocuments" :documents="practitionerDocuments"
:employee-id="employee.id" :employee-id="employee.id"
@ -29,16 +32,31 @@
/> />
</div> </div>
<!-- Practitioner Tab (Only for thanatopractitioners) --> <div v-show="activeTab === 'practitioner' && practitioner" class="ed-pane">
<div v-show="activeTab === 'practitioner' && thanatopractitionerData"> <div class="ed-card">
<!-- <EmployeePractitionerTab <div class="ed-card__header">
:thanatopractitioner="thanatopractitionerData" <span class="ed-card__title">Informations praticien</span>
:employee="employee" </div>
/> --> <div class="ed-card__body">
<div class="ed-grid">
<div class="ed-data">
<span class="ed-data__label">Numero de licence</span>
<strong class="ed-data__value">{{ practitioner?.license_number || 'Non renseigne' }}</strong>
</div>
<div class="ed-data">
<span class="ed-data__label">Numero d'autorisation</span>
<strong class="ed-data__value">{{ practitioner?.authorization_number || 'Non renseigne' }}</strong>
</div>
<div class="ed-data">
<span class="ed-data__label">Validite</span>
<strong class="ed-data__value">{{ formatDate(practitioner?.authorization_valid_until) }}</strong>
</div>
</div>
</div>
</div>
</div> </div>
<!-- Activity Tab --> <div v-show="activeTab === 'activity'" class="ed-pane">
<div v-show="activeTab === 'activity'">
<EmployeeActivityTab :employee="employee" /> <EmployeeActivityTab :employee="employee" />
</div> </div>
</div> </div>
@ -47,12 +65,12 @@
<script setup> <script setup>
import EmployeeOverview from "@/components/molecules/employee/EmployeeOverview.vue"; import EmployeeOverview from "@/components/molecules/employee/EmployeeOverview.vue";
import EmployeeInfoTab from "@/components/molecules/employee/EmployeeInfoTab.vue"; import EmployeeInfoTab from "@/components/molecules/employee/EmployeeInfoTab.vue";
import EmployeeUserTab from "@/components/molecules/employee/EmployeeUserTab.vue";
import EmployeeDocumentsTab from "@/components/molecules/employee/EmployeeDocumentsTab.vue"; import EmployeeDocumentsTab from "@/components/molecules/employee/EmployeeDocumentsTab.vue";
import EmployeePractitionerTab from "@/components/molecules/employee/EmployeePractitionerTab.vue"; import { computed, defineProps, defineEmits } from "vue";
import { defineProps, defineEmits } from "vue";
import EmployeeActivityTab from "@/components/molecules/employee/EmployeeActivityTab.vue"; import EmployeeActivityTab from "@/components/molecules/employee/EmployeeActivityTab.vue";
defineProps({ const props = defineProps({
activeTab: { activeTab: {
type: String, type: String,
required: true, required: true,
@ -79,12 +97,18 @@ defineProps({
}, },
}); });
const practitioner = computed(
() => props.thanatopractitionerData || props.employee?.thanatopractitioner || null
);
const emit = defineEmits([ const emit = defineEmits([
"change-tab", "change-tab",
"updating-employee", "updating-employee",
"create-practitioner-document", "create-practitioner-document",
"updating-practitioner-document", "updating-practitioner-document",
"remove-practitioner-document", "remove-practitioner-document",
"notify-success",
"notify-error",
]); ]);
const updateEmployee = (updatedEmployee) => { const updateEmployee = (updatedEmployee) => {
@ -102,4 +126,80 @@ const handleModifiedDocument = (modifiedDocument) => {
const handleRemoveDocument = (documentId) => { const handleRemoveDocument = (documentId) => {
emit("remove-practitioner-document", documentId); emit("remove-practitioner-document", documentId);
}; };
const formatDate = (dateString) => {
if (!dateString) return "Non renseignee";
const date = new Date(dateString);
return date.toLocaleDateString("fr-FR", {
year: "numeric",
month: "long",
day: "numeric",
});
};
</script> </script>
<style scoped>
.ed-content {
min-width: 0;
}
.ed-pane {
min-width: 0;
}
.ed-card {
background: #fff;
border: 1px solid rgba(131, 146, 171, 0.25);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
}
.ed-card__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1.25rem;
border-bottom: 1px solid #f3f4f6;
}
.ed-card__title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #8392ab;
}
.ed-card__body {
padding: 1.25rem;
}
.ed-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.ed-data {
padding: 1rem;
border-radius: 0.85rem;
background: #f8fafc;
border: 1px solid #eef2f7;
}
.ed-data__label {
display: block;
margin-bottom: 0.35rem;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #8392ab;
}
.ed-data__value {
color: #344767;
font-size: 0.95rem;
}
</style>

View File

@ -1,37 +1,63 @@
<template> <template>
<div class="card position-sticky top-1"> <aside class="product-sidebar">
<!-- Employee Profile Card --> <div class="product-sidebar__img-wrap" @click="$emit('edit-avatar')">
<EmployeeProfileCard <img
:avatar-url="avatarUrl" v-if="avatarUrl"
:initials="initials" :src="avatarUrl"
:employee-name="employeeName" :alt="employeeName"
:job-title="jobTitle" class="employee-sidebar__avatar"
:status="status"
:hire-date="hireDate"
:is-active="isActive"
:is-thanatopractitioner="isThanatopractitioner"
@edit-avatar="$emit('edit-avatar')"
/>
<hr class="horizontal dark my-3 mx-3" />
<!-- Tab Navigation -->
<div class="card-body pt-0">
<EmployeeTabNavigation
:active-tab="activeTab"
:is-thanatopractitioner="isThanatopractitioner"
@change-tab="$emit('change-tab', $event)"
/> />
<div v-else class="employee-sidebar__avatar employee-sidebar__avatar--fallback">
{{ initials }}
</div>
</div> </div>
</div>
<div class="product-sidebar__meta">
<h6 class="product-sidebar__name">{{ employeeName }}</h6>
<p class="product-sidebar__ref">{{ jobTitle || status || "Employe" }}</p>
<div class="product-sidebar__badges employee-sidebar__badges">
<span
class="employee-sidebar__badge"
:class="isActive ? 'employee-sidebar__badge--success' : 'employee-sidebar__badge--muted'"
>
{{ isActive ? "Actif" : "Inactif" }}
</span>
<span v-if="isThanatopractitioner" class="employee-sidebar__badge employee-sidebar__badge--info">
Thanatopracteur
</span>
</div>
</div>
<div class="employee-sidebar__details">
<div class="employee-sidebar__detail-item">
<span class="employee-sidebar__detail-label">Embauche</span>
<span class="employee-sidebar__detail-value">{{ hireDate }}</span>
</div>
<div class="employee-sidebar__detail-item">
<span class="employee-sidebar__detail-label">Contact</span>
<span class="employee-sidebar__detail-value">{{ employee.email || employee.phone || "Non renseigne" }}</span>
</div>
</div>
<nav class="product-sidebar__nav">
<button
v-for="tab in tabs"
:key="tab.id"
class="product-sidebar__nav-item"
:class="{ 'is-active': activeTab === tab.id }"
@click="$emit('change-tab', tab.id)"
>
<component :is="tab.icon" class="nav-icon" />
{{ tab.label }}
</button>
</nav>
</aside>
</template> </template>
<script setup> <script setup>
import EmployeeProfileCard from "@/components/molecules/employee/EmployeeProfileCard.vue"; import { computed, defineComponent, defineProps, defineEmits, h } from "vue";
import EmployeeTabNavigation from "@/components/molecules/employee/EmployeeTabNavigation.vue";
import { defineProps, defineEmits } from "vue";
defineProps({ const props = defineProps({
avatarUrl: { avatarUrl: {
type: String, type: String,
default: null, default: null,
@ -46,7 +72,7 @@ defineProps({
}, },
jobTitle: { jobTitle: {
type: String, type: String,
default: "Employé", default: "Employe",
}, },
status: { status: {
type: String, type: String,
@ -68,18 +94,292 @@ defineProps({
type: String, type: String,
required: true, required: true,
}, },
employee: {
type: Object,
required: true,
},
}); });
defineEmits(["edit-avatar", "change-tab"]); defineEmits(["edit-avatar", "change-tab"]);
const IconOverview = defineComponent({
render: () =>
h(
"svg",
{
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
"stroke-width": "1.5",
},
[
h("path", { d: "M2 8s2.5-4 6-4 6 4 6 4-2.5 4-6 4-6-4-6-4z" }),
h("circle", { cx: "8", cy: "8", r: "1.75" }),
]
),
});
const IconInfo = defineComponent({
render: () =>
h(
"svg",
{
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
"stroke-width": "1.5",
},
[
h("circle", { cx: "8", cy: "8", r: "6" }),
h("path", { d: "M8 7v3M8 5.25h.01" }),
]
),
});
const IconDocument = defineComponent({
render: () =>
h(
"svg",
{
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
"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: "M9 2.5V5h2.5" }),
]
),
});
const IconPractitioner = defineComponent({
render: () =>
h(
"svg",
{
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
"stroke-width": "1.5",
},
[
h("circle", { cx: "8", cy: "5", r: "2.5" }),
h("path", { d: "M3.5 13c.5-2.3 2.3-3.5 4.5-3.5s4 1.2 4.5 3.5" }),
]
),
});
const IconActivity = defineComponent({
render: () =>
h(
"svg",
{
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
"stroke-width": "1.5",
},
[h("path", { d: "M2.5 11.5h2l1.5-3 2.25 4 1.75-3h3.5" })]
),
});
const tabs = computed(() => {
const baseTabs = [
{ id: "overview", label: "Apercu", icon: IconOverview },
{ id: "info", label: "Informations", icon: IconInfo },
{ id: "user", label: "Utilisateur", icon: IconPractitioner },
{ id: "documents", label: "Documents", icon: IconDocument },
{ id: "activity", label: "Activite", icon: IconActivity },
];
if (props.isThanatopractitioner) {
baseTabs.splice(3, 0, {
id: "practitioner",
label: "Praticien",
icon: IconPractitioner,
});
}
return baseTabs;
});
</script> </script>
<style scoped> <style scoped>
.position-sticky { .product-sidebar {
top: 1rem; position: sticky;
top: 1.25rem;
display: flex;
flex-direction: column;
gap: 0;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
} }
.card { .product-sidebar__img-wrap {
border: 0; width: 72px;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15); height: 72px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e5e7eb;
margin: 1.25rem auto 0;
flex-shrink: 0;
cursor: pointer;
background: #fff;
}
.employee-sidebar__avatar {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.employee-sidebar__avatar--fallback {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(310deg, #5e72e4 0%, #825ee4 100%);
color: #fff;
font-size: 1.25rem;
font-weight: 700;
}
.product-sidebar__meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 0.75rem 1rem 1rem;
border-bottom: 1px solid #f3f4f6;
}
.product-sidebar__name {
font-size: 14px;
font-weight: 700;
color: #111827;
text-align: center;
margin: 0;
}
.product-sidebar__ref {
font-size: 12px;
color: #9ca3af;
margin: 0;
}
.product-sidebar__badges {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 5px;
margin-top: 4px;
}
.employee-sidebar__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.employee-sidebar__badge--success {
background: #ecfdf3;
color: #047857;
}
.employee-sidebar__badge--muted {
background: #f3f4f6;
color: #6b7280;
}
.employee-sidebar__badge--info {
background: #eff6ff;
color: #1d4ed8;
}
.employee-sidebar__details {
display: grid;
gap: 0.5rem;
padding: 0.9rem 1rem 1rem;
border-bottom: 1px solid #f3f4f6;
}
.employee-sidebar__detail-item {
padding: 0.7rem 0.8rem;
border: 1px solid #eef2f7;
border-radius: 8px;
background: #f8fafc;
}
.employee-sidebar__detail-label {
display: block;
margin-bottom: 0.2rem;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #8392ab;
}
.employee-sidebar__detail-value {
display: block;
color: #344767;
font-size: 0.82rem;
word-break: break-word;
}
.product-sidebar__nav {
display: flex;
flex-direction: column;
padding: 0.5rem;
gap: 2px;
}
.product-sidebar__nav-item {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 12px;
border-radius: 7px;
border: none;
background: transparent;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: #6b7280;
font-family: inherit;
text-align: left;
transition: background 0.12s, color 0.12s;
}
.product-sidebar__nav-item:hover {
background: #f9fafb;
color: #111827;
}
.product-sidebar__nav-item.is-active {
background: #111827;
color: #fff;
}
.nav-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
}
@media (max-width: 768px) {
.product-sidebar {
position: static;
}
} }
</style> </style>

View File

@ -70,7 +70,7 @@
<!-- Main Layout --> <!-- Main Layout -->
<div v-else-if="mappedIntervention" class="page-layout"> <div v-else-if="mappedIntervention" class="page-layout">
<!-- LEFT SIDEBAR --> <!-- LEFT SIDEBAR -->
<aside class="sidebar"> <aside class="sidebar card position-sticky top-1">
<!-- Hero Card --> <!-- Hero Card -->
<div class="hero-card"> <div class="hero-card">
<div class="hero-avatar"> <div class="hero-avatar">
@ -89,10 +89,14 @@
</svg> </svg>
</div> </div>
<div class="hero-info"> <div class="hero-info">
<h2 class="hero-name">{{ mappedIntervention.defuntName }}</h2> <h5 class="hero-name font-weight-bolder mb-0">
<p class="hero-type">{{ mappedIntervention.title }}</p> {{ mappedIntervention.defuntName }}
</h5>
<p class="hero-type text-sm text-secondary mb-3">
{{ mappedIntervention.title }}
</p>
</div> </div>
<StatusPill :status="mappedIntervention.status" large /> <StatusPill :status="mappedIntervention.status" />
</div> </div>
<!-- Quick Stats --> <!-- Quick Stats -->
@ -115,7 +119,9 @@
</div> </div>
<div> <div>
<div class="stat-label">Date</div> <div class="stat-label">Date</div>
<div class="stat-value">{{ mappedIntervention.date }}</div> <div class="stat-value">
{{ mappedIntervention.date }}
</div>
</div> </div>
</div> </div>
<div class="stat-item"> <div class="stat-item">
@ -134,7 +140,9 @@
</div> </div>
<div> <div>
<div class="stat-label">Lieu</div> <div class="stat-label">Lieu</div>
<div class="stat-value">{{ mappedIntervention.lieux }}</div> <div class="stat-value">
{{ mappedIntervention.lieux }}
</div>
</div> </div>
</div> </div>
<div class="stat-item"> <div class="stat-item">
@ -153,25 +161,21 @@
</div> </div>
<div> <div>
<div class="stat-label">Durée</div> <div class="stat-label">Durée</div>
<div class="stat-value">{{ mappedIntervention.duree }}</div> <div class="stat-value">
{{ mappedIntervention.duree }}
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Tab Navigation --> <!-- Tab Navigation -->
<nav class="sidebar-nav"> <InterventionTabNavigation
<button class="sidebar-nav"
v-for="tab in tabs" :active-tab="localActiveTab"
:key="tab.id" :team-count="mappedIntervention.practitioners?.length || 0"
class="nav-item" :documents-count="documentAttachments.length"
:class="{ active: localActiveTab === tab.id }" @change-tab="changeTab"
@click="changeTab(tab.id)" />
>
<span class="nav-icon" v-html="tab.icon"></span>
<span class="nav-label">{{ tab.label }}</span>
<span v-if="tab.badge" class="nav-badge">{{ tab.badge }}</span>
</button>
</nav>
<!-- Assign Button --> <!-- Assign Button -->
<button class="assign-btn" @click="$emit('assign-practitioner')"> <button class="assign-btn" @click="$emit('assign-practitioner')">
@ -629,6 +633,7 @@ import AssignPractitionerModal from "@/components/molecules/intervention/AssignP
import StatusPill from "@/components/molecules/intervention/StatusPill.vue"; import StatusPill from "@/components/molecules/intervention/StatusPill.vue";
import InfoSection from "@/components/molecules/intervention/InfoSection.vue"; import InfoSection from "@/components/molecules/intervention/InfoSection.vue";
import DataRow from "@/components/molecules/intervention/DataRow.vue"; import DataRow from "@/components/molecules/intervention/DataRow.vue";
import InterventionTabNavigation from "@/components/molecules/intervention/InterventionTabNavigation.vue";
import { useDocumentAttachmentStore } from "@/stores/documentAttachmentStore"; import { useDocumentAttachmentStore } from "@/stores/documentAttachmentStore";
const props = defineProps({ const props = defineProps({
@ -698,28 +703,6 @@ const mappedIntervention = computed(() => {
}; };
}); });
const tabs = computed(() => [
{ id: "overview", label: "Vue d'ensemble", icon: eyeIcon },
{ id: "details", label: "Détails", icon: listIcon },
{
id: "team",
label: "Équipe",
icon: teamIcon,
badge: mappedIntervention.value?.practitioners?.length || null,
},
{ id: "documents", label: "Documents", icon: docIcon },
{ id: "quote", label: "Devis", icon: quoteIcon },
{ id: "history", label: "Historique", icon: histIcon },
]);
// SVG icons as strings for tab nav
const eyeIcon = `<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>`;
const listIcon = `<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>`;
const teamIcon = `<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>`;
const docIcon = `<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>`;
const quoteIcon = `<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"/><polyline points="10 9 9 9 8 9"/></svg>`;
const histIcon = `<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 // Helpers
const getStatusLabel = (s) => const getStatusLabel = (s) =>
({ ({
@ -870,18 +853,7 @@ onMounted(() => {
/* ─── Page Shell ────────────────────────────────────────────────── */ /* ─── Page Shell ────────────────────────────────────────────────── */
.intervention-page { .intervention-page {
min-height: 100vh; min-height: 100vh;
background: background: var(--bs-body-bg);
radial-gradient(
circle at 8% 8%,
rgba(99, 102, 241, 0.12) 0%,
rgba(99, 102, 241, 0) 34%
),
radial-gradient(
circle at 92% 4%,
rgba(16, 185, 129, 0.09) 0%,
rgba(16, 185, 129, 0) 30%
),
linear-gradient(180deg, #f8fbff 0%, #f2f6fd 100%);
font-family: inherit; font-family: inherit;
color: var(--text-primary); color: var(--text-primary);
display: flex; display: flex;
@ -947,25 +919,27 @@ onMounted(() => {
/* ─── Sidebar ───────────────────────────────────────────────────── */ /* ─── Sidebar ───────────────────────────────────────────────────── */
.sidebar { .sidebar {
background: var(--surface); background: #fff;
border-right: 1px solid var(--border); border: 0;
border-right: 0;
border-radius: 1rem;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
position: sticky; top: 1rem;
top: 57px;
height: calc(100vh - 57px); height: calc(100vh - 57px);
overflow-y: auto; overflow-y: auto;
} }
.hero-card { .hero-card {
padding: 24px 20px 20px; padding: 1.5rem 1.5rem 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 10px; gap: 0.5rem;
text-align: center; text-align: center;
border-bottom: 1px solid var(--border-light); border-bottom: 0;
} }
.hero-avatar { .hero-avatar {
@ -982,26 +956,26 @@ onMounted(() => {
} }
.hero-name { .hero-name {
font-size: 16px; font-size: 1.25rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: #344767;
margin: 0; margin: 0;
line-height: 1.3; line-height: 1.3;
} }
.hero-type { .hero-type {
font-size: 12px; font-size: 0.875rem;
color: var(--text-secondary); color: #67748e;
margin: 0; margin: 0;
font-weight: 500; font-weight: 400;
} }
/* Quick Stats */ /* Quick Stats */
.quick-stats { .quick-stats {
padding: 16px 20px; padding: 0 1.5rem 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 0.75rem;
border-bottom: 1px solid var(--border-light); border-bottom: 0;
} }
.stat-item { .stat-item {
display: flex; display: flex;
@ -1031,78 +1005,24 @@ onMounted(() => {
} }
.stat-label { .stat-label {
font-size: 11px; font-size: 0.75rem;
color: var(--text-muted); color: #8392ab;
font-weight: 500; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.4px; letter-spacing: 0.4px;
line-height: 1.2;
} }
.stat-value { .stat-value {
font-size: 13px; font-size: 0.875rem;
color: var(--text-primary); color: #344767;
font-weight: 500; font-weight: 600;
margin-top: 1px; margin-top: 0.1rem;
line-height: 1.35;
} }
/* Sidebar Nav */ /* Sidebar Nav */
.sidebar-nav { .sidebar-nav {
padding: 12px 12px; padding: 0 1rem 1rem;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
cursor: pointer;
width: 100%;
text-align: left;
font-size: 13.5px;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.15s;
}
.nav-item:hover {
background: var(--surface-3);
color: var(--text-primary);
}
.nav-item.active {
background: var(--brand-light);
color: var(--brand);
font-weight: 600;
}
.nav-item.active .nav-icon {
color: var(--brand);
}
.nav-icon {
flex-shrink: 0;
display: flex;
color: var(--text-muted);
transition: color 0.15s;
}
.nav-label {
flex: 1;
}
.nav-badge {
min-width: 20px;
height: 20px;
padding: 0 6px;
background: var(--brand);
color: white;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.nav-item.active .nav-badge {
background: var(--brand-dark);
} }
/* Assign Button */ /* Assign Button */
@ -1630,8 +1550,7 @@ onMounted(() => {
.sidebar { .sidebar {
position: static; position: static;
height: auto; height: auto;
border-right: none; border-bottom: 0;
border-bottom: 1px solid var(--border);
} }
.sidebar-nav { .sidebar-nav {
flex-direction: row; flex-direction: row;

View File

@ -1,258 +1,134 @@
<template> <template>
<div> <div class="eo">
<!-- Header --> <div class="eo-card eo-card--hero">
<div class="row mb-4"> <div class="eo-card__body">
<div class="col-12"> <div class="eo-hero">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">Aperçu de l'employé</h6>
<div> <div>
<button <p class="eo-eyebrow">Apercu collaborateur</p>
class="btn btn-sm btn-outline-primary" <h2 class="eo-title">{{ employee.full_name }}</h2>
@click="$emit('view-info-tab')" <p class="eo-subtitle">
> {{ employee.job_title || "Poste non renseigne" }}
<i class="fas fa-edit me-1"></i> </p>
Modifier </div>
</button>
<button class="eo-action" type="button" @click="$emit('view-info-tab')">
<i class="fas fa-pen"></i>
Modifier la fiche
</button>
</div>
<div class="eo-highlights">
<div class="eo-highlight">
<span class="eo-highlight__label">Email</span>
<strong class="eo-highlight__value">{{ employee.email || "Non renseigne" }}</strong>
</div>
<div class="eo-highlight">
<span class="eo-highlight__label">Telephone</span>
<strong class="eo-highlight__value">{{ employee.phone || "Non renseigne" }}</strong>
</div>
<div class="eo-highlight">
<span class="eo-highlight__label">Statut</span>
<strong class="eo-highlight__value">{{ employee.active ? "Actif" : "Inactif" }}</strong>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Basic Information Cards --> <div class="eo-grid mt-3">
<div class="row"> <div class="eo-card">
<div class="col-md-6 mb-4"> <div class="eo-card__header">
<div class="card card-stats card-round"> <span class="eo-card__title">Coordonnees</span>
<div class="card-body"> </div>
<div class="row align-items-center"> <div class="eo-card__body">
<div class="col-icon"> <div class="eo-list">
<div <div class="eo-list__item">
class="icon-big text-center icon-warning bubble-shadow-small" <span class="eo-list__label">Prenom</span>
> <strong class="eo-list__value">{{ employee.first_name || "Non renseigne" }}</strong>
<i class="fas fa-user"></i> </div>
</div> <div class="eo-list__item">
</div> <span class="eo-list__label">Nom</span>
<div class="col col-stats ms-3 ms-sm-0 mt-3"> <strong class="eo-list__value">{{ employee.last_name || "Non renseigne" }}</strong>
<div class="numbers"> </div>
<p class="card-category">Prénom</p> <div class="eo-list__item">
<h4 class="card-title">{{ employee.first_name }}</h4> <span class="eo-list__label">Email</span>
</div> <strong class="eo-list__value">{{ employee.email || "Non renseigne" }}</strong>
</div> </div>
<div class="eo-list__item">
<span class="eo-list__label">Telephone</span>
<strong class="eo-list__value">{{ employee.phone || "Non renseigne" }}</strong>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6 mb-4"> <div class="eo-card">
<div class="card card-stats card-round"> <div class="eo-card__header">
<div class="card-body"> <span class="eo-card__title">Informations d'emploi</span>
<div class="row align-items-center">
<div class="col-icon">
<div
class="icon-big text-center icon-warning bubble-shadow-small"
>
<i class="fas fa-user"></i>
</div>
</div>
<div class="col col-stats ms-3 ms-sm-0 mt-3">
<div class="numbers">
<p class="card-category">Nom</p>
<h4 class="card-title">{{ employee.last_name }}</h4>
</div>
</div>
</div>
</div>
</div> </div>
</div> <div class="eo-card__body">
<div class="eo-list">
<div class="col-md-6 mb-4"> <div class="eo-list__item">
<div class="card card-stats card-round"> <span class="eo-list__label">Date d'embauche</span>
<div class="card-body"> <strong class="eo-list__value">{{ formattedHireDate }}</strong>
<div class="row align-items-center">
<div class="col-icon">
<div
class="icon-big text-center icon-success bubble-shadow-small"
>
<i class="fas fa-envelope"></i>
</div>
</div>
<div class="col col-stats ms-3 ms-sm-0 mt-3">
<div class="numbers">
<p class="card-category">Email</p>
<h4 class="card-title">
{{ employee.email || "Non renseigné" }}
</h4>
</div>
</div>
</div> </div>
</div> <div class="eo-list__item">
</div> <span class="eo-list__label">Poste</span>
</div> <strong class="eo-list__value">{{ employee.job_title || "Non renseigne" }}</strong>
</div>
<div class="col-md-6 mb-4"> <div class="eo-list__item">
<div class="card card-stats card-round"> <span class="eo-list__label">Salaire</span>
<div class="card-body"> <strong class="eo-list__value">{{ employee.salary ? `${employee.salary} EUR` : "Non renseigne" }}</strong>
<div class="row align-items-center"> </div>
<div class="col-icon"> <div class="eo-list__item">
<div class="icon-big text-center icon-info bubble-shadow-small"> <span class="eo-list__label">Type</span>
<i class="fas fa-phone"></i> <strong class="eo-list__value">{{ employee.thanatopractitioner ? "Thanatopracteur" : "Employe" }}</strong>
</div>
</div>
<div class="col col-stats ms-3 ms-sm-0 mt-3">
<div class="numbers">
<p class="card-category">Téléphone</p>
<h4 class="card-title">
{{ employee.phone || "Non renseigné" }}
</h4>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Employment Information --> <div v-if="employee.thanatopractitioner" class="eo-card mt-3">
<div class="row"> <div class="eo-card__header">
<div class="col-12 mb-4"> <span class="eo-card__title">Certification praticien</span>
<div class="card"> </div>
<div class="card-header"> <div class="eo-card__body">
<div class="card-head-row"> <div class="eo-grid eo-grid--triple">
<div class="card-title">Informations d'emploi</div> <div class="eo-list__item">
</div> <span class="eo-list__label">Numero de licence</span>
<strong class="eo-list__value">
{{ employee.thanatopractitioner.license_number || "Non renseigne" }}
</strong>
</div> </div>
<div class="card-body"> <div class="eo-list__item">
<div class="row"> <span class="eo-list__label">Numero d'autorisation</span>
<div class="col-md-6"> <strong class="eo-list__value">
<div class="form-group"> {{ employee.thanatopractitioner.authorization_number || "Non renseigne" }}
<label class="form-label">Date d'embauche</label> </strong>
<p class="form-control-static">{{ formattedHireDate }}</p> </div>
</div> <div class="eo-list__item">
</div> <span class="eo-list__label">Validite</span>
<div class="col-md-6"> <strong class="eo-list__value">
<div class="form-group"> {{ formatDate(employee.thanatopractitioner.authorization_valid_until) }}
<label class="form-label">Poste occupé</label> </strong>
<p class="form-control-static">
{{ employee.job_title || "Non renseigné" }}
</p>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label class="form-label">Salaire</label>
<p class="form-control-static">
{{
employee.salary ? `${employee.salary}` : "Non renseigné"
}}
</p>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label class="form-label">Statut</label>
<p class="form-control-static">
<span
:class="
employee.active
? 'badge bg-success'
: 'badge bg-secondary'
"
>
{{ employee.active ? "Actif" : "Inactif" }}
</span>
</p>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Specialization for Thanatopractitioners --> <div class="eo-card mt-3">
<div <div class="eo-card__header">
v-if=" <span class="eo-card__title">Informations systeme</span>
employee.thanatopractitioner &&
employee.thanatopractitioner.license_number
"
class="row"
>
<div class="col-12 mb-4">
<div class="card">
<div class="card-header">
<div class="card-head-row">
<div class="card-title">Certification Thanatopractitioner</div>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label class="form-label">Numéro de licence</label>
<p class="form-control-static">
{{
employee.thanatopractitioner.license_number ||
"Non renseigné"
}}
</p>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label class="form-label">Numéro d'autorisation</label>
<p class="form-control-static">
{{
employee.thanatopractitioner.authorization_number ||
"Non renseigné"
}}
</p>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label class="form-label">Validité de l'autorisation</label>
<p class="form-control-static">
{{
formatDate(
employee.thanatopractitioner.authorization_valid_until
)
}}
</p>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> <div class="eo-card__body">
<div class="eo-grid">
<!-- System Information --> <div class="eo-list__item">
<div class="row"> <span class="eo-list__label">Date de creation</span>
<div class="col-12"> <strong class="eo-list__value">{{ formatDate(employee.created_at) }}</strong>
<div class="card">
<div class="card-header">
<div class="card-head-row">
<div class="card-title">Informations système</div>
</div>
</div> </div>
<div class="card-body"> <div class="eo-list__item">
<div class="row"> <span class="eo-list__label">Derniere modification</span>
<div class="col-md-6"> <strong class="eo-list__value">{{ formatDate(employee.updated_at) }}</strong>
<div class="form-group">
<label class="form-label">Date de création</label>
<p class="form-control-static">
{{ formatDate(employee.created_at) }}
</p>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label class="form-label">Dernière modification</label>
<p class="form-control-static">
{{ formatDate(employee.updated_at) }}
</p>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -278,10 +154,10 @@ defineProps({
}, },
}); });
const emit = defineEmits(["view-info-tab"]); defineEmits(["view-info-tab"]);
const formatDate = (dateString) => { const formatDate = (dateString) => {
if (!dateString) return "Non renseignée"; if (!dateString) return "Non renseignee";
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleDateString("fr-FR", { return date.toLocaleDateString("fr-FR", {
year: "numeric", year: "numeric",
@ -294,53 +170,127 @@ const formatDate = (dateString) => {
</script> </script>
<style scoped> <style scoped>
.card-stats { .eo {
border: none; min-width: 0;
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.075);
border-radius: 0.5rem;
} }
.card-title { .eo-card {
font-size: 1.1rem; background: #fff;
margin-bottom: 0; border: 1px solid rgba(131, 146, 171, 0.25);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
} }
.card-category { .eo-card--hero {
font-size: 0.875rem; border-color: rgba(94, 114, 228, 0.2);
color: #6c757d; background: linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%);
margin-bottom: 0.25rem;
} }
.form-label { .eo-card__header {
font-weight: 600;
color: #495057;
margin-bottom: 0.5rem;
}
.form-control-static {
padding: 0;
margin: 0;
border: none;
background-color: transparent;
color: #212529;
}
.icon-big {
font-size: 2.5rem;
}
.bubble-shadow-small {
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
}
.col-icon {
flex: 0 0 auto;
width: 4.5rem;
}
.card-head-row {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
justify-content: space-between;
padding: 0.875rem 1.25rem;
border-bottom: 1px solid #f3f4f6;
}
.eo-card__title,
.eo-eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #8392ab;
}
.eo-card__body {
padding: 1.25rem;
}
.eo-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.eo-title {
margin: 0.3rem 0 0;
font-size: 1.5rem;
color: #344767;
}
.eo-subtitle {
margin: 0.35rem 0 0;
color: #67748e;
}
.eo-action {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: 1px solid rgba(94, 114, 228, 0.35);
border-radius: 0.75rem;
background: #fff;
color: #5e72e4;
padding: 0.65rem 0.9rem;
font-size: 0.85rem;
font-weight: 600;
}
.eo-highlights,
.eo-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.eo-highlights {
margin-top: 1.25rem;
}
.eo-grid--triple {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.eo-highlight,
.eo-list__item {
padding: 1rem;
border-radius: 0.85rem;
background: #f8fafc;
border: 1px solid #eef2f7;
}
.eo-highlight__label,
.eo-list__label {
display: block;
margin-bottom: 0.35rem;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #8392ab;
}
.eo-highlight__value,
.eo-list__value {
color: #344767;
font-size: 0.95rem;
line-height: 1.45;
}
.eo-list {
display: grid;
gap: 1rem;
}
@media (max-width: 768px) {
.eo-highlights,
.eo-grid,
.eo-grid--triple {
grid-template-columns: 1fr;
}
} }
</style> </style>

View File

@ -1,72 +1,54 @@
<template> <template>
<div class="card-body text-center"> <div class="epc card">
<!-- Employee Avatar --> <div class="epc__cover"></div>
<div class="avatar avatar-xl position-relative">
<img
v-if="avatarUrl"
:src="avatarUrl"
:alt="employeeName"
class="w-100 border-radius-lg shadow-sm"
/>
<div
v-else
class="avatar avatar-xl rounded-circle bg-gradient-primary text-white border-radius-lg shadow-sm d-flex align-items-center justify-content-center"
>
<span class="text-xl font-weight-bold">{{ initials }}</span>
</div>
<a
href="javascript:;"
class="btn btn-sm btn-icon-only bg-gradient-primary position-absolute bottom-0 end-0 mb-n2 me-n2"
@click="$emit('edit-avatar')"
>
<i class="fa fa-pen"></i>
</a>
</div>
<!-- Employee Name --> <div class="epc__body">
<h5 class="font-weight-bolder mb-0"> <div class="epc__avatar-wrap">
{{ employeeName }} <img
</h5> v-if="avatarUrl"
<p class="text-sm text-secondary mb-3"> :src="avatarUrl"
{{ jobTitle }} :alt="employeeName"
</p> class="epc__avatar"
/>
<div v-else class="epc__avatar epc__avatar--fallback">
<span>{{ initials }}</span>
</div>
<!-- Quick Stats --> <button type="button" class="epc__edit" @click="$emit('edit-avatar')">
<div class="row text-center mt-3"> <i class="fas fa-pen"></i>
<div class="col-6 border-end"> </button>
<h6 class="text-sm font-weight-bolder mb-0">
{{ hireDate }}
</h6>
<p class="text-xs text-secondary mb-0">Date embauche</p>
</div> </div>
<div class="col-6">
<h6 class="text-sm font-weight-bolder mb-0">
<i
class="fas"
:class="
isActive
? 'fa-check-circle text-success'
: 'fa-times-circle text-danger'
"
></i>
</h6>
<p class="text-xs text-secondary mb-0">Statut</p>
</div>
</div>
<!-- Thanatopractitioner Badge --> <div class="epc__identity">
<div v-if="isThanatopractitioner" class="mt-3"> <div class="epc__badges">
<span class="badge badge-sm bg-gradient-info"> <span class="epc__badge" :class="isActive ? 'epc__badge--success' : 'epc__badge--muted'">
<i class="fas fa-user-md me-1"></i> {{ isActive ? "Actif" : "Inactif" }}
Thanatopractitioner </span>
</span> <span v-if="isThanatopractitioner" class="epc__badge epc__badge--info">
Thanatopracteur
</span>
</div>
<h1 class="epc__name">{{ employeeName }}</h1>
<p class="epc__role">{{ jobTitle || status }}</p>
</div>
<div class="epc__meta">
<div class="epc__meta-item">
<span class="epc__meta-label">Embauche</span>
<strong>{{ hireDate }}</strong>
</div>
<div class="epc__meta-item">
<span class="epc__meta-label">Contact</span>
<strong>{{ employee.email || employee.phone || "Non renseigne" }}</strong>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { defineProps, defineEmits } from "vue"; import { defineProps, defineEmits } from 'vue';
defineProps({ defineProps({
avatarUrl: { avatarUrl: {
type: String, type: String,
@ -76,13 +58,17 @@ defineProps({
type: String, type: String,
required: true, required: true,
}, },
employee: {
type: Object,
required: true,
},
employeeName: { employeeName: {
type: String, type: String,
required: true, required: true,
}, },
jobTitle: { jobTitle: {
type: String, type: String,
default: "Employé", default: "Employe",
}, },
status: { status: {
type: String, type: String,
@ -106,35 +92,140 @@ defineEmits(["edit-avatar"]);
</script> </script>
<style scoped> <style scoped>
.avatar { .epc {
width: 4rem; overflow: hidden;
height: 4rem; border: 1px solid rgba(131, 146, 171, 0.25);
border-radius: 10px;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
} }
.avatar-xl { .epc__cover {
width: 6rem; height: 84px;
height: 6rem; background: linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%);
border-bottom: 1px solid rgba(94, 114, 228, 0.15);
} }
.border-radius-lg { .epc__body {
border-radius: 0.5rem; padding: 0 1.25rem 1.25rem;
} }
.bg-gradient-primary { .epc__avatar-wrap {
background: linear-gradient(310deg, #7928ca, #ff0080); position: relative;
width: 84px;
height: 84px;
margin-top: -42px;
} }
.bg-gradient-info { .epc__avatar {
background: linear-gradient(310deg, #0dcaf0, #6bb9f0); width: 100%;
height: 100%;
border-radius: 18px;
object-fit: cover;
border: 4px solid #fff;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.14);
} }
.shadow-sm { .epc__avatar--fallback {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(310deg, #5e72e4 0%, #825ee4 100%);
color: #fff;
font-size: 1.5rem;
font-weight: 700;
} }
.btn-icon-only { .epc__edit {
width: 2.5rem; position: absolute;
height: 2.5rem; right: -6px;
padding: 0.625rem; bottom: -6px;
width: 32px;
height: 32px;
border: 0;
border-radius: 999px;
background: #fff;
color: #5e72e4;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.16);
}
.epc__identity {
margin-top: 1rem;
}
.epc__badges {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.epc__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0.25rem 0.7rem;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.epc__badge--success {
background: #ecfdf3;
color: #047857;
}
.epc__badge--muted {
background: #f3f4f6;
color: #6b7280;
}
.epc__badge--info {
background: #eff6ff;
color: #1d4ed8;
}
.epc__name {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: #344767;
}
.epc__role {
margin: 0.35rem 0 0;
color: #8392ab;
font-size: 0.95rem;
}
.epc__meta {
display: grid;
gap: 0.75rem;
margin-top: 1rem;
}
.epc__meta-item {
padding: 0.85rem 0.95rem;
border: 1px solid #f1f5f9;
border-radius: 10px;
background: #f8fafc;
}
.epc__meta-label {
display: block;
margin-bottom: 0.2rem;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #8392ab;
}
.epc__meta strong {
display: block;
color: #344767;
font-size: 0.92rem;
} }
</style> </style>

View File

@ -1,43 +1,23 @@
<template> <template>
<ul class="nav nav-pills flex-column"> <nav class="ed-nav">
<TabNavigationItem <button
icon="fas fa-eye" v-for="tab in tabs"
label="Aperçu" :key="tab.id"
:is-active="activeTab === 'overview'" type="button"
spacing="" class="ed-nav__item"
@click="$emit('change-tab', 'overview')" :class="{ 'ed-nav__item--active': activeTab === tab.id }"
/> @click="$emit('change-tab', tab.id)"
<TabNavigationItem >
icon="fas fa-info-circle" <span class="ed-nav__icon">
label="Informations" <i :class="tab.icon"></i>
:is-active="activeTab === 'info'" </span>
@click="$emit('change-tab', 'info')" <span class="ed-nav__label">{{ tab.label }}</span>
/> </button>
<TabNavigationItem </nav>
icon="fas fa-file-alt"
label="Documents"
:is-active="activeTab === 'documents'"
@click="$emit('change-tab', 'documents')"
/>
<TabNavigationItem
v-if="isThanatopractitioner"
icon="fas fa-user-md"
label="Praticien"
:is-active="activeTab === 'practitioner'"
@click="$emit('change-tab', 'practitioner')"
/>
<TabNavigationItem
icon="fas fa-chart-line"
label="Activité"
:is-active="activeTab === 'activity'"
@click="$emit('change-tab', 'activity')"
/>
</ul>
</template> </template>
<script setup> <script setup>
import TabNavigationItem from "@/components/atoms/client/TabNavigationItem.vue"; import { computed, defineProps, defineEmits } from "vue";
import { defineProps, defineEmits } from "vue";
const props = defineProps({ const props = defineProps({
activeTab: { activeTab: {
@ -50,5 +30,80 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(["change-tab"]); defineEmits(["change-tab"]);
const tabs = computed(() => {
const items = [
{ id: "overview", label: "Apercu", icon: "fas fa-eye" },
{ id: "info", label: "Informations", icon: "fas fa-info-circle" },
{ id: "documents", label: "Documents", icon: "fas fa-file-alt" },
{ id: "activity", label: "Activite", icon: "fas fa-chart-line" },
];
if (props.isThanatopractitioner) {
items.splice(3, 0, {
id: "practitioner",
label: "Praticien",
icon: "fas fa-user-md",
});
}
return items;
});
</script> </script>
<style scoped>
.ed-nav {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.ed-nav__item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.8rem 0.95rem;
border: 1px solid transparent;
border-radius: 0.85rem;
background: #fff;
color: #67748e;
transition: all 0.18s ease;
}
.ed-nav__item:hover {
border-color: rgba(94, 114, 228, 0.18);
background: #f8f9ff;
color: #344767;
}
.ed-nav__item--active {
background: linear-gradient(135deg, rgba(94, 114, 228, 0.12), #ffffff);
border-color: rgba(94, 114, 228, 0.22);
color: #344767;
box-shadow: 0 10px 24px rgba(94, 114, 228, 0.08);
}
.ed-nav__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.7rem;
background: #f3f4f6;
color: #5e72e4;
flex-shrink: 0;
}
.ed-nav__item--active .ed-nav__icon {
background: linear-gradient(310deg, #5e72e4 0%, #825ee4 100%);
color: #fff;
}
.ed-nav__label {
font-size: 0.9rem;
font-weight: 600;
}
</style>

View File

@ -0,0 +1,275 @@
<template>
<div class="eut">
<div class="eut-card">
<div class="eut-card__header">
<span class="eut-card__title">Compte utilisateur</span>
</div>
<div class="eut-card__body">
<div v-if="employee.user" class="eut-linked">
<div class="eut-linked__item">
<span class="eut-label">Utilisateur lie</span>
<strong class="eut-value">{{ employee.user.name }}</strong>
</div>
<div class="eut-linked__item">
<span class="eut-label">Email</span>
<strong class="eut-value">{{ employee.user.email }}</strong>
</div>
<button
type="button"
class="eut-btn eut-btn--muted"
:disabled="isLoading"
@click="detachUser"
>
Detacher l'utilisateur
</button>
</div>
<div v-else-if="showCreateForm" class="eut-create">
<div class="eut-create__header">Creer un utilisateur</div>
<div class="eut-grid">
<div>
<label class="eut-label" for="create-user-name">Nom</label>
<input
id="create-user-name"
v-model="createForm.name"
type="text"
class="eut-input"
/>
</div>
<div>
<label class="eut-label" for="create-user-email">Email</label>
<input
id="create-user-email"
v-model="createForm.email"
type="email"
class="eut-input"
/>
</div>
</div>
<div class="eut-actions">
<button
type="button"
class="eut-btn eut-btn--primary"
:disabled="isLoading || !createForm.name.trim() || !createForm.email.trim()"
@click="createAndAttachUser"
>
{{ isLoading ? "Creation..." : "Creer" }}
</button>
<button
type="button"
class="eut-btn eut-btn--muted"
:disabled="isLoading"
@click="cancelCreate"
>
Annuler
</button>
</div>
</div>
<div v-else class="eut-empty">
<p class="eut-empty__title">Aucun utilisateur lie</p>
<p class="eut-empty__sub">
Cet employe n'a pas encore de compte utilisateur associe.
</p>
<button
type="button"
class="eut-btn eut-btn--primary"
@click="openCreate"
>
Creer un utilisateur
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref, watch, defineProps, defineEmits } from "vue";
import { useUserStore } from "@/stores/userStore";
const props = defineProps({
employee: {
type: Object,
required: true,
},
});
const emit = defineEmits(["employee-updated", "notify-success", "notify-error"]);
const userStore = useUserStore();
const showCreateForm = ref(false);
const createForm = reactive({
name: props.employee.full_name || "",
email: props.employee.email || "",
});
const isLoading = computed(() => userStore.isLoading);
const resetCreateForm = () => {
createForm.name = props.employee.full_name || "";
createForm.email = props.employee.email || "";
};
watch(
() => props.employee,
() => {
resetCreateForm();
if (props.employee.user) {
showCreateForm.value = false;
}
},
{ immediate: true }
);
const openCreate = () => {
resetCreateForm();
showCreateForm.value = true;
};
const cancelCreate = () => {
resetCreateForm();
showCreateForm.value = false;
};
const detachUser = async () => {
await emit("employee-updated", { user_id: null });
emit("notify-success", "Utilisateur detache");
};
const createAndAttachUser = async () => {
try {
const user = await userStore.createUser({
name: createForm.name.trim(),
email: createForm.email.trim(),
password: null,
});
await emit("employee-updated", { user_id: user.id });
emit("notify-success", "Utilisateur attache");
showCreateForm.value = false;
} catch (error) {
const message =
error?.response?.data?.message ||
error?.response?.data?.error ||
(error?.response?.data?.errors
? Object.values(error.response.data.errors).flat()[0]
: null) ||
"Impossible de creer l'utilisateur";
emit("notify-error", message);
}
};
</script>
<style scoped>
.eut-card {
background: #fff;
border: 1px solid rgba(131, 146, 171, 0.25);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
}
.eut-card__header {
padding: 0.875rem 1.25rem;
border-bottom: 1px solid #f3f4f6;
}
.eut-card__title,
.eut-label,
.eut-create__header {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #8392ab;
}
.eut-card__body {
padding: 1.25rem;
display: grid;
gap: 1rem;
}
.eut-create,
.eut-linked,
.eut-empty {
display: grid;
gap: 0.75rem;
}
.eut-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.eut-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.eut-input {
width: 100%;
border: 1px solid #d2d6da;
border-radius: 0.65rem;
padding: 0.75rem 0.9rem;
font-size: 0.9rem;
}
.eut-btn {
border: 1px solid #111827;
background: #111827;
color: #fff;
border-radius: 0.65rem;
padding: 0.75rem 1rem;
font-size: 0.85rem;
font-weight: 600;
}
.eut-btn--primary {
justify-self: start;
}
.eut-btn--muted {
border-color: #e5e7eb;
background: #fff;
color: #374151;
}
.eut-linked__item {
padding: 1rem;
border-radius: 0.85rem;
background: #f8fafc;
border: 1px solid #eef2f7;
}
.eut-empty__title {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: #344767;
}
.eut-empty__sub,
.eut-value {
margin: 0;
color: #67748e;
}
@media (max-width: 768px) {
.eut-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -67,65 +67,56 @@ const tabs = [
<style scoped> <style scoped>
.tab-nav { .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; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px;
font-family: inherit;
} }
.tab-item { .tab-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 9px; gap: 0.5rem;
padding: 8px 11px; padding: 0.65rem 0.9rem;
border-radius: var(--r-sm); border-radius: 0.5rem;
border: none; border: none;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
width: 100%; width: 100%;
text-align: left; text-align: left;
font-size: 13.5px; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: var(--text-2); color: #67748e;
transition: background 0.12s, color 0.12s; transition: all 0.2s ease-in-out;
} }
.tab-item:hover { .tab-item:hover {
background: var(--surface-2); background-color: #f8f9fa;
color: var(--text-1);
} }
.tab-item.active { .tab-item.active {
background: var(--brand-lt); background: linear-gradient(310deg, #7928ca, #ff0080);
color: var(--brand); color: #fff;
font-weight: 600; font-weight: 600;
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.09),
0 2px 3px -1px rgba(0, 0, 0, 0.07);
} }
.tab-icon { .tab-icon {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
color: var(--text-3); color: inherit;
} }
.tab-item.active .tab-icon { .tab-item.active .tab-icon {
color: var(--brand); color: #fff;
} }
.tab-label { .tab-label {
flex: 1; flex: 1;
margin-right: 10px;
} }
.tab-badge { .tab-badge {
min-width: 19px; min-width: 19px;
height: 19px; height: 19px;
padding: 0 5px; padding: 0 5px;
border-radius: 10px; border-radius: 999px;
background: var(--brand); background: linear-gradient(310deg, #17ad37 0%, #98ec2d 100%);
color: white; color: white;
font-size: 10.5px; font-size: 10.5px;
font-weight: 700; font-weight: 700;
@ -134,6 +125,6 @@ const tabs = [
justify-content: center; justify-content: center;
} }
.tab-item.active .tab-badge { .tab-item.active .tab-badge {
background: var(--brand-dk); background: rgba(255, 255, 255, 0.2);
} }
</style> </style>

View File

@ -30,6 +30,15 @@ export interface LoginResponse {
message: string; message: string;
} }
export interface CheckEmailResponse {
success: boolean;
data: {
user: User;
has_password: boolean;
};
message: string;
}
export const AuthService = { export const AuthService = {
async login(payload: LoginPayload): Promise<LoginResponse> { async login(payload: LoginPayload): Promise<LoginResponse> {
const response = await request<LoginResponse>({ const response = await request<LoginResponse>({
@ -61,6 +70,40 @@ export const AuthService = {
return response; return response;
}, },
async checkEmail(email: string): Promise<CheckEmailResponse> {
return request<CheckEmailResponse>({
url: "/api/auth/check-email",
method: "post",
data: { email },
});
},
async createPassword(payload: {
email: string;
password: string;
password_confirmation: string;
}): Promise<LoginResponse> {
const response = await request<LoginResponse>({
url: "/api/auth/create-password",
method: "post",
data: payload,
});
if (response.success && response.data.token) {
localStorage.setItem("auth_token", response.data.token);
}
return response;
},
applyLoginResponse(response: LoginResponse) {
if (response.success && response.data.token) {
localStorage.setItem("auth_token", response.data.token);
}
return response;
},
async logout() { async logout() {
try { try {
await request<void>({ url: "/api/auth/logout", method: "post" }); await request<void>({ url: "/api/auth/logout", method: "post" });

View File

@ -11,6 +11,7 @@ export interface EmployeeAddress {
export interface Employee { export interface Employee {
id: number; id: number;
user_id?: number | null;
first_name: string; first_name: string;
last_name: string; last_name: string;
full_name: string; full_name: string;
@ -31,6 +32,11 @@ export interface Employee {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} | null; } | null;
user?: {
id: number;
name: string;
email: string;
} | null;
} }
export interface EmployeeListResponse { export interface EmployeeListResponse {
@ -80,6 +86,7 @@ export interface CreateEmployeePayload {
export interface UpdateEmployeePayload extends Partial<CreateEmployeePayload> { export interface UpdateEmployeePayload extends Partial<CreateEmployeePayload> {
id: number; id: number;
user_id?: number | null;
} }
export const EmployeeService = { export const EmployeeService = {

View File

@ -0,0 +1,65 @@
import { request } from "./http";
export interface UserSummary {
id: number;
name: string;
email: string;
}
export interface CreateUserPayload {
name: string;
email: string;
password?: string | null;
}
export interface UpdateUserPayload {
id: number;
name: string;
email: string;
password?: string | null;
}
export const UserService = {
async searchUserByEmail(email: string): Promise<UserSummary | null> {
const response = await request<{
data: UserSummary | null;
message: string;
}>({
url: "/api/users",
method: "get",
params: { email },
});
return response.data;
},
async createUser(payload: CreateUserPayload): Promise<UserSummary> {
const response = await request<{
data: UserSummary;
message: string;
}>({
url: "/api/users",
method: "post",
data: payload,
});
return response.data;
},
async updateUser(payload: UpdateUserPayload): Promise<UserSummary> {
const { id, ...data } = payload;
const response = await request<{
data: UserSummary;
message: string;
}>({
url: `/api/users/${id}`,
method: "put",
data,
});
return response.data;
},
};
export default UserService;

View File

@ -1,5 +1,10 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import type { User, LoginPayload, RegisterPayload } from "@/services/auth"; import type {
User,
LoginPayload,
LoginResponse,
RegisterPayload,
} from "@/services/auth";
import AuthService from "@/services/auth"; import AuthService from "@/services/auth";
export const useAuthStore = defineStore("auth", { export const useAuthStore = defineStore("auth", {
@ -49,6 +54,18 @@ export const useAuthStore = defineStore("auth", {
throw new Error(response.message || "Login failed"); throw new Error(response.message || "Login failed");
} }
}, },
applyLoginResponse(response: LoginResponse) {
const applied = AuthService.applyLoginResponse(response);
if (applied.success && applied.data.user && applied.data.token) {
this.user = applied.data.user;
this.token = applied.data.token;
this.checked = true;
return this.user;
}
throw new Error(applied.message || "Login failed");
},
async register(payload: RegisterPayload) { async register(payload: RegisterPayload) {
const response = await AuthService.register(payload); const response = await AuthService.register(payload);

View File

@ -0,0 +1,79 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import UserService from "@/services/user";
import type {
CreateUserPayload,
UpdateUserPayload,
UserSummary,
} from "@/services/user";
export const useUserStore = defineStore("user", () => {
const loading = ref(false);
const error = ref<string | null>(null);
const searchedUser = ref<UserSummary | null>(null);
const isLoading = computed(() => loading.value);
const currentSearchUser = computed(() => searchedUser.value);
const searchUserByEmail = async (email: string) => {
loading.value = true;
error.value = null;
try {
const user = await UserService.searchUserByEmail(email);
searchedUser.value = user;
return user;
} catch (err: any) {
error.value =
err.response?.data?.message || err.message || "Failed to search user";
throw err;
} finally {
loading.value = false;
}
};
const createUser = async (payload: CreateUserPayload) => {
loading.value = true;
error.value = null;
try {
const user = await UserService.createUser(payload);
searchedUser.value = user;
return user;
} catch (err: any) {
error.value =
err.response?.data?.message || err.message || "Failed to create user";
throw err;
} finally {
loading.value = false;
}
};
const updateUser = async (payload: UpdateUserPayload) => {
loading.value = true;
error.value = null;
try {
const user = await UserService.updateUser(payload);
searchedUser.value = user;
return user;
} catch (err: any) {
error.value =
err.response?.data?.message || err.message || "Failed to update user";
throw err;
} finally {
loading.value = false;
}
};
return {
loading,
error,
searchedUser,
isLoading,
currentSearchUser,
searchUserByEmail,
createUser,
updateUser,
};
});

View File

@ -5,13 +5,14 @@
:is-loading="employeeStore.isLoading" :is-loading="employeeStore.isLoading"
:employee-avatar="employeeAvatar" :employee-avatar="employeeAvatar"
:active-tab="activeTab" :active-tab="activeTab"
:file-input="fileInput"
:thanatopractitioner-data="thanatopractitionerData" :thanatopractitioner-data="thanatopractitionerData"
:practitioner-documents="practitionerDocuments" :practitioner-documents="practitionerDocuments"
@update-the-employee="updateEmployee" @update-the-employee="updateEmployee"
@create-practitioner-document="createPractitionerDocument" @create-practitioner-document="createPractitionerDocument"
@updating-practitioner-document="updatePractitionerDocument" @updating-practitioner-document="updatePractitionerDocument"
@remove-practitioner-document="removePractitionerDocument" @remove-practitioner-document="removePractitionerDocument"
@notify-success="handleSuccessMessage"
@notify-error="handleErrorMessage"
/> />
</template> </template>
@ -30,7 +31,6 @@ const notificationStore = useNotificationStore();
const employee_id = Number(route.params.id); const employee_id = Number(route.params.id);
const activeTab = ref("overview"); const activeTab = ref("overview");
const employeeAvatar = ref(null); const employeeAvatar = ref(null);
const fileInput = ref(null);
const thanatopractitionerData = ref(null); const thanatopractitionerData = ref(null);
const practitionerDocuments = ref([]); const practitionerDocuments = ref([]);
@ -104,4 +104,12 @@ const removePractitionerDocument = async (documentId) => {
notificationStore.error("Erreur", "Impossible de supprimer le document"); notificationStore.error("Erreur", "Impossible de supprimer le document");
} }
}; };
const handleSuccessMessage = (message) => {
notificationStore.updated(message || "Employé");
};
const handleErrorMessage = (message) => {
notificationStore.error("Erreur", message || "Une erreur est survenue");
};
</script> </script>

View File

@ -12,21 +12,23 @@
<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">
Veuillez entrer vos identifiants pour vous connecter Entrez votre email pour continuer
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="container"> <div class="container">
<div class="row mt-lg-n10 mt-md-n11 mt-n10 justify-content-center"> <div class="row mt-lg-n10 mt-md-n11 mt-n10 justify-content-center">
<div class="mx-auto col-xl-4 col-lg-5 col-md-7"> <div class="mx-auto col-xl-4 col-lg-5 col-md-7">
<div class="card z-index-0"> <div class="card z-index-0">
<div class="pt-4 text-center card-header"> <div class="pt-4 text-center card-header">
<h5>Connectez-vous</h5> <h5>{{ stepTitle }}</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<form role="form" class="text-start" @submit.prevent="handleLogin"> <form role="form" class="text-start" @submit.prevent="handleSubmit">
<div class="mb-3"> <div class="mb-3">
<SoftInput <SoftInput
id="email" id="email"
@ -35,26 +37,56 @@
placeholder="Email" placeholder="Email"
name="email" name="email"
:is-required="true" :is-required="true"
:disabled="currentStep !== 'email'"
/> />
</div> </div>
<div class="mb-3">
<SoftInput <template v-if="currentStep === 'password'">
id="password" <div class="mb-3">
v-model="password" <SoftInput
name="password" id="password"
type="password" v-model="password"
placeholder="Mot de passe" name="password"
:is-required="true" type="password"
/> placeholder="Mot de passe"
</div> :is-required="true"
<SoftSwitch />
id="rememberMe" </div>
:checked="remember"
name="rememberMe" <SoftSwitch
@change="remember = $event.target.checked" id="rememberMe"
> :checked="remember"
Souvenez de moi name="rememberMe"
</SoftSwitch> @change="remember = $event.target.checked"
>
Souvenez de moi
</SoftSwitch>
</template>
<template v-if="currentStep === 'create-password'">
<div class="mb-3">
<SoftInput
id="new-password"
v-model="password"
name="password"
type="password"
placeholder="Nouveau mot de passe"
:is-required="true"
/>
</div>
<div class="mb-3">
<SoftInput
id="password-confirmation"
v-model="passwordConfirmation"
name="password_confirmation"
type="password"
placeholder="Confirmer le mot de passe"
:is-required="true"
/>
</div>
</template>
<div <div
v-if="errorMessage" v-if="errorMessage"
class="alert alert-danger text-white" class="alert alert-danger text-white"
@ -62,16 +94,30 @@
> >
{{ errorMessage }} {{ errorMessage }}
</div> </div>
<div class="text-center">
<div class="text-center d-grid gap-2">
<SoftButton <SoftButton
type="submit" type="submit"
class="my-4 mb-2" class="my-2 mb-0"
variant="gradient" variant="gradient"
color="info" color="info"
full-width full-width
:disabled="isLoading" :disabled="isLoading"
> >
{{ isLoading ? "Connexion..." : "Se connecter" }} {{ submitLabel }}
</SoftButton>
<SoftButton
v-if="currentStep !== 'email'"
type="button"
class="mt-0"
variant="outline"
color="secondary"
full-width
:disabled="isLoading"
@click="goBack"
>
Retour
</SoftButton> </SoftButton>
</div> </div>
</form> </form>
@ -81,47 +127,134 @@
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { computed, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import AuthService from "@/services/auth";
import SoftInput from "@/components/SoftInput.vue"; import SoftInput from "@/components/SoftInput.vue";
import SoftSwitch from "@/components/SoftSwitch.vue"; import SoftSwitch from "@/components/SoftSwitch.vue";
import SoftButton from "@/components/SoftButton.vue"; import SoftButton from "@/components/SoftButton.vue";
type Step = "email" | "password" | "create-password";
const router = useRouter(); const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore();
const currentStep = ref<Step>("email");
const checkedEmail = ref("");
const email = ref(""); const email = ref("");
const password = ref(""); const password = ref("");
const passwordConfirmation = ref("");
const remember = ref(false); const remember = ref(false);
const isLoading = ref(false); const isLoading = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const handleLogin = async () => { const stepTitle = computed(() => {
if (!email.value || !password.value) { if (currentStep.value === "password") return "Saisissez votre mot de passe";
errorMessage.value = "Veuillez remplir tous les champs"; if (currentStep.value === "create-password") return "Creez votre mot de passe";
return "Connectez-vous";
});
const submitLabel = computed(() => {
if (isLoading.value) {
if (currentStep.value === "email") return "Verification...";
if (currentStep.value === "create-password") return "Creation...";
return "Connexion...";
}
if (currentStep.value === "email") return "Suivant";
if (currentStep.value === "create-password") return "Creer et se connecter";
return "Se connecter";
});
const resetPasswords = () => {
password.value = "";
passwordConfirmation.value = "";
};
const goBack = () => {
currentStep.value = "email";
errorMessage.value = "";
resetPasswords();
email.value = checkedEmail.value || email.value;
};
const checkEmailStep = async () => {
if (!email.value) {
errorMessage.value = "Veuillez entrer votre email";
return; return;
} }
const response = await AuthService.checkEmail(email.value);
checkedEmail.value = email.value;
currentStep.value = response.data.has_password ? "password" : "create-password";
errorMessage.value = "";
resetPasswords();
};
const loginStep = async () => {
if (!password.value) {
errorMessage.value = "Veuillez entrer votre mot de passe";
return;
}
await authStore.login({
email: email.value,
password: password.value,
remember: remember.value,
});
router.push("/dashboards/dashboard-default");
};
const createPasswordStep = async () => {
if (!password.value || !passwordConfirmation.value) {
errorMessage.value = "Veuillez remplir les deux champs de mot de passe";
return;
}
if (password.value !== passwordConfirmation.value) {
errorMessage.value = "Les mots de passe ne correspondent pas";
return;
}
const response = await AuthService.createPassword({
email: email.value,
password: password.value,
password_confirmation: passwordConfirmation.value,
});
authStore.applyLoginResponse(response);
router.push("/dashboards/dashboard-default");
};
const handleSubmit = async () => {
isLoading.value = true; isLoading.value = true;
errorMessage.value = ""; errorMessage.value = "";
try { try {
await authStore.login({ if (currentStep.value === "email") {
email: email.value, await checkEmailStep();
password: password.value, return;
remember: remember.value, }
});
// Redirect to dashboard on success if (currentStep.value === "password") {
router.push("/dashboards/dashboard-default"); await loginStep();
return;
}
await createPasswordStep();
} catch (error: any) { } catch (error: any) {
console.error("Login error:", error);
errorMessage.value = errorMessage.value =
error.response?.data?.message || error.response?.data?.message ||
error.response?.data?.error ||
(error.response?.data?.data
? Object.values(error.response.data.data).flat()[0]
: null) ||
error.message || error.message ||
"Email ou mot de passe incorrect"; "Une erreur est survenue";
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }

View File

@ -342,13 +342,13 @@
"affectsGlobalScope": false "affectsGlobalScope": false
}, },
"./src/services/auth.ts": { "./src/services/auth.ts": {
"version": "16ebba3a31a188cd83e24cbf6f51059b11e9bae45b70d74cb846ef98b180bc83", "version": "2eeaa10e80a0e2b6e8b2132d04bfd5807f406410e1937e96d835c0da3869a3c0",
"signature": "28ae1b4b650b0b5a33be34a437b12709e6720f5b793cfe4989c6de4d9712f1be", "signature": "9fe51dbe2f2d4cf03c23b09324a28a1c4803ec8b1243dc934eb66b7e3911b141",
"affectsGlobalScope": false "affectsGlobalScope": false
}, },
"./src/stores/auth.ts": { "./src/stores/auth.ts": {
"version": "54a7256147ecea348010e7838bd11cfad8bbe0e1088ff33157270cf49b83d2df", "version": "51e85d745280cf0053821aeab38ee8784d536c0a9011d7406cebcaec02466421",
"signature": "e372e4cc9e9ec057aec7bf27009c69b9a61f7496adb6982537fcefe8878c29cf", "signature": "4cbf57bf88491cd2a06ecf58fa813172f1b1a9055fdd2891820cc85c5a357b03",
"affectsGlobalScope": false "affectsGlobalScope": false
}, },
"./src/plugins/pinia.ts": { "./src/plugins/pinia.ts": {
@ -447,8 +447,8 @@
"affectsGlobalScope": false "affectsGlobalScope": false
}, },
"./src/services/employee.ts": { "./src/services/employee.ts": {
"version": "4f17ff95c56e77592e539121d1ae7da2acecb5f2bb19e2cdae453190e08f163a", "version": "ac861e6512abfd47d9ff77021c1ca31c1fc875ee628e9b393c0337016d8d0fd9",
"signature": "fec2accc984083793b74bbd8a19ff8450a3408ebb2233ae639757320a8555174", "signature": "2143dd5efb96ee5404f670294fd7449c1711ed38e586f8f0699dd25fab6c500f",
"affectsGlobalScope": false "affectsGlobalScope": false
}, },
"./src/services/file.ts": { "./src/services/file.ts": {
@ -541,6 +541,11 @@
"signature": "e21d680d6f67c6ea626a7c5b4cf2261ac7c4aad308edb8b97b67b158a3f721b9", "signature": "e21d680d6f67c6ea626a7c5b4cf2261ac7c4aad308edb8b97b67b158a3f721b9",
"affectsGlobalScope": false "affectsGlobalScope": false
}, },
"./src/services/user.ts": {
"version": "f7649eeaf793a7004ce2a5c4617372d5a4cb962af933709dedbeed63c9b2ea00",
"signature": "c12a954ed184461f9d4d1c629838212d3656038c2ab0fb45e0b37b05649cf9b1",
"affectsGlobalScope": false
},
"./src/services/warehouse.ts": { "./src/services/warehouse.ts": {
"version": "937e7069ca212188f8754cf7ae1c6d136ca78a758b1a106e12d25892ef2325bb", "version": "937e7069ca212188f8754cf7ae1c6d136ca78a758b1a106e12d25892ef2325bb",
"signature": "e3d801bce51f50e67b236f77f0f227d6b157a4b551655bfb92f70239547c85b6", "signature": "e3d801bce51f50e67b236f77f0f227d6b157a4b551655bfb92f70239547c85b6",
@ -593,7 +598,7 @@
}, },
"./src/stores/employeeStore.ts": { "./src/stores/employeeStore.ts": {
"version": "dc347efda269601d49e43b247f69739c88174a328b1fa6a0c2f23fcaa0b84085", "version": "dc347efda269601d49e43b247f69739c88174a328b1fa6a0c2f23fcaa0b84085",
"signature": "ec41938d36dc9cb76375b8614f99c5734c0ebe0560bd597c4eaa2a509b50504c", "signature": "9f15b71f8e81a981254013d8436a3a679fe7a1f7d590628f775e103b8573a52f",
"affectsGlobalScope": false "affectsGlobalScope": false
}, },
"./src/stores/factureFournisseurStore.ts": { "./src/stores/factureFournisseurStore.ts": {
@ -666,6 +671,11 @@
"signature": "e166686f15adbdbc03e40ba1da69613e706f1d672d790fc7ac0a081490f859c3", "signature": "e166686f15adbdbc03e40ba1da69613e706f1d672d790fc7ac0a081490f859c3",
"affectsGlobalScope": false "affectsGlobalScope": false
}, },
"./src/stores/userStore.ts": {
"version": "78156d36cb4fbfc87d10010e3984a88489a3de7e4f50df674aaca6aca9bbce8f",
"signature": "6ca38694a86319822f6d998d9b027ea3f68310a518964d9d98d5dc29c9696fd7",
"affectsGlobalScope": false
},
"./src/stores/warehouseStore.ts": { "./src/stores/warehouseStore.ts": {
"version": "eb8a88b09c39a9ec6edb001cbca3399f4f15267e8f2cea71b3e9f928db8b1656", "version": "eb8a88b09c39a9ec6edb001cbca3399f4f15267e8f2cea71b3e9f928db8b1656",
"signature": "df6be19856c6c2efb03600d31d00ae8650ccca92ba971a40f020c86801ca6b1d", "signature": "df6be19856c6c2efb03600d31d00ae8650ccca92ba971a40f020c86801ca6b1d",
@ -936,6 +946,10 @@
"./node_modules/tslib/tslib.d.ts", "./node_modules/tslib/tslib.d.ts",
"./src/services/http.ts" "./src/services/http.ts"
], ],
"./src/services/user.ts": [
"./node_modules/tslib/tslib.d.ts",
"./src/services/http.ts"
],
"./src/services/warehouse.ts": [ "./src/services/warehouse.ts": [
"./node_modules/tslib/tslib.d.ts", "./node_modules/tslib/tslib.d.ts",
"./src/services/http.ts" "./src/services/http.ts"
@ -1103,6 +1117,12 @@
"./src/services/thanatopractitioner.ts", "./src/services/thanatopractitioner.ts",
"./src/stores/notification.ts" "./src/stores/notification.ts"
], ],
"./src/stores/userStore.ts": [
"./node_modules/pinia/dist/pinia.d.ts",
"./node_modules/tslib/tslib.d.ts",
"./node_modules/vue/dist/vue.d.ts",
"./src/services/user.ts"
],
"./src/stores/warehouseStore.ts": [ "./src/stores/warehouseStore.ts": [
"./node_modules/pinia/dist/pinia.d.ts", "./node_modules/pinia/dist/pinia.d.ts",
"./node_modules/tslib/tslib.d.ts", "./node_modules/tslib/tslib.d.ts",
@ -1334,6 +1354,11 @@
"./node_modules/vue/dist/vue.d.ts", "./node_modules/vue/dist/vue.d.ts",
"./src/services/thanatopractitioner.ts" "./src/services/thanatopractitioner.ts"
], ],
"./src/stores/userStore.ts": [
"./node_modules/pinia/dist/pinia.d.ts",
"./node_modules/vue/dist/vue.d.ts",
"./src/services/user.ts"
],
"./src/stores/warehouseStore.ts": [ "./src/stores/warehouseStore.ts": [
"./node_modules/pinia/dist/pinia.d.ts", "./node_modules/pinia/dist/pinia.d.ts",
"./src/services/warehouse.ts" "./src/services/warehouse.ts"
@ -1444,6 +1469,7 @@
"./src/services/supplierInvoice.ts", "./src/services/supplierInvoice.ts",
"./src/services/thanatopractitioner.ts", "./src/services/thanatopractitioner.ts",
"./src/services/tvaRate.ts", "./src/services/tvaRate.ts",
"./src/services/user.ts",
"./src/services/warehouse.ts", "./src/services/warehouse.ts",
"./src/shims-vue.d.ts", "./src/shims-vue.d.ts",
"./src/soft-ui-dashboard.js", "./src/soft-ui-dashboard.js",
@ -1630,6 +1656,7 @@
"./src/stores/stockStore.ts", "./src/stores/stockStore.ts",
"./src/stores/supplierInvoiceStore.ts", "./src/stores/supplierInvoiceStore.ts",
"./src/stores/thanatopractitionerStore.ts", "./src/stores/thanatopractitionerStore.ts",
"./src/stores/userStore.ts",
"./src/stores/warehouseStore.ts", "./src/stores/warehouseStore.ts",
"./src/types/intervention.ts" "./src/types/intervention.ts"
], ],
@ -1898,6 +1925,10 @@
"./src/services/tvaRate.ts", "./src/services/tvaRate.ts",
1 1
], ],
[
"./src/services/user.ts",
1
],
[ [
"./src/services/warehouse.ts", "./src/services/warehouse.ts",
1 1
@ -2018,6 +2049,10 @@
"./src/stores/thanatopractitionerStore.ts", "./src/stores/thanatopractitionerStore.ts",
1 1
], ],
[
"./src/stores/userStore.ts",
1
],
[ [
"./src/stores/warehouseStore.ts", "./src/stores/warehouseStore.ts",
1 1