assigner thanato

This commit is contained in:
Nyavokevin 2025-11-21 17:26:43 +03:00
parent 4b7e075918
commit a51e05559a
27 changed files with 985 additions and 60 deletions

View File

@ -359,4 +359,66 @@ class InterventionController extends Controller
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Assign a practitioner to an intervention
*
* @param Request $request
* @param int $id
* @return JsonResponse
*/
public function assignPractitioner(Request $request, int $id): JsonResponse
{
try {
$validated = $request->validate([
'principal_practitioner_id' => 'nullable|integer|exists:thanatopractitioners,id',
'assistant_practitioner_ids' => 'nullable|array',
'assistant_practitioner_ids.*' => 'integer|exists:thanatopractitioners,id',
]);
$intervention = $this->interventionRepository->findById($id);
if (!$intervention) {
return response()->json([
'message' => 'Intervention non trouvée.'
], Response::HTTP_NOT_FOUND);
}
// Sync practitioners with their roles
$practitioners = [];
if (isset($validated['principal_practitioner_id'])) {
$practitioners[$validated['principal_practitioner_id']] = ['role' => 'principal'];
}
if (isset($validated['assistant_practitioner_ids'])) {
foreach ($validated['assistant_practitioner_ids'] as $assistantId) {
$practitioners[$assistantId] = ['role' => 'assistant'];
}
}
// Sync the practitioners (this will replace existing assignments)
$intervention->practitioners()->sync($practitioners);
// Reload the intervention with relationships
$intervention = $this->interventionRepository->findById($id);
return response()->json([
'data' => new InterventionResource($intervention),
'message' => 'Praticien(s) assigné(s) avec succès.'
], Response::HTTP_OK);
} catch (\Exception $e) {
Log::error('Error assigning practitioner to intervention: ' . $e->getMessage(), [
'intervention_id' => $id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return response()->json([
'message' => 'Une erreur est survenue lors de l\'assignation du praticien.',
'error' => $e->getMessage()
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}

View File

@ -258,6 +258,41 @@ class ThanatopractitionerController extends Controller
}
}
/**
* Search thanatopractitioners by employee name.
*/
public function searchByEmployeeName(Request $request): JsonResponse
{
try {
$query = $request->get('query', '');
if (strlen($query) < 2) {
return response()->json([
'data' => [],
'message' => 'Veuillez entrer au moins 2 caractères pour la recherche.',
], 200);
}
$thanatopractitioners = $this->thanatopractitionerRepository->searchByEmployeeName($query);
return response()->json([
'data' => new ThanatopractitionerCollection($thanatopractitioners),
'message' => 'Recherche effectuée avec succès.',
], 200);
} catch (\Exception $e) {
Log::error('Error searching thanatopractitioners by employee name: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'query' => $request->get('query'),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la recherche.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Update the specified thanatopractitioner.
*/

View File

@ -45,7 +45,11 @@ class StoreInterventionRequest extends FormRequest
'termine',
'annule'
])],
'assigned_practitioner_id' => ['nullable', 'exists:thanatopractitioners,id'],
'practitioners' => ['nullable', 'array'],
'practitioners.*' => ['exists:thanatopractitioners,id'],
'principal_practitioner_id' => ['nullable', 'exists:thanatopractitioners,id'],
'assistant_practitioner_ids' => ['nullable', 'array'],
'assistant_practitioner_ids.*' => ['exists:thanatopractitioners,id'],
'notes' => ['nullable', 'string'],
'created_by' => ['nullable', 'exists:users,id']
];
@ -68,7 +72,11 @@ class StoreInterventionRequest extends FormRequest
'duration_min.integer' => 'La durée doit être un nombre entier.',
'duration_min.min' => 'La durée ne peut pas être négative.',
'status.in' => 'Le statut de l\'intervention est invalide.',
'assigned_practitioner_id.exists' => 'Le praticien sélectionné est invalide.',
'practitioners.array' => 'Les praticiens doivent être un tableau.',
'practitioners.*.exists' => 'Un des praticiens sélectionnés est invalide.',
'principal_practitioner_id.exists' => 'Le praticien principal sélectionné est invalide.',
'assistant_practitioner_ids.array' => 'Les praticiens assistants doivent être un tableau.',
'assistant_practitioner_ids.*.exists' => 'Un des praticiens assistants est invalide.',
'created_by.exists' => 'L\'utilisateur créateur est invalide.'
];
}

