diff --git a/thanasoft-back/FILE_API_DOCUMENTATION.md b/thanasoft-back/FILE_API_DOCUMENTATION.md new file mode 100644 index 0000000..a2a244e --- /dev/null +++ b/thanasoft-back/FILE_API_DOCUMENTATION.md @@ -0,0 +1,411 @@ +# File Management API Documentation + +## Overview + +The File Management API provides a complete CRUD system for handling file uploads with organized storage, metadata management, and file organization by categories and clients. + +## Base URL + +``` +/api/files +``` + +## Authentication + +All file routes require authentication using Sanctum tokens. + +## File Organization Structure + +Files are automatically organized in storage following this structure: + +``` +storage/app/public/ +├── client/{client_id}/{category}/{subcategory}/filename.pdf +├── client/{client_id}/devis/DEVIS_2024_12_01_10_30_45.pdf +├── client/{client_id}/facture/FACT_2024_12_01_10_30_45.pdf +└── general/{category}/{subcategory}/filename.pdf +``` + +### Supported Categories + +- `devis` - Quotes/Devis +- `facture` - Invoices/Factures +- `contrat` - Contracts +- `document` - General Documents +- `image` - Images +- `autre` - Other files + +## Endpoints Overview + +### 1. List All Files + +```http +GET /api/files +``` + +**Parameters:** + +- `per_page` (optional): Number of files per page (default: 15) +- `search` (optional): Search by filename +- `mime_type` (optional): Filter by MIME type +- `uploaded_by` (optional): Filter by uploader user ID +- `category` (optional): Filter by category +- `client_id` (optional): Filter by client ID +- `date_from` (optional): Filter files uploaded after date (YYYY-MM-DD) +- `date_to` (optional): Filter files uploaded before date (YYYY-MM-DD) +- `sort_by` (optional): Sort field (default: uploaded_at) +- `sort_direction` (optional): Sort direction (default: desc) + +**Response:** + +```json +{ + "data": [ + { + "id": 1, + "file_name": "document.pdf", + "mime_type": "application/pdf", + "size_bytes": 1024000, + "size_formatted": "1000.00 KB", + "extension": "pdf", + "storage_uri": "client/123/devis/DEVIS_2024_12_01_10_30_45.pdf", + "organized_path": "client/123/devis/DEVIS_2024_12_01_10_30_45.pdf", + "sha256": "abc123...", + "uploaded_by": 1, + "uploader_name": "John Doe", + "uploaded_at": "2024-12-01 10:30:45", + "is_image": false, + "is_pdf": true, + "category": "devis", + "subcategory": "general" + } + ], + "pagination": { + "current_page": 1, + "from": 1, + "last_page": 5, + "per_page": 15, + "to": 15, + "total": 75, + "has_more_pages": true + }, + "summary": { + "total_files": 15, + "total_size": 15360000, + "total_size_formatted": "14.65 MB", + "categories": { + "devis": { + "count": 8, + "total_size_formatted": "8.24 MB" + }, + "facture": { + "count": 7, + "total_size_formatted": "6.41 MB" + } + } + } +} +``` + +### 2. Upload File + +```http +POST /api/files +``` + +**Content-Type:** `multipart/form-data` + +**Parameters:** + +- `file` (required): The file to upload (max 10MB) +- `file_name` (optional): Custom filename (uses original name if not provided) +- `category` (required): File category (devis|facture|contrat|document|image|autre) +- `client_id` (optional): Associated client ID +- `subcategory` (optional): Subcategory for better organization +- `description` (optional): File description (max 500 chars) +- `tags` (optional): Array of tags (max 10 tags, 50 chars each) +- `is_public` (optional): Whether file is publicly accessible + +**Example Request:** + +```bash +curl -X POST \ + http://localhost/api/files \ + -H "Authorization: Bearer {token}" \ + -F "file=@/path/to/document.pdf" \ + -F "category=devis" \ + -F "client_id=123" \ + -F "subcategory=annual" \ + -F "description=Annual quote for client" \ + -F 'tags[]=quote' \ + -F 'tags[]=annual' +``` + +**Success Response (201):** + +```json +{ + "data": { + "id": 1, + "file_name": "document.pdf", + "mime_type": "application/pdf", + "size_bytes": 1024000, + "storage_uri": "client/123/devis/annual/document_2024_12_01_10_30_45.pdf", + "category": "devis", + "subcategory": "annual", + "uploaded_at": "2024-12-01 10:30:45" + }, + "message": "Fichier téléchargé avec succès." +} +``` + +### 3. Get File Details + +```http +GET /api/files/{id} +``` + +**Response:** Same as file object in list endpoint + +### 4. Update File Metadata + +```http +PUT /api/files/{id} +``` + +**Parameters:** + +- `file_name` (optional): New filename +- `description` (optional): Updated description +- `tags` (optional): Updated tags array +- `is_public` (optional): Updated public status +- `category` (optional): Move to new category +- `client_id` (optional): Change associated client +- `subcategory` (optional): Update subcategory + +**Note:** If category, client_id, or subcategory are changed, the file will be automatically moved to the new location. + +### 5. Delete File + +```http +DELETE /api/files/{id} +``` + +**Response:** + +```json +{ + "message": "Fichier supprimé avec succès." +} +``` + +### 6. Get Files by Category + +```http +GET /api/files/by-category/{category} +``` + +**Parameters:** Same as list endpoint with additional `per_page` + +### 7. Get Files by Client + +```http +GET /api/files/by-client/{clientId} +``` + +**Parameters:** Same as list endpoint with additional `per_page` + +### 8. Get Organized File Structure + +```http +GET /api/files/organized +``` + +**Response:** + +```json +{ + "data": { + "devis/general": { + "category": "devis", + "subcategory": "general", + "files": [...], + "count": 25 + }, + "facture/annual": { + "category": "facture", + "subcategory": "annual", + "files": [...], + "count": 15 + } + }, + "message": "Structure de fichiers récupérée avec succès." +} +``` + +### 9. Get Storage Statistics + +```http +GET /api/files/statistics +``` + +**Response:** + +```json +{ + "data": { + "total_files": 150, + "total_size_bytes": 1073741824, + "total_size_formatted": "1.00 GB", + "by_type": { + "application/pdf": { + "count": 85, + "total_size": 734003200 + }, + "image/jpeg": { + "count": 45, + "total_size": 209715200 + } + }, + "by_category": { + "devis": { + "count": 60, + "total_size": 429496729 + }, + "facture": { + "count": 50, + "total_size": 322122547 + } + } + }, + "message": "Statistiques de stockage récupérées avec succès." +} +``` + +### 10. Generate Download URL + +```http +GET /api/files/{id}/download +``` + +**Response:** + +```json +{ + "data": { + "download_url": "/storage/client/123/devis/document_2024_12_01_10_30_45.pdf", + "file_name": "document.pdf", + "mime_type": "application/pdf" + }, + "message": "URL de téléchargement générée avec succès." +} +``` + +## Error Responses + +### Validation Error (422) + +```json +{ + "message": "Les données fournies ne sont pas valides.", + "errors": { + "file": ["Le fichier est obligatoire."], + "category": ["La catégorie est obligatoire."] + } +} +``` + +### Not Found (404) + +```json +{ + "message": "Fichier non trouvé." +} +``` + +### Server Error (500) + +```json +{ + "message": "Une erreur est survenue lors du traitement de la requête.", + "error": "Detailed error message (only in debug mode)" +} +``` + +## File Features + +### Automatic Organization + +- Files are automatically organized by category and client +- Timestamps are added to prevent filename conflicts +- Safe slug generation for subcategories + +### Security + +- SHA256 hash calculation for file integrity +- User-based access control +- File size validation (10MB limit) + +### Metadata Support + +- MIME type detection +- File size tracking +- Upload timestamp +- User attribution +- Custom tags and descriptions + +### Storage Management + +- Public storage disk usage +- Efficient path organization +- Duplicate prevention through hashing +- Automatic file moving on metadata updates + +## Usage Examples + +### Upload a Quote for Client + +```bash +curl -X POST \ + http://localhost/api/files \ + -H "Authorization: Bearer {token}" \ + -F "file=@quote_2024.pdf" \ + -F "category=devis" \ + -F "client_id=123" \ + -F "subcategory=annual_2024" \ + -F 'tags[]=quote' \ + -F 'tags[]=annual' +``` + +### Get All Client Files + +```bash +curl -X GET \ + "http://localhost/api/files/by-client/123?per_page=20&sort_by=uploaded_at&sort_direction=desc" \ + -H "Authorization: Bearer {token}" +``` + +### Get File Statistics + +```bash +curl -X GET \ + "http://localhost/api/files/statistics" \ + -H "Authorization: Bearer {token}" +``` + +### Search Files + +```bash +curl -X GET \ + "http://localhost/api/files?search=annual&category=devis" \ + -H "Authorization: Bearer {token}" +``` + +## Notes + +- All file operations are logged for audit purposes +- Files are stored in `storage/app/public/` directory +- The system automatically handles file moving when categories change +- Download URLs are generated on-demand for security +- Pagination is applied to all list endpoints +- French language is used for all API messages and validations diff --git a/thanasoft-back/app/Http/Controllers/Api/FileAttachmentController.php b/thanasoft-back/app/Http/Controllers/Api/FileAttachmentController.php new file mode 100644 index 0000000..1fa56ce --- /dev/null +++ b/thanasoft-back/app/Http/Controllers/Api/FileAttachmentController.php @@ -0,0 +1,438 @@ +validate([ + 'file_id' => 'required|exists:files,id', + 'attachable_type' => 'required|string|in:App\Models\Intervention,App\Models\Client,App\Models\Deceased', + 'attachable_id' => 'required|integer', + 'label' => 'nullable|string|max:255', + 'sort_order' => 'nullable|integer|min:0', + ]); + + // Verify the attachable model exists + $attachableModel = $this->getAttachableModel($request->attachable_type, $request->attachable_id); + if (!$attachableModel) { + return response()->json([ + 'message' => 'Le modèle cible n\'existe pas.', + ], 404); + } + + // Check if file is already attached to this model + $existingAttachment = FileAttachment::where('file_id', $request->file_id) + ->where('attachable_type', $request->attachable_type) + ->where('attachable_id', $request->attachable_id) + ->first(); + + if ($existingAttachment) { + return response()->json([ + 'message' => 'Ce fichier est déjà attaché à cet élément.', + ], 422); + } + + DB::beginTransaction(); + + try { + // Create the attachment + $attachment = FileAttachment::create([ + 'file_id' => $request->file_id, + 'attachable_type' => $request->attachable_type, + 'attachable_id' => $request->attachable_id, + 'label' => $request->label, + 'sort_order' => $request->sort_order ?? 0, + ]); + + // Load relationships for response + $attachment->load('file'); + + DB::commit(); + + return response()->json([ + 'data' => new FileAttachmentResource($attachment), + 'message' => 'Fichier attaché avec succès.', + ], 201); + + } catch (\Exception $e) { + DB::rollBack(); + throw $e; + } + + } catch (\Exception $e) { + Log::error('Error attaching file: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'request_data' => $request->all(), + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de l\'attachement du fichier.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Detach a file from a model + */ + public function detach(Request $request, string $attachmentId): JsonResponse + { + try { + $attachment = FileAttachment::find($attachmentId); + + if (!$attachment) { + return response()->json([ + 'message' => 'Attachement de fichier non trouvé.', + ], 404); + } + + DB::beginTransaction(); + + try { + $attachment->delete(); + + DB::commit(); + + return response()->json([ + 'message' => 'Fichier détaché avec succès.', + ], 200); + + } catch (\Exception $e) { + DB::rollBack(); + throw $e; + } + + } catch (\Exception $e) { + Log::error('Error detaching file: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'attachment_id' => $attachmentId, + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors du détachement du fichier.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Update file attachment metadata + */ + public function update(Request $request, string $attachmentId): JsonResponse + { + try { + $request->validate([ + 'label' => 'nullable|string|max:255', + 'sort_order' => 'nullable|integer|min:0', + ]); + + $attachment = FileAttachment::find($attachmentId); + + if (!$attachment) { + return response()->json([ + 'message' => 'Attachement de fichier non trouvé.', + ], 404); + } + + DB::beginTransaction(); + + try { + $attachment->update($request->only(['label', 'sort_order'])); + $attachment->load('file'); + + DB::commit(); + + return response()->json([ + 'data' => new FileAttachmentResource($attachment), + 'message' => 'Attachement de fichier mis à jour avec succès.', + ], 200); + + } catch (\Exception $e) { + DB::rollBack(); + throw $e; + } + + } catch (\Exception $e) { + Log::error('Error updating file attachment: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'attachment_id' => $attachmentId, + 'request_data' => $request->all(), + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la mise à jour de l\'attachement.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Get files attached to a specific model + */ + public function getAttachedFiles(Request $request): JsonResponse + { + try { + $request->validate([ + 'attachable_type' => 'required|string|in:App\Models\Intervention,App\Models\Client,App\Models\Deceased', + 'attachable_id' => 'required|integer', + ]); + + $attachments = FileAttachment::where('attachable_type', $request->attachable_type) + ->where('attachable_id', $request->attachable_id) + ->with('file') + ->orderBy('sort_order') + ->orderBy('created_at') + ->get(); + + return response()->json([ + 'data' => FileAttachmentResource::collection($attachments), + 'count' => $attachments->count(), + 'message' => 'Fichiers attachés récupérés avec succès.', + ], 200); + + } catch (\Exception $e) { + Log::error('Error getting attached files: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'request_data' => $request->all(), + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la récupération des fichiers attachés.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Get files attached to an intervention + */ + public function getInterventionFiles(Request $request, int $interventionId): JsonResponse + { + try { + $intervention = Intervention::find($interventionId); + + if (!$intervention) { + return response()->json([ + 'message' => 'Intervention non trouvée.', + ], 404); + } + + $attachments = $intervention->fileAttachments()->with('file')->get(); + + return response()->json([ + 'data' => FileAttachmentResource::collection($attachments), + 'count' => $attachments->count(), + 'message' => 'Fichiers de l\'intervention récupérés avec succès.', + ], 200); + + } catch (\Exception $e) { + Log::error('Error getting intervention files: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'intervention_id' => $interventionId, + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la récupération des fichiers de l\'intervention.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Get files attached to a client + */ + public function getClientFiles(Request $request, int $clientId): JsonResponse + { + try { + $client = Client::find($clientId); + + if (!$client) { + return response()->json([ + 'message' => 'Client non trouvé.', + ], 404); + } + + $attachments = $client->fileAttachments()->with('file')->get(); + + return response()->json([ + 'data' => FileAttachmentResource::collection($attachments), + 'count' => $attachments->count(), + 'message' => 'Fichiers du client récupérés avec succès.', + ], 200); + + } catch (\Exception $e) { + Log::error('Error getting client files: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'client_id' => $clientId, + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la récupération des fichiers du client.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Get files attached to a deceased + */ + public function getDeceasedFiles(Request $request, int $deceasedId): JsonResponse + { + try { + $deceased = Deceased::find($deceasedId); + + if (!$deceased) { + return response()->json([ + 'message' => 'Défunt non trouvé.', + ], 404); + } + + $attachments = $deceased->fileAttachments()->with('file')->get(); + + return response()->json([ + 'data' => FileAttachmentResource::collection($attachments), + 'count' => $attachments->count(), + 'message' => 'Fichiers du défunt récupérés avec succès.', + ], 200); + + } catch (\Exception $e) { + Log::error('Error getting deceased files: ' . $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 fichiers du défunt.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Detach multiple files at once + */ + public function detachMultiple(Request $request): JsonResponse + { + try { + $request->validate([ + 'attachment_ids' => 'required|array|min:1', + 'attachment_ids.*' => 'exists:file_attachments,id', + ]); + + DB::beginTransaction(); + + try { + $deletedCount = FileAttachment::whereIn('id', $request->attachment_ids)->delete(); + + DB::commit(); + + return response()->json([ + 'deleted_count' => $deletedCount, + 'message' => $deletedCount . ' fichier(s) détaché(s) avec succès.', + ], 200); + + } catch (\Exception $e) { + DB::rollBack(); + throw $e; + } + + } catch (\Exception $e) { + Log::error('Error detaching multiple files: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'attachment_ids' => $request->attachment_ids ?? [], + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors du détachement des fichiers.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Reorder file attachments + */ + public function reorder(Request $request): JsonResponse + { + try { + $request->validate([ + 'attachments' => 'required|array|min:1', + 'attachments.*.id' => 'required|exists:file_attachments,id', + 'attachments.*.sort_order' => 'required|integer|min:0', + ]); + + DB::beginTransaction(); + + try { + foreach ($request->attachments as $attachmentData) { + FileAttachment::where('id', $attachmentData['id']) + ->update(['sort_order' => $attachmentData['sort_order']]); + } + + DB::commit(); + + return response()->json([ + 'message' => 'Ordre des fichiers mis à jour avec succès.', + ], 200); + + } catch (\Exception $e) { + DB::rollBack(); + throw $e; + } + + } catch (\Exception $e) { + Log::error('Error reordering file attachments: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'attachments' => $request->attachments ?? [], + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la réorganisation des fichiers.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Get the attachable model instance + */ + private function getAttachableModel(string $type, int $id): ?Model + { + return match ($type) { + Intervention::class => Intervention::find($id), + Client::class => Client::find($id), + Deceased::class => Deceased::find($id), + default => null, + }; + } +} diff --git a/thanasoft-back/app/Http/Controllers/Api/FileController.php b/thanasoft-back/app/Http/Controllers/Api/FileController.php new file mode 100644 index 0000000..b8f3859 --- /dev/null +++ b/thanasoft-back/app/Http/Controllers/Api/FileController.php @@ -0,0 +1,444 @@ +get('per_page', 15); + $filters = [ + 'search' => $request->get('search'), + 'mime_type' => $request->get('mime_type'), + 'uploaded_by' => $request->get('uploaded_by'), + 'category' => $request->get('category'), + 'client_id' => $request->get('client_id'), + 'date_from' => $request->get('date_from'), + 'date_to' => $request->get('date_to'), + 'sort_by' => $request->get('sort_by', 'uploaded_at'), + 'sort_direction' => $request->get('sort_direction', 'desc'), + ]; + + // Remove null filters + $filters = array_filter($filters, function ($value) { + return $value !== null && $value !== ''; + }); + + $files = $this->fileRepository->paginate($perPage, $filters); + + return new FileCollection($files); + + } catch (\Exception $e) { + Log::error('Error fetching files: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la récupération des fichiers.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Store a newly uploaded file. + */ + public function store(StoreFileRequest $request): FileResource|JsonResponse + { + try { + $validatedData = $request->validated(); + $file = $request->file('file'); + + // Generate organized storage path + $storagePath = $this->generateOrganizedPath( + $validatedData['category'], + $validatedData['client_id'] ?? null, + $validatedData['subcategory'] ?? null, + $validatedData['file_name'] + ); + + // Store the file + $storedFilePath = $file->store($storagePath, 'public'); + + // Calculate SHA256 hash + $hash = hash_file('sha256', $file->path()); + + // Prepare data for database + $fileData = array_merge($validatedData, [ + 'storage_uri' => $storedFilePath, + 'sha256' => $hash, + 'uploaded_by' => $request->user()->id, + 'uploaded_at' => now(), + ]); + + $createdFile = $this->fileRepository->create($fileData); + + return new FileResource($createdFile); + + } catch (\Exception $e) { + Log::error('Error uploading file: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'user_id' => $request->user()->id, + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de l\'upload du fichier.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Display the specified file. + */ + public function show(string $id): FileResource|JsonResponse + { + try { + $file = $this->fileRepository->find($id); + + if (!$file) { + return response()->json([ + 'message' => 'Fichier non trouvé.', + ], 404); + } + + return new FileResource($file); + } catch (\Exception $e) { + Log::error('Error fetching file: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'file_id' => $id, + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la récupération du fichier.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Update the specified file metadata. + */ + public function update(UpdateFileRequest $request, string $id): FileResource|JsonResponse + { + try { + $file = $this->fileRepository->find($id); + + if (!$file) { + return response()->json([ + 'message' => 'Fichier non trouvé.', + ], 404); + } + + $validatedData = $request->validated(); + + // If category or client changed, move the file + if (isset($validatedData['category']) || isset($validatedData['client_id']) || isset($validatedData['subcategory'])) { + $newStoragePath = $this->generateOrganizedPath( + $validatedData['category'] ?? $this->extractCategoryFromPath($file->storage_uri), + $validatedData['client_id'] ?? $this->extractClientFromPath($file->storage_uri), + $validatedData['subcategory'] ?? $this->extractSubcategoryFromPath($file->storage_uri), + $file->file_name + ); + + if ($newStoragePath !== $file->storage_uri) { + // Move file to new location + Storage::disk('public')->move($file->storage_uri, $newStoragePath); + $validatedData['storage_uri'] = $newStoragePath; + } + } + + $updated = $this->fileRepository->update($id, $validatedData); + + if (!$updated) { + return response()->json([ + 'message' => 'Fichier non trouvé ou échec de la mise à jour.', + ], 404); + } + + $updatedFile = $this->fileRepository->find($id); + return new FileResource($updatedFile); + } catch (\Exception $e) { + Log::error('Error updating file: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'file_id' => $id, + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la mise à jour du fichier.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Remove the specified file. + */ + public function destroy(string $id): JsonResponse + { + try { + $file = $this->fileRepository->find($id); + + if (!$file) { + return response()->json([ + 'message' => 'Fichier non trouvé.', + ], 404); + } + + // Delete file from storage + Storage::disk('public')->delete($file->storage_uri); + + // Delete from database + $deleted = $this->fileRepository->delete($id); + + if (!$deleted) { + return response()->json([ + 'message' => 'Fichier non trouvé ou échec de la suppression.', + ], 404); + } + + return response()->json([ + 'message' => 'Fichier supprimé avec succès.', + ], 200); + } catch (\Exception $e) { + Log::error('Error deleting file: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'file_id' => $id, + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la suppression du fichier.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Get files by category. + */ + public function byCategory(Request $request, string $category): FileCollection|JsonResponse + { + try { + $perPage = $request->get('per_page', 15); + $files = $this->fileRepository->getByCategory($category, $perPage); + + return new FileCollection($files); + } catch (\Exception $e) { + Log::error('Error fetching files by category: ' . $e->getMessage(), [ + 'exception' => $e, + 'category' => $category, + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la récupération des fichiers par catégorie.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Get files by client. + */ + public function byClient(Request $request, int $clientId): FileCollection|JsonResponse + { + try { + $perPage = $request->get('per_page', 15); + $files = $this->fileRepository->getByClient($clientId, $perPage); + + return new FileCollection($files); + } catch (\Exception $e) { + Log::error('Error fetching files by client: ' . $e->getMessage(), [ + 'exception' => $e, + 'client_id' => $clientId, + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la récupération des fichiers du client.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Get organized file structure. + */ + public function organized(): JsonResponse + { + try { + $organizedFiles = $this->fileRepository->getOrganizedFiles(); + + return response()->json([ + 'data' => $organizedFiles, + 'message' => 'Structure de fichiers récupérée avec succès.', + ], 200); + } catch (\Exception $e) { + Log::error('Error fetching organized files: ' . $e->getMessage(), [ + 'exception' => $e, + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la récupération de la structure de fichiers.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Get storage statistics. + */ + public function stats(): JsonResponse + { + try { + $stats = $this->fileRepository->getStorageStats(); + + return response()->json([ + 'data' => $stats, + 'message' => 'Statistiques de stockage récupérées avec succès.', + ], 200); + } catch (\Exception $e) { + Log::error('Error fetching storage stats: ' . $e->getMessage(), [ + 'exception' => $e, + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la récupération des statistiques.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Download a file. + */ + public function download(string $id): JsonResponse + { + try { + $file = $this->fileRepository->find($id); + + if (!$file) { + return response()->json([ + 'message' => 'Fichier non trouvé.', + ], 404); + } + + if (!Storage::disk('public')->exists($file->storage_uri)) { + return response()->json([ + 'message' => 'Fichier physique non trouvé sur le stockage.', + ], 404); + } + + $downloadUrl = Storage::disk('public')->url($file->storage_uri); + + return response()->json([ + 'data' => [ + 'download_url' => $downloadUrl, + 'file_name' => $file->file_name, + 'mime_type' => $file->mime_type, + ], + 'message' => 'URL de téléchargement générée avec succès.', + ], 200); + } catch (\Exception $e) { + Log::error('Error generating download URL: ' . $e->getMessage(), [ + 'exception' => $e, + 'file_id' => $id, + ]); + + return response()->json([ + 'message' => 'Une erreur est survenue lors de la génération de l\'URL de téléchargement.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * Generate organized storage path. + */ + private function generateOrganizedPath(string $category, ?int $clientId, ?string $subcategory, string $fileName): string + { + $pathParts = []; + + if ($clientId) { + $pathParts[] = 'client'; + $pathParts[] = $clientId; + } else { + $pathParts[] = 'general'; + } + + $pathParts[] = $category; + + if ($subcategory) { + $pathParts[] = Str::slug($subcategory); + } else { + $pathParts[] = 'files'; + } + + // Add timestamp to avoid conflicts + $timestamp = now()->format('Y-m-d_H-i-s'); + $extension = pathinfo($fileName, PATHINFO_EXTENSION); + $basename = pathinfo($fileName, PATHINFO_FILENAME); + $safeFilename = Str::slug($basename) . '_' . $timestamp . '.' . $extension; + + $pathParts[] = $safeFilename; + + return implode('/', $pathParts); + } + + /** + * Extract category from storage path. + */ + private function extractCategoryFromPath(string $storageUri): string + { + $pathParts = explode('/', $storageUri); + return $pathParts[count($pathParts) - 3] ?? 'general'; + } + + /** + * Extract client ID from storage path. + */ + private function extractClientFromPath(string $storageUri): ?int + { + $pathParts = explode('/', $storageUri); + if (count($pathParts) >= 4 && $pathParts[0] === 'client') { + return (int) $pathParts[1]; + } + return null; + } + + /** + * Extract subcategory from storage path. + */ + private function extractSubcategoryFromPath(string $storageUri): ?string + { + $pathParts = explode('/', $storageUri); + return $pathParts[count($pathParts) - 2] ?? null; + } +} diff --git a/thanasoft-back/app/Http/Requests/StoreFileRequest.php b/thanasoft-back/app/Http/Requests/StoreFileRequest.php new file mode 100644 index 0000000..fee7133 --- /dev/null +++ b/thanasoft-back/app/Http/Requests/StoreFileRequest.php @@ -0,0 +1,109 @@ +|string> + */ + public function rules(): array + { + return [ + 'file' => 'required|file|max:10240', // Max 10MB + 'file_name' => 'nullable|string|max:255', + 'category' => 'required|string|in:devis,facture,contrat,document,image,autre', + 'client_id' => 'nullable|integer|exists:clients,id', + 'subcategory' => 'nullable|string|max:100', + 'description' => 'nullable|string|max:500', + 'tags' => 'nullable|array|max:10', + 'tags.*' => 'string|max:50', + 'is_public' => 'boolean', + ]; + } + + /** + * Get custom attributes for validator errors. + */ + public function attributes(): array + { + return [ + 'file' => 'fichier', + 'file_name' => 'nom du fichier', + 'category' => 'catégorie', + 'client_id' => 'client', + 'subcategory' => 'sous-catégorie', + 'description' => 'description', + 'tags' => 'étiquettes', + 'is_public' => 'visibilité publique', + ]; + } + + /** + * Get the error messages for the defined validation rules. + */ + public function messages(): array + { + return [ + 'file.required' => 'Le fichier est obligatoire.', + 'file.file' => 'Le fichier doit être un fichier valide.', + 'file.max' => 'Le fichier ne peut pas dépasser 10 MB.', + 'file_name.string' => 'Le nom du fichier doit être une chaîne de caractères.', + 'file_name.max' => 'Le nom du fichier ne peut pas dépasser 255 caractères.', + 'category.required' => 'La catégorie est obligatoire.', + 'category.in' => 'La catégorie sélectionnée n\'est pas valide.', + 'client_id.exists' => 'Le client sélectionné n\'existe pas.', + 'subcategory.string' => 'La sous-catégorie doit être une chaîne de caractères.', + 'subcategory.max' => 'La sous-catégorie ne peut pas dépasser 100 caractères.', + 'description.string' => 'La description doit être une chaîne de caractères.', + 'description.max' => 'La description ne peut pas dépasser 500 caractères.', + 'tags.array' => 'Les étiquettes doivent être un tableau.', + 'tags.max' => 'Vous ne pouvez pas ajouter plus de 10 étiquettes.', + 'tags.*.string' => 'Chaque étiquette doit être une chaîne de caractères.', + 'tags.*.max' => 'Chaque étiquette ne peut pas dépasser 50 caractères.', + 'is_public.boolean' => 'La visibilité publique doit être vrai ou faux.', + ]; + } + + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + // Set default values + $this->merge([ + 'uploaded_by' => $this->user()->id, + 'is_public' => $this->boolean('is_public', false), + 'category' => $this->input('category', 'autre'), // Default category to 'autre' if not provided + ]); + + // If no file_name provided, use the original file name + if (!$this->has('file_name') && $this->hasFile('file')) { + $this->merge([ + 'file_name' => $this->file->getClientOriginalName(), + ]); + } + + // Extract file information + if ($this->hasFile('file')) { + $file = $this->file; + $this->merge([ + 'mime_type' => $file->getMimeType(), + 'size_bytes' => $file->getSize(), + ]); + } + } +} diff --git a/thanasoft-back/app/Http/Requests/UpdateFileRequest.php b/thanasoft-back/app/Http/Requests/UpdateFileRequest.php new file mode 100644 index 0000000..39cf9c3 --- /dev/null +++ b/thanasoft-back/app/Http/Requests/UpdateFileRequest.php @@ -0,0 +1,96 @@ +route('file'); + + // Allow if user owns the file or is admin + return Auth::check() && ( + $file->uploaded_by === Auth::id() || + Auth::user()->hasRole('admin') + ); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'file_name' => 'sometimes|string|max:255', + 'description' => 'nullable|string|max:500', + 'tags' => 'nullable|array|max:10', + 'tags.*' => 'string|max:50', + 'is_public' => 'boolean', + 'category' => 'sometimes|string|in:devis,facture,contrat,document,image,autre', + 'client_id' => 'nullable|integer|exists:clients,id', + 'subcategory' => 'nullable|string|max:100', + ]; + } + + /** + * Get custom attributes for validator errors. + */ + public function attributes(): array + { + return [ + 'file_name' => 'nom du fichier', + 'description' => 'description', + 'tags' => 'étiquettes', + 'is_public' => 'visibilité publique', + 'category' => 'catégorie', + 'client_id' => 'client', + 'subcategory' => 'sous-catégorie', + ]; + } + + /** + * Get the error messages for the defined validation rules. + */ + public function messages(): array + { + return [ + 'file_name.string' => 'Le nom du fichier doit être une chaîne de caractères.', + 'file_name.max' => 'Le nom du fichier ne peut pas dépasser 255 caractères.', + 'description.string' => 'La description doit être une chaîne de caractères.', + 'description.max' => 'La description ne peut pas dépasser 500 caractères.', + 'tags.array' => 'Les étiquettes doivent être un tableau.', + 'tags.max' => 'Vous ne pouvez pas ajouter plus de 10 étiquettes.', + 'tags.*.string' => 'Chaque étiquette doit être une chaîne de caractères.', + 'tags.*.max' => 'Chaque étiquette ne peut pas dépasser 50 caractères.', + 'is_public.boolean' => 'La visibilité publique doit être vrai ou faux.', + 'category.string' => 'La catégorie doit être une chaîne de caractères.', + 'category.in' => 'La catégorie sélectionnée n\'est pas valide.', + 'client_id.exists' => 'Le client sélectionné n\'existe pas.', + 'subcategory.string' => 'La sous-catégorie doit être une chaîne de caractères.', + 'subcategory.max' => 'La sous-catégorie ne peut pas dépasser 100 caractères.', + ]; + } + + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + // Only merge fields that are present in the request + $data = []; + + if ($this->has('is_public')) { + $data['is_public'] = $this->boolean('is_public'); + } + + $this->merge($data); + } +} diff --git a/thanasoft-back/app/Http/Resources/File/FileCollection.php b/thanasoft-back/app/Http/Resources/File/FileCollection.php new file mode 100644 index 0000000..847babd --- /dev/null +++ b/thanasoft-back/app/Http/Resources/File/FileCollection.php @@ -0,0 +1,86 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'data' => FileResource::collection($this->collection), + 'pagination' => [ + 'current_page' => $this->currentPage(), + 'from' => $this->firstItem(), + 'last_page' => $this->lastPage(), + 'per_page' => $this->perPage(), + 'to' => $this->lastItem(), + 'total' => $this->total(), + 'has_more_pages' => $this->hasMorePages(), + ], + 'summary' => [ + 'total_files' => $this->collection->count(), + 'total_size' => $this->collection->sum('size_bytes'), + 'total_size_formatted' => $this->formatBytes($this->collection->sum('size_bytes')), + 'categories' => $this->getCategoryStats(), + ], + ]; + } + + /** + * Calculate category statistics from the collection + */ + private function getCategoryStats(): array + { + $categories = []; + + foreach ($this->collection as $file) { + $pathParts = explode('/', $file->storage_uri); + $category = $pathParts[count($pathParts) - 3] ?? 'general'; + + if (!isset($categories[$category])) { + $categories[$category] = [ + 'count' => 0, + 'total_size' => 0, + 'files' => [] + ]; + } + + $categories[$category]['count']++; + $categories[$category]['total_size'] += $file->size_bytes ?? 0; + $categories[$category]['files'][] = $file->file_name; + } + + // Format sizes + foreach ($categories as $category => &$stats) { + $stats['total_size_formatted'] = $this->formatBytes($stats['total_size']); + // Remove file list to avoid too much data in collection + unset($stats['files']); + } + + return $categories; + } + + /** + * Format bytes to human readable format + */ + private function formatBytes(int $bytes, int $precision = 2): string + { + if ($bytes === 0) { + return '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $base = 1024; + $factor = floor((strlen($bytes) - 1) / 3); + + return sprintf("%.{$precision}f", $bytes / pow($base, $factor)) . ' ' . $units[$factor]; + } +} diff --git a/thanasoft-back/app/Http/Resources/File/FileResource.php b/thanasoft-back/app/Http/Resources/File/FileResource.php new file mode 100644 index 0000000..18d17af --- /dev/null +++ b/thanasoft-back/app/Http/Resources/File/FileResource.php @@ -0,0 +1,68 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'file_name' => $this->file_name, + 'mime_type' => $this->mime_type, + 'size_bytes' => $this->size_bytes, + 'size_formatted' => $this->formatted_size, + 'extension' => $this->extension, + 'storage_uri' => $this->storage_uri, + 'organized_path' => $this->organized_path, + 'sha256' => $this->sha256, + 'uploaded_by' => $this->uploaded_by, + 'uploader_name' => $this->uploader_name, + 'uploaded_at' => $this->uploaded_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'), + + // File type helpers + 'is_image' => $this->is_image, + 'is_pdf' => $this->is_pdf, + + // URL for accessing the file (if public) + 'url' => $this->when( + $this->is_public ?? false, + asset('storage/' . $this->storage_uri) + ), + + // Relations + 'user' => [ + 'id' => $this->user?->id, + 'name' => $this->user?->name, + 'email' => $this->user?->email, + ], + + // Additional metadata from the file's path structure + 'category' => $this->when( + $this->storage_uri, + function () { + $pathParts = explode('/', $this->storage_uri); + return $pathParts[count($pathParts) - 3] ?? 'general'; + } + ), + + 'subcategory' => $this->when( + $this->storage_uri, + function () { + $pathParts = explode('/', $this->storage_uri); + return $pathParts[count($pathParts) - 2] ?? 'general'; + } + ), + ]; + } +} diff --git a/thanasoft-back/app/Http/Resources/FileAttachment/FileAttachmentResource.php b/thanasoft-back/app/Http/Resources/FileAttachment/FileAttachmentResource.php new file mode 100644 index 0000000..6693d8a --- /dev/null +++ b/thanasoft-back/app/Http/Resources/FileAttachment/FileAttachmentResource.php @@ -0,0 +1,90 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'file_id' => $this->file_id, + 'label' => $this->label, + 'sort_order' => $this->sort_order, + 'attachable_type' => $this->attachable_type, + 'attachable_id' => $this->attachable_id, + 'created_at' => $this->created_at->format('Y-m-d H:i:s'), + 'updated_at' => $this->updated_at->format('Y-m-d H:i:s'), + + // File information + 'file' => $this->whenLoaded('file', function () { + return [ + 'id' => $this->file->id, + 'name' => $this->file->name, + 'original_name' => $this->file->original_name ?? $this->file->name, + 'path' => $this->file->path, + 'mime_type' => $this->file->mime_type, + 'size' => $this->file->size, + 'size_formatted' => $this->formatFileSize($this->file->size ?? 0), + 'extension' => pathinfo($this->file->name, PATHINFO_EXTENSION), + 'download_url' => url('/api/files/' . $this->file->id . '/download'), + ]; + }), + + // Attachable model information + 'attachable' => $this->whenLoaded('attachable', function () { + return [ + 'id' => $this->attachable->id, + 'type' => class_basename($this->attachable), + 'name' => $this->getAttachableName(), + ]; + }), + + // Helper methods + 'is_for_intervention' => $this->isForIntervention(), + 'is_for_client' => $this->isForClient(), + 'is_for_deceased' => $this->isForDeceased(), + 'download_url' => $this->downloadUrl, + ]; + } + + /** + * Format file size in human readable format + */ + private function formatFileSize(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + + $bytes /= (1 << (10 * $pow)); + + return round($bytes, 2) . ' ' . $units[$pow]; + } + + /** + * Get the display name of the attached model + */ + private function getAttachableName(): string + { + if (!$this->attachable) { + return 'Unknown'; + } + + return match (get_class($this->attachable)) { + \App\Models\Intervention::class => $this->attachable->title ?? "Intervention #{$this->attachable->id}", + \App\Models\Client::class => $this->attachable->name ?? "Client #{$this->attachable->id}", + \App\Models\Deceased::class => $this->attachable->name ?? "Deceased #{$this->attachable->id}", + default => 'Unknown Model' + }; + } +} diff --git a/thanasoft-back/app/Models/Client.php b/thanasoft-back/app/Models/Client.php index 5af8ae4..1d01112 100644 --- a/thanasoft-back/app/Models/Client.php +++ b/thanasoft-back/app/Models/Client.php @@ -67,4 +67,20 @@ class Client extends Model return !empty($parts) ? implode(', ', $parts) : null; } + + /** + * Get the file attachments for the client (polymorphic). + */ + public function fileAttachments() + { + return $this->morphMany(FileAttachment::class, 'attachable')->orderBy('sort_order'); + } + + /** + * Get the files attached to this client. + */ + public function attachedFiles() + { + return $this->fileAttachments()->with('file'); + } } diff --git a/thanasoft-back/app/Models/Deceased.php b/thanasoft-back/app/Models/Deceased.php index 77c5f92..98f8515 100644 --- a/thanasoft-back/app/Models/Deceased.php +++ b/thanasoft-back/app/Models/Deceased.php @@ -56,4 +56,20 @@ class Deceased extends Model { return $this->hasMany(Intervention::class); } + + /** + * Get the file attachments for the deceased (polymorphic). + */ + public function fileAttachments() + { + return $this->morphMany(FileAttachment::class, 'attachable')->orderBy('sort_order'); + } + + /** + * Get the files attached to this deceased. + */ + public function attachedFiles() + { + return $this->fileAttachments()->with('file'); + } } diff --git a/thanasoft-back/app/Models/File.php b/thanasoft-back/app/Models/File.php new file mode 100644 index 0000000..7124946 --- /dev/null +++ b/thanasoft-back/app/Models/File.php @@ -0,0 +1,98 @@ + 'integer', + 'uploaded_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'uploaded_by'); + } + + /** + * Get the uploader name. + */ + public function getUploaderName(): string + { + return $this->user ? $this->user->name : 'Utilisateur inconnu'; + } + + /** + * Get the formatted file size. + */ + public function getFormattedSize(): string + { + if (!$this->size_bytes) { + return '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $bytes = $this->size_bytes; + $i = 0; + + while ($bytes >= 1024 && $i < count($units) - 1) { + $bytes /= 1024; + $i++; + } + + return round($bytes, 2) . ' ' . $units[$i]; + } + + /** + * Get the file extension from the file name. + */ + public function getExtension(): string + { + return pathinfo($this->file_name, PATHINFO_EXTENSION); + } + + /** + * Check if the file is an image. + */ + public function isImage(): bool + { + return str_starts_with($this->mime_type ?? '', 'image/'); + } + + /** + * Check if the file is a PDF. + */ + public function isPdf(): bool + { + return $this->mime_type === 'application/pdf'; + } + + /** + * Get the organized storage path (e.g., client/devis/filename.pdf). + */ + public function getOrganizedPath(): string + { + // Extract directory structure from storage_uri + $path = $this->storage_uri; + + // Remove storage path prefix if present + if (str_contains($path, 'storage/')) { + $path = substr($path, strpos($path, 'storage/') + 8); + } + + return $path; + } +} diff --git a/thanasoft-back/app/Models/FileAttachment.php b/thanasoft-back/app/Models/FileAttachment.php new file mode 100644 index 0000000..a85c39f --- /dev/null +++ b/thanasoft-back/app/Models/FileAttachment.php @@ -0,0 +1,141 @@ + 'integer', + ]; + + /** + * Get the parent attachable model (polymorphic). + */ + public function attachable(): MorphTo + { + return $this->morphTo(); + } + + /** + * Get the file associated with the attachment. + */ + public function file(): BelongsTo + { + return $this->belongsTo(File::class); + } + + /** + * Get the intervention associated with the attachment (legacy support). + */ + public function intervention(): BelongsTo + { + return $this->belongsTo(Intervention::class, 'attachable_id')->where('attachable_type', Intervention::class); + } + + /** + * Get the client associated with the attachment. + */ + public function client(): BelongsTo + { + return $this->belongsTo(Client::class, 'attachable_id')->where('attachable_type', Client::class); + } + + /** + * Get the deceased associated with the attachment. + */ + public function deceased(): BelongsTo + { + return $this->belongsTo(Deceased::class, 'attachable_id')->where('attachable_type', Deceased::class); + } + + /** + * Scope to filter by attachable type. + */ + public function scopeOfType($query, string $type) + { + return $query->where('attachable_type', $type); + } + + /** + * Scope to filter by attachable model. + */ + public function scopeFor($query, Model $model) + { + return $query->where('attachable_type', get_class($model)) + ->where('attachable_id', $model->getKey()); + } + + /** + * Get the display name of the attached model. + */ + public function getAttachableNameAttribute(): string + { + return $this->attachable?->name ?? $this->attachable?->file_name ?? 'Unknown'; + } + + /** + * Get the URL for downloading the attached file. + */ + public function getDownloadUrlAttribute(): string + { + return url('/api/files/' . $this->file_id . '/download'); + } + + /** + * Check if this attachment belongs to an intervention. + */ + public function isForIntervention(): bool + { + return $this->attachable_type === Intervention::class; + } + + /** + * Check if this attachment belongs to a client. + */ + public function isForClient(): bool + { + return $this->attachable_type === Client::class; + } + + /** + * Check if this attachment belongs to a deceased. + */ + public function isForDeceased(): bool + { + return $this->attachable_type === Deceased::class; + } +} diff --git a/thanasoft-back/app/Models/Intervention.php b/thanasoft-back/app/Models/Intervention.php index bb40846..2ce4329 100644 --- a/thanasoft-back/app/Models/Intervention.php +++ b/thanasoft-back/app/Models/Intervention.php @@ -114,13 +114,29 @@ class Intervention extends Model } /** - * Get the attachments for the intervention. + * Get the attachments for the intervention (legacy support). */ public function attachments(): HasMany { return $this->hasMany(InterventionAttachment::class); } + /** + * Get the file attachments for the intervention (polymorphic). + */ + public function fileAttachments() + { + return $this->morphMany(FileAttachment::class, 'attachable')->orderBy('sort_order'); + } + + /** + * Get the files attached to this intervention. + */ + public function attachedFiles() + { + return $this->fileAttachments()->with('file'); + } + /** * Get the notifications for the intervention. */ diff --git a/thanasoft-back/app/Providers/AppServiceProvider.php b/thanasoft-back/app/Providers/AppServiceProvider.php index 10641f7..4b57918 100644 --- a/thanasoft-back/app/Providers/AppServiceProvider.php +++ b/thanasoft-back/app/Providers/AppServiceProvider.php @@ -62,6 +62,8 @@ class AppServiceProvider extends ServiceProvider $this->app->bind(\App\Repositories\DeceasedRepositoryInterface::class, \App\Repositories\DeceasedRepository::class); $this->app->bind(\App\Repositories\InterventionPractitionerRepositoryInterface::class, \App\Repositories\InterventionPractitionerRepository::class); + + $this->app->bind(\App\Repositories\FileRepositoryInterface::class, \App\Repositories\FileRepository::class); } /** diff --git a/thanasoft-back/app/Providers/RepositoryServiceProvider.php b/thanasoft-back/app/Providers/RepositoryServiceProvider.php index 81543e7..a71fb2a 100644 --- a/thanasoft-back/app/Providers/RepositoryServiceProvider.php +++ b/thanasoft-back/app/Providers/RepositoryServiceProvider.php @@ -8,6 +8,8 @@ use App\Repositories\DeceasedDocumentRepositoryInterface; use App\Repositories\DeceasedDocumentRepository; use App\Repositories\InterventionRepositoryInterface; use App\Repositories\InterventionRepository; +use App\Repositories\FileRepositoryInterface; +use App\Repositories\FileRepository; use Illuminate\Support\ServiceProvider; class RepositoryServiceProvider extends ServiceProvider @@ -20,6 +22,7 @@ class RepositoryServiceProvider extends ServiceProvider $this->app->bind(DeceasedRepositoryInterface::class, DeceasedRepository::class); $this->app->bind(DeceasedDocumentRepositoryInterface::class, DeceasedDocumentRepository::class); $this->app->bind(InterventionRepositoryInterface::class, InterventionRepository::class); + $this->app->bind(FileRepositoryInterface::class, FileRepository::class); } /** diff --git a/thanasoft-back/app/Repositories/FileRepository.php b/thanasoft-back/app/Repositories/FileRepository.php new file mode 100644 index 0000000..5acf770 --- /dev/null +++ b/thanasoft-back/app/Repositories/FileRepository.php @@ -0,0 +1,232 @@ +model->newQuery(); + + // Apply filters + if (!empty($filters['search'])) { + $query->where('file_name', 'like', '%' . $filters['search'] . '%'); + } + + if (!empty($filters['mime_type'])) { + $query->where('mime_type', 'like', '%' . $filters['mime_type'] . '%'); + } + + if (!empty($filters['uploaded_by'])) { + $query->where('uploaded_by', $filters['uploaded_by']); + } + + if (!empty($filters['category'])) { + // Extract category from storage_uri path + $query->where('storage_uri', 'like', '%/' . $filters['category'] . '/%'); + } + + if (!empty($filters['client_id'])) { + // Extract client files from storage path + $query->where('storage_uri', 'like', '%/client/' . $filters['client_id'] . '/%'); + } + + // Date range filter + if (!empty($filters['date_from'])) { + $query->whereDate('uploaded_at', '>=', $filters['date_from']); + } + + if (!empty($filters['date_to'])) { + $query->whereDate('uploaded_at', '<=', $filters['date_to']); + } + + // Apply sorting + $sortField = $filters['sort_by'] ?? 'uploaded_at'; + $sortDirection = $filters['sort_direction'] ?? 'desc'; + $query->orderBy($sortField, $sortDirection); + + return $query->paginate($perPage); + } + + /** + * Get files by category/type (e.g., devis, facture) + */ + public function getByCategory(string $category, int $perPage = 15): LengthAwarePaginator + { + return $this->model->newQuery() + ->where('storage_uri', 'like', '%/' . $category . '/%') + ->orderBy('uploaded_at', 'desc') + ->paginate($perPage); + } + + /** + * Get files by client ID + */ + public function getByClient(int $clientId, int $perPage = 15): LengthAwarePaginator + { + return $this->model->newQuery() + ->where('storage_uri', 'like', '%/client/' . $clientId . '/%') + ->orderBy('uploaded_at', 'desc') + ->paginate($perPage); + } + + /** + * Get files by user/uploader + */ + public function getByUploader(int $uploaderId, int $perPage = 15): LengthAwarePaginator + { + return $this->model->newQuery() + ->where('uploaded_by', $uploaderId) + ->orderBy('uploaded_at', 'desc') + ->paginate($perPage); + } + + /** + * Search files by filename + */ + public function search(string $searchTerm, int $perPage = 15): LengthAwarePaginator + { + return $this->model->newQuery() + ->where('file_name', 'like', '%' . $searchTerm . '%') + ->orWhere('storage_uri', 'like', '%' . $searchTerm . '%') + ->orderBy('uploaded_at', 'desc') + ->paginate($perPage); + } + + /** + * Get recent files + */ + public function getRecent(int $limit = 10): Collection + { + return $this->model->newQuery() + ->orderBy('uploaded_at', 'desc') + ->limit($limit) + ->get(); + } + + /** + * Get files by storage path pattern + */ + public function getByPathPattern(string $pattern, int $perPage = 15): LengthAwarePaginator + { + return $this->model->newQuery() + ->where('storage_uri', 'like', '%' . $pattern . '%') + ->orderBy('uploaded_at', 'desc') + ->paginate($perPage); + } + + /** + * Get files organized by path structure + */ + public function getOrganizedFiles(): Collection + { + $files = $this->model->newQuery() + ->orderBy('storage_uri') + ->get(); + + // Group files by their organized path structure + $organized = collect(); + + foreach ($files as $file) { + $pathParts = explode('/', $file->storage_uri); + + // Skip if not enough path parts + if (count($pathParts) < 3) { + continue; + } + + // Extract structure like: category/subcategory/filename + $category = $pathParts[count($pathParts) - 3] ?? 'root'; + $subcategory = $pathParts[count($pathParts) - 2] ?? 'general'; + + $key = $category . '/' . $subcategory; + + if (!$organized->has($key)) { + $organized->put($key, collect([ + 'category' => $category, + 'subcategory' => $subcategory, + 'files' => collect(), + 'count' => 0 + ])); + } + + $group = $organized->get($key); + $group['files']->push($file); + $group['count']++; + } + + return $organized; + } + + /** + * Get storage usage statistics + */ + public function getStorageStats(): array + { + $totalFiles = $this->model->newQuery()->count(); + $totalSize = $this->model->newQuery()->sum('size_bytes'); + + $byType = $this->model->newQuery() + ->selectRaw('mime_type, COUNT(*) as count, SUM(size_bytes) as total_size') + ->groupBy('mime_type') + ->get(); + + $byCategory = $this->model->newQuery() + ->selectRaw('storage_uri, COUNT(*) as count, SUM(size_bytes) as total_size') + ->get() + ->map(function ($item) { + $pathParts = explode('/', $item->storage_uri); + $category = $pathParts[count($pathParts) - 3] ?? 'root'; + return [ + 'category' => $category, + 'count' => $item->count, + 'total_size' => $item->total_size, + ]; + }) + ->groupBy('category') + ->map(function ($items) { + return [ + 'count' => $items->sum('count'), + 'total_size' => $items->sum('total_size'), + ]; + }); + + return [ + 'total_files' => $totalFiles, + 'total_size_bytes' => $totalSize, + 'total_size_formatted' => $this->formatBytes($totalSize), + 'by_type' => $byType, + 'by_category' => $byCategory, + ]; + } + + /** + * Format bytes to human readable format + */ + private function formatBytes(int $bytes, int $precision = 2): string + { + if ($bytes === 0) { + return '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $base = 1024; + $factor = floor((strlen($bytes) - 1) / 3); + + return sprintf("%.{$precision}f", $bytes / pow($base, $factor)) . ' ' . $units[$factor]; + } +} diff --git a/thanasoft-back/app/Repositories/FileRepositoryInterface.php b/thanasoft-back/app/Repositories/FileRepositoryInterface.php new file mode 100644 index 0000000..f2de426 --- /dev/null +++ b/thanasoft-back/app/Repositories/FileRepositoryInterface.php @@ -0,0 +1,46 @@ +string('attachable_type')->after('intervention_id')->nullable(); + $table->unsignedBigInteger('attachable_id')->after('attachable_type')->nullable(); + + // Add index for polymorphic queries + $table->index(['attachable_type', 'attachable_id'], 'polymorphic_attachment_index'); + + // Make intervention_id nullable for backward compatibility + // Existing records will still work with intervention_id + $table->unsignedBigInteger('intervention_id')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('intervention_attachments', function (Blueprint $table) { + $table->dropIndex('polymorphic_attachment_index'); + $table->dropColumn(['attachable_type', 'attachable_id']); + + // Restore intervention_id as required + $table->unsignedBigInteger('intervention_id')->nullable(false)->change(); + }); + } +}; diff --git a/thanasoft-back/database/migrations/2025_12_01_130000_create_file_attachments_table.php b/thanasoft-back/database/migrations/2025_12_01_130000_create_file_attachments_table.php new file mode 100644 index 0000000..0da66bf --- /dev/null +++ b/thanasoft-back/database/migrations/2025_12_01_130000_create_file_attachments_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('file_id')->constrained('files')->cascadeOnDelete(); + + // Polymorphic relationship columns + $table->string('attachable_type'); // intervention, client, deceased, etc. + $table->unsignedBigInteger('attachable_id'); // ID of the related model + + // Attachment metadata + $table->string('label')->nullable(); // Custom label for the attachment + $table->unsignedInteger('sort_order')->default(0); // For ordering attachments + + // Indexes for polymorphic queries + $table->index(['attachable_type', 'attachable_id'], 'polymorphic_attachment_index'); + $table->index(['file_id'], 'file_attachment_index'); + $table->index(['sort_order'], 'sort_order_index'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('file_attachments'); + } +}; diff --git a/thanasoft-back/routes/api.php b/thanasoft-back/routes/api.php index dd52e3e..bcfa8e4 100644 --- a/thanasoft-back/routes/api.php +++ b/thanasoft-back/routes/api.php @@ -16,6 +16,8 @@ use App\Http\Controllers\Api\PractitionerDocumentController; use App\Http\Controllers\Api\DeceasedController; use App\Http\Controllers\Api\DeceasedDocumentController; use App\Http\Controllers\Api\InterventionController; +use App\Http\Controllers\Api\FileController; +use App\Http\Controllers\Api\FileAttachmentController; /* @@ -136,4 +138,31 @@ Route::middleware('auth:sanctum')->group(function () { Route::get('/{intervention}/debug', [InterventionController::class, 'debugPractitioners']); }); + // File management + Route::prefix('files')->group(function () { + Route::get('/', [FileController::class, 'index']); + Route::post('/', [FileController::class, 'store']); + Route::get('/by-category/{category}', [FileController::class, 'byCategory']); + Route::get('/by-client/{clientId}', [FileController::class, 'byClient']); + Route::get('/organized', [FileController::class, 'organized']); + Route::get('/statistics', [FileController::class, 'stats']); + Route::get('/{id}', [FileController::class, 'show']); + Route::put('/{id}', [FileController::class, 'update']); + Route::delete('/{id}', [FileController::class, 'destroy']); + Route::get('/{id}/download', [FileController::class, 'download']); + }); + + // File Attachment management + Route::prefix('file-attachments')->group(function () { + Route::post('/', [FileAttachmentController::class, 'attach']); + Route::put('/{attachmentId}', [FileAttachmentController::class, 'update']); + Route::delete('/{attachmentId}', [FileAttachmentController::class, 'detach']); + Route::post('/detach-multiple', [FileAttachmentController::class, 'detachMultiple']); + Route::post('/reorder', [FileAttachmentController::class, 'reorder']); + Route::get('/attached-files', [FileAttachmentController::class, 'getAttachedFiles']); + Route::get('/intervention/{interventionId}/files', [FileAttachmentController::class, 'getInterventionFiles']); + Route::get('/client/{clientId}/files', [FileAttachmentController::class, 'getClientFiles']); + Route::get('/deceased/{deceasedId}/files', [FileAttachmentController::class, 'getDeceasedFiles']); + }); + }); diff --git a/thanasoft-front/FILE_MANAGEMENT_FRONTEND.md b/thanasoft-front/FILE_MANAGEMENT_FRONTEND.md new file mode 100644 index 0000000..ccd9133 --- /dev/null +++ b/thanasoft-front/FILE_MANAGEMENT_FRONTEND.md @@ -0,0 +1,815 @@ +# File Management Frontend System + +## Overview + +Complete frontend file management system with Vue.js 3, TypeScript, and Pinia for state management. This system provides file upload, organization, filtering, and management capabilities. + +## Architecture + +- **FileService**: API communication and data transformation +- **FileStore**: Pinia store for state management +- **TypeScript Interfaces**: Type safety for all data structures + +## Installation + +The file service and store are ready to use. Import them in your components: + +```typescript +import { useFileStore } from "@/stores/fileStore"; +import FileService from "@/services/file"; +``` + +## FileStore Usage + +### Basic Setup + +```typescript +import { useFileStore } from "@/stores/fileStore"; + +const fileStore = useFileStore(); + +// Reactive state +const { + files, + isLoading, + hasError, + getError, + getPagination, + totalSizeFormatted, +} = storeToRefs(fileStore); + +// Actions +const { fetchFiles, uploadFile, deleteFile, searchFiles } = fileStore; +``` + +### Store State Properties + +#### Files Management + +- `files` - Array of all files +- `currentFile` - Currently selected/viewed file +- `selectedFiles` - Array of selected file IDs for bulk operations +- `filters` - Current filtering criteria +- `pagination` - Pagination metadata + +#### Loading States + +- `loading` - General loading state +- `uploadProgress` - Upload progress (0-100%) +- `error` - Error message +- `hasError` - Boolean error state + +#### Statistics + +- `organizedFiles` - Files grouped by category/subcategory +- `storageStats` - Storage usage statistics + +### Store Actions + +#### File Retrieval + +```typescript +// Get all files with pagination and filters +await fileStore.fetchFiles({ + page: 1, + per_page: 15, + search: "document", + category: "devis", + sort_by: "uploaded_at", + sort_direction: "desc", +}); + +// Get specific file by ID +const file = await fileStore.fetchFile(123); + +// Search files +await fileStore.searchFiles("invoice", { page: 1, per_page: 10 }); + +// Get files by category +await fileStore.fetchFilesByCategory("devis", { per_page: 20 }); + +// Get files by client +await fileStore.fetchFilesByClient(456, { per_page: 15 }); +``` + +#### File Upload + +```typescript +// Upload a single file +const fileInput = ref(); + +const handleFileUpload = async (event: Event) => { + const target = event.target as HTMLInputElement; + const file = target.files?.[0]; + + if (file) { + try { + await fileStore.uploadFile({ + file, + category: "devis", + client_id: 123, + subcategory: "annual", + description: "Annual quote document", + tags: ["quote", "annual"], + is_public: false, + }); + + console.log("File uploaded successfully"); + } catch (error) { + console.error("Upload failed:", error); + } + } +}; +``` + +#### File Management + +```typescript +// Update file metadata +await fileStore.updateFile({ + id: 123, + file_name: "updated_filename.pdf", + description: "Updated description", + tags: ["updated", "tag"], + category: "facture", +}); + +// Delete single file +await fileStore.deleteFile(123); + +// Delete multiple files +await fileStore.deleteMultipleFiles([123, 124, 125]); + +// Download file +await fileStore.downloadFile(123); + +// Generate download URL +const downloadUrl = await fileStore.generateDownloadUrl(123); +``` + +#### Filtering and Organization + +```typescript +// Set filters +fileStore.setFilters({ + category: "devis", + client_id: 123, + date_from: "2024-01-01", + date_to: "2024-12-31", + mime_type: "application/pdf", +}); + +// Clear filters +fileStore.clearFilters(); + +// Get organized structure +await fileStore.fetchOrganizedStructure(); + +// Get storage statistics +await fileStore.fetchStorageStatistics(); +``` + +#### Selection Management + +```typescript +// Select/deselect files +fileStore.selectFile(123); +fileStore.deselectFile(123); + +// Select all/none +fileStore.selectAllFiles(); +fileStore.deselectAllFiles(); + +// Get selected files +const selectedFiles = computed(() => fileStore.getSelectedFiles.value); +``` + +### Computed Properties + +```typescript +// Basic getters +const allFiles = computed(() => fileStore.allFiles); +const isLoading = computed(() => fileStore.isLoading); +const hasError = computed(() => fileStore.hasError); +const totalSize = computed(() => fileStore.totalSizeFormatted); + +// Filtered views +const imageFiles = computed(() => fileStore.imageFiles); +const pdfFiles = computed(() => fileStore.pdfFiles); +const recentFiles = computed(() => fileStore.recentFiles); + +// Grouped views +const filesByCategory = computed(() => fileStore.filesByCategory); +const filesByClient = computed(() => fileStore.filesByClient); + +// Pagination +const pagination = computed(() => fileStore.getPagination); +``` + +## FileService Usage + +### Direct Service Methods + +```typescript +import FileService from "@/services/file"; + +// File validation before upload +const validation = FileService.validateFile(file, 10 * 1024 * 1024); // 10MB +if (!validation.valid) { + console.error(validation.error); + return; +} + +// Format file size +const sizeFormatted = FileService.formatFileSize(1024000); // "1000.00 KB" + +// Get file icon +const icon = FileService.getFileIcon("application/pdf"); // "📄" + +// Check file type +const isImage = FileService.isImageFile("image/jpeg"); // true +const isPdf = FileService.isPdfFile("application/pdf"); // true + +// Get file extension +const extension = FileService.getFileExtension("document.pdf"); // "pdf" +``` + +## Component Examples + +### File List Component + +```vue + + + +``` + +### File Upload Component + +```vue + + + +``` + +### File Statistics Component + +```vue + + + +``` + +## Best Practices + +### Error Handling + +```typescript +try { + await fileStore.uploadFile(payload); + // Success handling +} catch (error: any) { + // Display user-friendly error message + const message = error.response?.data?.message || error.message; + // Show in toast/notification +} +``` + +### File Validation + +```typescript +// Always validate files before upload +const validation = FileService.validateFile(file); +if (!validation.valid) { + showError(validation.error); + return; +} +``` + +### Progress Tracking + +```vue + +``` + +### Bulk Operations + +```typescript +// Delete multiple files +const deleteSelected = async () => { + if (fileStore.selectedFiles.length === 0) return; + + if (confirm(`Supprimer ${fileStore.selectedFiles.length} fichiers ?`)) { + await fileStore.deleteMultipleFiles(fileStore.selectedFiles); + fileStore.deselectAllFiles(); + } +}; +``` + +### Performance Optimization + +```typescript +// Use computed properties for filtered views +const pdfFiles = computed(() => fileStore.pdfFiles); +const imageFiles = computed(() => fileStore.imageFiles); + +// Cache expensive operations +const fileStats = computed(() => { + return { + totalSize: fileStore.totalSizeFormatted, + fileCount: fileStore.files.length, + }; +}); +``` + +## Integration with Backend + +The frontend system integrates with the Laravel backend API through the FileService. All API endpoints are mapped: + +- `GET /api/files` → `FileService.getAllFiles()` +- `POST /api/files` → `FileService.uploadFile()` +- `GET /api/files/{id}` → `FileService.getFile()` +- `PUT /api/files/{id}` → `FileService.updateFile()` +- `DELETE /api/files/{id}` → `FileService.deleteFile()` +- And specialized endpoints for categories, clients, statistics, etc. + +This ensures full compatibility with the backend file management system while providing a rich, type-safe frontend experience. diff --git a/thanasoft-front/src/components/Organism/Interventions/InterventionDetailPresentation.vue b/thanasoft-front/src/components/Organism/Interventions/InterventionDetailPresentation.vue index 463a7ed..348c979 100644 --- a/thanasoft-front/src/components/Organism/Interventions/InterventionDetailPresentation.vue +++ b/thanasoft-front/src/components/Organism/Interventions/InterventionDetailPresentation.vue @@ -30,18 +30,16 @@ diff --git a/thanasoft-front/src/components/Organism/Interventions/intervention/InterventionDetailContent.vue b/thanasoft-front/src/components/Organism/Interventions/intervention/InterventionDetailContent.vue index 6d0a037..ac722be 100644 --- a/thanasoft-front/src/components/Organism/Interventions/intervention/InterventionDetailContent.vue +++ b/thanasoft-front/src/components/Organism/Interventions/intervention/InterventionDetailContent.vue @@ -31,15 +31,6 @@
Détails de l'intervention
-
@@ -53,48 +44,23 @@
  • Nom du défunt: - {{ + {{ intervention.defuntName || "-" }} -
  • Date: - {{ - intervention.date || "-" - }} - + {{ intervention.date || "-" }}
  • Lieu: - {{ - intervention.lieux || "-" + {{ + intervention.location?.name || intervention.lieux || "-" }} -
  • Durée: - {{ - intervention.duree || "-" - }} - + {{ intervention.duree || "-" }}
@@ -109,36 +75,19 @@
  • Contact familial: - {{ + {{ intervention.contactFamilial || "-" }} -
  • Coordonnées: - {{ + {{ intervention.coordonneesContact || "-" }} -
  • Type de cérémonie: - {{ - intervention.title || "-" - }} - + {{ intervention.title || "-" }}
@@ -157,42 +106,21 @@ Nombre de personnes attendues: - {{ + {{ intervention.nombrePersonnes || "-" }} -
- - +

+ Prestations supplémentaires: + {{ + intervention.prestationsSupplementaires || "-" + }} +

@@ -204,47 +132,28 @@ title="Description" icon="fas fa-file-alt text-warning" > - - +

+ {{ + intervention.description || + "Aucune description disponible" + }} +

+ - -
- - -
+ +
+
@@ -343,26 +252,17 @@
-
-
-
-
Documents
-
-
-
-
-
-
- -
-
-
Documents
-

- Interface de gestion des documents à implémenter... -

-
-
-
+
@@ -392,37 +292,6 @@ -
- - -
- - - -
-
@@ -433,10 +302,11 @@ diff --git a/thanasoft-front/src/components/Organism/Location/LocationManager.vue b/thanasoft-front/src/components/Organism/Location/LocationManager.vue new file mode 100644 index 0000000..9f52988 --- /dev/null +++ b/thanasoft-front/src/components/Organism/Location/LocationManager.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/thanasoft-front/src/components/atoms/Location/LocationDisplay.vue b/thanasoft-front/src/components/atoms/Location/LocationDisplay.vue new file mode 100644 index 0000000..78a71dd --- /dev/null +++ b/thanasoft-front/src/components/atoms/Location/LocationDisplay.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/thanasoft-front/src/components/atoms/Location/LocationSearchInput.vue b/thanasoft-front/src/components/atoms/Location/LocationSearchInput.vue new file mode 100644 index 0000000..6ed4d48 --- /dev/null +++ b/thanasoft-front/src/components/atoms/Location/LocationSearchInput.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/thanasoft-front/src/components/molecules/Interventions/DocumentManagement.vue b/thanasoft-front/src/components/molecules/Interventions/DocumentManagement.vue new file mode 100644 index 0000000..241e772 --- /dev/null +++ b/thanasoft-front/src/components/molecules/Interventions/DocumentManagement.vue @@ -0,0 +1,555 @@ + + + + + diff --git a/thanasoft-front/src/components/molecules/Interventions/interventionDetails.vue b/thanasoft-front/src/components/molecules/Interventions/interventionDetails.vue index 3d4f28e..75cfb20 100644 --- a/thanasoft-front/src/components/molecules/Interventions/interventionDetails.vue +++ b/thanasoft-front/src/components/molecules/Interventions/interventionDetails.vue @@ -31,13 +31,6 @@
Détails de l'Intervention
- - {{ mappedIntervention.status.label }} -
@@ -72,12 +65,12 @@ class="mb-3" /> - +
+ +
@@ -120,99 +113,24 @@
- -
-
Informations Complémentaires
-
-
- - - -
-
- -
-
-
- - -
-
-
Équipe Assignée
- -
- -
- - Image placeholder - -
-
- -
-
- - -
+
-
@@ -229,21 +147,50 @@