Attacher des fichiers sur les internvetions

This commit is contained in:
Nyavokevin 2025-12-01 17:02:01 +03:00
parent 496b427e13
commit 23bce2abcf
35 changed files with 7152 additions and 428 deletions

View File

@ -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

View File

@ -0,0 +1,438 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\FileAttachment\FileAttachmentResource;
use App\Models\File;
use App\Models\FileAttachment;
use App\Models\Intervention;
use App\Models\Client;
use App\Models\Deceased;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
class FileAttachmentController extends Controller
{
/**
* Attach a file to a model (Intervention, Client, Deceased, etc.)
*/
public function attach(Request $request): JsonResponse
{
try {
$request->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,
};
}
}

View File

@ -0,0 +1,444 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreFileRequest;
use App\Http\Requests\UpdateFileRequest;
use App\Http\Resources\File\FileResource;
use App\Http\Resources\File\FileCollection;
use App\Repositories\FileRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class FileController extends Controller
{
public function __construct(
private readonly FileRepositoryInterface $fileRepository
) {
}
/**
* Display a listing of files.
*/
public function index(Request $request): FileCollection|JsonResponse
{
try {
$perPage = $request->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;
}
}

View File

@ -0,0 +1,109 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
class StoreFileRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return Auth::check();
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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(),
]);
}
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
class UpdateFileRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$file = $this->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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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);
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Http\Resources\File;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class FileCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
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];
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Http\Resources\File;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class FileResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
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';
}
),
];
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Http\Resources\FileAttachment;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class FileAttachmentResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
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'
};
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class File extends Model
{
protected $fillable = [
'file_name',
'mime_type',
'size_bytes',
'storage_uri',
'sha256',
'uploaded_by',
'uploaded_at',
];
protected $casts = [
'size_bytes' => '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;
}
}

View File

@ -0,0 +1,141 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\MorphTo;
class FileAttachment extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'file_attachments';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'file_id',
'label',
'attachable_type',
'attachable_id',
'sort_order',
'created_at',
'updated_at',
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'sort_order' => '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;
}
}

View File

@ -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.
*/

View File

@ -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);
}
/**

View File

@ -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);
}
/**

View File

@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\File;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class FileRepository extends BaseRepository implements FileRepositoryInterface
{
public function __construct(File $model)
{
parent::__construct($model);
}
/**
* Get paginated files with filtering
*/
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator
{
$query = $this->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];
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
interface FileRepositoryInterface extends BaseRepositoryInterface
{
/**
* Get paginated files with filtering
*/
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator;
/**
* Get files by category/type (e.g., devis, facture)
*/
public function getByCategory(string $category, int $perPage = 15): LengthAwarePaginator;
/**
* Get files by client ID
*/
public function getByClient(int $clientId, int $perPage = 15): LengthAwarePaginator;
/**
* Get files by user/uploader
*/
public function getByUploader(int $uploaderId, int $perPage = 15): LengthAwarePaginator;
/**
* Search files by filename or content
*/
public function search(string $searchTerm, int $perPage = 15): LengthAwarePaginator;
/**
* Get recent files
*/
public function getRecent(int $limit = 10): Collection;
/**
* Get files by storage path pattern
*/
public function getByPathPattern(string $pattern, int $perPage = 15): LengthAwarePaginator;
}

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('intervention_attachments', function (Blueprint $table) {
// Add polymorphic columns to support attaching files to any model
$table->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();
});
}
};

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('file_attachments', function (Blueprint $table) {
$table->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');
}
};

View File

@ -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']);
});
});

View File

@ -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<HTMLInputElement>();
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
<template>
<div class="file-management">
<!-- Header with actions -->
<div class="flex justify-between items-center mb-4">
<h2>Fichiers ({{ fileStore.files.length }})</h2>
<div class="flex gap-2">
<SoftButton @click="refreshFiles" :loading="fileStore.isLoading">
Actualiser
</SoftButton>
<SoftButton @click="showUploadModal = true" color="primary">
Télécharger
</SoftButton>
</div>
</div>
<!-- Filters -->
<div class="filters mb-4">
<input
v-model="searchQuery"
@input="handleSearch"
placeholder="Rechercher des fichiers..."
class="form-control"
/>
<select v-model="selectedCategory" @change="handleCategoryChange">
<option value="">Toutes les catégories</option>
<option value="devis">Devis</option>
<option value="facture">Factures</option>
<option value="contrat">Contrats</option>
<option value="document">Documents</option>
<option value="image">Images</option>
<option value="autre">Autres</option>
</select>
</div>
<!-- File list -->
<div v-if="fileStore.isLoading" class="text-center">
<div class="spinner">Chargement...</div>
</div>
<div v-else-if="fileStore.hasError" class="alert alert-danger">
{{ fileStore.getError }}
</div>
<div v-else class="file-grid">
<div
v-for="file in fileStore.files"
:key="file.id"
class="file-card"
:class="{ selected: fileStore.selectedFiles.includes(file.id) }"
@click="toggleFileSelection(file.id)"
>
<div class="file-icon">
{{ FileService.getFileIcon(file.mime_type) }}
</div>
<div class="file-info">
<h4>{{ file.file_name }}</h4>
<p>{{ file.size_formatted }} • {{ file.category }}</p>
<small>{{ formatDate(file.uploaded_at) }}</small>
</div>
<div class="file-actions">
<button @click.stop="downloadFile(file.id)" class="btn btn-sm">
Télécharger
</button>
<button @click.stop="editFile(file.id)" class="btn btn-sm">
Modifier
</button>
<button
@click.stop="confirmDelete(file.id)"
class="btn btn-sm text-danger"
>
Supprimer
</button>
</div>
</div>
</div>
<!-- Pagination -->
<div class="pagination mt-4">
<SoftPagination
:current-page="fileStore.getPagination.current_page"
:last-page="fileStore.getPagination.last_page"
@page-changed="handlePageChange"
/>
</div>
<!-- Upload Modal -->
<FileUploadModal
v-if="showUploadModal"
@close="showUploadModal = false"
@uploaded="handleFileUploaded"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useFileStore } from "@/stores/fileStore";
import FileService from "@/services/file";
const fileStore = useFileStore();
const showUploadModal = ref(false);
const searchQuery = ref("");
const selectedCategory = ref("");
onMounted(() => {
fileStore.fetchFiles();
});
const handleSearch = () => {
fileStore.setFilters({ search: searchQuery.value });
fileStore.fetchFiles({ page: 1 });
};
const handleCategoryChange = () => {
fileStore.setFilters({ category: selectedCategory.value });
fileStore.fetchFiles({ page: 1 });
};
const handlePageChange = (page: number) => {
fileStore.fetchFiles({ page });
};
const toggleFileSelection = (fileId: number) => {
if (fileStore.selectedFiles.includes(fileId)) {
fileStore.deselectFile(fileId);
} else {
fileStore.selectFile(fileId);
}
};
const downloadFile = async (fileId: number) => {
try {
await fileStore.downloadFile(fileId);
} catch (error) {
console.error("Download failed:", error);
}
};
const editFile = (fileId: number) => {
// Navigate to file edit page or open modal
router.push(`/files/${fileId}/edit`);
};
const confirmDelete = (fileId: number) => {
if (confirm("Êtes-vous sûr de vouloir supprimer ce fichier ?")) {
fileStore.deleteFile(fileId);
}
};
const handleFileUploaded = () => {
showUploadModal.value = false;
fileStore.fetchFiles({ page: 1 });
};
const refreshFiles = () => {
fileStore.fetchFiles();
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("fr-FR");
};
</script>
```
### File Upload Component
```vue
<template>
<div class="upload-modal">
<div class="modal-header">
<h3>Télécharger un fichier</h3>
<button @click="$emit('close')" class="close-btn">&times;</button>
</div>
<form @submit.prevent="handleUpload" class="upload-form">
<!-- File selection -->
<div class="form-group">
<label>Fichier *</label>
<input
type="file"
@change="handleFileSelect"
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
required
/>
<div v-if="selectedFile" class="file-preview">
<p>
{{ selectedFile.name }} ({{
FileService.formatFileSize(selectedFile.size)
}})
</p>
</div>
</div>
<!-- Category -->
<div class="form-group">
<label>Catégorie *</label>
<select v-model="form.category" required>
<option value="">Sélectionner une catégorie</option>
<option value="devis">Devis</option>
<option value="facture">Facture</option>
<option value="contrat">Contrat</option>
<option value="document">Document</option>
<option value="image">Image</option>
<option value="autre">Autre</option>
</select>
</div>
<!-- Client ID -->
<div class="form-group">
<label>Client (optionnel)</label>
<input
v-model.number="form.client_id"
type="number"
placeholder="ID du client"
/>
</div>
<!-- Subcategory -->
<div class="form-group">
<label>Sous-catégorie (optionnel)</label>
<input
v-model="form.subcategory"
type="text"
placeholder="Ex: annual, monthly"
/>
</div>
<!-- Description -->
<div class="form-group">
<label>Description</label>
<textarea
v-model="form.description"
placeholder="Description du fichier"
rows="3"
></textarea>
</div>
<!-- Tags -->
<div class="form-group">
<label>Étiquettes</label>
<input
v-model="tagInput"
@keydown.enter.prevent="addTag"
placeholder="Ajouter une étiquette et appuyer sur Entrée"
/>
<div class="tags">
<span v-for="(tag, index) in form.tags" :key="index" class="tag">
{{ tag }}
<button type="button" @click="removeTag(index)">&times;</button>
</span>
</div>
</div>
<!-- Public checkbox -->
<div class="form-group">
<label class="checkbox-label">
<input v-model="form.is_public" type="checkbox" />
Fichier public
</label>
</div>
<!-- Progress bar -->
<div v-if="fileStore.isLoading" class="progress-bar">
<div
class="progress-fill"
:style="{ width: fileStore.getUploadProgress + '%' }"
></div>
</div>
<!-- Error message -->
<div v-if="fileStore.hasError" class="alert alert-danger">
{{ fileStore.getError }}
</div>
<!-- Actions -->
<div class="modal-actions">
<button
type="button"
@click="$emit('close')"
:disabled="fileStore.isLoading"
>
Annuler
</button>
<button
type="submit"
:disabled="!selectedFile || !form.category || fileStore.isLoading"
class="btn-primary"
>
{{ fileStore.isLoading ? "Téléchargement..." : "Télécharger" }}
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from "vue";
import { useFileStore } from "@/stores/fileStore";
import FileService from "@/services/file";
const emit = defineEmits<{
close: [];
uploaded: [];
}>();
const fileStore = useFileStore();
const selectedFile = ref<File | null>(null);
const tagInput = ref("");
const form = reactive({
category: "",
client_id: undefined as number | undefined,
subcategory: "",
description: "",
tags: [] as string[],
is_public: false,
});
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
// Validate file
const validation = FileService.validateFile(file);
if (!validation.valid) {
alert(validation.error);
target.value = "";
return;
}
selectedFile.value = file;
}
};
const addTag = () => {
const tag = tagInput.value.trim();
if (tag && !form.tags.includes(tag) && form.tags.length < 10) {
form.tags.push(tag);
tagInput.value = "";
}
};
const removeTag = (index: number) => {
form.tags.splice(index, 1);
};
const handleUpload = async () => {
if (!selectedFile.value || !form.category) return;
try {
await fileStore.uploadFile({
file: selectedFile.value,
...form,
});
emit("uploaded");
} catch (error) {
console.error("Upload failed:", error);
}
};
</script>
```
### File Statistics Component
```vue
<template>
<div class="file-statistics">
<div class="stats-header">
<h3>Statistiques de Stockage</h3>
<button @click="refreshStats" :disabled="fileStore.isLoading">
Actualiser
</button>
</div>
<div v-if="fileStore.storageStats" class="stats-content">
<!-- Overview cards -->
<div class="stats-grid">
<div class="stat-card">
<h4>Total des Fichiers</h4>
<p class="stat-number">{{ fileStore.storageStats.total_files }}</p>
</div>
<div class="stat-card">
<h4>Espace Utilisé</h4>
<p class="stat-number">
{{ fileStore.storageStats.total_size_formatted }}
</p>
</div>
</div>
<!-- Files by type -->
<div class="stats-section">
<h4>Par Type de Fichier</h4>
<div class="type-grid">
<div
v-for="(typeData, mimeType) in fileStore.storageStats.by_type"
:key="mimeType"
class="type-card"
>
<div class="type-icon">{{ FileService.getFileIcon(mimeType) }}</div>
<div class="type-info">
<p class="type-name">{{ getTypeDisplayName(mimeType) }}</p>
<p class="type-count">{{ typeData.count }} fichiers</p>
<p class="type-size">
{{ FileService.formatFileSize(typeData.total_size) }}
</p>
</div>
</div>
</div>
</div>
<!-- Files by category -->
<div class="stats-section">
<h4>Par Catégorie</h4>
<div class="category-grid">
<div
v-for="(categoryData, category) in fileStore.storageStats
.by_category"
:key="category"
class="category-card"
>
<h5>{{ getCategoryDisplayName(category) }}</h5>
<div class="category-stats">
<span>{{ categoryData.count }} fichiers</span>
<span>{{
FileService.formatFileSize(categoryData.total_size)
}}</span>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="fileStore.isLoading" class="loading">
Chargement des statistiques...
</div>
<div v-else-if="fileStore.hasError" class="error">
{{ fileStore.getError }}
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { useFileStore } from "@/stores/fileStore";
import FileService from "@/services/file";
const fileStore = useFileStore();
onMounted(() => {
fileStore.fetchStorageStatistics();
});
const refreshStats = () => {
fileStore.fetchStorageStatistics();
};
const getTypeDisplayName = (mimeType: string): string => {
const typeMap: Record<string, string> = {
"application/pdf": "PDF",
"image/jpeg": "Image JPEG",
"image/png": "Image PNG",
"application/msword": "Document Word",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
"Document Word",
};
return typeMap[mimeType] || mimeType;
};
const getCategoryDisplayName = (category: string): string => {
const categoryMap: Record<string, string> = {
devis: "Devis",
facture: "Factures",
contrat: "Contrats",
document: "Documents",
image: "Images",
autre: "Autres",
};
return categoryMap[category] || category;
};
</script>
```
## 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
<template>
<div v-if="fileStore.isLoading" class="upload-progress">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: fileStore.getUploadProgress + '%' }"
></div>
</div>
<p>{{ fileStore.getUploadProgress }}%</p>
</div>
</template>
```
### 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.