View File

@ -83,7 +83,11 @@ class StoreInterventionWithAllDataRequest extends FormRequest
'termine',
'annule'
])],
'intervention.assigned_practitioner_id' => ['nullable', 'exists:thanatopractitioners,id'],
'intervention.practitioners' => ['nullable', 'array'],
'intervention.practitioners.*' => ['exists:thanatopractitioners,id'],
'intervention.principal_practitioner_id' => ['nullable', 'exists:thanatopractitioners,id'],
'intervention.assistant_practitioner_ids' => ['nullable', 'array'],
'intervention.assistant_practitioner_ids.*' => ['exists:thanatopractitioners,id'],
'intervention.order_giver' => ['nullable', 'string', 'max:255'],
'intervention.notes' => ['nullable', 'string'],
'intervention.created_by' => ['nullable', 'exists:users,id']
@ -131,7 +135,11 @@ class StoreInterventionWithAllDataRequest extends FormRequest
'intervention.duration_min.integer' => 'La durée doit être un nombre entier.',
'intervention.duration_min.min' => 'La durée ne peut pas être négative.',
'intervention.status.in' => 'Le statut de l\'intervention est invalide.',
'intervention.assigned_practitioner_id.exists' => 'Le praticien sélectionné est invalide.',
'intervention.practitioners.array' => 'Les praticiens doivent être un tableau.',
'intervention.practitioners.*.exists' => 'Un des praticiens sélectionnés est invalide.',
'intervention.principal_practitioner_id.exists' => 'Le praticien principal sélectionné est invalide.',
'intervention.assistant_practitioner_ids.array' => 'Les praticiens assistants doivent être un tableau.',
'intervention.assistant_practitioner_ids.*.exists' => 'Un des praticiens assistants est invalide.',
'intervention.order_giver.max' => 'Le donneur d\'ordre ne peut pas dépasser 255 caractères.',
'intervention.created_by.exists' => 'L\'utilisateur créateur est invalide.'
];

View File

@ -45,7 +45,11 @@ class UpdateInterventionRequest extends FormRequest
'termine',
'annule'
])],
'assigned_practitioner_id' => ['nullable', 'exists:thanatopractitioners,id'],
'practitioners' => ['nullable', 'array'],
'practitioners.*' => ['exists:thanatopractitioners,id'],
'principal_practitioner_id' => ['nullable', 'exists:thanatopractitioners,id'],
'assistant_practitioner_ids' => ['nullable', 'array'],
'assistant_practitioner_ids.*' => ['exists:thanatopractitioners,id'],
'notes' => ['nullable', 'string'],
'created_by' => ['nullable', 'exists:users,id']
];
@ -68,7 +72,11 @@ class UpdateInterventionRequest extends FormRequest
'duration_min.integer' => 'La durée doit être un nombre entier.',
'duration_min.min' => 'La durée ne peut pas être négative.',
'status.in' => 'Le statut de l\'intervention est invalide.',
'assigned_practitioner_id.exists' => 'Le praticien sélectionné est invalide.',
'practitioners.array' => 'Les praticiens doivent être un tableau.',
'practitioners.*.exists' => 'Un des praticiens sélectionnés est invalide.',
'principal_practitioner_id.exists' => 'Le praticien principal sélectionné est invalide.',
'assistant_practitioner_ids.array' => 'Les praticiens assistants doivent être un tableau.',
'assistant_practitioner_ids.*.exists' => 'Un des praticiens assistants est invalide.',
'created_by.exists' => 'L\'utilisateur créateur est invalide.'
];
}

View File

