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
{
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
{
return [
'first_name' => 'required|string|max:191',
'last_name' => 'required|string|max:191',
'first_name' => 'nullable|string|max:191',
'last_name' => 'nullable|string|max:191',
'email' => [
'nullable',
'email',
@ -32,6 +32,7 @@ class UpdateEmployeeRequest extends FormRequest
Rule::unique('employees', 'email')->ignore($this->route('employee'))
],
'phone' => 'nullable|string|max:50',
'user_id' => 'nullable|exists:users,id',
'job_title' => 'nullable|string|max:191',
'hire_date' => 'nullable|date',
'active' => 'boolean',
@ -56,6 +57,7 @@ class UpdateEmployeeRequest extends FormRequest
'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.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.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.',

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,
'email' => $this->email,
'phone' => $this->phone,
'user_id' => $this->user_id,
'job_title' => $this->job_title,
'hire_date' => $this->hire_date?->format('Y-m-d'),
'active' => $this->active,
@ -32,6 +33,15 @@ class EmployeeResource extends JsonResource
$this->relationLoaded('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\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;
@ -19,6 +20,7 @@ class Employee extends Model
protected $fillable = [
'first_name',
'last_name',
'user_id',
'email',
'phone',
'job_title',
@ -46,6 +48,11 @@ class Employee extends Model
return $this->hasOne(Thanatopractitioner::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the full name of the employee.
*/

View File

@ -4,6 +4,7 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
@ -46,4 +47,9 @@ class User extends Authenticatable
'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));
});
$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) {
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
{
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
{
$paginator = $this->model->newQuery()->paginate($perPage);
$paginator = $this->model->newQuery()->with(['thanatopractitioner', 'user'])->paginate($perPage);
return [
'employees' => $paginator->getCollection(),
@ -107,11 +107,18 @@ class EmployeeRepository extends BaseRepository implements EmployeeRepositoryInt
public function getWithThanatopractitioner(): Collection
{
return $this->model->newQuery()
->with('thanatopractitioner')
->with(['thanatopractitioner', 'user'])
->orderBy('last_name')
->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.
*/

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\TvaRateController;
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::post('/register', [AuthController::class, 'register']);
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::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::apiResource('client-groups', ClientGroupController::class);
Route::apiResource('price-lists', PriceListController::class);
Route::apiResource('users', UserController::class);
Route::apiResource('client-locations', ClientLocationController::class);
Route::apiResource('client-locations', ClientLocationController::class);

View File

@ -1,23 +1,57 @@
<template>
<employee-detail-template>
<template #button-return>
<div class="col-12">
<router-link
to="/employes"
class="btn btn-outline-secondary btn-sm mb-3"
>
<i class="fas fa-arrow-left me-2"></i>Retour aux employés
</router-link>
</div>
</template>
<template #loading-state>
<div v-if="isLoading" class="text-center p-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
<div class="edp">
<div class="edp__topbar">
<div class="edp__topbar-left">
<RouterLink to="/employes">
<SoftButton color="secondary" variant="outline" size="sm">
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M10 3L5 8l5 5" />
</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>
</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
:avatar-url="employeeAvatar"
:initials="getInitials(employee.full_name)"
@ -28,43 +62,46 @@
:active-tab="activeTab"
:is-active="employee.active"
:is-thanatopractitioner="!!thanatopractitionerData"
:employee="employee"
@edit-avatar="triggerFileInput"
@change-tab="activeTab = $event"
/>
</template>
<template #file-input>
<input
:ref="fileInput"
type="file"
class="d-none"
accept="image/*"
@change="handleAvatarUpload"
/>
</template>
<template #employee-detail-content>
<EmployeeDetailContent
:active-tab="activeTab"
:employee="employee"
:thanatopractitioner-data="thanatopractitionerData"
:practitioner-documents="practitionerDocuments"
:formatted-hire-date="formatDate(employee.hire_date)"
:employee-id="employee.id"
@change-tab="activeTab = $event"
@updating-employee="handleUpdateEmployee"
@create-practitioner-document="handleCreatePractitionerDocument"
@updating-practitioner-document="handleModifiedPractitionerDocument"
@remove-practitioner-document="handleRemovePractitionerDocument"
/>
</template>
</employee-detail-template>
<div class="edp__panel">
<EmployeeDetailContent
:active-tab="activeTab"
:employee="employee"
:thanatopractitioner-data="thanatopractitionerData"
:practitioner-documents="practitionerDocuments"
:formatted-hire-date="formatDate(employee.hire_date)"
:employee-id="employee.id"
@change-tab="activeTab = $event"
@updating-employee="handleUpdateEmployee"
@create-practitioner-document="handleCreatePractitionerDocument"
@updating-practitioner-document="handleModifiedPractitionerDocument"
@remove-practitioner-document="handleRemovePractitionerDocument"
@notify-success="emit('notify-success', $event)"
@notify-error="emit('notify-error', $event)"
/>
</div>
</div>
<input
ref="fileInput"
type="file"
class="d-none"
accept="image/*"
@change="handleAvatarUpload"
/>
</div>
</template>
<script setup>
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 EmployeeDetailContent from "./employee/EmployeeDetailContent.vue";
import { RouterLink } from "vue-router";
const props = defineProps({
employee: {
@ -83,10 +120,6 @@ const props = defineProps({
type: String,
default: "overview",
},
fileInput: {
type: Object,
required: true,
},
thanatopractitionerData: {
type: Object,
default: null,
@ -98,21 +131,30 @@ const props = defineProps({
});
const localAvatar = ref(props.employeeAvatar);
const fileInput = ref(null);
const emit = defineEmits([
"updateTheEmployee",
"create-practitioner-document",
"updating-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 file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
localAvatar.value = e.target.result;
// TODO: Upload to server
console.log("Upload avatar to server");
};
reader.readAsDataURL(file);
@ -149,11 +191,11 @@ const getEmployeeType = (employee) => {
if (employee.thanatopractitioner) {
return "Thanatopractitioner";
}
return "Employé";
return "Employe";
};
const formatDate = (dateString) => {
if (!dateString) return "Non renseignée";
if (!dateString) return "Non renseignee";
const date = new Date(dateString);
return date.toLocaleDateString("fr-FR", {
year: "numeric",
@ -162,3 +204,96 @@ const formatDate = (dateString) => {
});
};
</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>
<div>
<!-- Overview Tab -->
<div v-show="activeTab === 'overview'">
<div class="ed-content">
<div v-show="activeTab === 'overview'" class="ed-pane">
<EmployeeOverview
:employee="employee"
:formatted-hire-date="formattedHireDate"
@ -10,16 +9,20 @@
/>
</div>
<!-- Information Tab -->
<div v-show="activeTab === 'info'">
<EmployeeInfoTab
<div v-show="activeTab === 'info'" class="ed-pane">
<EmployeeInfoTab :employee="employee" @employee-updated="updateEmployee" />
</div>
<div v-show="activeTab === 'user'" class="ed-pane">
<EmployeeUserTab
:employee="employee"
@employee-updated="updateEmployee"
@notify-success="$emit('notify-success', $event)"
@notify-error="$emit('notify-error', $event)"
/>
</div>
<!-- Documents Tab -->
<div v-show="activeTab === 'documents'">
<div v-show="activeTab === 'documents'" class="ed-pane">
<EmployeeDocumentsTab
:documents="practitionerDocuments"
:employee-id="employee.id"
@ -29,16 +32,31 @@
/>
</div>
<!-- Practitioner Tab (Only for thanatopractitioners) -->
<div v-show="activeTab === 'practitioner' && thanatopractitionerData">
<!-- <EmployeePractitionerTab
:thanatopractitioner="thanatopractitionerData"
:employee="employee"
/> -->
<div v-show="activeTab === 'practitioner' && practitioner" class="ed-pane">
<div class="ed-card">
<div class="ed-card__header">
<span class="ed-card__title">Informations praticien</span>
</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>
<!-- Activity Tab -->
<div v-show="activeTab === 'activity'">
<div v-show="activeTab === 'activity'" class="ed-pane">
<EmployeeActivityTab :employee="employee" />
</div>
</div>
@ -47,12 +65,12 @@
<script setup>
import EmployeeOverview from "@/components/molecules/employee/EmployeeOverview.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 EmployeePractitionerTab from "@/components/molecules/employee/EmployeePractitionerTab.vue";
import { defineProps, defineEmits } from "vue";
import { computed, defineProps, defineEmits } from "vue";
import EmployeeActivityTab from "@/components/molecules/employee/EmployeeActivityTab.vue";
defineProps({
const props = defineProps({
activeTab: {
type: String,
required: true,
@ -79,12 +97,18 @@ defineProps({
},
});
const practitioner = computed(
() => props.thanatopractitionerData || props.employee?.thanatopractitioner || null
);
const emit = defineEmits([
"change-tab",
"updating-employee",
"create-practitioner-document",
"updating-practitioner-document",
"remove-practitioner-document",
"notify-success",
"notify-error",
]);
const updateEmployee = (updatedEmployee) => {
@ -102,4 +126,80 @@ const handleModifiedDocument = (modifiedDocument) => {
const handleRemoveDocument = (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>
<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>
<div class="card position-sticky top-1">
<!-- Employee Profile Card -->
<EmployeeProfileCard
:avatar-url="avatarUrl"
:initials="initials"
:employee-name="employeeName"
:job-title="jobTitle"
: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)"
<aside class="product-sidebar">
<div class="product-sidebar__img-wrap" @click="$emit('edit-avatar')">
<img
v-if="avatarUrl"
:src="avatarUrl"
:alt="employeeName"
class="employee-sidebar__avatar"
/>
<div v-else class="employee-sidebar__avatar employee-sidebar__avatar--fallback">
{{ initials }}
</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>
<script setup>
import EmployeeProfileCard from "@/components/molecules/employee/EmployeeProfileCard.vue";
import EmployeeTabNavigation from "@/components/molecules/employee/EmployeeTabNavigation.vue";
import { defineProps, defineEmits } from "vue";
import { computed, defineComponent, defineProps, defineEmits, h } from "vue";
defineProps({
const props = defineProps({
avatarUrl: {
type: String,
default: null,
@ -46,7 +72,7 @@ defineProps({
},
jobTitle: {
type: String,
default: "Employé",
default: "Employe",
},
status: {
type: String,
@ -68,18 +94,292 @@ defineProps({
type: String,
required: true,
},
employee: {
type: Object,
required: true,
},
});
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>
<style scoped>
.position-sticky {
top: 1rem;
.product-sidebar {
position: sticky;
top: 1.25rem;
display: flex;
flex-direction: column;
gap: 0;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
}
.card {
border: 0;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
.product-sidebar__img-wrap {
width: 72px;
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>

View File

@ -70,7 +70,7 @@
<!-- Main Layout -->
<div v-else-if="mappedIntervention" class="page-layout">
<!-- LEFT SIDEBAR -->
<aside class="sidebar">
<aside class="sidebar card position-sticky top-1">
<!-- Hero Card -->
<div class="hero-card">
<div class="hero-avatar">
@ -89,10 +89,14 @@
</svg>
</div>
<div class="hero-info">
<h2 class="hero-name">{{ mappedIntervention.defuntName }}</h2>
<p class="hero-type">{{ mappedIntervention.title }}</p>
<h5 class="hero-name font-weight-bolder mb-0">
{{ mappedIntervention.defuntName }}
</h5>
<p class="hero-type text-sm text-secondary mb-3">
{{ mappedIntervention.title }}
</p>
</div>
<StatusPill :status="mappedIntervention.status" large />
<StatusPill :status="mappedIntervention.status" />
</div>
<!-- Quick Stats -->
@ -115,7 +119,9 @@
</div>
<div>
<div class="stat-label">Date</div>
<div class="stat-value">{{ mappedIntervention.date }}</div>
<div class="stat-value">
{{ mappedIntervention.date }}
</div>
</div>
</div>
<div class="stat-item">
@ -134,7 +140,9 @@
</div>
<div>
<div class="stat-label">Lieu</div>
<div class="stat-value">{{ mappedIntervention.lieux }}</div>
<div class="stat-value">
{{ mappedIntervention.lieux }}
</div>
</div>
</div>
<div class="stat-item">
@ -153,25 +161,21 @@
</div>
<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>
<!-- Tab Navigation -->
<nav class="sidebar-nav">
<button
v-for="tab in tabs"
:key="tab.id"
class="nav-item"
:class="{ active: localActiveTab === tab.id }"
@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>
<InterventionTabNavigation
class="sidebar-nav"
:active-tab="localActiveTab"
:team-count="mappedIntervention.practitioners?.length || 0"
:documents-count="documentAttachments.length"
@change-tab="changeTab"
/>
<!-- Assign Button -->
<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 InfoSection from "@/components/molecules/intervention/InfoSection.vue";
import DataRow from "@/components/molecules/intervention/DataRow.vue";
import InterventionTabNavigation from "@/components/molecules/intervention/InterventionTabNavigation.vue";
import { useDocumentAttachmentStore } from "@/stores/documentAttachmentStore";
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
const getStatusLabel = (s) =>
({
@ -870,18 +853,7 @@ onMounted(() => {
/* ─── Page Shell ────────────────────────────────────────────────── */
.intervention-page {
min-height: 100vh;
background:
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%);
background: var(--bs-body-bg);
font-family: inherit;
color: var(--text-primary);
display: flex;
@ -947,25 +919,27 @@ onMounted(() => {
/* ─── Sidebar ───────────────────────────────────────────────────── */
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
background: #fff;
border: 0;
border-right: 0;
border-radius: 1rem;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
display: flex;
flex-direction: column;
gap: 0;
position: sticky;
top: 57px;
top: 1rem;
height: calc(100vh - 57px);
overflow-y: auto;
}
.hero-card {
padding: 24px 20px 20px;
padding: 1.5rem 1.5rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
gap: 0.5rem;
text-align: center;
border-bottom: 1px solid var(--border-light);
border-bottom: 0;
}
.hero-avatar {
@ -982,26 +956,26 @@ onMounted(() => {
}
.hero-name {
font-size: 16px;
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
color: #344767;
margin: 0;
line-height: 1.3;
}
.hero-type {
font-size: 12px;
color: var(--text-secondary);
font-size: 0.875rem;
color: #67748e;
margin: 0;
font-weight: 500;
font-weight: 400;
}
/* Quick Stats */
.quick-stats {
padding: 16px 20px;
padding: 0 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: 12px;
border-bottom: 1px solid var(--border-light);
gap: 0.75rem;
border-bottom: 0;
}
.stat-item {
display: flex;
@ -1031,78 +1005,24 @@ onMounted(() => {
}
.stat-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
font-size: 0.75rem;
color: #8392ab;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
line-height: 1.2;
}
.stat-value {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
margin-top: 1px;
font-size: 0.875rem;
color: #344767;
font-weight: 600;
margin-top: 0.1rem;
line-height: 1.35;
}
/* Sidebar Nav */
.sidebar-nav {
padding: 12px 12px;
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);
padding: 0 1rem 1rem;
}
/* Assign Button */
@ -1630,8 +1550,7 @@ onMounted(() => {
.sidebar {
position: static;
height: auto;
border-right: none;
border-bottom: 1px solid var(--border);
border-bottom: 0;
}
.sidebar-nav {
flex-direction: row;

View File

@ -1,258 +1,134 @@
<template>
<div>
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">Aperçu de l'employé</h6>
<div class="eo">
<div class="eo-card eo-card--hero">
<div class="eo-card__body">
<div class="eo-hero">
<div>
<button
class="btn btn-sm btn-outline-primary"
@click="$emit('view-info-tab')"
>
<i class="fas fa-edit me-1"></i>
Modifier
</button>
<p class="eo-eyebrow">Apercu collaborateur</p>
<h2 class="eo-title">{{ employee.full_name }}</h2>
<p class="eo-subtitle">
{{ employee.job_title || "Poste non renseigne" }}
</p>
</div>
<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>
<!-- Basic Information Cards -->
<div class="row">
<div class="col-md-6 mb-4">
<div class="card card-stats card-round">
<div class="card-body">
<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">Prénom</p>
<h4 class="card-title">{{ employee.first_name }}</h4>
</div>
</div>
<div class="eo-grid mt-3">
<div class="eo-card">
<div class="eo-card__header">
<span class="eo-card__title">Coordonnees</span>
</div>
<div class="eo-card__body">
<div class="eo-list">
<div class="eo-list__item">
<span class="eo-list__label">Prenom</span>
<strong class="eo-list__value">{{ employee.first_name || "Non renseigne" }}</strong>
</div>
<div class="eo-list__item">
<span class="eo-list__label">Nom</span>
<strong class="eo-list__value">{{ employee.last_name || "Non renseigne" }}</strong>
</div>
<div class="eo-list__item">
<span class="eo-list__label">Email</span>
<strong class="eo-list__value">{{ employee.email || "Non renseigne" }}</strong>
</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 class="col-md-6 mb-4">
<div class="card card-stats card-round">
<div class="card-body">
<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 class="eo-card">
<div class="eo-card__header">
<span class="eo-card__title">Informations d'emploi</span>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card card-stats card-round">
<div class="card-body">
<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 class="eo-card__body">
<div class="eo-list">
<div class="eo-list__item">
<span class="eo-list__label">Date d'embauche</span>
<strong class="eo-list__value">{{ formattedHireDate }}</strong>
</div>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card card-stats card-round">
<div class="card-body">
<div class="row align-items-center">
<div class="col-icon">
<div class="icon-big text-center icon-info bubble-shadow-small">
<i class="fas fa-phone"></i>
</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 class="eo-list__item">
<span class="eo-list__label">Poste</span>
<strong class="eo-list__value">{{ employee.job_title || "Non renseigne" }}</strong>
</div>
<div class="eo-list__item">
<span class="eo-list__label">Salaire</span>
<strong class="eo-list__value">{{ employee.salary ? `${employee.salary} EUR` : "Non renseigne" }}</strong>
</div>
<div class="eo-list__item">
<span class="eo-list__label">Type</span>
<strong class="eo-list__value">{{ employee.thanatopractitioner ? "Thanatopracteur" : "Employe" }}</strong>
</div>
</div>
</div>
</div>
</div>
<!-- Employment Information -->
<div class="row">
<div class="col-12 mb-4">
<div class="card">
<div class="card-header">
<div class="card-head-row">
<div class="card-title">Informations d'emploi</div>
</div>
<div v-if="employee.thanatopractitioner" class="eo-card mt-3">
<div class="eo-card__header">
<span class="eo-card__title">Certification praticien</span>
</div>
<div class="eo-card__body">
<div class="eo-grid eo-grid--triple">
<div class="eo-list__item">
<span class="eo-list__label">Numero de licence</span>
<strong class="eo-list__value">
{{ employee.thanatopractitioner.license_number || "Non renseigne" }}
</strong>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label class="form-label">Date d'embauche</label>
<p class="form-control-static">{{ formattedHireDate }}</p>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label class="form-label">Poste occupé</label>
<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 class="eo-list__item">
<span class="eo-list__label">Numero d'autorisation</span>
<strong class="eo-list__value">
{{ employee.thanatopractitioner.authorization_number || "Non renseigne" }}
</strong>
</div>
<div class="eo-list__item">
<span class="eo-list__label">Validite</span>
<strong class="eo-list__value">
{{ formatDate(employee.thanatopractitioner.authorization_valid_until) }}
</strong>
</div>
</div>
</div>
</div>
<!-- Specialization for Thanatopractitioners -->
<div
v-if="
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 class="eo-card mt-3">
<div class="eo-card__header">
<span class="eo-card__title">Informations systeme</span>
</div>
</div>
<!-- System Information -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="card-head-row">
<div class="card-title">Informations système</div>
</div>
<div class="eo-card__body">
<div class="eo-grid">
<div class="eo-list__item">
<span class="eo-list__label">Date de creation</span>
<strong class="eo-list__value">{{ formatDate(employee.created_at) }}</strong>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<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 class="eo-list__item">
<span class="eo-list__label">Derniere modification</span>
<strong class="eo-list__value">{{ formatDate(employee.updated_at) }}</strong>
</div>
</div>
</div>
@ -278,10 +154,10 @@ defineProps({
},
});
const emit = defineEmits(["view-info-tab"]);
defineEmits(["view-info-tab"]);
const formatDate = (dateString) => {
if (!dateString) return "Non renseignée";
if (!dateString) return "Non renseignee";
const date = new Date(dateString);
return date.toLocaleDateString("fr-FR", {
year: "numeric",
@ -294,53 +170,127 @@ const formatDate = (dateString) => {
</script>
<style scoped>
.card-stats {
border: none;
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.075);
border-radius: 0.5rem;
.eo {
min-width: 0;
}
.card-title {
font-size: 1.1rem;
margin-bottom: 0;
.eo-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);
}
.card-category {
font-size: 0.875rem;
color: #6c757d;
margin-bottom: 0.25rem;
.eo-card--hero {
border-color: rgba(94, 114, 228, 0.2);
background: linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%);
}
.form-label {
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 {
.eo-card__header {
display: flex;
justify-content: space-between;
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>

View File

@ -1,72 +1,54 @@
<template>
<div class="card-body text-center">
<!-- Employee Avatar -->
<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>
<div class="epc card">
<div class="epc__cover"></div>
<!-- Employee Name -->
<h5 class="font-weight-bolder mb-0">
{{ employeeName }}
</h5>
<p class="text-sm text-secondary mb-3">
{{ jobTitle }}
</p>
<div class="epc__body">
<div class="epc__avatar-wrap">
<img
v-if="avatarUrl"
:src="avatarUrl"
:alt="employeeName"
class="epc__avatar"
/>
<div v-else class="epc__avatar epc__avatar--fallback">
<span>{{ initials }}</span>
</div>
<!-- Quick Stats -->
<div class="row text-center mt-3">
<div class="col-6 border-end">
<h6 class="text-sm font-weight-bolder mb-0">
{{ hireDate }}
</h6>
<p class="text-xs text-secondary mb-0">Date embauche</p>
<button type="button" class="epc__edit" @click="$emit('edit-avatar')">
<i class="fas fa-pen"></i>
</button>
</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 v-if="isThanatopractitioner" class="mt-3">
<span class="badge badge-sm bg-gradient-info">
<i class="fas fa-user-md me-1"></i>
Thanatopractitioner
</span>
<div class="epc__identity">
<div class="epc__badges">
<span class="epc__badge" :class="isActive ? 'epc__badge--success' : 'epc__badge--muted'">
{{ isActive ? "Actif" : "Inactif" }}
</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>
</template>
<script setup>
import { defineProps, defineEmits } from "vue";
import { defineProps, defineEmits } from 'vue';
defineProps({
avatarUrl: {
type: String,
@ -76,13 +58,17 @@ defineProps({
type: String,
required: true,
},
employee: {
type: Object,
required: true,
},
employeeName: {
type: String,
required: true,
},
jobTitle: {
type: String,
default: "Employé",
default: "Employe",
},
status: {
type: String,
@ -106,35 +92,140 @@ defineEmits(["edit-avatar"]);
</script>
<style scoped>
.avatar {
width: 4rem;
height: 4rem;
.epc {
overflow: hidden;
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 {
width: 6rem;
height: 6rem;
.epc__cover {
height: 84px;
background: linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%);
border-bottom: 1px solid rgba(94, 114, 228, 0.15);
}
.border-radius-lg {
border-radius: 0.5rem;
.epc__body {
padding: 0 1.25rem 1.25rem;
}
.bg-gradient-primary {
background: linear-gradient(310deg, #7928ca, #ff0080);
.epc__avatar-wrap {
position: relative;
width: 84px;
height: 84px;
margin-top: -42px;
}
.bg-gradient-info {
background: linear-gradient(310deg, #0dcaf0, #6bb9f0);
.epc__avatar {
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 {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
.epc__avatar--fallback {
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 {
width: 2.5rem;
height: 2.5rem;
padding: 0.625rem;
.epc__edit {
position: absolute;
right: -6px;
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>

View File

@ -1,43 +1,23 @@
<template>
<ul class="nav nav-pills flex-column">
<TabNavigationItem
icon="fas fa-eye"
label="Aperçu"
:is-active="activeTab === 'overview'"
spacing=""
@click="$emit('change-tab', 'overview')"
/>
<TabNavigationItem
icon="fas fa-info-circle"
label="Informations"
:is-active="activeTab === 'info'"
@click="$emit('change-tab', 'info')"
/>
<TabNavigationItem
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>
<nav class="ed-nav">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="ed-nav__item"
:class="{ 'ed-nav__item--active': activeTab === tab.id }"
@click="$emit('change-tab', tab.id)"
>
<span class="ed-nav__icon">
<i :class="tab.icon"></i>
</span>
<span class="ed-nav__label">{{ tab.label }}</span>
</button>
</nav>
</template>
<script setup>
import TabNavigationItem from "@/components/atoms/client/TabNavigationItem.vue";
import { defineProps, defineEmits } from "vue";
import { computed, defineProps, defineEmits } from "vue";
const props = defineProps({
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>
<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>
.tab-nav {
--brand: #4f46e5;
--brand-lt: #eef2ff;
--brand-dk: #3730a3;
--surface-2: #f8fafc;
--border-lt: #f1f5f9;
--text-1: #0f172a;
--text-2: #64748b;
--text-3: #94a3b8;
--r-sm: 8px;
display: flex;
flex-direction: column;
gap: 2px;
font-family: inherit;
}
.tab-item {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 11px;
border-radius: var(--r-sm);
gap: 0.5rem;
padding: 0.65rem 0.9rem;
border-radius: 0.5rem;
border: none;
background: transparent;
cursor: pointer;
width: 100%;
text-align: left;
font-size: 13.5px;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-2);
transition: background 0.12s, color 0.12s;
color: #67748e;
transition: all 0.2s ease-in-out;
}
.tab-item:hover {
background: var(--surface-2);
color: var(--text-1);
background-color: #f8f9fa;
}
.tab-item.active {
background: var(--brand-lt);
color: var(--brand);
background: linear-gradient(310deg, #7928ca, #ff0080);
color: #fff;
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 {
flex-shrink: 0;
display: flex;
color: var(--text-3);
color: inherit;
}
.tab-item.active .tab-icon {
color: var(--brand);
color: #fff;
}
.tab-label {
flex: 1;
margin-right: 10px;
}
.tab-badge {
min-width: 19px;
height: 19px;
padding: 0 5px;
border-radius: 10px;
background: var(--brand);
border-radius: 999px;
background: linear-gradient(310deg, #17ad37 0%, #98ec2d 100%);
color: white;
font-size: 10.5px;
font-weight: 700;
@ -134,6 +125,6 @@ const tabs = [
justify-content: center;
}
.tab-item.active .tab-badge {
background: var(--brand-dk);
background: rgba(255, 255, 255, 0.2);
}
</style>

View File

@ -30,6 +30,15 @@ export interface LoginResponse {
message: string;
}
export interface CheckEmailResponse {
success: boolean;
data: {
user: User;
has_password: boolean;
};
message: string;
}
export const AuthService = {
async login(payload: LoginPayload): Promise<LoginResponse> {
const response = await request<LoginResponse>({
@ -61,6 +70,40 @@ export const AuthService = {
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() {
try {
await request<void>({ url: "/api/auth/logout", method: "post" });

View File

@ -11,6 +11,7 @@ export interface EmployeeAddress {
export interface Employee {
id: number;
user_id?: number | null;
first_name: string;
last_name: string;
full_name: string;
@ -31,6 +32,11 @@ export interface Employee {
created_at: string;
updated_at: string;
} | null;
user?: {
id: number;
name: string;
email: string;
} | null;
}
export interface EmployeeListResponse {
@ -80,6 +86,7 @@ export interface CreateEmployeePayload {
export interface UpdateEmployeePayload extends Partial<CreateEmployeePayload> {
id: number;
user_id?: number | null;
}
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 type { User, LoginPayload, RegisterPayload } from "@/services/auth";
import type {
User,
LoginPayload,
LoginResponse,
RegisterPayload,
} from "@/services/auth";
import AuthService from "@/services/auth";
export const useAuthStore = defineStore("auth", {
@ -49,6 +54,18 @@ export const useAuthStore = defineStore("auth", {
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) {
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"
:employee-avatar="employeeAvatar"
:active-tab="activeTab"
:file-input="fileInput"
:thanatopractitioner-data="thanatopractitionerData"
:practitioner-documents="practitionerDocuments"
@update-the-employee="updateEmployee"
@create-practitioner-document="createPractitionerDocument"
@updating-practitioner-document="updatePractitionerDocument"
@remove-practitioner-document="removePractitionerDocument"
@notify-success="handleSuccessMessage"
@notify-error="handleErrorMessage"
/>
</template>
@ -30,7 +31,6 @@ const notificationStore = useNotificationStore();
const employee_id = Number(route.params.id);
const activeTab = ref("overview");
const employeeAvatar = ref(null);
const fileInput = ref(null);
const thanatopractitionerData = ref(null);
const practitionerDocuments = ref([]);
@ -104,4 +104,12 @@ const removePractitionerDocument = async (documentId) => {
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>

View File

@ -12,21 +12,23 @@
<div class="mx-auto text-center col-lg-5">
<h1 class="mt-5 mb-2 text-white">Bonjour !</h1>
<p class="text-white text-lead">
Veuillez entrer vos identifiants pour vous connecter
Entrez votre email pour continuer
</p>
</div>
</div>
</div>
</div>
<div class="container">
<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="card z-index-0">
<div class="pt-4 text-center card-header">
<h5>Connectez-vous</h5>
<h5>{{ stepTitle }}</h5>
</div>
<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">
<SoftInput
id="email"
@ -35,26 +37,56 @@
placeholder="Email"
name="email"
:is-required="true"
:disabled="currentStep !== 'email'"
/>
</div>
<div class="mb-3">
<SoftInput
id="password"
v-model="password"
name="password"
type="password"
placeholder="Mot de passe"
:is-required="true"
/>
</div>
<SoftSwitch
id="rememberMe"
:checked="remember"
name="rememberMe"
@change="remember = $event.target.checked"
>
Souvenez de moi
</SoftSwitch>
<template v-if="currentStep === 'password'">
<div class="mb-3">
<SoftInput
id="password"
v-model="password"
name="password"
type="password"
placeholder="Mot de passe"
:is-required="true"
/>
</div>
<SoftSwitch
id="rememberMe"
:checked="remember"
name="rememberMe"
@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
v-if="errorMessage"
class="alert alert-danger text-white"
@ -62,16 +94,30 @@
>
{{ errorMessage }}
</div>
<div class="text-center">
<div class="text-center d-grid gap-2">
<SoftButton
type="submit"
class="my-4 mb-2"
class="my-2 mb-0"
variant="gradient"
color="info"
full-width
: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>
</div>
</form>
@ -81,47 +127,134 @@
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import AuthService from "@/services/auth";
import SoftInput from "@/components/SoftInput.vue";
import SoftSwitch from "@/components/SoftSwitch.vue";
import SoftButton from "@/components/SoftButton.vue";
type Step = "email" | "password" | "create-password";
const router = useRouter();
const authStore = useAuthStore();
const currentStep = ref<Step>("email");
const checkedEmail = ref("");
const email = ref("");
const password = ref("");
const passwordConfirmation = ref("");
const remember = ref(false);
const isLoading = ref(false);
const errorMessage = ref("");
const handleLogin = async () => {
if (!email.value || !password.value) {
errorMessage.value = "Veuillez remplir tous les champs";
const stepTitle = computed(() => {
if (currentStep.value === "password") return "Saisissez votre mot de passe";
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;
}
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;
errorMessage.value = "";
try {
await authStore.login({
email: email.value,
password: password.value,
remember: remember.value,
});
if (currentStep.value === "email") {
await checkEmailStep();
return;
}
// Redirect to dashboard on success
router.push("/dashboards/dashboard-default");
if (currentStep.value === "password") {
await loginStep();
return;
}
await createPasswordStep();
} catch (error: any) {
console.error("Login error:", error);
errorMessage.value =
error.response?.data?.message ||
error.response?.data?.error ||
(error.response?.data?.data
? Object.values(error.response.data.data).flat()[0]
: null) ||
error.message ||
"Email ou mot de passe incorrect";
"Une erreur est survenue";
} finally {
isLoading.value = false;
}

View File

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