Attacher des fichiers sur les internvetions
This commit is contained in:
parent
496b427e13
commit
23bce2abcf
411
thanasoft-back/FILE_API_DOCUMENTATION.md
Normal file
411
thanasoft-back/FILE_API_DOCUMENTATION.md
Normal 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
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
444
thanasoft-back/app/Http/Controllers/Api/FileController.php
Normal file
444
thanasoft-back/app/Http/Controllers/Api/FileController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
109
thanasoft-back/app/Http/Requests/StoreFileRequest.php
Normal file
109
thanasoft-back/app/Http/Requests/StoreFileRequest.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
96
thanasoft-back/app/Http/Requests/UpdateFileRequest.php
Normal file
96
thanasoft-back/app/Http/Requests/UpdateFileRequest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
86
thanasoft-back/app/Http/Resources/File/FileCollection.php
Normal file
86
thanasoft-back/app/Http/Resources/File/FileCollection.php
Normal 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];
|
||||
}
|
||||
}
|
||||
68
thanasoft-back/app/Http/Resources/File/FileResource.php
Normal file
68
thanasoft-back/app/Http/Resources/File/FileResource.php
Normal 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';
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
98
thanasoft-back/app/Models/File.php
Normal file
98
thanasoft-back/app/Models/File.php
Normal 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;
|
||||
}
|
||||
}
|
||||
141
thanasoft-back/app/Models/FileAttachment.php
Normal file
141
thanasoft-back/app/Models/FileAttachment.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
232
thanasoft-back/app/Repositories/FileRepository.php
Normal file
232
thanasoft-back/app/Repositories/FileRepository.php
Normal 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];
|
||||
}
|
||||
}
|
||||
46
thanasoft-back/app/Repositories/FileRepositoryInterface.php
Normal file
46
thanasoft-back/app/Repositories/FileRepositoryInterface.php
Normal 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;
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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']);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
815
thanasoft-front/FILE_MANAGEMENT_FRONTEND.md
Normal file
815
thanasoft-front/FILE_MANAGEMENT_FRONTEND.md
Normal 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">×</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)">×</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.
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
|
||||
344
thanasoft-front/src/services/documentAttachment.ts
Normal file
344
thanasoft-front/src/services/documentAttachment.ts
Normal 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;
|
||||
426
thanasoft-front/src/services/file.ts
Normal file
426
thanasoft-front/src/services/file.ts
Normal 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;
|
||||
505
thanasoft-front/src/stores/documentAttachmentStore.ts
Normal file
505
thanasoft-front/src/stores/documentAttachmentStore.ts
Normal 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;
|
||||
625
thanasoft-front/src/stores/fileStore.ts
Normal file
625
thanasoft-front/src/stores/fileStore.ts
Normal 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;
|
||||
56
thanasoft-front/src/types/intervention.ts
Normal file
56
thanasoft-front/src/types/intervention.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user