@ -36,8 +36,12 @@ class InterventionResource extends JsonResource
'scheduled_at' => $this->scheduled_at ? $this->scheduled_at->format('Y-m-d H:i:s') : null,
'duration_min' => $this->duration_min,
'status' => $this->status,
'assigned_practitioner' => $this->whenLoaded('assignedPractitioner', function () {
return new ThanatopractitionerResource($this->assignedPractitioner);
'practitioners' => $this->whenLoaded('practitioners', function () {
return ThanatopractitionerResource::collection($this->practitioners);
}),
'principal_practitioner' => $this->whenLoaded('practitioners', function () {
$principal = $this->practitioners->where('pivot.role', 'principal')->first();
return $principal ? new ThanatopractitionerResource($principal) : null;
}),
'attachments_count' => $this->attachments_count,
'notes' => $this->notes,

View File

@ -5,6 +5,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\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Intervention extends Model
@ -25,7 +26,6 @@ class Intervention extends Model
'scheduled_at',
'duration_min',
'status',
'assigned_practitioner_id',
'attachments_count',
'notes',
'created_by'
@ -66,11 +66,43 @@ class Intervention extends Model
}
/**
* Get the practitioner assigned to the intervention.
* Get the practitioners assigned to the intervention.
*/
public function assignedPractitioner(): BelongsTo
public function practitioners(): BelongsToMany
{
return $this->belongsTo(Thanatopractitioner::class, 'assigned_practitioner_id');
return $this->belongsToMany(Thanatopractitioner::class, 'intervention_practitioner', 'intervention_id', 'practitioner_id')
->withPivot('role', 'assigned_at')
->withTimestamps();
}
/**
* Alias for practitioners relationship (for backward compatibility).
*/
public function assignedPractitioner(): BelongsToMany
{
return $this->practitioners();
}
/**
* Get the principal practitioner assigned to the intervention.
*/
public function principalPractitioner(): BelongsToMany
{
return $this->belongsToMany(Thanatopractitioner::class, 'intervention_practitioner', 'intervention_id', 'practitioner_id')
->wherePivot('role', 'principal')
->withPivot('role', 'assigned_at')
->withTimestamps();
}
/**
* Get the assistant practitioners assigned to the intervention.
*/
public function assistantPractitioners(): BelongsToMany
{
return $this->belongsToMany(Thanatopractitioner::class, 'intervention_practitioner', 'intervention_id', 'practitioner_id')
->wherePivot('role', 'assistant')
->withPivot('role', 'assigned_at')
->withTimestamps();
}
/**

View File

@ -5,6 +5,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\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Thanatopractitioner extends Model
@ -55,6 +56,38 @@ class Thanatopractitioner extends Model
return $this->hasMany(PractitionerDocument::class, 'practitioner_id');
}
/**
* Get the interventions assigned to the thanatopractitioner.
*/
public function interventions(): BelongsToMany
{
return $this->belongsToMany(Intervention::class, 'intervention_practitioner')
->withPivot('role', 'assigned_at')
->withTimestamps();
}
/**
* Get the interventions where this practitioner is the principal.
*/
public function principalInterventions(): BelongsToMany
{
return $this->belongsToMany(Intervention::class, 'intervention_practitioner')
->wherePivot('role', 'principal')
->withPivot('role', 'assigned_at')
->withTimestamps();
}
/**
* Get the interventions where this practitioner is an assistant.
*/
public function assistantInterventions(): BelongsToMany
{
return $this->belongsToMany(Intervention::class, 'intervention_practitioner')
->wherePivot('role', 'assistant')
->withPivot('role', 'assigned_at')
->withTimestamps();
}
/**
* Scope a query to only include practitioners with valid authorization.
*/

View File

@ -73,7 +73,7 @@ class InterventionRepository implements InterventionRepositoryInterface
'client',
'deceased',
'location',
'assignedPractitioner',
'practitioners',
'attachments',
'notifications'
])->findOrFail($id);

View File

@ -132,4 +132,20 @@ class ThanatopractitionerRepository extends BaseRepository implements Thanatopra
'with_documents' => $this->model->newQuery()->has('documents')->count(),
];
}
/**
* Search thanatopractitioners by employee name.
*/
public function searchByEmployeeName(string $query): Collection
{
return $this->model->newQuery()
->with(['employee'])
->whereHas('employee', function ($q) use ($query) {
$q->where('first_name', 'LIKE', "%{$query}%")
->orWhere('last_name', 'LIKE', "%{$query}%")
->orWhereRaw("CONCAT(first_name, ' ', last_name) LIKE ?", ["%{$query}%"]);
})
->limit(10)
->get();
}
}

View File

@ -70,5 +70,14 @@ interface ThanatopractitionerRepositoryInterface
*
* @return array<string, int>
*/
/**
* Search thanatopractitioners by employee name.
*
* @param string $query
* @return Collection<int, Thanatopractitioner>
*/
public function searchByEmployeeName(string $query): Collection;
public function getStatistics(): array;
}

View File

