Change select internvetion, defunt, client, et produit

This commit is contained in:
kevin 2026-01-19 17:52:33 +03:00
parent 39a3062009
commit 16a39014a2
28 changed files with 1576 additions and 123 deletions

View File

@ -0,0 +1,27 @@
{
"$schema": "https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/main/assets/antigravity.schema.json",
"quiet_mode": false,
"debug": false,
"auto_update": true,
"keep_thinking": false,
"session_recovery": true,
"auto_resume": true,
"resume_text": "continue",
"empty_response_max_attempts": 4,
"empty_response_retry_delay_ms": 2000,
"tool_id_recovery": true,
"claude_tool_hardening": true,
"proactive_token_refresh": true,
"proactive_refresh_buffer_seconds": 1800,
"proactive_refresh_check_interval_seconds": 300,
"max_rate_limit_wait_seconds": 300,
"quota_fallback": false,
"account_selection_strategy": "sticky",
"pid_offset_enabled": false,
"signature_cache": {
"enabled": true,
"memory_ttl_seconds": 3600,
"disk_ttl_seconds": 172800,
"write_interval_seconds": 60
}
}

73
.opencode/opencode.json Normal file
View File

@ -0,0 +1,73 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-antigravity-auth@beta"],
"provider": {
"google": {
"models": {
"antigravity-gemini-3-pro": {
"name": "Gemini 3 Pro (Antigravity)",
"limit": { "context": 1048576, "output": 65535 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] },
"variants": {
"low": { "thinkingLevel": "low" },
"high": { "thinkingLevel": "high" }
}
},
"antigravity-gemini-3-flash": {
"name": "Gemini 3 Flash (Antigravity)",
"limit": { "context": 1048576, "output": 65536 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] },
"variants": {
"minimal": { "thinkingLevel": "minimal" },
"low": { "thinkingLevel": "low" },
"medium": { "thinkingLevel": "medium" },
"high": { "thinkingLevel": "high" }
}
},
"antigravity-claude-sonnet-4-5": {
"name": "Claude Sonnet 4.5 (Antigravity)",
"limit": { "context": 200000, "output": 64000 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
},
"antigravity-claude-sonnet-4-5-thinking": {
"name": "Claude Sonnet 4.5 Thinking (Antigravity)",
"limit": { "context": 200000, "output": 64000 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] },
"variants": {
"low": { "thinkingConfig": { "thinkingBudget": 8192 } },
"max": { "thinkingConfig": { "thinkingBudget": 32768 } }
}
},
"antigravity-claude-opus-4-5-thinking": {
"name": "Claude Opus 4.5 Thinking (Antigravity)",
"limit": { "context": 200000, "output": 64000 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] },
"variants": {
"low": { "thinkingConfig": { "thinkingBudget": 8192 } },
"max": { "thinkingConfig": { "thinkingBudget": 32768 } }
}
},
"gemini-2.5-flash": {
"name": "Gemini 2.5 Flash (Gemini CLI)",
"limit": { "context": 1048576, "output": 65536 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
},
"gemini-2.5-pro": {
"name": "Gemini 2.5 Pro (Gemini CLI)",
"limit": { "context": 1048576, "output": 65536 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
},
"gemini-3-flash-preview": {
"name": "Gemini 3 Flash Preview (Gemini CLI)",
"limit": { "context": 1048576, "output": 65536 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
},
"gemini-3-pro-preview": {
"name": "Gemini 3 Pro Preview (Gemini CLI)",
"limit": { "context": 1048576, "output": 65535 },
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
}
}
}
}
}

View File

