add calendar

This commit is contained in:
Nyavokevin 2025-11-13 17:47:52 +03:00
parent 69fbe1a7a1
commit 98d1743def
22 changed files with 1981 additions and 4 deletions

View File

@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreDeceasedDocumentRequest;
use App\Http\Requests\UpdateDeceasedDocumentRequest;
use App\Http\Resources\Deceased\DeceasedDocumentResource;
use App\Http\Resources\Deceased\DeceasedDocumentCollection;
use App\Repositories\DeceasedDocumentRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class DeceasedDocumentController extends Controller
{
public function __construct(
private readonly DeceasedDocumentRepositoryInterface $deceasedDocumentRepository
) {
}
/**
* Display a listing of deceased documents.
*/
public function index(Request $request): DeceasedDocumentCollection|JsonResponse
{
try {
$perPage = $request->get('per_page', 15);
$filters = [
'search' => $request->get('search'),
'deceased_id' => $request->get('deceased_id'),
'doc_type' => $request->get('doc_type'),
'file_id' => $request->get('file_id'),
'sort_by' => $request->get('sort_by', 'created_at'),
'sort_direction' => $request->get('sort_direction', 'desc'),
];
// Remove null filters
$filters = array_filter($filters, function ($value) {
return $value !== null && $value !== '';
});
$documents = $this->deceasedDocumentRepository->paginate($perPage, $filters);
return new DeceasedDocumentCollection($documents);
} catch (\Exception $e) {
Log::error('Erreur lors de la récupération des documents du défunt: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des documents du défunt.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Get documents by deceased ID.
*/
public function byDeceased(string $deceasedId): DeceasedDocumentCollection|JsonResponse
{
try {
$documents = $this->deceasedDocumentRepository->getByDeceasedId((int) $deceasedId);
return new DeceasedDocumentCollection($documents);
} catch (\Exception $e) {
Log::error('Erreur lors de la récupération des documents par défunt: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'deceased_id' => $deceasedId,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des documents du défunt.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Get documents by document type.
*/
public function byDocType(Request $request): DeceasedDocumentCollection|JsonResponse
{
try {
$docType = $request->get('doc_type');
if (!$docType) {
return response()->json([
'message' => 'Le paramètre doc_type est requis.',
], 400);
}
$documents = $this->deceasedDocumentRepository->getByDocType($docType);
return new DeceasedDocumentCollection($documents);
} catch (\Exception $e) {
Log::error('Erreur lors de la récupération des documents par type: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'doc_type' => $request->get('doc_type'),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des documents par type.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Get documents by file ID.
*/
public function byFile(string $fileId): DeceasedDocumentCollection|JsonResponse
{
try {
$documents = $this->deceasedDocumentRepository->getByFileId((int) $fileId);
return new DeceasedDocumentCollection($documents);
} catch (\Exception $e) {
Log::error('Erreur lors de la récupération des documents par fichier: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'file_id' => $fileId,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des documents par fichier.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Search documents by various criteria.
*/
public function search(Request $request): DeceasedDocumentCollection|JsonResponse
{
try {
$criteria = [
'deceased_id' => $request->get('deceased_id'),
'doc_type' => $request->get('doc_type'),
'file_id' => $request->get('file_id'),
'generated_from' => $request->get('generated_from'),
'generated_to' => $request->get('generated_to'),
];
// Remove null criteria
$criteria = array_filter($criteria, function ($value) {
return $value !== null && $value !== '';
});
$documents = $this->deceasedDocumentRepository->search($criteria);
return new DeceasedDocumentCollection($documents);
} catch (\Exception $e) {
Log::error('Erreur lors de la recherche de documents: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'criteria' => $request->all(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la recherche de documents.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Store a newly created deceased document.
*/
public function store(StoreDeceasedDocumentRequest $request): DeceasedDocumentResource|JsonResponse
{
try {
$document = $this->deceasedDocumentRepository->create($request->validated());
return new DeceasedDocumentResource($document);
} catch (\Exception $e) {
Log::error('Erreur lors de la création du document du défunt: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la création du document du défunt.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Display the specified deceased document.
*/
public function show(string $id): DeceasedDocumentResource|JsonResponse
{
try {
$document = $this->deceasedDocumentRepository->find($id);
if (!$document) {
return response()->json([
'message' => 'Document du défunt non trouvé.',
], 404);
}
return new DeceasedDocumentResource($document);
} catch (\Exception $e) {
Log::error('Erreur lors de la récupération du document du défunt: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'document_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération du document du défunt.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Update the specified deceased document.
*/
public function update(UpdateDeceasedDocumentRequest $request, string $id): DeceasedDocumentResource|JsonResponse
{
try {
$updated = $this->deceasedDocumentRepository->update($id, $request->validated());
if (!$updated) {
return response()->json([
'message' => 'Document du défunt non trouvé ou échec de la mise à jour.',
], 404);
}
$document = $this->deceasedDocumentRepository->find($id);
return new DeceasedDocumentResource($document);
} catch (\Exception $e) {
Log::error('Erreur lors de la mise à jour du document du défunt: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'document_id' => $id,
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la mise à jour du document du défunt.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Remove the specified deceased document.
*/
public function destroy(string $id): JsonResponse
{
try {
$deleted = $this->deceasedDocumentRepository->delete($id);
if (!$deleted) {
return response()->json([
'message' => 'Document du défunt non trouvé ou échec de la suppression.',
], 404);
}
return response()->json([
'message' => 'Document du défunt supprimé avec succès.',
], 200);
} catch (\Exception $e) {
Log::error('Erreur lors de la suppression du document du défunt: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'document_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la suppression du document du défunt.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@ -173,4 +173,40 @@ class InterventionController extends Controller
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Get interventions for a specific month.
*/
public function byMonth(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'year' => 'required|integer|min:2000|max:2100',
'month' => 'required|integer|min:1|max:12'
]);
$interventions = $this->interventionRepository->getByMonth(
$validated['year'],
$validated['month']
);
return response()->json([
'data' => $interventions->map(function ($intervention) {
return new InterventionResource($intervention);
}),
'meta' => [
'total' => $interventions->count(),
'year' => $validated['year'],
'month' => $validated['month'],
]
]);
} catch (\Exception $e) {
Log::error('Error fetching interventions by month: ' . $e->getMessage());
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des interventions du mois.',
'error' => $e->getMessage()
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreDeceasedDocumentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Add your authorization logic here
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array
{
return [
'deceased_id' => 'required|exists:deceased,id',
'doc_type' => 'required|string|max:191',
'file_id' => 'nullable|exists:files,id',
'generated_at' => 'nullable|date',
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'deceased_id.required' => 'Le défunt est obligatoire.',
'deceased_id.exists' => 'Le défunt sélectionné n\'existe pas.',
'doc_type.required' => 'Le type de document est obligatoire.',
'doc_type.string' => 'Le type de document doit être une chaîne de caractères.',
'doc_type.max' => 'Le type de document ne peut pas dépasser :max caractères.',
'file_id.exists' => 'Le fichier sélectionné n\'existe pas.',
'generated_at.date' => 'La date de génération doit être une date valide.',
];
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateDeceasedDocumentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Add your authorization logic here
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array
{
return [
'deceased_id' => 'sometimes|required|exists:deceased,id',
'doc_type' => 'sometimes|required|string|max:191',
'file_id' => 'nullable|exists:files,id',
'generated_at' => 'nullable|date',
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'deceased_id.required' => 'Le défunt est obligatoire.',
'deceased_id.exists' => 'Le défunt sélectionné n\'existe pas.',
'doc_type.required' => 'Le type de document est obligatoire.',
'doc_type.string' => 'Le type de document doit être une chaîne de caractères.',
'doc_type.max' => 'Le type de document ne peut pas dépasser :max caractères.',
'file_id.exists' => 'Le fichier sélectionné n\'existe pas.',
'generated_at.date' => 'La date de génération doit être une date valide.',
];
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Http\Resources\Deceased;
use Illuminate\Http\Resources\Json\ResourceCollection;
class DeceasedDocumentCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @param \Illuminate\Http\Request $request
* @return array<int|string, mixed>
*/
public function toArray($request): array
{
return [
'data' => $this->collection->map(function ($document) {
return [
'id' => $document->id,
'deceased_id' => $document->deceased_id,
'doc_type' => $document->doc_type,
'file_id' => $document->file_id,
'generated_at' => $document->generated_at?->format('Y-m-d H:i:s'),
'created_at' => $document->created_at?->format('Y-m-d H:i:s'),
'updated_at' => $document->updated_at?->format('Y-m-d H:i:s'),
// Relations
'deceased' => $document->deceased ? [
'id' => $document->deceased->id,
'first_name' => $document->deceased->first_name,
'last_name' => $document->deceased->last_name,
'full_name' => $document->deceased->first_name . ' ' . $document->deceased->last_name,
'date_of_birth' => $document->deceased->date_of_birth?->format('Y-m-d'),
'date_of_death' => $document->deceased->date_of_death?->format('Y-m-d'),
] : null,
'file' => $document->file ? [
'id' => $document->file->id,
'filename' => $document->file->filename ?? null,
'path' => $document->file->path ?? null,
'mime_type' => $document->file->mime_type ?? null,
'size' => $document->file->size ?? null,
] : null,
];
}),
];
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Http\Resources\Deceased;
use Illuminate\Http\Resources\Json\JsonResource;
class DeceasedDocumentResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array<string, mixed>
*/
public function toArray($request): array
{
return [
'id' => $this->id,
'deceased_id' => $this->deceased_id,
'doc_type' => $this->doc_type,
'file_id' => $this->file_id,
'generated_at' => $this->generated_at?->format('Y-m-d H:i:s'),
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
// Relations
'deceased' => $this->when(
$this->relationLoaded('deceased'),
function () {
return [
'id' => $this->deceased->id,
'first_name' => $this->deceased->first_name,
'last_name' => $this->deceased->last_name,
'full_name' => $this->deceased->first_name . ' ' . $this->deceased->last_name,
'date_of_birth' => $this->deceased->date_of_birth?->format('Y-m-d'),
'date_of_death' => $this->deceased->date_of_death?->format('Y-m-d'),
];
}
),
'file' => $this->when(
$this->relationLoaded('file'),
function () {
return $this->file ? [
'id' => $this->file->id,
'filename' => $this->file->filename ?? null,
'path' => $this->file->path ?? null,
'mime_type' => $this->file->mime_type ?? null,
'size' => $this->file->size ?? null,
] : null;
}
),
];
}
}

View File

@ -4,6 +4,8 @@ namespace App\Providers;
use App\Repositories\DeceasedRepositoryInterface;
use App\Repositories\DeceasedRepository;
use App\Repositories\DeceasedDocumentRepositoryInterface;
use App\Repositories\DeceasedDocumentRepository;
use App\Repositories\InterventionRepositoryInterface;
use App\Repositories\InterventionRepository;
use Illuminate\Support\ServiceProvider;
@ -16,6 +18,7 @@ class RepositoryServiceProvider extends ServiceProvider
public function register(): void
{
$this->app->bind(DeceasedRepositoryInterface::class, DeceasedRepository::class);
$this->app->bind(DeceasedDocumentRepositoryInterface::class, DeceasedDocumentRepository::class);
$this->app->bind(InterventionRepositoryInterface::class, InterventionRepository::class);
}

View File

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\DeceasedDocument;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class DeceasedDocumentRepository extends BaseRepository implements DeceasedDocumentRepositoryInterface
{
public function __construct(DeceasedDocument $model)
{
parent::__construct($model);
}
/**
* Get paginated deceased documents with optional filters
*/
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator
{
$query = $this->model->newQuery()->with(['deceased', 'file']);
// Apply filters
if (!empty($filters['search'])) {
$query->where(function ($q) use ($filters) {
$q->where('doc_type', 'like', '%' . $filters['search'] . '%')
->orWhereHas('deceased', function ($q) use ($filters) {
$q->where('first_name', 'like', '%' . $filters['search'] . '%')
->orWhere('last_name', 'like', '%' . $filters['search'] . '%');
});
});
}
if (!empty($filters['deceased_id'])) {
$query->where('deceased_id', $filters['deceased_id']);
}
if (!empty($filters['doc_type'])) {
$query->where('doc_type', $filters['doc_type']);
}
if (!empty($filters['file_id'])) {
$query->where('file_id', $filters['file_id']);
}
// Apply sorting
$sortField = $filters['sort_by'] ?? 'created_at';
$sortDirection = $filters['sort_direction'] ?? 'desc';
$query->orderBy($sortField, $sortDirection);
return $query->paginate($perPage);
}
/**
* Get documents by deceased ID
*/
public function getByDeceasedId(int $deceasedId): Collection
{
return $this->model->newQuery()
->with(['deceased', 'file'])
->where('deceased_id', $deceasedId)
->orderBy('generated_at', 'desc')
->get();
}
/**
* Get documents by document type
*/
public function getByDocType(string $docType): Collection
{
return $this->model->newQuery()
->with(['deceased', 'file'])
->where('doc_type', $docType)
->orderBy('generated_at', 'desc')
->get();
}
/**
* Get documents by file ID
*/
public function getByFileId(int $fileId): Collection
{
return $this->model->newQuery()
->with(['deceased', 'file'])
->where('file_id', $fileId)
->orderBy('generated_at', 'desc')
->get();
}
/**
* Search documents by various criteria
*/
public function search(array $criteria): Collection
{
$query = $this->model->newQuery()->with(['deceased', 'file']);
if (!empty($criteria['deceased_id'])) {
$query->where('deceased_id', $criteria['deceased_id']);
}
if (!empty($criteria['doc_type'])) {
$query->where('doc_type', $criteria['doc_type']);
}
if (!empty($criteria['file_id'])) {
$query->where('file_id', $criteria['file_id']);
}
if (!empty($criteria['generated_from'])) {
$query->where('generated_at', '>=', $criteria['generated_from']);
}
if (!empty($criteria['generated_to'])) {
$query->where('generated_at', '<=', $criteria['generated_to']);
}
return $query->orderBy('generated_at', 'desc')->get();
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Model;
/**
* Interface for DeceasedDocument repository operations.
*/
interface DeceasedDocumentRepositoryInterface extends BaseRepositoryInterface
{
/**
* Get paginated deceased documents with optional filters
*/
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator;
/**
* Get documents by deceased ID
*/
public function getByDeceasedId(int $deceasedId): \Illuminate\Database\Eloquent\Collection;
/**
* Get documents by document type
*/
public function getByDocType(string $docType): \Illuminate\Database\Eloquent\Collection;
/**
* Get documents by file ID
*/
public function getByFileId(int $fileId): \Illuminate\Database\Eloquent\Collection;
/**
* Search documents by various criteria
*/
public function search(array $criteria): \Illuminate\Database\Eloquent\Collection;
}

View File

@ -134,4 +134,23 @@ class InterventionRepository implements InterventionRepositoryInterface
return $intervention;
});
}
/**
* Get interventions for a specific month
*
* @param int $year
* @param int $month
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getByMonth(int $year, int $month): \Illuminate\Database\Eloquent\Collection
{
$startDate = sprintf('%04d-%02d-01', $year, $month);
$endDate = date('Y-m-t', strtotime($startDate));
return Intervention::query()
->whereBetween('scheduled_at', [$startDate . ' 00:00:00', $endDate . ' 23:59:59'])
->with(['client', 'deceased', 'location', 'assignedPractitioner'])
->orderBy('scheduled_at', 'asc')
->get();
}
}

View File

@ -57,4 +57,13 @@ interface InterventionRepositoryInterface
* @return Intervention
*/
public function changeStatus(Intervention $intervention, string $status): Intervention;
/**
* Get interventions for a specific month
*
* @param int $year
* @param int $month
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getByMonth(int $year, int $month): \Illuminate\Database\Eloquent\Collection;
}

View File

@ -14,6 +14,7 @@ use App\Http\Controllers\Api\EmployeeController;
use App\Http\Controllers\Api\ThanatopractitionerController;
use App\Http\Controllers\Api\PractitionerDocumentController;
use App\Http\Controllers\Api\DeceasedController;
use App\Http\Controllers\Api\DeceasedDocumentController;
use App\Http\Controllers\Api\InterventionController;
@ -106,8 +107,22 @@ Route::middleware('auth:sanctum')->group(function () {
Route::delete('/{deceased}', [DeceasedController::class, 'destroy']);
});
// Deceased Document Routes
Route::prefix('deceased-documents')->group(function () {
Route::get('/by-deceased/{deceasedId}', [DeceasedDocumentController::class, 'byDeceased']);
Route::get('/by-doc-type', [DeceasedDocumentController::class, 'byDocType']);
Route::get('/by-file/{fileId}', [DeceasedDocumentController::class, 'byFile']);
Route::get('/search', [DeceasedDocumentController::class, 'search']);
Route::get('/', [DeceasedDocumentController::class, 'index']);
Route::post('/', [DeceasedDocumentController::class, 'store']);
Route::get('/{id}', [DeceasedDocumentController::class, 'show']);
Route::put('/{id}', [DeceasedDocumentController::class, 'update']);
Route::delete('/{id}', [DeceasedDocumentController::class, 'destroy']);
});
// Intervention Routes
Route::prefix('interventions')->group(function () {
Route::get('/by-month', [InterventionController::class, 'byMonth']);
Route::get('/', [InterventionController::class, 'index']);
Route::post('/', [InterventionController::class, 'store']);
Route::get('/{intervention}', [InterventionController::class, 'show']);

View File

@ -0,0 +1,130 @@
<template>
<agenda-template>
<template #agenda-title>
<h5 class="mb-0">Agenda des Interventions</h5>
<p class="text-sm text-muted mb-0">
Gérez vos interventions et rendez-vous
</p>
</template>
<template #agenda-actions>
<add-intervention-button
text="Nouvelle Intervention"
@click="handleAddIntervention"
/>
</template>
<template #agenda-calendar>
<agenda-calendar
:events="calendarEvents"
@date-click="handleDateClick"
@event-click="handleEventClick"
@month-change="handleMonthChange"
/>
</template>
<template #agenda-sidebar>
<upcoming-interventions :interventions="upcomingInterventions" />
</template>
</agenda-template>
</template>
<script setup>
import { computed } from "vue";
import AgendaTemplate from "@/components/templates/Agenda/AgendaTemplate.vue";
import AddInterventionButton from "@/components/atoms/Agenda/AddInterventionButton.vue";
import AgendaCalendar from "@/components/molecules/Agenda/AgendaCalendar.vue";
import UpcomingInterventions from "@/components/molecules/Agenda/UpcomingInterventions.vue";
import { defineProps, defineEmits } from "vue";
const props = defineProps({
interventions: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
"add-intervention",
"date-click",
"event-click",
"edit-intervention",
"month-change",
]);
// Transform interventions to calendar events format
const calendarEvents = computed(() => {
return props.interventions.map((intervention) => {
const statusColors = {
scheduled: "bg-gradient-info",
"in-progress": "bg-gradient-warning",
completed: "bg-gradient-success",
cancelled: "bg-gradient-secondary",
};
return {
id: intervention.id,
title: intervention.type || "Intervention",
start: intervention.scheduled_at,
end: intervention.scheduled_at,
className: statusColors[intervention.status] || "bg-gradient-info",
extendedProps: {
deceased: intervention.deceased,
status: intervention.status,
practitioner: intervention.assignedPractitioner,
type: intervention.type,
},
};
});
});
// Get upcoming interventions (next 7 days)
const upcomingInterventions = computed(() => {
const now = new Date();
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
return props.interventions
.filter((intervention) => {
const date = new Date(intervention.scheduled_at);
return (
date >= now && date <= nextWeek && intervention.status !== "annule"
);
})
.sort((a, b) => new Date(a.scheduled_at) - new Date(b.scheduled_at))
.slice(0, 5)
.map((intervention) => ({
id: intervention.id,
title: intervention.type || "Intervention",
date: intervention.scheduled_at,
status: intervention.status,
deceased: intervention.deceased
? `${intervention.deceased.first_name} ${intervention.deceased.last_name}`
: null,
}));
});
const handleAddIntervention = () => {
emit("add-intervention");
};
const handleDateClick = (info) => {
emit("date-click", info);
};
const handleEventClick = (info) => {
emit("event-click", info.event);
};
const handleMonthChange = (dateInfo) => {
emit("month-change", dateInfo);
};
</script>
<style scoped>
/* Additional styles if needed */
</style>

View File

@ -0,0 +1,37 @@
<template>
<button
class="btn bg-gradient-primary mb-0"
@click="$emit('click')"
type="button"
>
<i class="fas fa-plus me-2"></i>
{{ text }}
</button>
</template>
<script setup>
import { defineProps, defineEmits } from "vue";
defineProps({
text: {
type: String,
default: "Nouvelle Intervention",
},
});
defineEmits(["click"]);
</script>
<style scoped>
.btn {
font-weight: 600;
font-size: 0.875rem;
padding: 0.625rem 1.25rem;
border-radius: 0.5rem;
transition: all 0.15s ease-in;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 7px 14px rgba(50, 50, 93, 0.1), 0 3px 6px rgba(0, 0, 0, 0.08);
}
</style>

View File

@ -0,0 +1,50 @@
<template>
<span :class="['badge', badgeClass, 'px-3 py-2']">
<i :class="iconClass" class="me-1"></i>
{{ label }}
</span>
</template>
<script setup>
import { computed } from "vue";
import { defineProps, defineEmits } from "vue";
const props = defineProps({
type: {
type: String,
default: "scheduled", // scheduled, in-progress, completed, cancelled
},
label: {
type: String,
required: true,
},
});
const badgeClass = computed(() => {
const classes = {
scheduled: "bg-gradient-info",
"in-progress": "bg-gradient-warning",
completed: "bg-gradient-success",
cancelled: "bg-gradient-secondary",
};
return classes[props.type] || "bg-gradient-info";
});
const iconClass = computed(() => {
const icons = {
scheduled: "fas fa-clock",
"in-progress": "fas fa-spinner",
completed: "fas fa-check-circle",
cancelled: "fas fa-times-circle",
};
return icons[props.type] || "fas fa-clock";
});
</script>
<style scoped>
.badge {
font-size: 0.75rem;
font-weight: 600;
border-radius: 0.5rem;
}
</style>

View File

@ -0,0 +1,148 @@
<template>
<div class="card widget-calendar">
<div class="p-3 pb-0 card-header">
<h6 class="mb-0">{{ title }}</h6>
<div class="d-flex">
<div class="mb-0 text-sm p font-weight-bold widget-calendar-day">
{{ currentDay }}
</div>
<span>,&nbsp;</span>
<div class="mb-1 text-sm p font-weight-bold widget-calendar-year">
{{ currentYear }}
</div>
</div>
</div>
<div class="p-3 card-body">
<div :id="calendarId" data-toggle="widget-calendar"></div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from "vue";
import { Calendar } from "@fullcalendar/core";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import frLocale from "@fullcalendar/core/locales/fr";
import { defineProps, defineEmits } from "vue";
const props = defineProps({
title: {
type: String,
default: "Agenda",
},
events: {
type: Array,
default: () => [],
},
calendarId: {
type: String,
default: "agenda-calendar",
},
});
const emit = defineEmits(["date-click", "event-click", "month-change"]);
let calendar = null;
const currentDay = ref("");
const currentYear = ref("");
const updateCurrentDate = () => {
const now = new Date();
const days = [
"Dimanche",
"Lundi",
"Mardi",
"Mercredi",
"Jeudi",
"Vendredi",
"Samedi",
];
const months = [
"Janvier",
"Février",
"Mars",
"Avril",
"Mai",
"Juin",
"Juillet",
"Août",
"Septembre",
"Octobre",
"Novembre",
"Décembre",
];
currentDay.value = `${days[now.getDay()]}, ${now.getDate()} ${
months[now.getMonth()]
}`;
currentYear.value = now.getFullYear().toString();
};
onMounted(() => {
updateCurrentDate();
calendar = new Calendar(document.getElementById(props.calendarId), {
contentHeight: "auto",
plugins: [dayGridPlugin, interactionPlugin],
initialView: "dayGridMonth",
locale: frLocale,
selectable: true,
editable: true,
events: props.events,
headerToolbar: {
left: "prev,next today",
center: "title",
right: "dayGridMonth,dayGridWeek",
},
dateClick: (info) => {
emit("date-click", info);
},
eventClick: (info) => {
emit("event-click", info);
},
datesSet: (dateInfo) => {
// Emit month change when user navigates to different month
const date = dateInfo.view.currentStart;
emit("month-change", {
year: date.getFullYear(),
month: date.getMonth() + 1,
});
},
views: {
month: {
titleFormat: {
month: "long",
year: "numeric",
},
},
week: {
titleFormat: {
month: "long",
year: "numeric",
day: "numeric",
},
},
},
});
calendar.render();
});
onBeforeUnmount(() => {
if (calendar) {
calendar.destroy();
}
});
</script>
<style scoped>
.widget-calendar {
box-shadow: 0 20px 27px 0 rgba(0, 0, 0, 0.05);
}
.widget-calendar-day,
.widget-calendar-year {
color: #344767;
}
</style>

View File

@ -0,0 +1,88 @@
<template>
<div class="card">
<div class="card-header pb-0">
<h6 class="mb-0">Interventions à venir</h6>
<p class="text-sm mb-0">{{ interventions.length }} intervention(s)</p>
</div>
<div class="card-body p-3">
<div v-if="interventions.length === 0" class="text-center py-4">
<i class="fas fa-calendar-check fa-3x text-muted mb-3"></i>
<p class="text-muted mb-0">Aucune intervention prévue</p>
</div>
<ul v-else class="list-group list-group-flush">
<li
v-for="intervention in interventions"
:key="intervention.id"
class="list-group-item border-0 d-flex p-3 mb-2 bg-gray-100 border-radius-lg"
>
<div class="d-flex flex-column w-100">
<div class="d-flex justify-content-between mb-2">
<h6 class="mb-0 text-sm">{{ intervention.title }}</h6>
<intervention-badge
:type="intervention.status"
:label="getStatusLabel(intervention.status)"
/>
</div>
<div class="d-flex align-items-center text-sm text-muted">
<i class="fas fa-calendar me-2"></i>
<span>{{ formatDate(intervention.date) }}</span>
</div>
<div
v-if="intervention.deceased"
class="d-flex align-items-center text-sm text-muted mt-1"
>
<i class="fas fa-user me-2"></i>
<span>{{ intervention.deceased }}</span>
</div>
</div>
</li>
</ul>
</div>
</div>
</template>
<script setup>
import InterventionBadge from "@/components/atoms/Agenda/InterventionBadge.vue";
import { defineProps } from "vue";
defineProps({
interventions: {
type: Array,
default: () => [],
},
});
const getStatusLabel = (status) => {
const labels = {
scheduled: "Planifiée",
"in-progress": "En cours",
completed: "Terminée",
cancelled: "Annulée",
};
return labels[status] || status;
};
const formatDate = (date) => {
if (!date) return "";
const d = new Date(date);
return d.toLocaleDateString("fr-FR", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
</script>
<style scoped>
.list-group-item {
transition: all 0.15s ease-in;
}
.list-group-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
</style>

View File

@ -0,0 +1,30 @@
<template>
<div class="container-fluid py-4">
<div class="d-sm-flex justify-content-between mb-4">
<div>
<slot name="agenda-title"></slot>
</div>
<div class="d-flex">
<slot name="agenda-actions"></slot>
</div>
</div>
<div class="row">
<div class="col-xl-9 col-lg-8">
<slot name="agenda-calendar"></slot>
</div>
<div class="col-xl-3 col-lg-4">
<slot name="agenda-sidebar"></slot>
</div>
</div>
</div>
</template>
<script setup>
// Template component for Agenda page layout
</script>
<style scoped>
.container-fluid {
min-height: 100vh;
}
</style>

View File

@ -232,6 +232,22 @@ export const InterventionService = {
return response;
},
/**
* Get interventions by month
*/
async getInterventionsByMonth(
year: number,
month: number
): Promise<InterventionListResponse> {
const response = await request<InterventionListResponse>({
url: "/api/interventions/by-month",
method: "get",
params: { year, month },
});
return response;
},
};
export default InterventionService;

View File

@ -458,6 +458,36 @@ export const useInterventionStore = defineStore("intervention", () => {
}
};
/**
* Get interventions by month
*/
const fetchInterventionsByMonth = async (year: number, month: number) => {
setLoading(true);
setError(null);
setSuccess(false);
try {
const response = await InterventionService.getInterventionsByMonth(
year,
month
);
setInterventions(response.data);
if (response.meta) {
setPagination(response.meta);
}
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des interventions du mois";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Reset the state
*/
@ -517,6 +547,7 @@ export const useInterventionStore = defineStore("intervention", () => {
searchInterventions,
updateInterventionStatus,
assignPractitioner,
fetchInterventionsByMonth,
resetState,
};
});

View File

@ -1,11 +1,287 @@
<template>
<div>
<h1>Agenda</h1>
<agenda-presentation
:interventions="interventions"
:loading="loading"
@add-intervention="openCreateModal"
@date-click="handleDateClick"
@event-click="handleEventClick"
/>
<!-- Create/Edit Intervention Modal -->
<div
class="modal fade"
id="interventionModal"
tabindex="-1"
aria-labelledby="interventionModalLabel"
aria-hidden="true"
ref="modalRef"
>
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="interventionModalLabel">
{{
isEditing ? "Modifier l'intervention" : "Nouvelle Intervention"
}}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<form @submit.prevent="handleSubmit">
<div class="row">
<div class="col-md-12 mb-3">
<label class="form-label">Titre de l'intervention *</label>
<input
v-model="form.title"
type="text"
class="form-control"
placeholder="Ex: Soins de conservation"
required
/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Date et heure *</label>
<input
v-model="form.scheduled_date"
type="datetime-local"
class="form-control"
required
/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Statut</label>
<select v-model="form.status" class="form-select">
<option value="scheduled">Planifiée</option>
<option value="in-progress">En cours</option>
<option value="completed">Terminée</option>
<option value="cancelled">Annulée</option>
</select>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Défunt</label>
<select v-model="form.deceased_id" class="form-select">
<option value="">Sélectionner un défunt</option>
<option
v-for="deceased in deceasedList"
:key="deceased.id"
:value="deceased.id"
>
{{ deceased.first_name }} {{ deceased.last_name }}
</option>
</select>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Thanatopracteur</label>
<select v-model="form.practitioner_id" class="form-select">
<option value="">Sélectionner un thanatopracteur</option>
<option
v-for="practitioner in practitioners"
:key="practitioner.id"
:value="practitioner.id"
>
{{ practitioner.employee?.first_name }}
{{ practitioner.employee?.last_name }}
</option>
</select>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Description</label>
<textarea
v-model="form.description"
class="form-control"
rows="3"
placeholder="Détails de l'intervention..."
></textarea>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Lieu</label>
<input
v-model="form.location"
type="text"
class="form-control"
placeholder="Adresse du lieu"
/>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Annuler
</button>
<button
type="button"
class="btn bg-gradient-primary"
@click="handleSubmit"
:disabled="submitting"
>
<span v-if="submitting">
<i class="fas fa-spinner fa-spin me-2"></i>
Enregistrement...
</span>
<span v-else>
{{ isEditing ? "Mettre à jour" : "Créer" }}
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "Agenda",
<script setup>
import { ref, onMounted, computed } from "vue";
import { useRouter } from "vue-router";
import AgendaPresentation from "@/components/Organism/Agenda/AgendaPresentation.vue";
import { useInterventionStore } from "@/stores/interventionStore";
import { useDeceasedStore } from "@/stores/deceasedStore";
import { useThanatopractitionerStore } from "@/stores/thanatopractitionerStore";
import { Modal } from "bootstrap";
const router = useRouter();
const interventionStore = useInterventionStore();
const deceasedStore = useDeceasedStore();
const thanatopractitionerStore = useThanatopractitionerStore();
const loading = ref(false);
const submitting = ref(false);
const modalRef = ref(null);
const modalInstance = ref(null);
const isEditing = ref(false);
const form = ref({
title: "",
scheduled_date: "",
status: "scheduled",
deceased_id: "",
practitioner_id: "",
description: "",
location: "",
});
const interventions = computed(() => interventionStore.interventions || []);
const deceasedList = computed(() => deceasedStore.deceased || []);
const practitioners = computed(
() => thanatopractitionerStore.thanatopractitioners || []
);
onMounted(async () => {
loading.value = true;
try {
await Promise.all([
interventionStore.fetchInterventions(),
deceasedStore.fetchDeceased(),
thanatopractitionerStore.fetchThanatopractitioners(),
]);
} catch (error) {
console.error("Error loading data:", error);
} finally {
loading.value = false;
}
// Initialize Bootstrap modal
if (modalRef.value) {
modalInstance.value = new Modal(modalRef.value);
}
});
const openCreateModal = () => {
isEditing.value = false;
resetForm();
modalInstance.value?.show();
};
const handleDateClick = (info) => {
isEditing.value = false;
resetForm();
// Pre-fill the date from calendar click
const date = new Date(info.dateStr);
form.value.scheduled_date = date.toISOString().slice(0, 16);
modalInstance.value?.show();
};
const handleEventClick = (event) => {
// Navigate to intervention details or open edit modal
if (event.id) {
router.push({
name: "Intervention Details",
params: { id: event.id },
});
}
};
const resetForm = () => {
form.value = {
title: "",
scheduled_date: "",
status: "scheduled",
deceased_id: "",
practitioner_id: "",
description: "",
location: "",
};
};
const handleSubmit = async () => {
submitting.value = true;
try {
if (isEditing.value) {
await interventionStore.updateIntervention(form.value.id, form.value);
} else {
await interventionStore.createIntervention(form.value);
}
modalInstance.value?.hide();
resetForm();
await interventionStore.fetchInterventions();
} catch (error) {
console.error("Error saving intervention:", error);
alert("Erreur lors de l'enregistrement de l'intervention");
} finally {
submitting.value = false;
}
};
</script>
<style scoped>
.modal-content {
border-radius: 1rem;
}
.form-label {
font-weight: 600;
font-size: 0.875rem;
color: #344767;
margin-bottom: 0.5rem;
}
.form-control,
.form-select {
border: 1px solid #d2d6da;
border-radius: 0.5rem;
padding: 0.625rem 0.75rem;
font-size: 0.875rem;
}
.form-control:focus,
.form-select:focus {
border-color: #cb0c9f;
box-shadow: 0 0 0 2px rgba(203, 12, 159, 0.1);
}
</style>

View File

@ -0,0 +1,446 @@
<template>
<div>
<agenda-presentation
:interventions="interventions"
:loading="loading"
@add-intervention="openCreateModal"
@date-click="handleDateClick"
@event-click="handleEventClick"
@month-change="handleMonthChange"
/>
<!-- Create/Edit Intervention Modal -->
<div
class="modal fade"
id="interventionModal"
tabindex="-1"
aria-labelledby="interventionModalLabel"
aria-hidden="true"
ref="modalRef"
>
<div
class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg"
>
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="interventionModalLabel">
{{
isEditing ? "Modifier l'intervention" : "Nouvelle Intervention"
}}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<form @submit.prevent="handleSubmit">
<div class="row">
<div class="col-md-12 mb-3">
<label class="form-label">Type d'intervention *</label>
<select v-model="form.type" class="form-select" required>
<option value="">Sélectionner un type</option>
<option value="thanatopraxie">Thanatopraxie</option>
<option value="toilette_mortuaire">
Toilette mortuaire
</option>
<option value="exhumation">Exhumation</option>
<option value="retrait_pacemaker">Retrait pacemaker</option>
<option value="retrait_bijoux">Retrait bijoux</option>
<option value="autre">Autre</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Date et heure *</label>
<input
v-model="form.scheduled_at"
type="datetime-local"
class="form-control"
required
/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Statut</label>
<select v-model="form.status" class="form-select">
<option value="demande">Demande</option>
<option value="planifie">Planifiée</option>
<option value="en_cours">En cours</option>
<option value="termine">Terminée</option>
<option value="annule">Annulée</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Défunt *</label>
<select
v-model="form.deceased_id"
class="form-select"
required
>
<option value="">Sélectionner un défunt</option>
<option
v-for="deceased in deceasedList"
:key="deceased.id"
:value="deceased.id"
>
{{ deceased.first_name }} {{ deceased.last_name }}
</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Client *</label>
<input
v-model="form.client_id"
type="number"
class="form-control"
placeholder="ID du client"
required
/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Thanatopracteur</label>
<select
v-model="form.assigned_practitioner_id"
class="form-select"
>
<option value="">Sélectionner un thanatopracteur</option>
<option
v-for="practitioner in practitioners"
:key="practitioner.id"
:value="practitioner.id"
>
{{ practitioner.employee?.first_name }}
{{ practitioner.employee?.last_name }}
</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Durée (minutes)</label>
<input
v-model="form.duration_min"
type="number"
class="form-control"
placeholder="Ex: 60"
/>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Donneur d'ordre</label>
<input
v-model="form.order_giver"
type="text"
class="form-control"
placeholder="Nom du donneur d'ordre"
/>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Notes</label>
<textarea
v-model="form.notes"
class="form-control"
rows="3"
placeholder="Notes et détails de l'intervention..."
></textarea>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Annuler
</button>
<button
type="button"
class="btn bg-gradient-primary"
@click="handleSubmit"
:disabled="submitting"
>
<span v-if="submitting">
<i class="fas fa-spinner fa-spin me-2"></i>
Enregistrement...
</span>
<span v-else>
{{ isEditing ? "Mettre à jour" : "Créer" }}
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from "vue";
import { useRouter } from "vue-router";
import AgendaPresentation from "@/components/Organism/Agenda/AgendaPresentation.vue";
import { useInterventionStore } from "@/stores/interventionStore";
import { useDeceasedStore } from "@/stores/deceasedStore";
import { useThanatopractitionerStore } from "@/stores/thanatopractitionerStore";
import { Modal } from "bootstrap";
const router = useRouter();
const interventionStore = useInterventionStore();
const deceasedStore = useDeceasedStore();
const thanatopractitionerStore = useThanatopractitionerStore();
const loading = ref(false);
const submitting = ref(false);
const modalRef = ref(null);
const modalInstance = ref(null);
const isEditing = ref(false);
const form = ref({
type: "",
scheduled_at: "",
status: "demande",
deceased_id: "",
client_id: "",
assigned_practitioner_id: "",
notes: "",
location_id: "",
order_giver: "",
duration_min: null,
});
const interventions = computed(() => interventionStore.interventions || []);
const deceasedList = computed(() => deceasedStore.deceased || []);
const practitioners = computed(
() => thanatopractitionerStore.thanatopractitioners || []
);
onMounted(async () => {
loading.value = true;
try {
// Get current month and year
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1; // JavaScript months are 0-indexed
await Promise.all([
interventionStore.fetchInterventionsByMonth(currentYear, currentMonth),
deceasedStore.fetchDeceased(),
thanatopractitionerStore.fetchThanatopractitioners(),
]);
} catch (error) {
console.error("Error loading data:", error);
} finally {
loading.value = false;
}
// Initialize Bootstrap modal
if (modalRef.value) {
modalInstance.value = new Modal(modalRef.value);
}
});
const openCreateModal = () => {
isEditing.value = false;
resetForm();
modalInstance.value?.show();
};
const handleDateClick = (info) => {
isEditing.value = false;
resetForm();
// Pre-fill the date from calendar click
const date = new Date(info.dateStr);
form.value.scheduled_at = date.toISOString().slice(0, 16);
modalInstance.value?.show();
};
const handleEventClick = (event) => {
// Navigate to intervention details or open edit modal
if (event.id) {
router.push({
name: "Intervention Details",
params: { id: event.id },
});
}
};
const resetForm = () => {
form.value = {
type: "",
scheduled_at: "",
status: "demande",
deceased_id: "",
client_id: "",
assigned_practitioner_id: "",
notes: "",
location_id: "",
order_giver: "",
duration_min: null,
};
};
const handleSubmit = async () => {
submitting.value = true;
try {
if (isEditing.value) {
await interventionStore.updateIntervention(form.value.id, form.value);
} else {
await interventionStore.createIntervention(form.value);
}
modalInstance.value?.hide();
resetForm();
// Reload current month interventions
const now = new Date();
await interventionStore.fetchInterventionsByMonth(
now.getFullYear(),
now.getMonth() + 1
);
} catch (error) {
console.error("Error saving intervention:", error);
alert("Erreur lors de l'enregistrement de l'intervention");
} finally {
submitting.value = false;
}
};
const handleMonthChange = async (dateInfo) => {
// Fetch interventions for the new month when user navigates
try {
await interventionStore.fetchInterventionsByMonth(
dateInfo.year,
dateInfo.month
);
} catch (error) {
console.error("Error loading interventions for month:", error);
}
};
</script>
<style scoped>
/* Modal responsive styles */
.modal-dialog {
max-height: calc(100vh - 3.5rem);
margin: 1.75rem auto;
}
.modal-content {
border-radius: 1rem;
max-height: calc(100vh - 3.5rem);
display: flex;
flex-direction: column;
}
.modal-header {
flex-shrink: 0;
border-bottom: 1px solid #e9ecef;
padding: 1rem 1.5rem;
}
.modal-body {
flex: 1 1 auto;
overflow-y: auto;
padding: 1.5rem;
}
.modal-footer {
flex-shrink: 0;
border-top: 1px solid #e9ecef;
padding: 1rem 1.5rem;
}
.form-label {
font-weight: 600;
font-size: 0.875rem;
color: #344767;
margin-bottom: 0.5rem;
}
.form-control,
.form-select {
border: 1px solid #d2d6da;
border-radius: 0.5rem;
padding: 0.625rem 0.75rem;
font-size: 0.875rem;
}
.form-control:focus,
.form-select:focus {
border-color: #cb0c9f;
box-shadow: 0 0 0 2px rgba(203, 12, 159, 0.1);
}
/* Responsive adjustments for smaller screens */
@media (max-height: 768px) {
.modal-dialog {
max-height: calc(100vh - 2rem);
margin: 1rem auto;
}
.modal-content {
max-height: calc(100vh - 2rem);
}
.modal-header,
.modal-footer {
padding: 0.75rem 1rem;
}
.modal-body {
padding: 1rem;
}
.modal-title {
font-size: 1.1rem;
}
}
@media (max-width: 576px) {
.modal-dialog {
margin: 0.5rem;
max-width: calc(100% - 1rem);
}
.modal-header h5 {
font-size: 1rem;
}
.form-label {
font-size: 0.8125rem;
}
}
/* Ensure modal is always visible on small screens */
@media (max-height: 600px) {
.modal-dialog {
max-height: calc(100vh - 1rem);
margin: 0.5rem auto;
}
.modal-content {
max-height: calc(100vh - 1rem);
}
.modal-header {
padding: 0.5rem 1rem;
}
.modal-body {
padding: 0.75rem 1rem;
}
.modal-footer {
padding: 0.5rem 1rem;
}
.mb-3 {
margin-bottom: 0.75rem !important;
}
}
</style>