@ -9,7 +9,7 @@ return [
// IMPORTANT: Do NOT use '*' when sending credentials. List explicit origins.
// Set FRONTEND_URL in .env to override the default if needed.
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:8080', 'http://localhost:8081')],
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:8081')],
// Alternatively, use patterns (kept empty for clarity)
'allowed_origins_patterns' => [],

View File

@ -0,0 +1,38 @@
<?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::create('intervention_practitioner', function (Blueprint $table) {
$table->id();
$table->foreignId('intervention_id')->constrained('interventions')->onDelete('cascade');
$table->foreignId('practitioner_id')->constrained('thanatopractitioners')->onDelete('cascade');
$table->enum('role', ['principal', 'assistant'])->default('principal');
$table->timestamp('assigned_at')->useCurrent();
$table->timestamps();
// Unique constraint to prevent duplicate assignments
$table->unique(['intervention_id', 'practitioner_id']);
// Indexes for better query performance
$table->index('practitioner_id', 'idx_intervention_practitioner_practitioner');
$table->index('intervention_id', 'idx_intervention_practitioner_intervention');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('intervention_practitioner');
}
};

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
{
// First, let's migrate existing data from assigned_practitioner_id to the new pivot table
Schema::table('interventions', function (Blueprint $table) {
// Add temporary columns to handle data migration
$table->dropForeign(['assigned_practitioner_id']);
$table->dropColumn('assigned_practitioner_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// This migration is not easily reversible since we'd lose the many-to-many relationship
// In a real scenario, you'd want to handle this differently
Schema::table('interventions', function (Blueprint $table) {
$table->foreignId('assigned_practitioner_id')->nullable()->constrained('thanatopractitioners')->nullOnDelete();
});
}
};

View File

@ -87,6 +87,7 @@ Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('employees', EmployeeController::class);
// Thanatopractitioner management
Route::get('/thanatopractitioners/search', [ThanatopractitionerController::class, 'searchByEmployeeName']);
Route::apiResource('thanatopractitioners', ThanatopractitionerController::class);
Route::get('employees/{employeeId}/thanatopractitioners', [ThanatopractitionerController::class, 'getByEmployee']);
Route::get('/thanatopractitioners/{id}/documents', [PractitionerDocumentController::class, 'getByThanatopractitioner']);
@ -130,6 +131,7 @@ Route::middleware('auth:sanctum')->group(function () {
Route::put('/{intervention}', [InterventionController::class, 'update']);
Route::delete('/{intervention}', [InterventionController::class, 'destroy']);
Route::patch('/{intervention}/status', [InterventionController::class, 'changeStatus']);
Route::patch('/{intervention}/assign', [InterventionController::class, 'assignPractitioner']);
});
});

View File

@ -41,6 +41,7 @@
@change-tab="activeTab = $event"
@update-intervention="handleUpdateIntervention"
@cancel="handleCancel"
@assign-practitioner="handleAssignPractitioner"
/>
</div>
</div>
@ -119,16 +120,19 @@ const mappedIntervention = computed(() => {
"Non disponible"
: "Non disponible",
prestationsSupplementaires: "À définir",
members: props.intervention.practitioner
? [
{
name: `${props.intervention.practitioner.first_name || ""} ${
props.intervention.practitioner.last_name || ""
}`.trim(),
members:
props.intervention.practitioners &&
props.intervention.practitioners.length > 0
? props.intervention.practitioners.map((p) => ({
name: p.employee
? `${p.employee.first_name || ""} ${
p.employee.last_name || ""
}`.trim()
: `${p.first_name || ""} ${p.last_name || ""}`.trim(),
image: "/images/avatar-default.png",
},
]
: [],
role: p.pivot?.role || "assistant",
}))
: [],
// Map status from API string to expected object format
status: props.intervention.status

View File

@ -0,0 +1,112 @@
<template>
<!-- Modal -->
<div
class="modal fade"
:class="{ show: show, 'd-block': show }"
:style="{ display: show ? 'block' : 'none' }"
tabindex="-1"
role="dialog"
aria-labelledby="addPractitionerModalLabel"
:aria-hidden="!show"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addPractitionerModalLabel">
<i class="fas fa-user-plus me-2"></i>
Ajouter un praticien
</h5>
<button
type="button"
class="btn-close"
@click="handleClose"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<PractitionerSearchInput
:loading="loading"
:results="searchResults"
@search="handleSearch"
@select="handleSelect"
/>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-sm bg-gradient-secondary"
@click="handleClose"
>
Annuler
</button>
</div>
</div>
</div>
</div>
<!-- Modal Backdrop -->
<div
v-if="show"
class="modal-backdrop fade"
:class="{ show: show }"
@click="handleClose"
></div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import PractitionerSearchInput from '@/components/molecules/thanatopractitioner/PractitionerSearchInput.vue';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
searchResults: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['close', 'search', 'select']);
const handleClose = () => {
emit('close');
};
const handleSearch = (query) => {
emit('search', query);
};
const handleSelect = (practitioner) => {
emit('select', practitioner);
};
</script>
<style scoped>
.modal {
background-color: rgba(0, 0, 0, 0.5);
}
.modal.show {
opacity: 1;
}
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
z-index: 1040;
width: 100vw;
height: 100vh;
background-color: #000;
}
.modal-backdrop.show {
opacity: 0.5;
}
</style>

View File

@ -239,26 +239,30 @@
</div>
<div
v-if="intervention.members && intervention.members.length > 0"
class="row"
v-if="
intervention.practitioners &&
intervention.practitioners.length > 0
"
class="list-group list-group-flush"
>
<div
v-for="(member, index) in intervention.members"
v-for="(practitioner, index) in intervention.practitioners"
:key="index"
class="col-md-4 mb-3"
class="list-group-item d-flex justify-content-between align-items-center border-0 px-0"
>
<div class="card border-0">
<div class="card-body text-center">
<div class="avatar avatar-xl mb-3">
<img
alt="Image placeholder"
:src="member.image || '/images/avatar-default.png'"
class="rounded-circle"
/>
</div>
<h6 class="text-sm">{{ member.name }}</h6>
<p class="text-xs text-muted">Praticien</p>
</div>
<div>
<h6 class="text-sm mb-0">
{{
practitioner.employee?.full_name ||
(practitioner.employee?.first_name &&
practitioner.employee?.last_name
? practitioner.employee.first_name +
" " +
practitioner.employee.last_name
: "Praticien " + (index + 1))
}}
</h6>
<p class="text-xs text-muted mb-0">Praticien</p>
</div>
</div>
</div>
@ -375,7 +379,12 @@ const props = defineProps({
},
});
const emit = defineEmits(["change-tab", "update-intervention", "cancel"]);
const emit = defineEmits([
"change-tab",
"update-intervention",
"cancel",
"assign-practitioner",
]);
// État local pour l'édition
const editMode = ref(false);