@ -14,6 +14,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ClientController extends Controller
{
@ -152,12 +153,32 @@ class ClientController extends Controller
public function update(UpdateClientRequest $request, string $id): ClientResource|JsonResponse
{
try {
$updated = $this->clientRepository->update($id, $request->validated());
$validatedData = $request->validated();
$client = $this->clientRepository->find($id);
if (!$client) {
return response()->json([
'message' => 'Client non trouvé.',
], 404);
}
if ($request->hasFile('avatar')) {
// Delete old avatar if exists
if ($client->avatar) {
Storage::disk('public')->delete($client->avatar);
}
// Store new avatar
$path = $request->file('avatar')->store('avatars', 'public');
$validatedData['avatar'] = $path;
}
$updated = $this->clientRepository->update($id, $validatedData);
if (!$updated) {
return response()->json([
'message' => 'Client non trouvé ou échec de la mise à jour.',
], 404);
'message' => 'Échec de la mise à jour.',
], 500);
}
$client = $this->clientRepository->find($id);

View File

@ -76,7 +76,8 @@ class InterventionController extends Controller
ContactRepositoryInterface $contactRepository,
DeceasedRepositoryInterface $deceasedRepository,
QuoteRepositoryInterface $quoteRepository,
ProductRepositoryInterface $productRepository
ProductRepositoryInterface $productRepository,
private readonly \App\Repositories\ClientLocationRepositoryInterface $clientLocationRepository
) {
$this->interventionRepository = $interventionRepository;
$this->interventionPractitionerRepository = $interventionPractitionerRepository;
@ -150,9 +151,14 @@ class InterventionController extends Controller
// Wrap everything in a database transaction
$result = DB::transaction(function () use ($validated) {
// Step 1: Create the deceased
$deceasedData = $validated['deceased'];
$deceased = $this->deceasedRepository->create($deceasedData);
// Step 1: Handle Deceased (Create or Link)
$deceased = null;
if (!empty($validated['deceased_id'])) {
$deceased = $this->deceasedRepository->findById($validated['deceased_id']);
} else {
$deceasedData = $validated['deceased'];
$deceased = $this->deceasedRepository->create($deceasedData);
}
// Step 2: Create the client
$clientData = $validated['client'];
@ -168,10 +174,29 @@ class InterventionController extends Controller
$contactId = $contact->id;
}
// Step 4: Prepare location data (for now, we'll include it in intervention notes)
// In the future, you might want to create a ClientLocation entry
// Step 4: Handle Location
$locationData = $validated['location'] ?? [];
$locationId = $validated['location_id'] ?? null;
$locationNotes = '';
if (!$locationId && !empty($locationData)) {
// Create new location for the client
$locData = array_merge($locationData, [
'client_id' => $client->id,
'is_default' => false
]);
$newLocation = $this->clientLocationRepository->create($locData);
$locationId = $newLocation->id;
}
if ($locationId) {
// Fetch location to add details to notes if needed, or just rely on relation.
// For now, let's keep the legacy behavior of adding text to notes for quick reference,
// but also link the ID. Use the provided data or fetch?
// If we have an ID, we might not have the text data in $locationData if it came from search.
// So we only append text notes if we have $locationData (Create mode or if frontend sends it).
}
if (!empty($locationData)) {
$locationParts = [];
if (!empty($locationData['name'])) {
@ -196,6 +221,7 @@ class InterventionController extends Controller
$interventionData = array_merge($validated['intervention'], [
'deceased_id' => $deceased->id,
'client_id' => $client->id,
'location_id' => $locationId,
'notes' => ($validated['intervention']['notes'] ?? '') . $locationNotes
]);
$intervention = $this->interventionRepository->create($interventionData);

View File

@ -12,6 +12,9 @@ use App\Repositories\InvoiceRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Log;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Facades\Mail;
use App\Mail\DocumentMail;
class InvoiceController extends Controller
{
@ -175,4 +178,45 @@ class InvoiceController extends Controller
], 500);
}
}
/**
* Send the invoice by email to the client.
*/
public function sendByEmail(string $id): JsonResponse
{
try {
$invoice = $this->invoiceRepository->find($id);
if (!$invoice) {
return response()->json(['message' => 'Facture non trouvée.'], 404);
}
if (!$invoice->client || !$invoice->client->email) {
return response()->json(['message' => 'Le client n\'a pas d\'adresse email.'], 422);
}
// Load lines to ensure they are available in the view
$invoice->load('lines');
// Generate PDF
$pdfContent = Pdf::loadView('pdf.invoice_pdf', ['invoice' => $invoice])->output();
// Send Email
Mail::to($invoice->client->email)->send(new DocumentMail($invoice, 'invoice', $pdfContent));
return response()->json([
'message' => 'La facture a été envoyée avec succès à ' . $invoice->client->email,
], 200);
} catch (\Exception $e) {
Log::error('Error sending invoice email: ' . $e->getMessage(), [
'exception' => $e,
'invoice_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de l\'envoi de l\'email.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@ -14,6 +14,9 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Request;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Facades\Mail;
use App\Mail\DocumentMail;
class QuoteController extends Controller
{
@ -155,4 +158,45 @@ class QuoteController extends Controller
], 500);
}
}
/**
* Send the quote by email to the client.
*/
public function sendByEmail(string $id): JsonResponse
{
try {
$quote = $this->quoteRepository->find($id);
if (!$quote) {
return response()->json(['message' => 'Devis non trouvé.'], 404);
}
if (!$quote->client || !$quote->client->email) {
return response()->json(['message' => 'Le client n\'a pas d\'adresse email.'], 422);
}
// Load lines to ensure they are available in the view
$quote->load('lines');
// Generate PDF
$pdfContent = Pdf::loadView('pdf.quote_pdf', ['quote' => $quote])->output();
// Send Email
Mail::to($quote->client->email)->send(new DocumentMail($quote, 'quote', $pdfContent));
return response()->json([
'message' => 'Le devis a été envoyé avec succès à ' . $quote->client->email,
], 200);
} catch (\Exception $e) {
Log::error('Error sending quote email: ' . $e->getMessage(), [
'exception' => $e,
'quote_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de l\'envoi de l\'email.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@ -23,8 +23,9 @@ class StoreInterventionWithAllDataRequest extends FormRequest
public function rules(): array
{
return [
'deceased' => 'required|array',
'deceased.last_name' => ['required', 'string', 'max:191'],
'deceased_id' => ['nullable', 'exists:deceased,id'],
'deceased' => ['required_without:deceased_id', 'array'],
'deceased.last_name' => ['required_without:deceased_id', 'string', 'max:191'],
'deceased.first_name' => ['nullable', 'string', 'max:191'],
'deceased.birth_date' => ['nullable', 'date'],
'deceased.death_date' => ['nullable', 'date', 'after_or_equal:deceased.birth_date'],
@ -51,10 +52,11 @@ class StoreInterventionWithAllDataRequest extends FormRequest
'contact.phone' => ['nullable', 'string', 'max:50'],
'contact.role' => ['nullable', 'string', 'max:191'],
'location' => 'nullable|array',
'location.name' => ['nullable', 'string', 'max:255'],
'location_id' => ['nullable', 'exists:client_locations,id'],
'location' => ['nullable', 'array'],
'location.name' => ['required_without:location_id', 'string', 'max:255'],
'location.address' => ['nullable', 'string', 'max:255'],
'location.city' => ['nullable', 'string', 'max:191'],
'location.city' => ['required_without:location_id', 'string', 'max:191'],
'location.postal_code' => ['nullable', 'string', 'max:20'],
'location.country_code' => ['nullable', 'string', 'size:2'],
'location.access_instructions' => ['nullable', 'string'],

View File

@ -39,6 +39,7 @@ class UpdateClientRequest extends FormRequest
'is_parent' => 'boolean|nullable',
'parent_id' => 'nullable|exists:clients,id',
'default_tva_rate_id' => 'nullable|exists:tva_rates,id',
'avatar' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg|max:2048',
];
}

View File

@ -5,6 +5,7 @@ namespace App\Http\Resources\Client;
use App\Http\Resources\Contact\ContactResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
class ClientResource extends JsonResource
{
@ -25,6 +26,7 @@ class ClientResource extends JsonResource
'siret' => $this->siret,
'email' => $this->email,
'phone' => $this->phone,
'avatar_url' => $this->avatar ? Storage::url($this->avatar) : null,
'billing_address' => [
'line1' => $this->billing_address_line1,
'line2' => $this->billing_address_line2,

View File

@ -0,0 +1,70 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class DocumentMail extends Mailable
{
use Queueable, SerializesModels;
public $document;
public $type;
public $pdfContent;
/**
* Create a new message instance.
*/
public function __construct($document, string $type, $pdfContent)
{
$this->document = $document;
$this->type = $type;
$this->pdfContent = $pdfContent;
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
$subject = $this->type === 'quote'
? "Votre devis Thanasoft : " . $this->document->reference
: "Votre facture Thanasoft : " . $this->document->invoice_number;
return new Envelope(
subject: $subject,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
view: 'emails.document',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
$filename = $this->type === 'quote'
? 'Devis_' . $this->document->reference . '.pdf'
: 'Facture_' . $this->document->invoice_number . '.pdf';
return [
Attachment::fromData(fn () => $this->pdfContent, $filename)
->withMime('application/pdf'),
];
}
}

View File

@ -28,6 +28,7 @@ class Client extends Model
// 'default_tva_rate_id',
'client_category_id',
'user_id',
'avatar',
];
protected $casts = [

View File

@ -10,6 +10,7 @@
"license": "MIT",
"require": {
"php": "^8.2",
"barryvdh/laravel-dompdf": "^3.1",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.2",
"laravel/tinker": "^2.10.1"

View File

@ -4,8 +4,85 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "8f387a0734f3bf879214e4aa2fca6e2f",
"content-hash": "343ecaac4a8b061c5430a046847047e7",
"packages": [
{
"name": "barryvdh/laravel-dompdf",
"version": "v3.1.1",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-dompdf.git",
"reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
"reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
"shasum": ""
},
"require": {
"dompdf/dompdf": "^3.0",
"illuminate/support": "^9|^10|^11|^12",
"php": "^8.1"
},
"require-dev": {
"larastan/larastan": "^2.7|^3.0",
"orchestra/testbench": "^7|^8|^9|^10",
"phpro/grumphp": "^2.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
},
"providers": [
"Barryvdh\\DomPDF\\ServiceProvider"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Barryvdh\\DomPDF\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "A DOMPDF Wrapper for Laravel",
"keywords": [
"dompdf",
"laravel",
"pdf"
],
"support": {
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.1"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2025-02-13T15:07:54+00:00"
},
{
"name": "brick/math",
"version": "0.14.0",
@ -377,6 +454,161 @@
],
"time": "2024-02-05T11:56:58+00:00"
},
{
"name": "dompdf/dompdf",
"version": "v3.1.4",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "db712c90c5b9868df3600e64e68da62e78a34623"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623",
"reference": "db712c90c5b9868df3600e64e68da62e78a34623",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.4"
},
"time": "2025-10-29T12:43:30+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.1",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d",
"reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.1"
},
"time": "2024-12-02T14:37:59+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
"time": "2026-01-02T16:01:13+00:00"
},
{
"name": "dragonmantank/cron-expression",
"version": "v3.4.0",
@ -2074,6 +2306,73 @@
],
"time": "2024-12-08T08:18:47+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.0",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "technosophos@gmail.com"
},
{
"name": "Matt Farina",
"email": "matt@mattfarina.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
},
"time": "2025-07-25T09:04:22+00:00"
},
{
"name": "monolog/monolog",
"version": "3.9.0",
@ -3412,6 +3711,80 @@
},
"time": "2025-09-04T20:59:21+00:00"
},
{
"name": "sabberworm/php-css-parser",
"version": "v9.1.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb",
"reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.3"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.28 || 2.1.25",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.7",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.6",
"phpunit/phpunit": "8.5.46",
"rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.1.7",
"rector/type-perfect": "1.0.0 || 2.1.0"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.2.x-dev"
}
},
"autoload": {
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.1.0"
},
"time": "2025-09-14T07:37:21+00:00"
},
{
"name": "symfony/clock",
"version": "v7.3.0",
@ -5890,6 +6263,145 @@
],
"time": "2025-08-13T11:49:31+00:00"
},
{
"name": "thecodingmachine/safe",
"version": "v3.3.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/2cdd579eeaa2e78e51c7509b50cc9fb89a956236",
"reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.3.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2025-05-14T06:15:44+00:00"
},
{
"name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.3.0",
@ -8527,12 +9039,12 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.2"
},
"platform-dev": [],
"plugin-api-version": "2.6.0"
"platform-dev": {},
"plugin-api-version": "2.9.0"
}

View File

@ -0,0 +1,28 @@
<?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('clients', function (Blueprint $table) {
$table->string('avatar')->nullable()->after('phone');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('clients', function (Blueprint $table) {
$table->dropColumn('avatar');
});
}
};

View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: 'Helvetica', 'Arial', sans-serif; color: #333; line-height: 1.6; }
.container { width: 90%; margin: 20px auto; padding: 20px; border: 1px solid #eee; border-radius: 8px; }
.header { text-align: center; margin-bottom: 30px; }
.footer { margin-top: 40px; font-size: 12px; color: #777; border-top: 1px solid #eee; padding-top: 10px; }
.button { display: inline-block; padding: 10px 20px; background-color: #5d8a66; color: white; text-decoration: none; border-radius: 5px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2 style="color: #5d8a66;">Thanasoft</h2>
</div>
<p>Bonjour {{ $document->client->name }},</p>
@if($type === 'quote')
<p>Veuillez trouver ci-joint le devis <strong>{{ $document->reference }}</strong> suite à votre demande.</p>
<p>Ce devis est valable jusqu'au {{ $document->valid_until->format('d/m/Y') }}.</p>
@else
<p>Veuillez trouver ci-joint la facture <strong>{{ $document->invoice_number }}</strong> d'un montant de {{ number_format($document->total_ttc, 2, ',', ' ') }} {{ $document->currency }}.</p>
<p>La date d'échéance est fixée au {{ $document->due_date->format('d/m/Y') }}.</p>
@endif
<p>N'hésitez pas à nous contacter pour toute question supplémentaire.</p>
<p>Cordialement,<br>
L'équipe Thanasoft</p>
<div class="footer">
Ceci est un message automatique, merci de ne pas y répondre directement.
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,73 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Facture {{ $invoice->invoice_number }}</title>
<style>
body { font-family: 'Helvetica', 'Arial', sans-serif; color: #333; line-height: 1.5; }
.header { margin-bottom: 30px; border-bottom: 2px solid #5d8a66; padding-bottom: 10px; }
.company-info { float: left; width: 50%; }
.client-info { float: right; width: 40%; text-align: right; }
.clear { clear: both; }
.title { font-size: 24px; color: #5d8a66; margin-bottom: 20px; text-transform: uppercase; }
.details-table { width: 100%; border-collapse: collapse; margin-top: 30px; }
.details-table th { background-color: #5d8a66; color: white; padding: 10px; text-align: left; }
.details-table td { padding: 10px; border-bottom: 1px solid #eee; }
.totals { float: right; width: 30%; margin-top: 20px; }
.totals-row { display: block; text-align: right; padding: 5px 0; }
.totals-row.grand-total { font-weight: bold; border-top: 2px solid #5d8a66; margin-top: 10px; padding-top: 10px; color: #5d8a66; font-size: 18px; }
.footer { position: fixed; bottom: 0; width: 100%; text-align: center; font-size: 10px; color: #777; border-top: 1px solid #eee; padding-top: 10px; }
</style>
</head>
<body>
<div class="header">
<div class="company-info">
<h1>THANASOFT</h1>
<p>Solutions de gestion funéraire</p>
</div>
<div class="client-info">
<h3>CLIENT</h3>
<p><strong>{{ $invoice->client->name }}</strong></p>
<p>{{ $invoice->client->billing_address_line1 }}</p>
<p>{{ $invoice->client->billing_postal_code }} {{ $invoice->client->billing_city }}</p>
</div>
<div class="clear"></div>
</div>
<div class="title">Facture : {{ $invoice->invoice_number }}</div>
<p>Date : {{ $invoice->invoice_date->format('d/m/Y') }}<br>
Échéance : {{ $invoice->due_date->format('d/m/Y') }}</p>
<table class="details-table">
<thead>
<tr>
<th>Description</th>
<th style="text-align: right;">Quantité</th>
<th style="text-align: right;">Prix Unitaire</th>
<th style="text-align: right;">Total HT</th>
</tr>
</thead>
<tbody>
@foreach($invoice->lines as $line)
<tr>
<td>{{ $line->description }}</td>
<td style="text-align: right;">{{ $line->quantity }}</td>
<td style="text-align: right;">{{ number_format($line->unit_price, 2, ',', ' ') }} {{ $invoice->currency }}</td>
<td style="text-align: right;">{{ number_format($line->total_ht, 2, ',', ' ') }} {{ $invoice->currency }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="totals">
<div class="totals-row">Total HT : {{ number_format($invoice->total_ht, 2, ',', ' ') }} {{ $invoice->currency }}</div>
<div class="totals-row">TVA : {{ number_format($invoice->total_tva, 2, ',', ' ') }} {{ $invoice->currency }}</div>
<div class="totals-row grand-total">TOTAL TTC : {{ number_format($invoice->total_ttc, 2, ',', ' ') }} {{ $invoice->currency }}</div>
</div>
<div class="footer">
Thanasoft - SIRET: XXXXXXXXXXXXXX - TVA: FRXXXXXXXXXXX<br>
Document généré le {{ now()->format('d/m/Y H:i') }}
</div>
</body>
</html>

View File

@ -0,0 +1,73 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Devis {{ $quote->reference }}</title>
<style>
body { font-family: 'Helvetica', 'Arial', sans-serif; color: #333; line-height: 1.5; }
.header { margin-bottom: 30px; border-bottom: 2px solid #5d8a66; padding-bottom: 10px; }
.company-info { float: left; width: 50%; }
.client-info { float: right; width: 40%; text-align: right; }
.clear { clear: both; }
.title { font-size: 24px; color: #5d8a66; margin-bottom: 20px; text-transform: uppercase; }
.details-table { width: 100%; border-collapse: collapse; margin-top: 30px; }
.details-table th { background-color: #5d8a66; color: white; padding: 10px; text-align: left; }
.details-table td { padding: 10px; border-bottom: 1px solid #eee; }
.totals { float: right; width: 30%; margin-top: 20px; }
.totals-row { display: block; text-align: right; padding: 5px 0; }
.totals-row.grand-total { font-weight: bold; border-top: 2px solid #5d8a66; margin-top: 10px; padding-top: 10px; color: #5d8a66; font-size: 18px; }
.footer { position: fixed; bottom: 0; width: 100%; text-align: center; font-size: 10px; color: #777; border-top: 1px solid #eee; padding-top: 10px; }
</style>
</head>
<body>
<div class="header">
<div class="company-info">
<h1>THANASOFT</h1>
<p>Solutions de gestion funéraire</p>
</div>
<div class="client-info">
<h3>CLIENT</h3>
<p><strong>{{ $quote->client->name }}</strong></p>
<p>{{ $quote->client->billing_address_line1 }}</p>
<p>{{ $quote->client->billing_postal_code }} {{ $quote->client->billing_city }}</p>
</div>
<div class="clear"></div>
</div>
<div class="title">Devis : {{ $quote->reference }}</div>
<p>Date : {{ $quote->quote_date->format('d/m/Y') }}<br>
Valable jusqu'au : {{ $quote->valid_until->format('d/m/Y') }}</p>
<table class="details-table">
<thead>
<tr>
<th>Description</th>
<th style="text-align: right;">Quantité</th>
<th style="text-align: right;">Prix Unitaire</th>
<th style="text-align: right;">Total HT</th>
</tr>
</thead>
<tbody>
@foreach($quote->lines as $line)
<tr>
<td>{{ $line->description }}</td>
<td style="text-align: right;">{{ $line->quantity }}</td>
<td style="text-align: right;">{{ number_format($line->unit_price, 2, ',', ' ') }} {{ $quote->currency }}</td>
<td style="text-align: right;">{{ number_format($line->total_ht, 2, ',', ' ') }} {{ $quote->currency }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="totals">
<div class="totals-row">Total HT : {{ number_format($quote->total_ht, 2, ',', ' ') }} {{ $quote->currency }}</div>
<div class="totals-row">TVA : {{ number_format($quote->total_tva, 2, ',', ' ') }} {{ $quote->currency }}</div>
<div class="totals-row grand-total">TOTAL TTC : {{ number_format($quote->total_ttc, 2, ',', ' ') }} {{ $quote->currency }}</div>
</div>
<div class="footer">
Thanasoft - SIRET: XXXXXXXXXXXXXX - TVA: FRXXXXXXXXXXX<br>
Document généré le {{ now()->format('d/m/Y H:i') }}
</div>
</body>
</html>

View File

@ -72,9 +72,11 @@ Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('client-categories', ClientCategoryController::class);
// Quote management
Route::post('/quotes/{id}/send-email', [QuoteController::class, 'sendByEmail']);
Route::apiResource('quotes', QuoteController::class);
// Invoice management
Route::post('/invoices/{id}/send-email', [\App\Http\Controllers\Api\InvoiceController::class, 'sendByEmail']);
Route::post('/invoices/from-quote/{quoteId}', [\App\Http\Controllers\Api\InvoiceController::class, 'createFromQuote']);
Route::apiResource('invoices', \App\Http\Controllers\Api\InvoiceController::class);

View File

@ -167,7 +167,7 @@ const steps = [
{ label: "Défunt", title: "Information du Défunt" },
{ label: "Client", title: "Information du Client" },
{ label: "Lieu", title: "Lieu de l'intervention" },
{ label: "Produit", title: "Choix du Produit" },
{ label: "Type de soins", title: "Sélection du type de soins" },
{ label: "Documents", title: "Documents à joindre" },
{ label: "Intervention", title: "Détails de l'intervention" },
];
@ -178,6 +178,8 @@ const globalErrors = ref([]);
// Form data
const deceasedForm = ref({
id: null,
is_existing: false, // UI state to track mode
first_name: "",
last_name: "",
birth_date: "",
@ -203,6 +205,8 @@ const clientForm = ref({
});
const locationForm = ref({
id: null,
is_existing: false,
name: "",
address: "",
city: "",
@ -273,10 +277,14 @@ const addStepError = (stepIndex, field, message) => {
const validateDeceasedStep = () => {
clearStepErrors(0);
let isValid = true;
if (!deceasedForm.value.last_name) {
if (!deceasedForm.value.is_existing && !deceasedForm.value.last_name) {
addStepError(0, "last_name", "Le nom est obligatoire.");
isValid = false;
}
if (deceasedForm.value.is_existing && !deceasedForm.value.id) {
addStepError(0, "deceased_id", "Veuillez sélectionner un défunt.");
isValid = false;
}
return isValid;
};
@ -293,10 +301,14 @@ const validateClientStep = () => {
const validateLocationStep = () => {
clearStepErrors(2);
let isValid = true;
if (!locationForm.value.city) {
if (!locationForm.value.is_existing && !locationForm.value.city) {
addStepError(2, "city", "La ville est obligatoire.");
isValid = false;
}
if (locationForm.value.is_existing && !locationForm.value.id) {
addStepError(2, "location_id", "Veuillez sélectionner un lieu.");
isValid = false;
}
return isValid;
};
@ -304,7 +316,7 @@ const validateProductStep = () => {
clearStepErrors(3);
let isValid = true;
if (!productForm.value.product_id) {
addStepError(3, "product_id", "Veuillez sélectionner un produit.");
addStepError(3, "product_id", "Veuillez sélectionner un type de soins.");
isValid = false;
}
return isValid;
@ -419,20 +431,28 @@ const handleSubmit = async () => {
const formData = new FormData();
// Deceased
Object.keys(deceasedForm.value).forEach((key) => {
if (deceasedForm.value[key] != null)
formData.append(`deceased[${key}]`, deceasedForm.value[key]);
});
if (deceasedForm.value.is_existing && deceasedForm.value.id) {
formData.append("deceased_id", deceasedForm.value.id);
} else {
Object.keys(deceasedForm.value).forEach((key) => {
if (deceasedForm.value[key] != null && key !== 'id' && key !== 'is_existing')
formData.append(`deceased[${key}]`, deceasedForm.value[key]);
});
}
// Client
Object.keys(clientForm.value).forEach((key) => {
if (clientForm.value[key] != null)
formData.append(`client[${key}]`, clientForm.value[key]);
});
// Location
Object.keys(locationForm.value).forEach((key) => {
if (locationForm.value[key] != null)
formData.append(`location[${key}]`, locationForm.value[key]);
});
if (locationForm.value.is_existing && locationForm.value.id) {
formData.append("location_id", locationForm.value.id);
} else {
Object.keys(locationForm.value).forEach((key) => {
if (locationForm.value[key] != null && key !== 'id' && key !== 'is_existing')
formData.append(`location[${key}]`, locationForm.value[key]);
});
}
// Intervention
Object.keys(interventionForm.value).forEach((key) => {
if (interventionForm.value[key] != null) {

View File

@ -2,7 +2,85 @@
<div class="multisteps-form__panel" :class="isActive ? activeClass : ''">
<h6 class="mb-3 text-dark font-weight-bold">Informations du Défunt</h6>
<div class="row">
<div class="row mb-4">
<div class="col-12 d-flex justify-content-center">
<div class="btn-group" role="group">
<input
type="radio"
class="btn-check"
name="deceasedMode"
id="modeNew"
autocomplete="off"
:checked="!formData.is_existing"
@change="formData.is_existing = false; formData.id = null"
>
<label class="btn btn-outline-primary" for="modeNew">Nouveau Défunt</label>
<input
type="radio"
class="btn-check"
name="deceasedMode"
id="modeSearch"
autocomplete="off"
:checked="formData.is_existing"
@change="formData.is_existing = true"
>
<label class="btn btn-outline-primary" for="modeSearch">Rechercher</label>
</div>
</div>
</div>
<!-- SEARCH MODE -->
<div v-if="formData.is_existing" class="row">
<div class="col-12 mb-3 position-relative">
<label class="form-label">Rechercher un défunt (Nom, Prénom)</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input
v-model="searchQuery"
type="text"
class="form-control"
:class="{ 'is-invalid': hasError('deceased_id') }"
placeholder="Tapez pour rechercher..."
@input="handleSearchInput"
/>
<button v-if="formData.id" class="btn btn-outline-secondary" type="button" @click="clearSelection">
<i class="fas fa-times"></i>
</button>
</div>
<div v-if="getFieldError('deceased_id')" class="invalid-feedback d-block">
{{ getFieldError("deceased_id") }}
</div>
<!-- Dropdown Results -->
<div v-if="showResults && searchResults.length" class="list-group position-absolute w-100 shadow" style="z-index: 1000; max-height: 200px; overflow-y: auto;">
<button
v-for="deceased in searchResults"
:key="deceased.id"
type="button"
class="list-group-item list-group-item-action"
@click="selectDeceased(deceased)"
>
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">{{ deceased.first_name }} {{ deceased.last_name }}</h6>
<small>{{ deceased.birth_date }} - {{ deceased.death_date }}</small>
</div>
</button>
</div>
<div v-if="showResults && searchResults.length === 0 && searchQuery.length >= 2 && !isSearching" class="list-group position-absolute w-100 shadow" style="z-index: 1000;">
<div class="list-group-item text-muted">Aucun résultat trouvé.</div>
</div>
</div>
<div v-if="formData.id" class="col-12">
<div class="alert alert-info">
<strong>Défunt sélectionné:</strong> {{ formData.first_name }} {{ formData.last_name }}
</div>
</div>
</div>
<!-- CREATE MODE -->
<div v-else class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Prénom</label>
<input
@ -114,7 +192,8 @@
</template>
<script setup>
import { defineProps, defineEmits, toRefs } from "vue";
import { defineProps, defineEmits, ref, watch } from "vue";
import DeceasedService from "@/services/deceased";
const props = defineProps({
formData: {
@ -141,6 +220,51 @@ const props = defineProps({
const emit = defineEmits(["next"]);
// Search state
const searchQuery = ref("");
const searchResults = ref([]);
const isSearching = ref(false);
const showResults = ref(false);
// Debounce search
let searchTimeout;
const handleSearchInput = () => {
if (searchTimeout) clearTimeout(searchTimeout);
if (searchQuery.value.length < 2) {
searchResults.value = [];
showResults.value = false;
return;
}
isSearching.value = true;
searchTimeout = setTimeout(async () => {
try {
const results = await DeceasedService.searchDeceased(searchQuery.value);
searchResults.value = results;
showResults.value = true;
} catch (e) {
console.error("Search failed", e);
} finally {
isSearching.value = false;
}
}, 300);
};
const selectDeceased = (deceased) => {
props.formData.id = deceased.id;
props.formData.first_name = deceased.first_name;
props.formData.last_name = deceased.last_name;
searchQuery.value = `${deceased.first_name || ""} ${deceased.last_name}`;
showResults.value = false;
};
const clearSelection = () => {
props.formData.id = null;
searchQuery.value = "";
searchResults.value = [];
};
// Error helpers using props
const getFieldError = (field) => {
const error = props.errors.find((err) => err.field === field);

View File

@ -2,7 +2,87 @@
<div class="multisteps-form__panel" :class="isActive ? activeClass : ''">
<h6 class="mb-3 text-dark font-weight-bold">Lieu de l'intervention</h6>
<div class="row">
<div class="row mb-4">
<div class="col-12 d-flex justify-content-center">
<div class="btn-group" role="group">
<input
type="radio"
class="btn-check"
name="locationMode"
id="locModeNew"
autocomplete="off"
:checked="!formData.is_existing"
@change="formData.is_existing = false; formData.id = null"
>
<label class="btn btn-outline-primary" for="locModeNew">Nouveau Lieu</label>
<input
type="radio"
class="btn-check"
name="locationMode"
id="locModeSearch"
autocomplete="off"
:checked="formData.is_existing"
@change="formData.is_existing = true"
>
<label class="btn btn-outline-primary" for="locModeSearch">Rechercher</label>
</div>
</div>
</div>
<!-- SEARCH MODE -->
<div v-if="formData.is_existing" class="row">
<div class="col-12 mb-3 position-relative">
<label class="form-label">Rechercher un lieu (Nom, Ville...)</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input
v-model="searchQuery"
type="text"
class="form-control"
:class="{ 'is-invalid': hasError('location_id') }"
placeholder="Hôpital, Funérarium, Ville..."
@input="handleSearchInput"
/>
<button v-if="formData.id" class="btn btn-outline-secondary" type="button" @click="clearSelection">
<i class="fas fa-times"></i>
</button>
</div>
<div v-if="getFieldError('location_id')" class="invalid-feedback d-block">
{{ getFieldError("location_id") }}
</div>
<!-- Dropdown Results -->
<div v-if="showResults && searchResults.length" class="list-group position-absolute w-100 shadow" style="z-index: 1000; max-height: 200px; overflow-y: auto;">
<button
v-for="loc in searchResults"
:key="loc.id"
type="button"
class="list-group-item list-group-item-action"
@click="selectLocation(loc)"
>
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">{{ loc.name || 'Lieu sans nom' }}</h6>
<small>{{ loc.city }}</small>
</div>
<small class="text-muted">{{ loc.address_line1 }}</small>
</button>
</div>
<div v-if="showResults && searchResults.length === 0 && searchQuery.length >= 2 && !isSearching" class="list-group position-absolute w-100 shadow" style="z-index: 1000;">
<div class="list-group-item text-muted">Aucun résultat trouvé.</div>
</div>
</div>
<div v-if="formData.id" class="col-12">
<div class="alert alert-info">
<strong>Lieu sélectionné:</strong> {{ formData.name }} <br/>
<small>{{ formData.address }}, {{ formData.postal_code }} {{ formData.city }}</small>
</div>
</div>
</div>
<!-- CREATE MODE -->
<div v-else class="row">
<div class="col-md-12 mb-3">
<label class="form-label">Nom du lieu</label>
<input
@ -116,7 +196,8 @@
</template>
<script setup>
import { defineProps, defineEmits } from "vue";
import { defineProps, defineEmits, ref, watch } from "vue";
import ClientLocationService from "@/services/client_location";
const props = defineProps({
formData: {
@ -143,6 +224,63 @@ const props = defineProps({
defineEmits(["next", "prev"]);
// Search state
const searchQuery = ref("");
const searchResults = ref([]);
const isSearching = ref(false);
const showResults = ref(false);
// Debounce search
let searchTimeout;
const handleSearchInput = () => {
if (searchTimeout) clearTimeout(searchTimeout);
if (searchQuery.value.length < 2) {
searchResults.value = [];
showResults.value = false;
return;
}
isSearching.value = true;
searchTimeout = setTimeout(async () => {
try {
// Use getAllClientLocations with search param
const response = await ClientLocationService.getAllClientLocations({
search: searchQuery.value,
per_page: 10
});
searchResults.value = response.data;
showResults.value = true;
} catch (e) {
console.error("Location search failed", e);
} finally {
isSearching.value = false;
}
}, 300);
};
const selectLocation = (location) => {
props.formData.id = location.id;
props.formData.name = location.name;
props.formData.address = location.address_line1; // Map address_line1 to address
props.formData.city = location.city;
props.formData.postal_code = location.postal_code;
props.formData.country_code = location.country_code;
// Construct display string
let display = location.name || "";
if (location.city) display += ` (${location.city})`;
searchQuery.value = display;
showResults.value = false;
};
const clearSelection = () => {
props.formData.id = null;
searchQuery.value = "";
searchResults.value = [];
};
const getFieldError = (field) => {
const error = props.errors.find((err) => err.field === field);
return error ? error.message : "";

View File

@ -1,57 +1,60 @@
<template>
<div class="multisteps-form__panel" :class="isActive ? activeClass : ''">
<h6 class="mb-3 text-dark font-weight-bold">Sélection du Produit</h6>
<!-- Removed Header and specific Label -->
<div class="row">
<div class="col-md-12 mb-3">
<label class="form-label">Type de produit *</label>
<div class="d-flex flex-column gap-2">
<div v-if="loading" class="text-center py-3">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<div
v-else-if="interventionProducts.length === 0"
class="text-center py-3 text-muted"
>
Aucun produit d'intervention trouvé.
</div>
<div
v-for="product in interventionProducts"
v-else
:key="product.id"
class="form-check p-3 border rounded"
:class="{
'border-primary bg-light': formData.product_id === product.id,
}"
>
<div class="col-md-12 mb-3 position-relative">
<label class="form-label">Type de soins</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input
:id="'product-' + product.id"
v-model="formData.product_id"
class="form-check-input"
type="radio"
name="productSelection"
:value="product.id"
v-model="searchQuery"
type="text"
class="form-control"
:class="{ 'is-invalid': hasError('product_id') }"
placeholder="Rechercher un soin..."
@input="handleSearchInput"
@focus="showResults = true"
/>
<label
class="form-check-label fw-bold w-100"
:for="'product-' + product.id"
>
{{ product.nom }}
<div class="text-muted fw-normal small">
{{ product.description || product.reference }}
</div>
</label>
</div>
<button v-if="formData.product_id" class="btn btn-outline-secondary" type="button" @click="clearSelection">
<i class="fas fa-times"></i>
</button>
</div>
<div
v-if="getFieldError('product_type')"
v-if="getFieldError('product_id')"
class="invalid-feedback d-block"
>
{{ getFieldError("product_type") }}
{{ getFieldError("product_id") }}
</div>
<!-- Dropdown Results -->
<div v-if="showResults" class="list-group position-absolute w-100 shadow" style="z-index: 1000; max-height: 250px; overflow-y: auto;">
<div v-if="loading" class="list-group-item text-center">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
<button
v-else
v-for="product in searchResults"
:key="product.id"
type="button"
class="list-group-item list-group-item-action"
@click="selectProduct(product)"
>
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">{{ product.nom }}</h6>
<small>{{ product.reference }}</small>
</div>
<small class="text-muted">{{ product.description }}</small>
</button>
<div v-if="!loading && searchResults.length === 0" class="list-group-item text-muted">Aucun résultat trouvé.</div>
</div>
<!-- Selected Product Display -->
<div v-if="formData.product_id && selectedProductDisplay" class="alert alert-info mt-3">
<strong>Soin sélectionné:</strong> {{ selectedProductDisplay }}
</div>
</div>
</div>
@ -81,7 +84,7 @@
<script setup>
import { defineProps, defineEmits, onMounted, ref } from "vue";
import { useProductStore } from "@/stores/productStore";
import ProductService from "@/services/product";
const props = defineProps({
formData: {
@ -108,28 +111,88 @@ const props = defineProps({
defineEmits(["next", "prev"]);
const productStore = useProductStore();
const interventionProducts = ref([]);
const productService = new ProductService();
const instanceService = new ProductService(); // Fix instantiation if needed or use static
// Actually services are usually exported as objects or classes.
// Looking at product.ts content: 'class ProductService' and 'export default ProductService' at end but usually instantiated?
// The file has 'class ProductService' but also looks like it might be designed to be used as new ProductService().
// Wait, looking at `product.ts` again:
// `export default ProductService;` and it's a class.
// So `new ProductService()` is correct.
// Search state
const searchQuery = ref("");
const searchResults = ref([]);
const allProducts = ref([]);
const showResults = ref(false);
const selectedProductDisplay = ref("");
const loading = ref(false);
const filterProducts = () => {
if (!searchQuery.value) {
searchResults.value = allProducts.value;
return;
}
const query = searchQuery.value.toLowerCase();
searchResults.value = allProducts.value.filter(p =>
p.nom.toLowerCase().includes(query) ||
(p.reference && p.reference.toLowerCase().includes(query)) ||
(p.description && p.description.toLowerCase().includes(query))
);
};
onMounted(async () => {
loading.value = true;
try {
// Fetch products that belong to intervention categories
const response = await productStore.fetchProducts({
is_intervention: true,
per_page: 50,
const service = new ProductService();
// Fetch all intervention products (using existing logic or new method if needed)
// Assuming getProductsByCategory or getAllProducts with filter works.
// The previous code used productStore.fetchProducts({ is_intervention: true }).
// ProductService has getAllProducts but doesn't seem to explicitly have is_intervention param in interface,
// but the store might have passed it.
// Let's check ProductService.getAllProducts code in product.ts.
// It takes params object. We can pass 'is_intervention': true/1 if backend supports it.
// Based on previous code, it seems supported.
const response = await service.getAllProducts({
per_page: 100, // Fetch enough
is_intervention: true // Assuming backend handles this param as before
});
interventionProducts.value = response.data;
} catch (error) {
console.error("Error fetching intervention products:", error);
// Check response structure. product.ts says ProductListResponse { data: Product[] ... }
allProducts.value = response.data;
searchResults.value = allProducts.value;
} catch (e) {
console.error("Failed to load products", e);
} finally {
loading.value = false;
}
});
const handleSearchInput = () => {
showResults.value = true;
filterProducts();
};
const selectProduct = (product) => {
props.formData.product_id = product.id;
selectedProductDisplay.value = product.nom;
searchQuery.value = product.nom;
showResults.value = false;
};
const clearSelection = () => {
props.formData.product_id = null;
selectedProductDisplay.value = "";
searchQuery.value = "";
searchResults.value = [];
};
const getFieldError = (field) => {
const error = props.errors.find((err) => err.field === field);
return error ? error.message : "";
};
const hasError = (field) => {
return props.errors.some((err) => err.field === field);
};
</script>

View File

@ -19,7 +19,7 @@
</template>
<template #client-detail-sidebar>
<ClientDetailSidebar
:avatar-url="clientAvatar"
:avatar-url="localAvatar || clientAvatar"
:initials="getInitials(client.name)"
:client-name="client.name"
:client-type="client.type_label || 'Client'"
@ -29,13 +29,15 @@
:is-active="client.is_active"
:is-parent="client.is_parent"
:active-tab="activeTab"
:has-unsaved-changes="hasUnsavedChanges"
@edit-avatar="triggerFileInput"
@save-avatar="handleSaveAvatar"
@change-tab="$emit('change-tab', $event)"
/>
</template>
<template #file-input>
<input
:ref="fileInput"
ref="fileInputRef"
type="file"
class="d-none"
accept="image/*"
@ -64,7 +66,7 @@
</client-detail-template>
</template>
<script setup>
import { defineProps, defineEmits, ref } from "vue";
import { defineProps, defineEmits, ref, watch } from "vue";
import ClientDetailTemplate from "@/components/templates/CRM/ClientDetailTemplate.vue";
import ClientDetailSidebar from "./client/ClientDetailSidebar.vue";
import ClientDetailContent from "./client/ClientDetailContent.vue";
@ -102,10 +104,6 @@ const props = defineProps({
type: String,
default: "overview",
},
fileInput: {
type: Object,
required: true,
},
contactLoading: {
type: Boolean,
default: false,
@ -116,16 +114,25 @@ const props = defineProps({
},
});
const fileInputRef = ref(null);
const localAvatar = ref(props.clientAvatar);
const hasUnsavedChanges = ref(false);
const selectedFile = ref(null);
watch(
() => props.clientAvatar,
(newAvatar) => {
localAvatar.value = newAvatar;
hasUnsavedChanges.value = false;
}
);
const emit = defineEmits([
"updateTheClient",
"handleFileInput",
"add-new-contact",
"updating-contact",
"add-new-location",
"modify-location",
"modify-location",
"remove-location",
"change-tab",
]);
@ -133,22 +140,33 @@ const emit = defineEmits([
const handleAvatarUpload = (event) => {
const file = event.target.files[0];
if (file) {
selectedFile.value = file;
const reader = new FileReader();
reader.onload = (e) => {
localAvatar.value = e.target.result;
// TODO: Upload to server
console.log("Upload avatar to server");
hasUnsavedChanges.value = true;
};
reader.readAsDataURL(file);
}
};
const handleSaveAvatar = () => {
if (selectedFile.value) {
const formData = new FormData();
formData.append("avatar", selectedFile.value);
// The parent (ClientDetails.vue) will add the client ID
emit("updateTheClient", formData);
}
};
const handleUpdateClient = (updateData) => {
emit("updateTheClient", updateData);
};
const inputFile = () => {
emit("handleFileInput");
const triggerFileInput = () => {
if (fileInputRef.value) {
fileInputRef.value.click();
}
};
const handleAddContact = (data) => {

View File

@ -8,7 +8,9 @@
:client-type="clientType"
:contacts-count="contactsCount"
:is-active="isActive"
:has-unsaved-changes="hasUnsavedChanges"
@edit-avatar="$emit('edit-avatar')"
@save-avatar="$emit('save-avatar')"
/>
<hr class="horizontal dark my-3 mx-3" />
@ -72,9 +74,13 @@ defineProps({
type: String,
required: true,
},
hasUnsavedChanges: {
type: Boolean,
default: false,
},
});
defineEmits(["edit-avatar", "change-tab"]);
defineEmits(["edit-avatar", "save-avatar", "change-tab"]);
</script>
<style scoped>

View File

@ -15,19 +15,36 @@
</span>
</div>
<!-- Edit Avatar Button -->
<a
v-if="editable"
href="javascript:;"
class="btn btn-sm btn-icon-only bg-gradient-light position-absolute bottom-0 end-0 mb-n2 me-n2"
@click="$emit('edit')"
>
<i
class="fa fa-pen top-0"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Modifier l'image"
></i>
</a>
<div class="position-absolute bottom-0 end-0 mb-n2 me-n2 d-flex gap-1">
<a
v-if="editable"
href="javascript:;"
class="btn btn-sm btn-icon-only bg-gradient-light"
@click="$emit('edit')"
>
<i
class="fa fa-pen"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Modifier l'image"
></i>
</a>
<!-- Save Avatar Button -->
<a
v-if="hasUnsavedChanges"
href="javascript:;"
class="btn btn-sm btn-icon-only bg-gradient-success"
@click="$emit('save')"
>
<i
class="fa fa-save"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Enregistrer l'image"
></i>
</a>
</div>
</div>
</template>
@ -50,9 +67,13 @@ defineProps({
type: Boolean,
default: false,
},
hasUnsavedChanges: {
type: Boolean,
default: false,
},
});
defineEmits(["edit"]);
defineEmits(["edit", "save"]);
</script>
<style scoped>

View File

@ -13,7 +13,7 @@
</div>
</div>
<div class="p-3 card-body">
<div :id="calendarId" data-toggle="widget-calendar"></div>
<div ref="calendarEl" :id="calendarId" data-toggle="widget-calendar"></div>
</div>
</div>
</template>
@ -47,6 +47,7 @@ const props = defineProps({
const emit = defineEmits(["date-click", "event-click", "month-change"]);
const calendarEl = ref(null);
let calendar = null;
const currentDay = ref("");
const currentYear = ref("");
@ -88,7 +89,9 @@ const initializeCalendar = () => {
calendar.destroy();
}
calendar = new Calendar(document.getElementById(props.calendarId), {
if (!calendarEl.value) return;
calendar = new Calendar(calendarEl.value, {
contentHeight: "auto",
plugins: [dayGridPlugin, interactionPlugin],
initialView: "dayGridMonth",

View File

@ -6,7 +6,9 @@
:initials="initials"
:alt="clientName"
:editable="true"
:has-unsaved-changes="hasUnsavedChanges"
@edit="$emit('edit-avatar')"
@save="$emit('save-avatar')"
/>
<!-- Client Name -->
@ -70,7 +72,11 @@ defineProps({
type: Boolean,
default: true,
},
hasUnsavedChanges: {
type: Boolean,
default: false,
},
});
defineEmits(["edit-avatar"]);
defineEmits(["edit-avatar", "save-avatar"]);
</script>

View File

@ -114,14 +114,29 @@ export const ClientService = {
/**
* Update an existing client
*/
async updateClient(payload: UpdateClientPayload): Promise<ClientResponse> {
const { id, ...updateData } = payload;
const formattedPayload = this.transformClientPayload(updateData);
async updateClient(
payload: UpdateClientPayload | FormData
): Promise<ClientResponse> {
let id: number;
let data: any;
let method = "put";
if (payload instanceof FormData) {
id = Number(payload.get("id"));
data = payload;
// Use POST with _method=PUT to support file uploads in Laravel
data.set("_method", "PUT");
method = "post";
} else {
id = payload.id;
const { id: _, ...updateData } = payload;
data = this.transformClientPayload(updateData);
}
const response = await request<ClientResponse>({
url: `/api/clients/${id}`,
method: "put",
data: formattedPayload,
method: method,
data: data,
});
return response;