View File

@ -30,7 +30,6 @@
</template>
<template #intervention-detail-content>
<div class="card">
<div class="card-body">
<InterventionDetailContent
:active-tab="activeTab"
:intervention="mappedIntervention"
@ -42,7 +41,6 @@
@assign-practitioner="handleAssignPractitioner"
/>
</div>
</div>
</template>
</intervention-detail-template>
</template>

View File

@ -31,15 +31,6 @@
<div class="card-header pb-0">
<div class="d-flex align-items-center">
<h6 class="mb-0">Détails de l'intervention</h6>
<button
type="button"
class="btn btn-sm btn-primary ms-auto"
@click="toggleEditMode"
:disabled="loading"
>
<i class="fas fa-edit me-1"></i
>{{ editMode ? "Sauvegarder" : "Modifier" }}
</button>
</div>
</div>
<div class="card-body">
@ -53,48 +44,23 @@
<ul class="list-group list-group-flush">
<li class="list-group-item border-0 ps-0 pt-0 text-sm">
<strong class="text-dark">Nom du défunt:</strong>
<span v-if="!editMode" class="ms-2">{{
<span class="ms-2">{{
intervention.defuntName || "-"
}}</span>
<SoftInput
v-else
v-model="localIntervention.defuntName"
class="mt-2"
/>
</li>
<li class="list-group-item border-0 ps-0 text-sm">
<strong class="text-dark">Date:</strong>
<span v-if="!editMode" class="ms-2">{{
intervention.date || "-"
}}</span>
<SoftInput
v-else
type="datetime-local"
v-model="localIntervention.date"
class="mt-2"
/>
<span class="ms-2">{{ intervention.date || "-" }}</span>
</li>
<li class="list-group-item border-0 ps-0 text-sm">
<strong class="text-dark">Lieu:</strong>
<span v-if="!editMode" class="ms-2">{{
intervention.lieux || "-"
<span class="ms-2">{{
intervention.location?.name || intervention.lieux || "-"
}}</span>
<SoftInput
v-else
v-model="localIntervention.lieux"
class="mt-2"
/>
</li>
<li class="list-group-item border-0 ps-0 text-sm">
<strong class="text-dark">Durée:</strong>
<span v-if="!editMode" class="ms-2">{{
intervention.duree || "-"
}}</span>
<SoftInput
v-else
v-model="localIntervention.duree"
class="mt-2"
/>
<span class="ms-2">{{ intervention.duree || "-" }}</span>
</li>
</ul>
</InfoCard>
@ -109,36 +75,19 @@
<ul class="list-group list-group-flush">
<li class="list-group-item border-0 ps-0 pt-0 text-sm">
<strong class="text-dark">Contact familial:</strong>
<span v-if="!editMode" class="ms-2">{{
<span class="ms-2">{{
intervention.contactFamilial || "-"
}}</span>
<SoftInput
v-else
v-model="localIntervention.contactFamilial"
class="mt-2"
/>
</li>
<li class="list-group-item border-0 ps-0 text-sm">
<strong class="text-dark">Coordonnées:</strong>
<span v-if="!editMode" class="ms-2">{{
<span class="ms-2">{{
intervention.coordonneesContact || "-"
}}</span>
<SoftInput
v-else
v-model="localIntervention.coordonneesContact"
class="mt-2"
/>
</li>
<li class="list-group-item border-0 ps-0 text-sm">
<strong class="text-dark">Type de cérémonie:</strong>
<span v-if="!editMode" class="ms-2">{{
intervention.title || "-"
}}</span>
<SoftInput
v-else
v-model="localIntervention.title"
class="mt-2"
/>
<span class="ms-2">{{ intervention.title || "-" }}</span>
</li>
</ul>
</InfoCard>
@ -157,20 +106,13 @@
<strong class="text-dark"
>Nombre de personnes attendues:</strong
>
<span v-if="!editMode" class="ms-2">{{
<span class="ms-2">{{
intervention.nombrePersonnes || "-"
}}</span>
<SoftInput
v-else
type="number"
v-model="localIntervention.nombrePersonnes"
class="mt-2"
/>
</li>
</ul>
</div>
<div class="col-md-6">
<template v-if="!editMode">
<p class="text-sm">
<strong class="text-dark"
>Prestations supplémentaires:</strong
@ -179,20 +121,6 @@
intervention.prestationsSupplementaires || "-"
}}</span>
</p>
</template>
<template v-else>
<p class="text-sm">
<strong class="text-dark"
>Prestations supplémentaires:</strong
>
</p>
<SoftInput
type="textarea"
rows="3"
v-model="localIntervention.prestationsSupplementaires"
class="mt-2"
/>
</template>
</div>
</div>
</InfoCard>
@ -204,47 +132,28 @@
title="Description"
icon="fas fa-file-alt text-warning"
>
<template v-if="!editMode">
<p class="text-sm mb-0">
{{
intervention.description ||
"Aucune description disponible"
}}
</p>
</template>
<template v-else>
<SoftInput
type="textarea"
rows="3"
v-model="localIntervention.description"
class="mt-2"
/>
</template>
</InfoCard>
</div>
</div>
</div>
</div>
<!-- Action Buttons for Overview -->
<div class="d-flex justify-content-end mt-3" v-if="editMode">
<button
type="button"
class="btn btn-sm bg-gradient-secondary me-2"
@click="resetChanges"
:disabled="!hasChanges || loading"
>
Réinitialiser
</button>
<button
type="button"
class="btn btn-sm bg-gradient-primary"
@click="saveChanges"
:disabled="!hasChanges || loading"
>
Sauvegarder
</button>
</div>
<!-- Details Tab - Use the interventionDetails molecule -->
<div v-if="activeTab === 'details'" class="tab-pane fade show active">
<InterventionDetails
:intervention="intervention"
:loading="loading"
:error="error"
@update="handleUpdate"
@cancel="handleCancel"
/>
</div>
<!-- Team Tab -->
@ -343,26 +252,17 @@
<!-- Documents Tab -->
<div v-if="activeTab === 'documents'" class="tab-pane fade show active">
<div class="card">
<div class="card-header pb-0">
<div class="d-flex align-items-center">
<h6 class="mb-0">Documents</h6>
</div>
</div>
<div class="card-body">
<div class="text-center py-5">
<div class="avatar avatar-xl mb-3">
<div class="avatar-title bg-gradient-info text-white h5 mb-0">
<i class="fas fa-file-alt"></i>
</div>
</div>
<h6 class="text-sm text-muted">Documents</h6>
<p class="text-xs text-muted">
Interface de gestion des documents à implémenter...
</p>
</div>
</div>
</div>
<DocumentManagement
:documents="documentAttachments"
:loading="documentStore.isLoading"
:error="documentStore.getError"
@files-selected="handleFilesSelected"
@upload-files="handleUploadFiles"
@delete-document="handleDeleteDocument"
@delete-documents="handleDeleteDocuments"
@update-document-label="handleUpdateDocumentLabel"
@retry="loadDocumentAttachments"
/>
</div>
<!-- History Tab -->
@ -392,37 +292,6 @@
</div>
<!-- Navigation Actions -->
<div class="d-flex justify-content-between mt-3">
<button
type="button"
class="btn btn-sm bg-gradient-danger"
@click="$emit('cancel')"
:disabled="loading"
>
<i class="fas fa-arrow-left me-2"></i>Retour
</button>
<div>
<button
type="button"
class="btn btn-sm bg-gradient-secondary me-2"
@click="resetChanges"
:disabled="!hasChanges || loading"
>
Réinitialiser
</button>
<button
type="button"
class="btn btn-sm"
:class="`bg-gradient-${intervention.action?.color || 'primary'}`"
@click="saveChanges"
:disabled="!hasChanges || loading"
>
{{ intervention.action?.label || "Sauvegarder" }}
</button>
</div>
</div>
</template>
<!-- No data state -->
@ -433,10 +302,11 @@
</template>
<script setup>
import { ref, computed, watch } from "vue";
import SoftInput from "@/components/SoftInput.vue";
import InfoCard from "@/components/atoms/client/InfoCard.vue";
import { defineProps, defineEmits } from "vue";
import InterventionDetails from "@/components/molecules/Interventions/interventionDetails.vue";
import DocumentManagement from "@/components/molecules/Interventions/DocumentManagement.vue";
import { useDocumentAttachmentStore } from "@/stores/documentAttachmentStore";
import { defineProps, defineEmits, computed, watch, onMounted } from "vue";
const props = defineProps({
activeTab: {
@ -457,59 +327,106 @@ const props = defineProps({
},
});
// Document attachment store
const documentStore = useDocumentAttachmentStore();
// Computed properties for document attachments
const documentAttachments = computed(() =>
documentStore.getInterventionAttachments(props.intervention?.id || 0)
);
const emit = defineEmits([
"change-tab",
"update-intervention",
"cancel",
"assign-practitioner",
"unassign-practitioner",
"refresh-intervention",
"show-error",
]);
// État local pour l'édition
const editMode = ref(false);
const localIntervention = ref({});
// Handler methods for InterventionDetails component
const handleUpdate = (updatedIntervention) => {
emit("update-intervention", updatedIntervention);
};
// Computed pour détecter les changements
const hasChanges = computed(() => {
if (!props.intervention || !localIntervention.value) return false;
return (
JSON.stringify(localIntervention.value) !==
JSON.stringify(props.intervention)
const handleCancel = () => {
emit("cancel");
};
// Document management handlers
const handleFilesSelected = (files) => {
// Files have been selected, parent can handle validation if needed
console.log("Files selected:", files.length);
};
const handleUploadFiles = async (files) => {
if (!props.intervention?.id || !files.length) return;
try {
await documentStore.uploadAndAttachFiles(
files,
"App\\Models\\Intervention",
props.intervention.id
);
});
// Méthodes
const toggleEditMode = () => {
if (editMode.value && hasChanges.value) {
saveChanges();
} else {
editMode.value = !editMode.value;
emit("refresh-intervention");
} catch (error) {
console.error("Error uploading files:", error);
documentStore.clearError();
}
};
const saveChanges = () => {
if (hasChanges.value) {
emit("update-intervention", localIntervention.value);
}
editMode.value = false;
};
const resetChanges = () => {
if (props.intervention) {
localIntervention.value = { ...props.intervention };
const handleDeleteDocument = async (documentId) => {
try {
await documentStore.detachFile(documentId);
emit("refresh-intervention");
} catch (error) {
console.error("Error deleting document:", error);
documentStore.clearError();
}
};
// Watch pour mettre à jour les données locales quand les props changent
watch(
() => props.intervention,
(newVal) => {
if (newVal) {
localIntervention.value = { ...newVal };
const handleDeleteDocuments = async (documentIds) => {
try {
await documentStore.detachMultipleFiles({ attachment_ids: documentIds });
emit("refresh-intervention");
} catch (error) {
console.error("Error deleting documents:", error);
documentStore.clearError();
}
},
{ deep: true, immediate: true }
);
};
const handleUpdateDocumentLabel = async ({ id, label }) => {
try {
await documentStore.updateAttachmentMetadata(id, { label });
emit("refresh-intervention");
} catch (error) {
console.error("Error updating document label:", error);
documentStore.clearError();
}
};
const loadDocumentAttachments = async () => {
if (!props.intervention?.id) return;
try {
await documentStore.fetchInterventionFiles(props.intervention.id);
} catch (error) {
console.error("Error loading document attachments:", error);
documentStore.clearError();
}
};
const handleDocumentChange = () => {
// Refresh the intervention data to get updated attachments count
emit("refresh-intervention");
};
const handleError = (errorMessage) => {
// Show error notification
console.error("Document management error:", errorMessage);
emit("show-error", errorMessage);
};
const getInitials = (name) => {
if (!name) return "?";
@ -541,4 +458,33 @@ const unassignPractitioner = async (practitionerId) => {
// You might want to show a toast notification here
}
};
// Watchers
watch(
() => props.activeTab,
(newTab) => {
// Load document attachments when documents tab is activated
if (newTab === "documents" && props.intervention?.id) {
loadDocumentAttachments();
}
}
);
watch(
() => props.intervention?.id,
(newId) => {
// Load document attachments when intervention changes
if (newId && props.activeTab === "documents") {
loadDocumentAttachments();
}
}
);
// Lifecycle
onMounted(() => {
// Load document attachments if starting on documents tab
if (props.activeTab === "documents" && props.intervention?.id) {
loadDocumentAttachments();
}
});
</script>

View File

@ -0,0 +1,338 @@
<template>
<div class="location-manager">
<LocationSearchForm
v-model="selectedLocation"
:available-locations="locations"
:editable="editable"
:loading="loading"
:search-error="searchError"
@search="handleLocationSearch"
@select="handleLocationSelect"
@create="handleLocationCreate"
@edit="handleLocationEdit"
/>
<!-- Location Creation Modal -->
<div
v-if="showCreateModal"
class="modal fade show"
style="display: block"
tabindex="-1"
>
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-map-marker-alt me-2"></i>
Créer un nouveau lieu
</h5>
<button
type="button"
class="btn-close"
@click="closeCreateModal"
></button>
</div>
<div class="modal-body">
<form @submit.prevent="submitLocation">
<div class="row">
<div class="col-md-6">
<SoftInput
label="Nom du lieu *"
v-model="newLocation.name"
:disabled="creating"
required
class="mb-3"
/>
</div>
<div class="col-md-6">
<SoftInput
label="Adresse"
v-model="newLocation.address"
:disabled="creating"
class="mb-3"
/>
</div>
</div>
<div class="row">
<div class="col-md-6">
<SoftInput
label="Téléphone"
v-model="newLocation.phone"
:disabled="creating"
class="mb-3"
/>
</div>
<div class="col-md-6">
<SoftInput
label="Email"
type="email"
v-model="newLocation.email"
:disabled="creating"
class="mb-3"
/>
</div>
</div>
<div class="mb-3">
<SoftInput
label="Description"
type="textarea"
rows="3"
v-model="newLocation.description"
:disabled="creating"
placeholder="Informations supplémentaires sur le lieu..."
/>
</div>
<!-- Error Display -->
<div v-if="createError" class="alert alert-danger" role="alert">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ createError }}
</div>
<!-- Success Display -->
<div
v-if="createSuccess"
class="alert alert-success"
role="alert"
>
<i class="fas fa-check-circle me-2"></i>
{{ createSuccess }}
</div>
</form>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-sm bg-gradient-secondary"
@click="closeCreateModal"
:disabled="creating"
>
Annuler
</button>
<button
type="button"
class="btn btn-sm bg-gradient-primary"
@click="submitLocation"
:disabled="creating || !newLocation.name.trim()"
>
<span
v-if="creating"
class="spinner-border spinner-border-sm me-2"
></span>
<i v-else class="fas fa-save me-2"></i>
{{ creating ? "Création..." : "Créer le lieu" }}
</button>
</div>
</div>
</div>
</div>
<!-- Modal Backdrop -->
<div
v-if="showCreateModal"
class="modal-backdrop fade show"
style="display: block"
></div>
</div>
</template>
<script setup>
import { ref, computed, defineProps, defineEmits } from "vue";
import LocationSearchForm from "@/components/molecules/Location/LocationSearchForm.vue";
import SoftInput from "@/components/SoftInput.vue";
// Import API service when available
// import locationService from "@/services/location";
const props = defineProps({
modelValue: {
type: Object,
default: () => null,
},
editable: {
type: Boolean,
default: true,
},
});
const emit = defineEmits([
"update:modelValue",
"location-created",
"location-selected",
]);
// State
const selectedLocation = ref(props.modelValue);
const locations = ref([]);
const loading = ref(false);
const searchError = ref("");
// Modal state
const showCreateModal = ref(false);
const creating = ref(false);
const createError = ref("");
const createSuccess = ref("");
const newLocation = ref({
name: "",
address: "",
phone: "",
email: "",
description: "",
});
// Methods
const handleLocationSearch = async (query) => {
if (!query.trim()) {
locations.value = [];
return;
}
loading.value = true;
searchError.value = "";
try {
// Simulate API call - replace with actual service
// const response = await locationService.searchLocations(query);
// locations.value = response.data;
// For now, simulate with mock data
setTimeout(() => {
locations.value = [
{
id: 1,
name: "Mairie de Paris",
address: "Place de l'Hôtel de Ville, 75004 Paris",
phone: "01 42 76 16 16",
},
{
id: 2,
name: "Cimetière du Père-Lachaise",
address: "16 Rue du Repos, 75020 Paris",
phone: "01 55 25 82 10",
},
{
id: 3,
name: "Crématorium du Père-Lachaise",
address: "3 Rue des Amandiers, 75020 Paris",
phone: "01 43 28 25 83",
},
].filter(
(location) =>
location.name.toLowerCase().includes(query.toLowerCase()) ||
location.address.toLowerCase().includes(query.toLowerCase())
);
loading.value = false;
}, 500);
} catch (error) {
searchError.value = "Erreur lors de la recherche de lieux";
loading.value = false;
}
};
const handleLocationSelect = (location) => {
selectedLocation.value = location;
emit("update:modelValue", location);
emit("location-selected", location);
};
const handleLocationCreate = (locationData) => {
newLocation.value = {
...newLocation.value,
name: locationData.name,
};
showCreateModal.value = true;
createError.value = "";
createSuccess.value = "";
};
const handleLocationEdit = (location) => {
// Implementation for editing existing location
console.log("Edit location:", location);
};
const closeCreateModal = () => {
showCreateModal.value = false;
createError.value = "";
createSuccess.value = "";
newLocation.value = {
name: "",
address: "",
phone: "",
email: "",
description: "",
};
};
const submitLocation = async () => {
if (!newLocation.value.name.trim()) return;
creating.value = true;
createError.value = "";
createSuccess.value = "";
try {
// Simulate API call - replace with actual service
// const response = await locationService.createLocation(newLocation.value);
// const createdLocation = response.data;
// For now, simulate success
setTimeout(() => {
const createdLocation = {
id: Date.now(),
...newLocation.value,
isNew: true,
};
// Add to locations list
locations.value.unshift(createdLocation);
// Select the newly created location
selectedLocation.value = createdLocation;
emit("update:modelValue", createdLocation);
emit("location-created", createdLocation);
createSuccess.value = "Lieu créé avec succès!";
setTimeout(() => {
closeCreateModal();
}, 1500);
creating.value = false;
}, 1000);
} catch (error) {
createError.value = "Erreur lors de la création du lieu";
creating.value = false;
}
};
// Watch for external model changes
// watch(() => props.modelValue, (newVal) => {
// selectedLocation.value = newVal;
// });
</script>
<style scoped>
.location-manager {
position: relative;
}
.modal {
z-index: 1050;
}
.modal-backdrop {
z-index: 1040;
}
.alert {
margin-bottom: 0;
}
.spinner-border-sm {
width: 1rem;
height: 1rem;
}
</style>

View File

@ -0,0 +1,135 @@
<template>
<div class="location-display">
<div v-if="location" class="location-info">
<div class="d-flex align-items-center">
<div class="location-icon me-3">
<i class="fas fa-map-marker-alt text-primary"></i>
</div>
<div class="location-details flex-grow-1">
<h6 class="location-name mb-1">{{ location.name }}</h6>
<p v-if="location.address" class="location-address mb-0 text-muted">
{{ location.address }}
</p>
<p v-if="location.phone" class="location-phone mb-0 text-muted small">
<i class="fas fa-phone me-1"></i>
{{ location.phone }}
</p>
</div>
<div v-if="showActions && editable" class="location-actions">
<button
type="button"
class="btn btn-sm btn-outline-secondary"
@click="$emit('edit', location)"
:disabled="disabled"
>
<i class="fas fa-edit"></i>
</button>
<button
type="button"
class="btn btn-sm btn-outline-danger ms-1"
@click="$emit('remove')"
:disabled="disabled"
>
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<!-- Additional Info -->
<div v-if="location.description" class="location-description mt-2">
<small class="text-muted">{{ location.description }}</small>
</div>
<!-- New Location Badge -->
<div v-if="location.isNew" class="new-location-badge mt-2">
<span class="badge bg-warning">
<i class="fas fa-plus me-1"></i>
Nouveau lieu
</span>
</div>
</div>
<!-- Empty State -->
<div v-else class="location-empty text-center py-3">
<i class="fas fa-map-marker-alt text-muted fa-2x mb-2"></i>
<p class="text-muted mb-0">{{ emptyMessage }}</p>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from "vue";
const props = defineProps({
location: {
type: Object,
default: () => null,
},
editable: {
type: Boolean,
default: true,
},
showActions: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
emptyMessage: {
type: String,
default: "Aucun lieu sélectionné",
},
});
const emit = defineEmits(["edit", "remove"]);
</script>
<style scoped>
.location-display {
border: 1px solid #e9ecef;
border-radius: 0.375rem;
padding: 1rem;
background-color: #f8f9fa;
}
.location-info {
min-height: 60px;
}
.location-icon {
font-size: 1.2rem;
}
.location-name {
font-weight: 600;
color: #495057;
}
.location-address,
.location-phone {
font-size: 0.875rem;
}
.location-actions .btn {
width: 32px;
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.location-empty {
color: #6c757d;
}
.location-empty i {
opacity: 0.5;
}
.new-location-badge .badge {
font-size: 0.75rem;
}
</style>

View File

@ -0,0 +1,265 @@
<template>
<div class="location-search-input">
<SoftInput
:label="label"
:placeholder="placeholder"
v-model="searchQuery"
@input="handleSearch"
@keydown="handleKeydown"
:disabled="disabled"
:class="{ 'is-invalid': hasError }"
/>
<!-- Search Results Dropdown -->
<div
v-if="showResults && filteredLocations.length > 0"
class="search-results-dropdown"
>
<ul class="list-group">
<li
v-for="(location, index) in filteredLocations"
:key="location.id"
class="list-group-item list-group-item-action"
@click="selectLocation(location)"
@mouseenter="highlightIndex = index"
:class="{ active: highlightIndex === index }"
>
<div class="d-flex justify-content-between align-items-center">
<span>{{ location.name }}</span>
<small class="text-muted">{{ location.address || "" }}</small>
</div>
</li>
</ul>
</div>
<!-- No Results -->
<div
v-if="showResults && filteredLocations.length === 0 && searchQuery.trim()"
class="no-results"
>
<p class="text-muted mb-0">Aucun lieu trouvé</p>
<button
type="button"
class="btn btn-sm btn-outline-primary mt-2"
@click="createNewLocation"
>
<i class="fas fa-plus me-1"></i>
Créer "{{ searchQuery.trim() }}"
</button>
</div>
<!-- Error Message -->
<div v-if="error" class="invalid-feedback d-block">
{{ error }}
</div>
</div>
</template>
<script setup>
import {
ref,
computed,
watch,
defineProps,
defineEmits,
onMounted,
onUnmounted,
} from "vue";
import SoftInput from "@/components/SoftInput.vue";
const props = defineProps({
label: {
type: String,
default: "Lieu",
},
placeholder: {
type: String,
default: "Rechercher un lieu...",
},
modelValue: {
type: Object,
default: () => null,
},
availableLocations: {
type: Array,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
error: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:modelValue", "search", "create", "select"]);
// Local state
const searchQuery = ref("");
const showResults = ref(false);
const highlightIndex = ref(-1);
const hasError = computed(() => !!props.error);
// Computed
const filteredLocations = computed(() => {
if (!searchQuery.value.trim() || !props.availableLocations.length) {
return [];
}
const query = searchQuery.value.toLowerCase().trim();
return props.availableLocations.filter(
(location) =>
location.name.toLowerCase().includes(query) ||
(location.address && location.address.toLowerCase().includes(query))
);
});
// Methods
const handleSearch = () => {
showResults.value = true;
highlightIndex.value = -1;
emit("search", searchQuery.value);
};
const handleKeydown = (event) => {
if (!showResults.value) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
highlightIndex.value = Math.min(
highlightIndex.value + 1,
filteredLocations.value.length - 1
);
break;
case "ArrowUp":
event.preventDefault();
highlightIndex.value = Math.max(highlightIndex.value - 1, -1);
break;
case "Enter":
event.preventDefault();
if (
highlightIndex.value >= 0 &&
filteredLocations.value[highlightIndex.value]
) {
selectLocation(filteredLocations.value[highlightIndex.value]);
} else if (searchQuery.value.trim()) {
createNewLocation();
}
break;
case "Escape":
hideResults();
break;
}
};
const selectLocation = (location) => {
searchQuery.value = location.name;
hideResults();
emit("select", location);
emit("update:modelValue", location);
};
const createNewLocation = () => {
if (!searchQuery.value.trim()) return;
const newLocation = {
name: searchQuery.value.trim(),
isNew: true,
};
hideResults();
emit("create", newLocation);
emit("update:modelValue", newLocation);
};
const hideResults = () => {
showResults.value = false;
highlightIndex.value = -1;
};
// Watch for external model changes
watch(
() => props.modelValue,
(newVal) => {
if (newVal && newVal.name) {
searchQuery.value = newVal.name;
}
}
);
// Close results when clicking outside
const handleClickOutside = (event) => {
const target = event.target;
if (!target.closest(".location-search-input")) {
hideResults();
}
};
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
});
</script>
<style scoped>
.location-search-input {
position: relative;
}
.search-results-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
background: white;
border: 1px solid #dee2e6;
border-top: none;
border-radius: 0 0 0.375rem 0.375rem;
max-height: 200px;
overflow-y: auto;
}
.search-results-dropdown .list-group-item {
border: none;
border-bottom: 1px solid #f8f9fa;
cursor: pointer;
}
.search-results-dropdown .list-group-item:hover {
background-color: #f8f9fa;
}
.search-results-dropdown .list-group-item.active {
background-color: #007bff;
color: white;
}
.search-results-dropdown .list-group-item:last-child {
border-bottom: none;
}
.no-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
background: white;
border: 1px solid #dee2e6;
border-top: none;
border-radius: 0 0 0.375rem 0.375rem;
padding: 1rem;
text-align: center;
}
.is-invalid {
border-color: #dc3545;
}
</style>

View File

@ -0,0 +1,555 @@
<template>
<div class="document-management">
<!-- Upload Section -->
<div class="card mb-4">
<div class="card-header pb-0">
<div class="d-flex align-items-center">
<h6 class="mb-0">Ajouter des documents</h6>
<button
v-if="selectedFiles.length === 0"
type="button"
class="btn btn-sm btn-outline-primary ms-auto"
@click="triggerFileInput"
:disabled="loading"
>
<i class="fas fa-upload me-2"></i>
Sélectionner
</button>
<button
v-else
type="button"
class="btn btn-sm btn-success ms-auto"
@click="uploadFiles"
:disabled="loading"
>
<i v-if="loading" class="fas fa-spinner fa-spin me-2"></i>
<i v-else class="fas fa-save me-2"></i>
Upload
{{
selectedFiles.length > 1
? `(${selectedFiles.length} fichiers)`
: "(1 fichier)"
}}
</button>
<input
ref="fileInput"
type="file"
multiple
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.txt"
@change="handleFileSelect"
class="d-none"
/>
</div>
</div>
<div class="card-body">
<!-- Selected Files Preview -->
<div v-if="selectedFiles.length > 0" class="mb-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<h6 class="text-sm font-weight-bold mb-0">
Fichiers sélectionnés :
</h6>
<button
type="button"
class="btn btn-sm btn-outline-secondary"
@click="clearSelectedFiles"
:disabled="loading"
>
<i class="fas fa-times me-1"></i>
Tout effacer
</button>
</div>
<div class="row">
<div
v-for="(file, index) in selectedFiles"
:key="index"
class="col-md-6 col-lg-4 mb-2"
>
<div class="border rounded p-2 bg-light">
<div class="d-flex align-items-center">
<i
:class="getFileIcon(file.type)"
class="me-2 text-primary"
></i>
<div class="flex-grow-1">
<div class="text-sm font-weight-bold">{{ file.name }}</div>
<div class="text-xs text-muted">
{{ formatFileSize(file.size) }}
</div>
</div>
<button
type="button"
class="btn btn-sm btn-outline-danger"
@click="removeSelectedFile(index)"
:disabled="loading"
>
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- File Input Hint - Always Visible -->
<div
class="text-center py-3 border-dashed clickable-area"
:class="{ dragover: isDragging }"
@click="triggerFileInput"
@dragover.prevent="isDragging = true"
@dragenter.prevent="isDragging = true"
@dragleave="isDragging = false"
@drop.prevent="handleFileDrop"
>
<i
class="fas fa-cloud-upload-alt fa-2x text-muted mb-2"
:class="{ 'text-primary': isDragging }"
></i>
<p
class="text-sm text-muted mb-0"
:class="{ 'text-primary': isDragging }"
>
{{
isDragging
? "Déposez les fichiers ici"
: "Cliquez pour sélectionner des fichiers ou glissez-déposez ici"
}}
</p>
<small class="text-muted">{{ supportedExtensions }}</small>
</div>
</div>
</div>
<!-- Documents List -->
<div class="card">
<div class="card-header pb-0">
<div class="d-flex align-items-center">
<h6 class="mb-0">Documents ({{ documents.length }})</h6>
<button
v-if="documents.length > 0"
type="button"
class="btn btn-sm btn-outline-danger ms-auto"
@click="confirmDeleteSelected"
:disabled="selectedDocumentIds.length === 0 || loading"
>
<i class="fas fa-trash me-2"></i>
Supprimer ({{ selectedDocumentIds.length }})
</button>
</div>
</div>
<div class="card-body">
<!-- Loading State -->
<div v-if="loading" class="text-center py-4">
<div class="spinner-border" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-sm text-muted">Chargement des documents...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="alert alert-danger" role="alert">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ error }}
<button
type="button"
class="btn btn-sm btn-outline-danger ms-2"
@click="$emit('retry')"
>
Réessayer
</button>
</div>
<!-- Empty State -->
<div v-else-if="documents.length === 0" class="text-center py-5">
<div class="avatar avatar-xl mb-3">
<div class="avatar-title bg-gradient-info text-white h5 mb-0">
<i class="fas fa-file-alt"></i>
</div>
</div>
<h6 class="text-sm text-muted">Aucun document</h6>
<p class="text-xs text-muted">
Ajoutez des documents à cette intervention en utilisant le bouton de
téléchargement ci-dessus.
</p>
</div>
<!-- Documents List -->
<div v-else>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th width="50">
<input
type="checkbox"
class="form-check-input"
:checked="allDocumentsSelected"
@change="toggleSelectAll"
/>
</th>
<th>Fichier</th>
<th>Type</th>
<th>Taille</th>
<th>Ajouté le</th>
<th width="150">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="document in documents" :key="document.id">
<td>
<input
type="checkbox"
class="form-check-input"
:value="document.id"
v-model="selectedDocumentIds"
/>
</td>
<td>
<div class="d-flex align-items-center">
<i
:class="getFileIcon(document.file?.mime_type)"
class="me-2 text-primary"
></i>
<div>
<div class="text-sm font-weight-bold">
{{
document.label ||
document.file?.original_name ||
document.file?.name
}}
</div>
<div class="text-xs text-muted">
{{ document.file?.name }}
</div>
</div>
</div>
</td>
<td>
<span class="badge bg-secondary text-uppercase">
{{ document.file?.extension || "Unknown" }}
</span>
</td>
<td class="text-sm text-muted">
{{
document.file?.size_formatted ||
formatFileSize(document.file?.size)
}}
</td>
<td class="text-sm text-muted">
{{ formatDate(document.created_at) }}
</td>
<td>
<div class="btn-group" role="group">
<a
:href="document.download_url"
class="btn btn-sm btn-outline-primary"
title="Télécharger"
target="_blank"
>
<i class="fas fa-download"></i>
</a>
<button
type="button"
class="btn btn-sm btn-outline-secondary"
title="Modifier le libellé"
@click="editDocumentLabel(document)"
>
<i class="fas fa-edit"></i>
</button>
<button
type="button"
class="btn btn-sm btn-outline-danger"
title="Supprimer"
@click="$emit('delete-document', document.id)"
>
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Edit Label Modal -->
<div
class="modal fade"
id="editLabelModal"
tabindex="-1"
aria-labelledby="editLabelModalLabel"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editLabelModalLabel">
Modifier le libellé
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="documentLabel" class="form-label"
>Libellé du document</label
>
<input
type="text"
class="form-control"
id="documentLabel"
v-model="editingDocument.label"
placeholder="Entrez un libellé personnalisé"
/>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Annuler
</button>
<button
type="button"
class="btn btn-primary"
@click="saveDocumentLabel"
:disabled="loading"
>
<i v-if="loading" class="fas fa-spinner fa-spin me-2"></i>
Enregistrer
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { Modal } from "bootstrap";
import { defineProps, defineEmits } from "vue";
// Props
const props = defineProps({
documents: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
error: {
type: String,
default: null,
},
supportedExtensions: {
type: String,
default: "PDF, DOC, DOCX, XLS, XLSX, JPG, PNG, TXT (max 10MB par fichier)",
},
});
// Emits
const emit = defineEmits([
"files-selected",
"file-removed",
"upload-files",
"delete-document",
"delete-documents",
"update-document-label",
"retry",
]);
// Reactive state
const selectedFiles = ref([]);
const selectedDocumentIds = ref([]);
const editingDocument = ref({});
const isDragging = ref(false);
// Refs
const fileInput = ref(null);
// Computed
const allDocumentsSelected = computed(() => {
return (
props.documents.length > 0 &&
selectedDocumentIds.value.length === props.documents.length
);
});
// Methods
const handleFileSelect = (event) => {
const files = Array.from(event.target.files);
selectedFiles.value = [...selectedFiles.value, ...files];
event.target.value = ""; // Reset input
// Notify parent about selected files
emit("files-selected", selectedFiles.value);
};
const removeSelectedFile = (index) => {
selectedFiles.value.splice(index, 1);
emit("files-selected", selectedFiles.value);
};
const clearSelectedFiles = () => {
selectedFiles.value = [];
emit("files-selected", selectedFiles.value);
};
const triggerFileInput = () => {
fileInput.value?.click();
};
const uploadFiles = () => {
if (selectedFiles.value.length > 0) {
emit("upload-files", selectedFiles.value);
// Clear selected files after upload signal
selectedFiles.value = [];
}
};
const handleFileDrop = (event) => {
isDragging.value = false;
const files = Array.from(event.dataTransfer.files);
const validFiles = files.filter((file) => {
const validExtensions = [
".pdf",
".doc",
".docx",
".xls",
".xlsx",
".jpg",
".jpeg",
".png",
".txt",
];
const fileExtension = "." + file.name.split(".").pop().toLowerCase();
return (
validExtensions.includes(fileExtension) && file.size <= 10 * 1024 * 1024
); // 10MB limit
});
if (validFiles.length > 0) {
selectedFiles.value = [...selectedFiles.value, ...validFiles];
emit("files-selected", selectedFiles.value);
}
};
const confirmDeleteSelected = () => {
if (selectedDocumentIds.value.length > 0) {
emit("delete-documents", selectedDocumentIds.value);
}
};
const toggleSelectAll = () => {
if (allDocumentsSelected.value) {
selectedDocumentIds.value = [];
} else {
selectedDocumentIds.value = props.documents.map((doc) => doc.id);
}
};
const editDocumentLabel = (document) => {
editingDocument.value = { ...document };
const modalElement = document.getElementById("editLabelModal");
if (modalElement) {
const modal = new Modal(modalElement);
modal.show();
}
};
const saveDocumentLabel = () => {
if (!editingDocument.value.id) return;
emit("update-document-label", {
id: editingDocument.value.id,
label: editingDocument.value.label,
});
const modal = Modal.getInstance(document.getElementById("editLabelModal"));
modal.hide();
};
// Helper functions
const getFileIcon = (mimeType) => {
if (!mimeType) return "fas fa-file";
if (mimeType.includes("pdf")) return "fas fa-file-pdf text-danger";
if (mimeType.includes("word") || mimeType.includes("document"))
return "fas fa-file-word text-primary";
if (mimeType.includes("excel") || mimeType.includes("spreadsheet"))
return "fas fa-file-excel text-success";
if (mimeType.includes("image")) return "fas fa-file-image text-warning";
if (mimeType.includes("text")) return "fas fa-file-alt text-info";
return "fas fa-file text-secondary";
};
const formatFileSize = (bytes) => {
if (!bytes) return "0 B";
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
</script>
<style scoped>
.border-dashed {
border: 2px dashed #dee2e6;
border-radius: 0.5rem;
transition: all 0.3s ease;
cursor: pointer;
}
.border-dashed:hover {
border-color: #6c757d;
background-color: #f8f9fa;
}
.border-dashed.dragover {
border-color: #0d6efd;
background-color: #e7f1ff;
transform: scale(1.02);
}
.clickable-area {
cursor: pointer;
user-select: none;
}
.document-management .table th {
border-top: none;
font-weight: 600;
color: #495057;
font-size: 0.875rem;
}
.document-management .btn-group .btn {
border-radius: 0.375rem;
margin-right: 2px;
}
.document-management .btn-group .btn:last-child {
margin-right: 0;
}
</style>

View File

@ -31,13 +31,6 @@
<!-- En-tête avec titre et badge de statut -->
<div class="d-flex align-items-center justify-content-between mb-4">
<h5 class="mb-0">Détails de l'Intervention</h5>
<SoftBadge
:color="mappedIntervention.status.color"
:variant="mappedIntervention.status.variant"
:size="mappedIntervention.status.size"
>
{{ mappedIntervention.status.label }}
</SoftBadge>
</div>
<!-- Informations Client -->
@ -72,13 +65,13 @@
class="mb-3"
/>
<SoftInput
label="Lieu"
v-model="localIntervention.lieux"
:disabled="!editMode"
class="mb-3"
<div class="mb-3">
<LocationManager
v-model="localIntervention.location"
:editable="editMode"
/>
</div>
</div>
<!-- Colonne droite -->
<div class="col-md-6">
@ -120,99 +113,24 @@
<hr class="horizontal dark" />
<!-- Informations supplémentaires -->
<div class="mb-4">
<h6 class="mb-3">Informations Complémentaires</h6>
<div class="row">
<div class="col-md-6">
<SoftInput
label="Nombre de personnes attendues"
type="number"
v-model="localIntervention.nombrePersonnes"
:disabled="!editMode"
class="mb-3"
/>
<SoftInput
label="Coordonnées du contact"
v-model="localIntervention.coordonneesContact"
:disabled="!editMode"
class="mb-3"
/>
</div>
<div class="col-md-6">
<SoftInput
label="Prestations supplémentaires"
type="textarea"
rows="2"
v-model="localIntervention.prestationsSupplementaires"
:disabled="!editMode"
class="mb-3"
/>
</div>
</div>
</div>
<!-- Équipe assignée -->
<div class="mb-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<h6 class="mb-0">Équipe Assignée</h6>
<button
type="button"
class="btn btn-sm bg-gradient-info"
@click="showTeamModal = true"
:disabled="loading"
>
Gérer l'équipe
</button>
</div>
<div class="avatar-group">
<a
v-for="(member, index) in localIntervention.members"
:key="index"
href="javascript:;"
class="avatar avatar-sm rounded-circle"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
:title="member.name"
>
<img alt="Image placeholder" :src="member.image" />
</a>
</div>
</div>
<hr class="horizontal dark" />
<!-- Actions -->
<div class="d-flex justify-content-between">
<div v-if="editMode">
<button
type="button"
class="btn btn-sm bg-gradient-danger"
@click="$emit('cancel')"
class="btn btn-sm bg-gradient-danger me-2"
@click="resetChanges"
:disabled="loading"
>
Annuler
</button>
<div>
<button
type="button"
class="btn btn-sm bg-gradient-secondary me-2"
@click="resetChanges"
:disabled="!hasChanges || loading"
>
Réinitialiser
</button>
<button
type="button"
class="btn btn-sm"
:class="`bg-gradient-${mappedIntervention.action.color}`"
class="btn btn-sm bg-gradient-success"
@click="saveChanges"
:disabled="!hasChanges || loading"
:disabled="loading || !hasChanges"
>
{{ mappedIntervention.action.label }}
<i class="fas fa-save me-2"></i>Sauvegarder
</button>
</div>
</div>
@ -229,21 +147,50 @@
<div
v-if="showTeamModal"
class="modal fade show"
style="display: block"
style="display: block; background-color: rgba(0, 0, 0, 0.5)"
tabindex="-1"
role="dialog"
@click.self="showTeamModal = false"
>
<div class="modal-dialog">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Ajouter équipe</h5>
<h5 class="modal-title">Gérer l'équipe</h5>
<button
type="button"
class="btn-close"
@click="showTeamModal = false"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<p>Interface de gestion de l'équipe à implémenter...</p>
<p>Sélectionnez les membres à assigner à cette intervention :</p>
<!-- Exemple de liste de membres disponibles -->
<div class="list-group">
<div
v-for="availableMember in availableMembers"
:key="availableMember.id"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
@click="toggleMemberSelection(availableMember)"
>
<div class="d-flex align-items-center">
<div
class="avatar-sm rounded-circle bg-primary text-white d-flex align-items-center justify-content-center me-2"
>
{{ availableMember.initials }}
</div>
<div>
<div class="fw-bold">{{ availableMember.name }}</div>
<small class="text-muted">{{ availableMember.role }}</small>
</div>
</div>
<i
v-if="isMemberSelected(availableMember.id)"
class="fas fa-check text-success"
></i>
</div>
</div>
</div>
<div class="modal-footer">
<button
@ -251,7 +198,14 @@
class="btn btn-sm bg-gradient-secondary"
@click="showTeamModal = false"
>
Fermer
Annuler
</button>
<button
type="button"
class="btn btn-sm bg-gradient-primary"
@click="saveTeamSelection"
>
Valider la sélection
</button>
</div>
</div>
@ -263,6 +217,7 @@
import { ref, computed, watch, defineProps, defineEmits } from "vue";
import SoftInput from "@/components/SoftInput.vue";
import SoftBadge from "@/components/SoftBadge.vue";
import LocationManager from "@/components/Organism/Location/LocationManager.vue";
const props = defineProps({
intervention: {
@ -281,64 +236,6 @@ const props = defineProps({
const emit = defineEmits(["update", "cancel"]);
// État local pour l'édition
const editMode = ref(false);
const showTeamModal = ref(false);
const localIntervention = ref({});
// Map API data to expected format
const mappedIntervention = computed(() => {
if (!props.intervention) return null;
return {
...props.intervention,
// Map API fields to component expected fields
defuntName: props.intervention.deceased
? `${props.intervention.deceased.last_name || ""} ${
props.intervention.deceased.first_name || ""
}`.trim()
: `Personne ${props.intervention.deceased_id || "inconnue"}`,
date: props.intervention.scheduled_at
? new Date(props.intervention.scheduled_at).toLocaleString("fr-FR")
: "Non définie",
lieux: props.intervention.location
? props.intervention.location.name || "Lieu non défini"
: "Lieu non défini",
duree: props.intervention.duration_min
? `${props.intervention.duration_min} minutes`
: "Non définie",
title: props.intervention.type
? getInterventionTypeLabel(props.intervention.type)
: "Type non défini",
contactFamilial: props.intervention.order_giver || "Non renseigné",
description: props.intervention.notes || "Aucune description disponible",
nombrePersonnes: props.intervention.attachments_count || 0,
coordonneesContact: props.intervention.client
? props.intervention.client.email ||
props.intervention.client.phone ||
"Non disponible"
: "Non disponible",
prestationsSupplementaires: "À définir",
members: [], // Could be populated from practitioner data if available
// Map status from API string to expected object format
status: props.intervention.status
? {
label: getStatusLabel(props.intervention.status),
color: getStatusColor(props.intervention.status),
variant: "fill",
size: "md",
}
: { label: "En attente", color: "warning", variant: "fill", size: "md" },
// Map action (add if missing)
action: {
color: "primary",
label: "Modifier",
},
};
});
// Helper function to map status string to readable label
const getStatusLabel = (status) => {
const statusLabels = {
@ -376,35 +273,237 @@ const getStatusColor = (status) => {
return statusColors[status] || "secondary";
};
// État local pour l'édition
const editMode = ref(false);
const showTeamModal = ref(false);
const localIntervention = ref({});
const selectedTeamMembers = ref([]);
// Mock data for available members - replace with actual API call
const availableMembers = ref([
{ id: 1, name: "Jean Dupont", initials: "JD", role: "Thanatopracteur" },
{ id: 2, name: "Marie Martin", initials: "MM", role: "Assistante" },
{ id: 3, name: "Pierre Durand", initials: "PD", role: "Conducteur" },
{ id: 4, name: "Sophie Bernard", initials: "SB", role: "Thanatopracteur" },
]);
// Map API data to expected format
const mappedIntervention = computed(() => {
if (!props.intervention) return null;
return {
...props.intervention,
// Map API fields to component expected fields
defuntName: props.intervention.deceased
? `${props.intervention.deceased.last_name || ""} ${
props.intervention.deceased.first_name || ""
}`.trim()
: `Personne ${props.intervention.deceased_id || "inconnue"}`,
date: props.intervention.scheduled_at
? (() => {
try {
return new Date(props.intervention.scheduled_at)
.toISOString()
.slice(0, 16);
} catch (error) {
return props.intervention.scheduled_at;
}
})()
: "",
location: props.intervention.location || null,
duree: props.intervention.duration_min
? `${props.intervention.duration_min} minutes`
: "Non définie",
title: props.intervention.type
? getInterventionTypeLabel(props.intervention.type)
: "Type non défini",
contactFamilial: props.intervention.order_giver || "Non renseigné",
description: props.intervention.notes || "Aucune description disponible",
nombrePersonnes: props.intervention.attachments_count || 0,
coordonneesContact: props.intervention.client
? props.intervention.client.email ||
props.intervention.client.phone ||
"Non disponible"
: "Non disponible",
prestationsSupplementaires: "À définir",
members: props.intervention.practitioners
? props.intervention.practitioners.map((p) => ({
id: p.id,
name: `${p.first_name} ${p.last_name}`,
initials: `${p.first_name?.[0] || ""}${p.last_name?.[0] || ""}`,
role: p.role || "Membre",
}))
: [],
// Map status from API string to expected object format
status: props.intervention.status
? {
label: getStatusLabel(props.intervention.status),
color: getStatusColor(props.intervention.status),
variant: "fill",
size: "md",
}
: { label: "En attente", color: "warning", variant: "fill", size: "md" },
// Map action (add if missing)
action: {
color: "primary",
label: "Modifier",
},
};
});
// Computed property for safe status access
const statusObject = computed(() => {
if (!mappedIntervention.value || !mappedIntervention.value.status) {
return {
label: "Inconnu",
color: "secondary",
variant: "fill",
size: "md",
};
}
return mappedIntervention.value.status;
});
// Computed pour détecter les changements
const hasChanges = computed(() => {
if (!props.intervention || !localIntervention.value) return false;
return (
JSON.stringify(localIntervention.value) !==
JSON.stringify(props.intervention)
);
if (
!props.intervention ||
!localIntervention.value ||
!mappedIntervention.value
)
return false;
// Compare only the fields that can be edited
const editableFields = [
"defuntName",
"date",
"duree",
"title",
"contactFamilial",
"description",
];
return editableFields.some((field) => {
const originalValue = getOriginalFieldValue(field);
const localValue = localIntervention.value[field];
return originalValue !== localValue;
});
});
// Helper function to get original field values from props
const getOriginalFieldValue = (field) => {
if (!props.intervention) return null;
switch (field) {
case "defuntName":
return props.intervention.deceased
? `${props.intervention.deceased.last_name || ""} ${
props.intervention.deceased.first_name || ""
}`.trim()
: `Personne ${props.intervention.deceased_id || "inconnue"}`;
case "date":
if (props.intervention.scheduled_at) {
try {
return new Date(props.intervention.scheduled_at)
.toISOString()
.slice(0, 16);
} catch (error) {
return props.intervention.scheduled_at;
}
}
return "";
case "duree":
return props.intervention.duration_min
? `${props.intervention.duration_min} minutes`
: "Non définie";
case "title":
return props.intervention.type
? getInterventionTypeLabel(props.intervention.type)
: "Type non défini";
case "contactFamilial":
return props.intervention.order_giver || "Non renseigné";
case "description":
return props.intervention.notes || "Aucune description disponible";
default:
return null;
}
};
// Team management methods
const isMemberSelected = (memberId) => {
return selectedTeamMembers.value.some((member) => member.id === memberId);
};
const toggleMemberSelection = (member) => {
const index = selectedTeamMembers.value.findIndex((m) => m.id === member.id);
if (index > -1) {
selectedTeamMembers.value.splice(index, 1);
} else {
selectedTeamMembers.value.push(member);
}
};
const saveTeamSelection = () => {
// Update local intervention with selected members
localIntervention.value.members = [...selectedTeamMembers.value];
showTeamModal.value = false;
};
const removeMember = (memberId) => {
if (!editMode.value) return;
localIntervention.value.members = localIntervention.value.members.filter(
(member) => member.id !== memberId
);
};
// Méthodes
const toggleEditMode = () => {
if (editMode.value && hasChanges.value) {
saveChanges();
} else {
editMode.value = !editMode.value;
// Reset team selection when entering edit mode
if (editMode.value) {
selectedTeamMembers.value = [...(localIntervention.value.members || [])];
}
}
};
const saveChanges = () => {
if (hasChanges.value) {
emit("update", localIntervention.value);
if (hasChanges.value && props.intervention) {
// Map the local intervention data back to API format
const updatedData = {
...props.intervention,
notes: localIntervention.value.description,
order_giver: localIntervention.value.contactFamilial,
scheduled_at: localIntervention.value.date
? new Date(localIntervention.value.date).toISOString()
: null,
// Map members back to practitioners if needed
practitioners:
localIntervention.value.members?.map((member) => ({
id: member.id,
first_name: member.name.split(" ")[0],
last_name: member.name.split(" ")[1] || "",
})) || [],
};
emit("update", updatedData);
}
editMode.value = false;
};
const resetChanges = () => {
if (props.intervention) {
localIntervention.value = { ...props.intervention };
if (mappedIntervention.value) {
localIntervention.value = JSON.parse(
JSON.stringify(mappedIntervention.value)
);
}
editMode.value = false;
};
// Watch pour mettre à jour les données locales quand les props changent
@ -412,7 +511,10 @@ watch(
() => props.intervention,
(newVal) => {
if (newVal) {
localIntervention.value = mappedIntervention.value || {};
localIntervention.value = JSON.parse(
JSON.stringify(mappedIntervention.value || {})
);
selectedTeamMembers.value = [...(localIntervention.value.members || [])];
}
},
{ deep: true, immediate: true }
@ -424,6 +526,7 @@ watch(
(newLoading) => {
if (newLoading) {
editMode.value = false;
showTeamModal.value = false;
}
}
);
@ -438,13 +541,19 @@ watch(
.avatar-sm {
width: 32px;
height: 32px;
}
.modal {
background-color: rgba(0, 0, 0, 0.5);
font-size: 12px;
font-weight: bold;
}
.modal.show {
display: block;
}
.list-group-item {
cursor: pointer;
}
.list-group-item:hover {
background-color: #f8f9fa;
}
</style>

View File

@ -0,0 +1,211 @@
<template>
<div class="location-search-form">
<!-- Location Display (when selected) -->
<div v-if="selectedLocation" class="selected-location mb-3">
<LocationDisplay
:location="selectedLocation"
:editable="editable"
:show-actions="editable"
:disabled="loading"
@edit="handleEdit"
@remove="handleRemove"
/>
</div>
<!-- Location Search Input (when in edit mode or no location selected) -->
<div v-if="!selectedLocation || editing" class="location-search-section">
<LocationSearchInput
v-model="searchLocation"
:available-locations="availableLocations"
:disabled="loading || !editable"
:error="searchError"
label="Lieu de l'intervention"
placeholder="Rechercher ou créer un lieu..."
@search="handleLocationSearch"
@select="handleLocationSelect"
@create="handleLocationCreate"
/>
<!-- Loading indicator -->
<div v-if="loading" class="loading-indicator mt-2">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<span class="ms-2 text-muted">Recherche en cours...</span>
</div>
</div>
<!-- Action Buttons -->
<div
v-if="editable && selectedLocation && !editing"
class="action-buttons mt-3"
>
<button
type="button"
class="btn btn-sm btn-outline-primary"
@click="startEditing"
:disabled="loading"
>
<i class="fas fa-edit me-1"></i>
Changer le lieu
</button>
</div>
<!-- Cancel Button (when editing) -->
<div v-if="editing" class="cancel-button mt-2">
<button
type="button"
class="btn btn-sm btn-outline-secondary"
@click="cancelEditing"
:disabled="loading"
>
<i class="fas fa-times me-1"></i>
Annuler
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, defineProps, defineEmits } from "vue";
import LocationSearchInput from "@/components/atoms/Location/LocationSearchInput.vue";
import LocationDisplay from "@/components/atoms/Location/LocationDisplay.vue";
const props = defineProps({
modelValue: {
type: Object,
default: () => null,
},
availableLocations: {
type: Array,
default: () => [],
},
editable: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: false,
},
searchError: {
type: String,
default: "",
},
});
const emit = defineEmits([
"update:modelValue",
"search",
"select",
"create",
"edit",
]);
// Local state
const editing = ref(false);
const searchLocation = ref(null);
// Computed
const selectedLocation = computed(() => props.modelValue);
// Methods
const startEditing = () => {
editing.value = true;
searchLocation.value = selectedLocation.value;
};
const cancelEditing = () => {
editing.value = false;
searchLocation.value = selectedLocation.value;
};
const handleLocationSearch = (query) => {
emit("search", query);
};
const handleLocationSelect = (location) => {
emit("select", location);
emit("update:modelValue", location);
editing.value = false;
};
const handleLocationCreate = (location) => {
emit("create", location);
emit("update:modelValue", location);
editing.value = false;
};
const handleEdit = (location) => {
emit("edit", location);
editing.value = true;
};
const handleRemove = () => {
emit("update:modelValue", null);
editing.value = true;
};
// Watch for external model changes
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
searchLocation.value = newVal;
editing.value = false;
}
},
{ immediate: true }
);
</script>
<style scoped>
.location-search-form {
position: relative;
}
.selected-location {
animation: fadeIn 0.3s ease-in-out;
}
.location-search-section {
animation: slideDown 0.3s ease-in-out;
}
.action-buttons {
animation: fadeIn 0.3s ease-in-out 0.2s both;
}
.cancel-button {
animation: fadeIn 0.3s ease-in-out;
}
.loading-indicator {
display: flex;
align-items: center;
color: #6c757d;
font-size: 0.875rem;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -1,12 +1,5 @@
<template>
<div class="card-body text-center">
<!-- Intervention Icon/Avatar -->
<div class="avatar avatar-xl mb-3">
<div class="avatar-title bg-gradient-primary text-white h5 mb-0">
<i class="fas fa-procedures"></i>
</div>
</div>
<!-- Intervention Title -->
<h5 class="font-weight-bolder mb-0">
{{ intervention.title || "Intervention" }}
@ -43,27 +36,6 @@
</div>
<!-- Team Members -->
<div
v-if="intervention.members && intervention.members.length > 0"
class="mt-3"
>
<div class="avatar-group">
<a
v-for="(member, index) in intervention.members"
:key="index"
href="javascript:;"
class="avatar avatar-sm rounded-circle"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
:title="member.name"
>
<img
alt="Image placeholder"
:src="member.image || '/images/avatar-default.png'"
/>
</a>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,344 @@
import { request } from "./http";
export interface DocumentAttachment {
id: number;
file_id: number;
label: string | null;
sort_order: number;
attachable_type: string;
attachable_id: number;
created_at: string;
updated_at: string;
file?: {
id: number;
name: string;
original_name: string | null;
path: string;
mime_type: string;
size: number;
size_formatted: string;
extension: string;
download_url: string;
};
attachable?: {
id: number;
type: string;
name: string;
};
is_for_intervention: boolean;
is_for_client: boolean;
is_for_deceased: boolean;
download_url: string;
}
export interface DocumentAttachmentListResponse {
data: DocumentAttachment[];
count: number;
message: string;
}
export interface DocumentAttachmentResponse {
data: DocumentAttachment;
message: string;
}
export interface CreateAttachmentPayload {
file_id: number;
attachable_type: string;
attachable_id: number;
label?: string;
sort_order?: number;
}
export interface UpdateAttachmentPayload {
label?: string;
sort_order?: number;
}
export interface BulkDeletePayload {
attachment_ids: number[];
}
export interface ReorderAttachmentPayload {
attachments: Array<{
id: number;
sort_order: number;
}>;
}
export const DocumentAttachmentService = {
/**
* Attach a file to a model (Intervention, Client, Deceased, etc.)
*/
async attachFile(
payload: CreateAttachmentPayload
): Promise<DocumentAttachmentResponse> {
const response = await request<DocumentAttachmentResponse>({
url: "/api/file-attachments",
method: "post",
data: payload,
});
return response;
},
/**
* Detach a file from a model
*/
async detachFile(attachmentId: number): Promise<{ message: string }> {
const response = await request<{ message: string }>({
url: `/api/file-attachments/${attachmentId}`,
method: "delete",
});
return response;
},
/**
* Update file attachment metadata
*/
async updateAttachment(
attachmentId: number,
payload: UpdateAttachmentPayload
): Promise<DocumentAttachmentResponse> {
const response = await request<DocumentAttachmentResponse>({
url: `/api/file-attachments/${attachmentId}`,
method: "put",
data: payload,
});
return response;
},
/**
* Get files attached to a specific model
*/
async getAttachedFiles(
attachableType: string,
attachableId: number
): Promise<DocumentAttachmentListResponse> {
const response = await request<DocumentAttachmentListResponse>({
url: "/api/file-attachments/attached-files",
method: "get",
params: {
attachable_type: attachableType,
attachable_id: attachableId,
},
});
return response;
},
/**
* Get files attached to an intervention
*/
async getInterventionFiles(
interventionId: number
): Promise<DocumentAttachmentListResponse> {
const response = await request<DocumentAttachmentListResponse>({
url: `/api/file-attachments/intervention/${interventionId}/files`,
method: "get",
});
return response;
},
/**
* Get files attached to a client
*/
async getClientFiles(
clientId: number
): Promise<DocumentAttachmentListResponse> {
const response = await request<DocumentAttachmentListResponse>({
url: `/api/file-attachments/client/${clientId}/files`,
method: "get",
});
return response;
},
/**
* Get files attached to a deceased
*/
async getDeceasedFiles(
deceasedId: number
): Promise<DocumentAttachmentListResponse> {
const response = await request<DocumentAttachmentListResponse>({
url: `/api/file-attachments/deceased/${deceasedId}/files`,
method: "get",
});
return response;
},
/**
* Detach multiple files at once
*/
async detachMultiple(
payload: BulkDeletePayload
): Promise<{
deleted_count: number;
message: string;
}> {
const response = await request<{
deleted_count: number;
message: string;
}>({
url: "/api/file-attachments/detach-multiple",
method: "post",
data: payload,
});
return response;
},
/**
* Reorder file attachments
*/
async reorderAttachments(
payload: ReorderAttachmentPayload
): Promise<{ message: string }> {
const response = await request<{ message: string }>({
url: "/api/file-attachments/reorder",
method: "post",
data: payload,
});
return response;
},
/**
* Upload and attach files to a model
*/
async uploadAndAttachFiles(
files: File[],
attachableType: string,
attachableId: number,
options?: {
labels?: string[];
onProgress?: (progress: number) => void;
}
): Promise<DocumentAttachment[]> {
const uploadedAttachments: DocumentAttachment[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
try {
// First upload the file
const formData = new FormData();
formData.append("file", file);
formData.append("file_name", file.name);
formData.append("category", "autre"); // Default category for intervention uploads
const fileResponse = await request<{ data: any }>({
url: "/api/files",
method: "post",
data: formData,
});
// Then attach the uploaded file
const attachmentResponse = await this.attachFile({
file_id: fileResponse.data.id,
attachable_type: attachableType,
attachable_id: attachableId,
label: options?.labels?.[i] || file.name,
});
uploadedAttachments.push(attachmentResponse.data);
// Report progress if callback provided
if (options?.onProgress) {
const progress = ((i + 1) / files.length) * 100;
options.onProgress(progress);
}
} catch (error) {
console.error(`Error uploading file ${file.name}:`, error);
throw error;
}
}
return uploadedAttachments;
},
/**
* Format file size for display
*/
formatFileSize(bytes: number | null): string {
if (!bytes || bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
},
/**
* Get file icon based on MIME type
*/
getFileIcon(mimeType: string | null): string {
if (!mimeType) return "fas fa-file";
if (mimeType.includes("pdf")) return "fas fa-file-pdf text-danger";
if (mimeType.includes("word") || mimeType.includes("document"))
return "fas fa-file-word text-primary";
if (mimeType.includes("excel") || mimeType.includes("spreadsheet"))
return "fas fa-file-excel text-success";
if (mimeType.includes("image")) return "fas fa-file-image text-warning";
if (mimeType.includes("text")) return "fas fa-file-alt text-info";
return "fas fa-file text-secondary";
},
/**
* Validate file before upload
*/
validateFile(
file: File,
maxSize: number = 10 * 1024 * 1024
): { valid: boolean; error?: string } {
if (file.size > maxSize) {
return {
valid: false,
error: `La taille du fichier ne doit pas dépasser ${this.formatFileSize(
maxSize
)}`,
};
}
// Allowed file types
const allowedTypes = [
"application/pdf",
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
];
if (!allowedTypes.includes(file.type)) {
return {
valid: false,
error: "Type de fichier non autorisé",
};
}
return { valid: true };
},
/**
* Get supported file extensions
*/
getSupportedExtensions(): string {
return "PDF, DOC, DOCX, XLS, XLSX, JPG, PNG, TXT";
},
};
export default DocumentAttachmentService;

View File

@ -0,0 +1,426 @@
import { request } from "./http";
export interface FileUser {
id: number;
name: string;
email: string;
}
export interface FileResource {
id: number;
file_name: string;
mime_type: string | null;
size_bytes: number | null;
size_formatted: string;
extension: string;
storage_uri: string;
organized_path: string;
sha256: string | null;
uploaded_by: number;
uploader_name: string;
uploaded_at: string;
created_at: string;
updated_at: string;
is_image: boolean;
is_pdf: boolean;
url?: string;
user: FileUser;
category: string;
subcategory: string;
}
export interface FileListResponse {
data: FileResource[];
pagination: {
current_page: number;
from: number;
last_page: number;
per_page: number;
to: number;
total: number;
has_more_pages: boolean;
};
summary: {
total_files: number;
total_size: number;
total_size_formatted: string;
categories: Record<
string,
{
count: number;
total_size: number;
total_size_formatted: string;
}
>;
};
}
export interface FileResponse {
data: FileResource;
}
export interface FileCollectionResponse {
data: FileResource[];
pagination: {
current_page: number;
from: number;
last_page: number;
per_page: number;
to: number;
total: number;
has_more_pages: boolean;
};
}
export interface OrganizedFileStructure {
[key: string]: {
category: string;
subcategory: string;
files: FileResource[];
count: number;
};
}
export interface StorageStatistics {
total_files: number;
total_size_bytes: number;
total_size_formatted: string;
by_type: Record<
string,
{
count: number;
total_size: number;
}
>;
by_category: Record<
string,
{
count: number;
total_size: number;
}
>;
}
export interface DownloadResponse {
data: {
download_url: string;
file_name: string;
mime_type: string;
};
message: string;
}
export interface CreateFilePayload {
file: File;
file_name?: string;
category: "devis" | "facture" | "contrat" | "document" | "image" | "autre";
client_id?: number;
subcategory?: string;
description?: string;
tags?: string[];
is_public?: boolean;
}
export interface UpdateFilePayload {
id: number;
file_name?: string;
description?: string;
tags?: string[];
is_public?: boolean;
category?: "devis" | "facture" | "contrat" | "document" | "image" | "autre";
client_id?: number;
subcategory?: string;
}
export interface FileListParams {
page?: number;
per_page?: number;
search?: string;
mime_type?: string;
uploaded_by?: number;
category?: string;
client_id?: number;
date_from?: string;
date_to?: string;
sort_by?: string;
sort_direction?: "asc" | "desc";
}
export const FileService = {
/**
* Get all files with pagination and filters
*/
async getAllFiles(params?: FileListParams): Promise<FileListResponse> {
const response = await request<FileListResponse>({
url: "/api/files",
method: "get",
params,
});
return response;
},
/**
* Get a specific file by ID
*/
async getFile(id: number): Promise<FileResponse> {
const response = await request<FileResponse>({
url: `/api/files/${id}`,
method: "get",
});
return response;
},
/**
* Upload a new file
*/
async uploadFile(payload: CreateFilePayload): Promise<FileResponse> {
const formData = new FormData();
formData.append("file", payload.file);
if (payload.file_name) {
formData.append("file_name", payload.file_name);
}
formData.append("category", payload.category);
if (payload.client_id) {
formData.append("client_id", payload.client_id.toString());
}
if (payload.subcategory) {
formData.append("subcategory", payload.subcategory);
}
if (payload.description) {
formData.append("description", payload.description);
}
if (payload.tags && payload.tags.length > 0) {
payload.tags.forEach((tag, index) => {
formData.append(`tags[${index}]`, tag);
});
}
if (payload.is_public !== undefined) {
formData.append("is_public", payload.is_public.toString());
}
const response = await request<FileResponse>({
url: "/api/files",
method: "post",
data: formData,
headers: {
"Content-Type": "multipart/form-data",
},
});
return response;
},
/**
* Update file metadata
*/
async updateFile(payload: UpdateFilePayload): Promise<FileResponse> {
const { id, ...updateData } = payload;
const response = await request<FileResponse>({
url: `/api/files/${id}`,
method: "put",
data: updateData,
});
return response;
},
/**
* Delete a file
*/
async deleteFile(id: number): Promise<{ message: string }> {
const response = await request<{ message: string }>({
url: `/api/files/${id}`,
method: "delete",
});
return response;
},
/**
* Get files by category
*/
async getFilesByCategory(
category: string,
params?: { page?: number; per_page?: number }
): Promise<FileCollectionResponse> {
const response = await request<FileCollectionResponse>({
url: `/api/files/by-category/${category}`,
method: "get",
params,
});
return response;
},
/**
* Get files by client
*/
async getFilesByClient(
clientId: number,
params?: { page?: number; per_page?: number }
): Promise<FileCollectionResponse> {
const response = await request<FileCollectionResponse>({
url: `/api/files/by-client/${clientId}`,
method: "get",
params,
});
return response;
},
/**
* Get organized file structure
*/
async getOrganizedStructure(): Promise<{
data: OrganizedFileStructure;
message: string;
}> {
const response = await request<{
data: OrganizedFileStructure;
message: string;
}>({
url: "/api/files/organized",
method: "get",
});
return response;
},
/**
* Get storage statistics
*/
async getStorageStatistics(): Promise<{
data: StorageStatistics;
message: string;
}> {
const response = await request<{
data: StorageStatistics;
message: string;
}>({
url: "/api/files/statistics",
method: "get",
});
return response;
},
/**
* Generate download URL for a file
*/
async generateDownloadUrl(id: number): Promise<DownloadResponse> {
const response = await request<DownloadResponse>({
url: `/api/files/${id}/download`,
method: "get",
});
return response;
},
/**
* Format file size for display
*/
formatFileSize(bytes: number | null): string {
if (!bytes || bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
},
/**
* Get file icon based on MIME type
*/
getFileIcon(mimeType: string | null): string {
if (!mimeType) return "📄";
if (mimeType.startsWith("image/")) return "🖼️";
if (mimeType === "application/pdf") return "📄";
if (mimeType.includes("word") || mimeType.includes("document")) return "📝";
if (mimeType.includes("sheet") || mimeType.includes("excel")) return "📊";
if (mimeType.includes("presentation") || mimeType.includes("powerpoint"))
return "📋";
if (mimeType.includes("zip") || mimeType.includes("archive")) return "🗜️";
return "📄";
},
/**
* Check if file is an image
*/
isImageFile(mimeType: string | null): boolean {
return mimeType ? mimeType.startsWith("image/") : false;
},
/**
* Check if file is a PDF
*/
isPdfFile(mimeType: string | null): boolean {
return mimeType === "application/pdf";
},
/**
* Get file extension from filename
*/
getFileExtension(filename: string): string {
return filename.split(".").pop()?.toLowerCase() || "";
},
/**
* Validate file before upload
*/
validateFile(
file: File,
maxSize: number = 10 * 1024 * 1024
): { valid: boolean; error?: string } {
if (file.size > maxSize) {
return {
valid: false,
error: `La taille du fichier ne doit pas dépasser ${this.formatFileSize(
maxSize
)}`,
};
}
// Allow all file types for now, but you can add restrictions here
const allowedTypes = [
"application/pdf",
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"application/zip",
"application/x-zip-compressed",
];
if (!allowedTypes.includes(file.type)) {
return {
valid: false,
error: "Type de fichier non autorisé",
};
}
return { valid: true };
},
};
export default FileService;

View File

@ -0,0 +1,505 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import DocumentAttachmentService from "@/services/documentAttachment";
import type {
DocumentAttachment,
CreateAttachmentPayload,
UpdateAttachmentPayload,
BulkDeletePayload,
ReorderAttachmentPayload,
} from "@/services/documentAttachment";
export const useDocumentAttachmentStore = defineStore(
"documentAttachment",
() => {
// State
const attachments = ref<DocumentAttachment[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const success = ref(false);
// Getters
const allAttachments = computed(() => attachments.value);
const isLoading = computed(() => loading.value);
const hasError = computed(() => error.value !== null);
const getError = computed(() => error.value);
const isSuccess = computed(() => success.value);
const getAttachmentsByType = computed(() => (type: string) =>
attachments.value?.filter(
(attachment) => attachment.attachable_type === type
) || []
);
const getAttachmentsById = computed(() => (id: number) =>
attachments.value?.filter(
(attachment) => attachment.attachable_id === id
) || []
);
const getInterventionAttachments = computed(
() => (interventionId: number) =>
attachments.value?.filter(
(attachment) =>
attachment.attachable_type === "App\\Models\\Intervention" &&
attachment.attachable_id === interventionId
) || []
);
const getClientAttachments = computed(() => (clientId: number) =>
attachments.value?.filter(
(attachment) =>
attachment.attachable_type === "App\\Models\\Client" &&
attachment.attachable_id === clientId
) || []
);
const getDeceasedAttachments = computed(() => (deceasedId: number) =>
attachments.value?.filter(
(attachment) =>
attachment.attachable_type === "App\\Models\\Deceased" &&
attachment.attachable_id === deceasedId
) || []
);
// Actions
const setLoading = (isLoading: boolean) => {
loading.value = isLoading;
};
const setError = (err: string | null) => {
error.value = err;
};
const clearError = () => {
error.value = null;
};
const setSuccess = (isSuccess: boolean) => {
success.value = isSuccess;
};
const clearSuccess = () => {
success.value = false;
};
const setAttachments = (newAttachments: DocumentAttachment[]) => {
attachments.value = newAttachments || [];
};
const addAttachment = (attachment: DocumentAttachment) => {
if (!attachments.value) {
attachments.value = [];
}
attachments.value.push(attachment);
};
const updateAttachment = (updatedAttachment: DocumentAttachment) => {
if (!attachments.value) return;
const index = attachments.value.findIndex(
(attachment) => attachment.id === updatedAttachment.id
);
if (index !== -1) {
attachments.value[index] = updatedAttachment;
}
};
const removeAttachment = (attachmentId: number) => {
if (!attachments.value) return;
attachments.value = attachments.value.filter(
(attachment) => attachment.id !== attachmentId
);
};
const removeAttachments = (attachmentIds: number[]) => {
if (!attachments.value) return;
attachments.value = attachments.value.filter(
(attachment) => !attachmentIds.includes(attachment.id)
);
};
/**
* Attach a file to a model
*/
const attachFile = async (payload: CreateAttachmentPayload) => {
setLoading(true);
setError(null);
setSuccess(false);
try {
const response = await DocumentAttachmentService.attachFile(payload);
addAttachment(response.data);
setSuccess(true);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de l'attachement du fichier";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Detach a file from a model
*/
const detachFile = async (attachmentId: number) => {
setLoading(true);
setError(null);
setSuccess(false);
try {
await DocumentAttachmentService.detachFile(attachmentId);
removeAttachment(attachmentId);
setSuccess(true);
return { message: "Fichier détaché avec succès" };
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du détachement du fichier";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Update file attachment metadata
*/
const updateAttachmentMetadata = async (
attachmentId: number,
payload: UpdateAttachmentPayload
) => {
setLoading(true);
setError(null);
setSuccess(false);
try {
const response = await DocumentAttachmentService.updateAttachment(
attachmentId,
payload
);
updateAttachment(response.data);
setSuccess(true);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la mise à jour du document";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Get files attached to a specific model
*/
const fetchAttachedFiles = async (
attachableType: string,
attachableId: number
) => {
setLoading(true);
setError(null);
setSuccess(false);
try {
const response = await DocumentAttachmentService.getAttachedFiles(
attachableType,
attachableId
);
setAttachments(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des fichiers attachés";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Get files attached to an intervention
*/
const fetchInterventionFiles = async (interventionId: number) => {
setLoading(true);
setError(null);
setSuccess(false);
try {
const response = await DocumentAttachmentService.getInterventionFiles(
interventionId
);
setAttachments(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des fichiers de l'intervention";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Get files attached to a client
*/
const fetchClientFiles = async (clientId: number) => {
setLoading(true);
setError(null);
setSuccess(false);
try {
const response = await DocumentAttachmentService.getClientFiles(
clientId
);
setAttachments(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des fichiers du client";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Get files attached to a deceased
*/
const fetchDeceasedFiles = async (deceasedId: number) => {
setLoading(true);
setError(null);
setSuccess(false);
try {
const response = await DocumentAttachmentService.getDeceasedFiles(
deceasedId
);
setAttachments(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des fichiers du défunt";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Detach multiple files at once
*/
const detachMultipleFiles = async (payload: BulkDeletePayload) => {
setLoading(true);
setError(null);
setSuccess(false);
try {
const response = await DocumentAttachmentService.detachMultiple(
payload
);
removeAttachments(payload.attachment_ids);
setSuccess(true);
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la suppression des fichiers";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Reorder file attachments
*/
const reorderAttachments = async (payload: ReorderAttachmentPayload) => {
setLoading(true);
setError(null);
setSuccess(false);
try {
const response = await DocumentAttachmentService.reorderAttachments(
payload
);
// Update sort order in local state
if (attachments.value) {
payload.attachments.forEach((item) => {
const attachment = attachments.value.find((a) => a.id === item.id);
if (attachment) {
attachment.sort_order = item.sort_order;
}
});
// Sort attachments by sort_order
attachments.value.sort((a, b) => a.sort_order - b.sort_order);
}
setSuccess(true);
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la réorganisation des fichiers";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Upload and attach files to a model
*/
const uploadAndAttachFiles = async (
files: File[],
attachableType: string,
attachableId: number,
options?: {
labels?: string[];
onProgress?: (progress: number) => void;
}
) => {
setLoading(true);
setError(null);
setSuccess(false);
try {
const uploadedAttachments = await DocumentAttachmentService.uploadAndAttachFiles(
files,
attachableType,
attachableId,
options
);
// Add all uploaded attachments to the store
uploadedAttachments.forEach((attachment) => {
addAttachment(attachment);
});
setSuccess(true);
return uploadedAttachments;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du téléchargement des fichiers";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Validate file before upload
*/
const validateFile = (
file: File,
maxSize: number = 10 * 1024 * 1024
): { valid: boolean; error?: string } => {
return DocumentAttachmentService.validateFile(file, maxSize);
};
/**
* Format file size for display
*/
const formatFileSize = (bytes: number | null): string => {
return DocumentAttachmentService.formatFileSize(bytes);
};
/**
* Get file icon based on MIME type
*/
const getFileIcon = (mimeType: string | null): string => {
return DocumentAttachmentService.getFileIcon(mimeType);
};
/**
* Reset the state
*/
const resetState = () => {
attachments.value = [];
loading.value = false;
error.value = null;
success.value = false;
};
return {
// State
attachments,
loading,
error,
success,
// Getters
allAttachments,
isLoading,
hasError,
getError,
isSuccess,
getAttachmentsByType,
getAttachmentsById,
getInterventionAttachments,
getClientAttachments,
getDeceasedAttachments,
// Actions
setLoading,
setError,
clearError,
setSuccess,
clearSuccess,
setAttachments,
addAttachment,
updateAttachment,
removeAttachment,
removeAttachments,
attachFile,
detachFile,
updateAttachmentMetadata,
fetchAttachedFiles,
fetchInterventionFiles,
fetchClientFiles,
fetchDeceasedFiles,
detachMultipleFiles,
reorderAttachments,
uploadAndAttachFiles,
validateFile,
formatFileSize,
getFileIcon,
resetState,
};
}
);
export default useDocumentAttachmentStore;

View File

@ -0,0 +1,625 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import FileService, {
type FileResource,
type FileListParams,
type CreateFilePayload,
type UpdateFilePayload,
type FileListResponse,
type OrganizedFileStructure,
type StorageStatistics,
} from "@/services/file";
export const useFileStore = defineStore("file", () => {
// State
const files = ref<FileResource[]>([]);
const currentFile = ref<FileResource | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const uploadProgress = ref<number>(0);
const selectedFiles = ref<number[]>([]);
// Pagination state
const pagination = ref({
current_page: 1,
last_page: 1,
per_page: 15,
total: 0,
});
// Filter state
const filters = ref<FileListParams>({
search: '',
category: '',
client_id: undefined,
mime_type: '',
date_from: '',
date_to: '',
sort_by: 'uploaded_at',
sort_direction: 'desc',
});
// Organization state
const organizedFiles = ref<OrganizedFileStructure>({});
const storageStats = ref<StorageStatistics | null>(null);
// Getters
const allFiles = computed(() => files.value);
const filesByCategory = computed(() => {
const grouped: Record<string, FileResource[]> = {};
files.value.forEach(file => {
const category = file.category || 'general';
if (!grouped[category]) {
grouped[category] = [];
}
grouped[category].push(file);
});
return grouped;
});
const filesByClient = computed(() => {
const grouped: Record<number, FileResource[]> = {};
files.value.forEach(file => {
const clientId = extractClientIdFromPath(file.storage_uri);
if (clientId) {
if (!grouped[clientId]) {
grouped[clientId] = [];
}
grouped[clientId].push(file);
}
});
return grouped;
});
const imageFiles = computed(() =>
files.value.filter(file => file.is_image)
);
const pdfFiles = computed(() =>
files.value.filter(file => file.is_pdf)
);
const recentFiles = computed(() =>
[...files.value]
.sort((a, b) => new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime())
.slice(0, 10)
);
const isLoading = computed(() => loading.value);
const hasError = computed(() => error.value !== null);
const getError = computed(() => error.value);
const hasFiles = computed(() => files.value.length > 0);
const getPagination = computed(() => pagination.value);
const getFilters = computed(() => filters.value);
const getUploadProgress = computed(() => uploadProgress.value);
const getFileById = computed(() => (id: number) =>
files.value.find(file => file.id === id)
);
const getSelectedFiles = computed(() =>
files.value.filter(file => selectedFiles.value.includes(file.id))
);
const totalSizeFormatted = computed(() => {
const totalSize = files.value.reduce((sum, file) => sum + (file.size_bytes || 0), 0);
return FileService.formatFileSize(totalSize);
});
// Actions
const setLoading = (isLoading: boolean) => {
loading.value = isLoading;
};
const setError = (err: string | null) => {
error.value = err;
};
const clearError = () => {
error.value = null;
};
const setFiles = (newFiles: FileResource[]) => {
files.value = newFiles;
};
const setCurrentFile = (file: FileResource | null) => {
currentFile.value = file;
};
const setPagination = (meta: any) => {
if (meta) {
pagination.value = {
current_page: meta.current_page || 1,
last_page: meta.last_page || 1,
per_page: meta.per_page || 15,
total: meta.total || 0,
};
}
};
const setFilters = (newFilters: Partial<FileListParams>) => {
filters.value = { ...filters.value, ...newFilters };
};
const clearFilters = () => {
filters.value = {
search: '',
category: '',
client_id: undefined,
mime_type: '',
date_from: '',
date_to: '',
sort_by: 'uploaded_at',
sort_direction: 'desc',
};
};
const setUploadProgress = (progress: number) => {
uploadProgress.value = progress;
};
const selectFile = (fileId: number) => {
if (!selectedFiles.value.includes(fileId)) {
selectedFiles.value.push(fileId);
}
};
const deselectFile = (fileId: number) => {
selectedFiles.value = selectedFiles.value.filter(id => id !== fileId);
};
const selectAllFiles = () => {
selectedFiles.value = files.value.map(file => file.id);
};
const deselectAllFiles = () => {
selectedFiles.value = [];
};
/**
* Récupérer tous les fichiers avec pagination et filtres
*/
const fetchFiles = async (params?: FileListParams) => {
setLoading(true);
setError(null);
try {
const queryParams = { ...filters.value, ...params };
const response = await FileService.getAllFiles(queryParams);
setFiles(response.data);
setPagination(response.pagination);
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des fichiers";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Récupérer un seul fichier par ID
*/
const fetchFile = async (id: number) => {
setLoading(true);
setError(null);
try {
const response = await FileService.getFile(id);
setCurrentFile(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement du fichier";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Télécharger un nouveau fichier
*/
const uploadFile = async (payload: CreateFilePayload) => {
setLoading(true);
setError(null);
setUploadProgress(0);
try {
// Validate file before upload
const validation = FileService.validateFile(payload.file);
if (!validation.valid) {
throw new Error(validation.error);
}
const response = await FileService.uploadFile(payload);
// Add the new file to the beginning of the list
files.value.unshift(response.data);
setCurrentFile(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du téléchargement du fichier";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
setUploadProgress(0);
}
};
/**
* Mettre à jour les métadonnées d'un fichier
*/
const updateFile = async (payload: UpdateFilePayload) => {
setLoading(true);
setError(null);
try {
const response = await FileService.updateFile(payload);
const updatedFile = response.data;
// Update in files list
const index = files.value.findIndex(file => file.id === updatedFile.id);
if (index !== -1) {
files.value[index] = updatedFile;
}
// Update current file if it's the one being edited
if (currentFile.value && currentFile.value.id === updatedFile.id) {
setCurrentFile(updatedFile);
}
return updatedFile;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la mise à jour du fichier";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Supprimer un fichier
*/
const deleteFile = async (id: number) => {
setLoading(true);
setError(null);
try {
await FileService.deleteFile(id);
// Remove from files list
files.value = files.value.filter(file => file.id !== id);
// Remove from selection if selected
deselectFile(id);
// Clear current file if it's the one being deleted
if (currentFile.value && currentFile.value.id === id) {
setCurrentFile(null);
}
return { success: true, message: "Fichier supprimé avec succès" };
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la suppression du fichier";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Supprimer plusieurs fichiers
*/
const deleteMultipleFiles = async (fileIds: number[]) => {
setLoading(true);
setError(null);
try {
const results = await Promise.allSettled(
fileIds.map(id => FileService.deleteFile(id))
);
// Filter out successful deletions
const successfulIds = fileIds.filter((_, index) =>
results[index].status === 'fulfilled'
);
// Remove successful deletions from the list
files.value = files.value.filter(file => !successfulIds.includes(file.id));
// Clear selections
successfulIds.forEach(id => deselectFile(id));
return {
success: true,
deleted: successfulIds.length,
total: fileIds.length
};
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la suppression des fichiers";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Récupérer les fichiers par catégorie
*/
const fetchFilesByCategory = async (category: string, params?: { page?: number; per_page?: number }) => {
setLoading(true);
setError(null);
try {
const response = await FileService.getFilesByCategory(category, params);
setFiles(response.data);
setPagination(response.pagination);
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des fichiers par catégorie";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Récupérer les fichiers par client
*/
const fetchFilesByClient = async (clientId: number, params?: { page?: number; per_page?: number }) => {
setLoading(true);
setError(null);
try {
const response = await FileService.getFilesByClient(clientId, params);
setFiles(response.data);
setPagination(response.pagination);
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des fichiers du client";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Récupérer la structure organisée des fichiers
*/
const fetchOrganizedStructure = async () => {
setLoading(true);
setError(null);
try {
const response = await FileService.getOrganizedStructure();
organizedFiles.value = response.data;
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement de la structure organisée";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Récupérer les statistiques de stockage
*/
const fetchStorageStatistics = async () => {
setLoading(true);
setError(null);
try {
const response = await FileService.getStorageStatistics();
storageStats.value = response.data;
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des statistiques";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Générer une URL de téléchargement
*/
const generateDownloadUrl = async (fileId: number) => {
try {
const response = await FileService.generateDownloadUrl(fileId);
return response.data.download_url;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la génération de l'URL de téléchargement";
setError(errorMessage);
throw err;
}
};
/**
* Rechercher des fichiers
*/
const searchFiles = async (query: string, params?: { page?: number; per_page?: number }) => {
setLoading(true);
setError(null);
try {
const searchParams = { ...filters.value, search: query, ...params };
const response = await FileService.getAllFiles(searchParams);
setFiles(response.data);
setPagination(response.pagination);
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la recherche de fichiers";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Télécharger un fichier
*/
const downloadFile = async (fileId: number) => {
try {
const downloadUrl = await generateDownloadUrl(fileId);
// Create a temporary link to trigger download
const link = document.createElement('a');
link.href = downloadUrl;
link.download = '';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
return { success: true };
} catch (err: any) {
throw err;
}
};
/**
* Extraire l'ID client du chemin de stockage
*/
const extractClientIdFromPath = (storageUri: string): number | null => {
const pathParts = storageUri.split('/');
if (pathParts.length >= 4 && pathParts[0] === 'client') {
const clientId = parseInt(pathParts[1]);
return isNaN(clientId) ? null : clientId;
}
return null;
};
/**
* Réinitialiser l'état
*/
const resetState = () => {
files.value = [];
currentFile.value = null;
loading.value = false;
error.value = null;
uploadProgress.value = 0;
selectedFiles.value = [];
pagination.value = {
current_page: 1,
last_page: 1,
per_page: 15,
total: 0,
};
clearFilters();
organizedFiles.value = {};
storageStats.value = null;
};
return {
// State
files,
currentFile,
loading,
error,
uploadProgress,
selectedFiles,
pagination,
filters,
organizedFiles,
storageStats,
// Getters
allFiles,
filesByCategory,
filesByClient,
imageFiles,
pdfFiles,
recentFiles,
isLoading,
hasError,
getError,
hasFiles,
getPagination,
getFilters,
getUploadProgress,
getFileById,
getSelectedFiles,
totalSizeFormatted,
// Actions
setLoading,
setError,
clearError,
setFiles,
setCurrentFile,
setPagination,
setFilters,
clearFilters,
setUploadProgress,
selectFile,
deselectFile,
selectAllFiles,
deselectAllFiles,
fetchFiles,
fetchFile,
uploadFile,
updateFile,
deleteFile,
deleteMultipleFiles,
fetchFilesByCategory,
fetchFilesByClient,
fetchOrganizedStructure,
fetchStorageStatistics,
generateDownloadUrl,
searchFiles,
downloadFile,
resetState,
};
});
export default useFileStore;

View File

@ -0,0 +1,56 @@
// Location interface
export interface Location {
id?: number;
name: string;
address?: string;
phone?: string;
email?: string;
description?: string;
isNew?: boolean; // Flag for newly created locations
}
// Updated Intervention interface with Location type
export interface Intervention {
id?: number;
client_id: number;
deceased_id: number;
order_giver?: string;
location_id?: number;
type?: string;
scheduled_at?: string;
duration_min?: number;
status?: string;
attachments_count?: number;
notes?: string;
created_by?: number;
// Relations
client?: any;
deceased?: any;
practitioners?: any[];
principal_practitioner?: any;
location?: Location | null;
// Timestamps
created_at?: string;
updated_at?: string;
}
// Location search/filter options
export interface LocationSearchFilters {
query?: string;
client_id?: number;
active?: boolean;
}
// Location creation data
export interface CreateLocationData {
name: string;
address?: string;
phone?: string;
email?: string;
description?: string;
}
// Location update data
export interface UpdateLocationData extends Partial<CreateLocationData> {
id: number;
}