View File

@ -16,7 +16,7 @@
</div>
<!-- Assign Practitioner Button -->
<div v-if="!practitioners.length" class="mx-3 mb-3">
<div class="mx-3 mb-3">
<button
type="button"
class="btn btn-sm btn-outline-primary w-100"

View File

@ -235,7 +235,7 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Gérer l'équipe</h5>
<h5 class="modal-title">Ajouter équipe</h5>
<button
type="button"
class="btn-close"

View File

@ -0,0 +1,241 @@
<template>
<div
class="modal fade"
:class="{ show: isOpen }"
:style="{ display: isOpen ? 'block' : 'none' }"
tabindex="-1"
role="dialog"
@click.self="close"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Assigner un thanatopracteur</h5>
<button
type="button"
class="btn-close"
@click="close"
aria-label="Fermer"
></button>
</div>
<div class="modal-body">
<!-- Search Input -->
<div class="mb-3">
<label class="form-label">Rechercher par nom</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-search"></i>
</span>
<input
v-model="searchQuery"
type="text"
class="form-control"
placeholder="Tapez le nom du thanatopracteur..."
@input="handleSearch"
/>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="text-center py-3">
<div
class="spinner-border spinner-border-sm text-primary"
role="status"
>
<span class="visually-hidden">Chargement...</span>
</div>
<span class="ms-2">Recherche en cours...</span>
</div>
<!-- Results List -->
<div v-else-if="searchResults.length > 0" class="list-group">
<button
v-for="practitioner in searchResults"
:key="practitioner.id"
type="button"
class="list-group-item list-group-item-action d-flex align-items-center"
:class="{ active: selectedPractitioner?.id === practitioner.id }"
@click="selectPractitioner(practitioner)"
>
<div class="avatar avatar-sm me-3">
<img
:src="practitioner.avatar || '/images/avatar-default.png'"
alt="Avatar"
class="rounded-circle"
style="width: 40px; height: 40px; object-fit: cover"
/>
</div>
<div class="flex-grow-1">
<h6 class="mb-0">
{{
practitioner.employee.full_name ||
`${practitioner.employee.first_name} ${practitioner.employee.last_name}`
}}
</h6>
<small class="text-muted">{{
practitioner.employee.email || "Email non disponible"
}}</small>
</div>
<i
v-if="selectedPractitioner?.id === practitioner.id"
class="fas fa-check text-success"
></i>
</button>
</div>
<!-- No Results -->
<div
v-else-if="searchQuery.length >= 2 && !loading"
class="text-center text-muted py-3"
>
<i class="fas fa-user-slash fa-2x mb-2"></i>
<p class="mb-0">Aucun thanatopracteur trouvé</p>
</div>
<!-- Initial State -->
<div v-else class="text-center text-muted py-3">
<i class="fas fa-search fa-2x mb-2"></i>
<p class="mb-0">Tapez au moins 2 caractères pour rechercher</p>
</div>
<!-- Role Selection -->
<div v-if="selectedPractitioner" class="mt-3">
<label class="form-label">Rôle dans l'intervention</label>
<select v-model="selectedRole" class="form-select">
<option value="principal">Principal</option>
<option value="assistant">Assistant</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="close">
Annuler
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!selectedPractitioner"
@click="confirmAssignment"
>
<i class="fas fa-user-plus me-2"></i>Assigner
</button>
</div>
</div>
</div>
</div>
<!-- Backdrop -->
<div v-if="isOpen" class="modal-backdrop fade show"></div>
</template>
<script setup>
import { ref, watch } from "vue";
import { useThanatopractitionerStore } from "@/stores/thanatopractitionerStore";
import { defineProps, defineEmits } from "vue";
const props = defineProps({
isOpen: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["close", "assign"]);
const thanatopractitionerStore = useThanatopractitionerStore();
const searchQuery = ref("");
const searchResults = ref([]);
const selectedPractitioner = ref(null);
const selectedRole = ref("principal");
const loading = ref(false);
let searchTimeout = null;
const handleSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (searchQuery.value.length < 2) {
searchResults.value = [];
return;
}
searchTimeout = setTimeout(async () => {
loading.value = true;
try {
const response = await thanatopractitionerStore.searchThanatopractitioners(
searchQuery.value
);
// The service returns the data directly, not wrapped in response.data
searchResults.value = Array.isArray(response) ? response : [];
} catch (error) {
console.error("Error searching practitioners:", error);
searchResults.value = [];
} finally {
loading.value = false;
}
}, 300);
};
const selectPractitioner = (practitioner) => {
selectedPractitioner.value = practitioner;
};
const confirmAssignment = () => {
if (selectedPractitioner.value) {
emit("assign", {
practitionerId: selectedPractitioner.value.id,
role: selectedRole.value,
});
resetForm();
}
};
const close = () => {
resetForm();
emit("close");
};
const resetForm = () => {
searchQuery.value = "";
searchResults.value = [];
selectedPractitioner.value = null;
selectedRole.value = "principal";
};
// Reset form when modal closes
watch(
() => props.isOpen,
(newValue) => {
if (!newValue) {
resetForm();
}
}
);
</script>
<style scoped>
.modal.show {
background-color: rgba(0, 0, 0, 0.5);
}
.list-group-item {
cursor: pointer;
transition: background-color 0.2s;
}
.list-group-item:hover {
background-color: #f8f9fa;
}
.list-group-item.active {
background-color: #e3f2fd;
border-color: #2196f3;
color: inherit;
}
.list-group-item.active:hover {
background-color: #bbdefb;
}
</style>

View File

@ -0,0 +1,131 @@
<template>
<div class="practitioner-search-input">
<div class="input-group mb-3">
<input
type="text"
class="form-control"
placeholder="Rechercher un praticien..."
v-model="searchQuery"
@input="handleInput"
@keyup.enter="handleSearch"
:disabled="loading"
/>
<button
class="btn btn-outline-primary"
type="button"
@click="handleSearch"
:disabled="loading || !searchQuery.trim()"
>
<i v-if="!loading" class="fas fa-search"></i>
<span
v-else
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
</button>
</div>
<!-- Search Results Dropdown -->
<div
v-if="results && results.length > 0"
class="search-results-dropdown card"
>
<div class="list-group list-group-flush">
<button
v-for="practitioner in results"
:key="practitioner.id"
type="button"
class="list-group-item list-group-item-action"
@click="handleSelect(practitioner)"
>
<div class="d-flex align-items-center">
<div class="avatar avatar-sm me-3">
<img
:src="practitioner.image || '/images/avatar-default.png'"
:alt="practitioner.full_name"
class="rounded-circle"
/>
</div>
<div class="flex-grow-1">
<h6 class="mb-0 text-sm">{{ practitioner.full_name }}</h6>
<p class="text-xs text-muted mb-0">
{{ practitioner.job_title || 'Praticien' }}
</p>
</div>
</div>
</button>
</div>
</div>
<!-- No Results Message -->
<div
v-else-if="results && results.length === 0 && searchQuery.trim()"
class="alert alert-info text-sm mb-0"
>
<i class="fas fa-info-circle me-2"></i>
Aucun praticien trouvé pour "{{ searchQuery }}"
</div>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits } from 'vue';
const props = defineProps({
loading: {
type: Boolean,
default: false,
},
results: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['search', 'select']);
const searchQuery = ref('');
const handleInput = () => {
// Optional: Implement debounce here if needed
// For now, we'll just search on button click or Enter
};
const handleSearch = () => {
if (searchQuery.value.trim()) {
emit('search', searchQuery.value.trim());
}
};
const handleSelect = (practitioner) => {
emit('select', practitioner);
searchQuery.value = ''; // Clear search after selection
};
</script>
<style scoped>
.practitioner-search-input {
position: relative;
}
.search-results-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
max-height: 300px;
overflow-y: auto;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.list-group-item {
cursor: pointer;
transition: background-color 0.2s;
}
.list-group-item:hover {
background-color: #f8f9fa;
}
</style>

View File

@ -10,14 +10,14 @@ export interface Intervention {
scheduled_at?: string;
duration_min?: number;
status?: string;
assigned_practitioner_id?: number;
attachments_count?: number;
notes?: string;
created_by?: number;
// Relations
client?: any;
deceased?: any;
practitioner?: any;
practitioners?: any[];
principal_practitioner?: any;
location?: any;
// Timestamps
created_at?: string;
@ -47,7 +47,9 @@ export interface CreateInterventionPayload {
scheduled_at?: string;
duration_min?: number;
status?: string;
assigned_practitioner_id?: number;
practitioners?: number[];
principal_practitioner_id?: number;
assistant_practitioner_ids?: number[];
notes?: string;
created_by?: number;
}
@ -233,16 +235,54 @@ export const InterventionService = {
},
/**
* Assign practitioner to intervention
* Assign practitioner(s) to intervention
*/
async assignPractitioner(
id: number,
practitionerId: number
practitionerData: {
practitioners?: number[];
principal_practitioner_id?: number;
assistant_practitioner_ids?: number[];
}
): Promise<Intervention> {
const response = await request<Intervention>({
url: `/api/interventions/${id}/assign`,
method: "patch",
data: { assigned_practitioner_id: practitionerId },
data: practitionerData,
});
return response;
},
/**
* Assign multiple practitioners to intervention
*/
async assignPractitioners(
id: number,
practitionerIds: number[],
principalPractitionerId?: number
): Promise<Intervention> {
return this.assignPractitioner(id, {
practitioners: practitionerIds,
principal_practitioner_id: principalPractitionerId,
});
},
/**
* Update practitioners for intervention (replace all existing)
*/
async updatePractitioners(
id: number,
practitionerData: {
practitioners?: number[];
principal_practitioner_id?: number;
assistant_practitioner_ids?: number[];
}
): Promise<Intervention> {
const response = await request<Intervention>({
url: `/api/interventions/${id}/practitioners`,
method: "patch",
data: practitionerData,
});
return response;

View File

@ -263,10 +263,10 @@ export const ThanatopractitionerService = {
data: Thanatopractitioner[];
};
}>({
url: "/api/thanatopractitioners",
url: "/api/thanatopractitioners/search",
method: "get",
params: {
search: query,
query: query,
},
});
return response.data.data;

View File

@ -444,9 +444,16 @@ export const useInterventionStore = defineStore("intervention", () => {
};
/**
* Assign practitioner to intervention
* Assign practitioner(s) to intervention
*/
const assignPractitioner = async (id: number, practitionerId: number) => {
const assignPractitioner = async (
id: number,
practitionerData: {
practitioners?: number[];
principal_practitioner_id?: number;
assistant_practitioner_ids?: number[];
}
) => {
setLoading(true);
setError(null);
setSuccess(false);
@ -454,7 +461,7 @@ export const useInterventionStore = defineStore("intervention", () => {
try {
const intervention = await InterventionService.assignPractitioner(
id,
practitionerId
practitionerData
);
// Update in the interventions list
@ -479,7 +486,72 @@ export const useInterventionStore = defineStore("intervention", () => {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de l'assignation du praticien";
"Échec de l'assignation des praticiens";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Assign multiple practitioners to intervention
*/
const assignPractitioners = async (
id: number,
practitionerIds: number[],
principalPractitionerId?: number
) => {
return assignPractitioner(id, {
practitioners: practitionerIds,
principal_practitioner_id: principalPractitionerId,
});
};
/**
* Update practitioners for intervention (replace all existing)
*/
const updatePractitioners = async (
id: number,
practitionerData: {
practitioners?: number[];
principal_practitioner_id?: number;
assistant_practitioner_ids?: number[];
}
) => {
setLoading(true);
setError(null);
setSuccess(false);
try {
const intervention = await InterventionService.updatePractitioners(
id,
practitionerData
);
// Update in the interventions list
const index = interventions.value.findIndex(
(i) => i.id === intervention.id
);
if (index !== -1) {
interventions.value[index] = intervention;
}
// Update current intervention if it's the one being updated
if (
currentIntervention.value &&
currentIntervention.value.id === intervention.id
) {
setCurrentIntervention(intervention);
}
setSuccess(true);
return intervention;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la mise à jour des praticiens";
setError(errorMessage);
throw err;
} finally {
@ -577,6 +649,8 @@ export const useInterventionStore = defineStore("intervention", () => {
searchInterventions,
updateInterventionStatus,
assignPractitioner,
assignPractitioners,
updatePractitioners,
fetchInterventionsByMonth,
resetState,
};

View File

@ -8,7 +8,14 @@
:practitioners="practitioners"
@update-intervention="handleUpdate"
@cancel="handleCancel"
@assign-practitioner="handleAssignPractitioner"
@assign-practitioner="openAssignModal"
/>
<!-- Assign Practitioner Modal -->
<AssignPractitionerModal
:is-open="isModalOpen"
@close="closeAssignModal"
@assign="handleAssignPractitioner"
/>
</template>
@ -16,6 +23,7 @@
import { ref, onMounted, watch } from "vue";
import { useRoute } from "vue-router";
import InterventionDetailPresentation from "@/components/Organism/Interventions/InterventionDetailPresentation.vue";
import AssignPractitionerModal from "@/components/molecules/intervention/AssignPractitionerModal.vue";
import { useInterventionStore } from "@/stores/interventionStore";
import { useNotificationStore } from "@/stores/notification";
@ -27,6 +35,7 @@ const notificationStore = useNotificationStore();
const intervention = ref(null);
const activeTab = ref("overview");
const practitioners = ref([]);
const isModalOpen = ref(false);
// Fetch intervention data
const fetchIntervention = async () => {
@ -47,18 +56,37 @@ const fetchIntervention = async () => {
}
};
// Open assign modal
const openAssignModal = () => {
isModalOpen.value = true;
};
// Close assign modal
const closeAssignModal = () => {
isModalOpen.value = false;
};
// Handle practitioner assignment
const handleAssignPractitioner = async (practitionerData) => {
try {
if (intervention.value?.id) {
// Build the assignment payload based on role
const payload = {};
if (practitionerData.role === "principal") {
payload.principal_practitioner_id = practitionerData.practitionerId;
} else {
payload.assistant_practitioner_ids = [practitionerData.practitionerId];
}
await interventionStore.assignPractitioner(
intervention.value.id,
practitionerData.practitionerId
payload
);
// Refresh intervention data to get updated practitioner info
await fetchIntervention();
notificationStore.created("Praticien assigné");
closeAssignModal();
}
} catch (error) {
console.error("Error assigning practitioner:", error);

View File

@ -30,23 +30,21 @@
<div class="mb-3">
<SoftInput
id="email"
:value="email"
v-model="email"
type="email"
placeholder="Email"
name="email"
:is-required="true"
@input="email = $event.target.value"
/>
</div>
<div class="mb-3">
<SoftInput
id="password"
:value="password"
v-model="password"
name="password"
type="password"
placeholder="Mot de passe"
:is-required="true"
@input="password = $event.target.value"
/>
</div>
<SoftSwitch