Change select internvetion, defunt, client, et produit
This commit is contained in:
parent
39a3062009
commit
16a39014a2
27
.opencode/antigravity.json
Normal file
27
.opencode/antigravity.json
Normal 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
73
.opencode/opencode.json
Normal 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"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@ use Illuminate\Http\JsonResponse;
|
|||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class ClientController extends Controller
|
class ClientController extends Controller
|
||||||
{
|
{
|
||||||
@ -152,12 +153,32 @@ class ClientController extends Controller
|
|||||||
public function update(UpdateClientRequest $request, string $id): ClientResource|JsonResponse
|
public function update(UpdateClientRequest $request, string $id): ClientResource|JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
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) {
|
if (!$updated) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Client non trouvé ou échec de la mise à jour.',
|
'message' => 'Échec de la mise à jour.',
|
||||||
], 404);
|
], 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
$client = $this->clientRepository->find($id);
|
$client = $this->clientRepository->find($id);
|
||||||
|
|||||||
@ -76,7 +76,8 @@ class InterventionController extends Controller
|
|||||||
ContactRepositoryInterface $contactRepository,
|
ContactRepositoryInterface $contactRepository,
|
||||||
DeceasedRepositoryInterface $deceasedRepository,
|
DeceasedRepositoryInterface $deceasedRepository,
|
||||||
QuoteRepositoryInterface $quoteRepository,
|
QuoteRepositoryInterface $quoteRepository,
|
||||||
ProductRepositoryInterface $productRepository
|
ProductRepositoryInterface $productRepository,
|
||||||
|
private readonly \App\Repositories\ClientLocationRepositoryInterface $clientLocationRepository
|
||||||
) {
|
) {
|
||||||
$this->interventionRepository = $interventionRepository;
|
$this->interventionRepository = $interventionRepository;
|
||||||
$this->interventionPractitionerRepository = $interventionPractitionerRepository;
|
$this->interventionPractitionerRepository = $interventionPractitionerRepository;
|
||||||
@ -150,9 +151,14 @@ class InterventionController extends Controller
|
|||||||
|
|
||||||
// Wrap everything in a database transaction
|
// Wrap everything in a database transaction
|
||||||
$result = DB::transaction(function () use ($validated) {
|
$result = DB::transaction(function () use ($validated) {
|
||||||
// Step 1: Create the deceased
|
// 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'];
|
$deceasedData = $validated['deceased'];
|
||||||
$deceased = $this->deceasedRepository->create($deceasedData);
|
$deceased = $this->deceasedRepository->create($deceasedData);
|
||||||
|
}
|
||||||
|
|
||||||
// Step 2: Create the client
|
// Step 2: Create the client
|
||||||
$clientData = $validated['client'];
|
$clientData = $validated['client'];
|
||||||
@ -168,10 +174,29 @@ class InterventionController extends Controller
|
|||||||
$contactId = $contact->id;
|
$contactId = $contact->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Prepare location data (for now, we'll include it in intervention notes)
|
// Step 4: Handle Location
|
||||||
// In the future, you might want to create a ClientLocation entry
|
|
||||||
$locationData = $validated['location'] ?? [];
|
$locationData = $validated['location'] ?? [];
|
||||||
|
$locationId = $validated['location_id'] ?? null;
|
||||||
$locationNotes = '';
|
$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)) {
|
if (!empty($locationData)) {
|
||||||
$locationParts = [];
|
$locationParts = [];
|
||||||
if (!empty($locationData['name'])) {
|
if (!empty($locationData['name'])) {
|
||||||
@ -196,6 +221,7 @@ class InterventionController extends Controller
|
|||||||
$interventionData = array_merge($validated['intervention'], [
|
$interventionData = array_merge($validated['intervention'], [
|
||||||
'deceased_id' => $deceased->id,
|
'deceased_id' => $deceased->id,
|
||||||
'client_id' => $client->id,
|
'client_id' => $client->id,
|
||||||
|
'location_id' => $locationId,
|
||||||
'notes' => ($validated['intervention']['notes'] ?? '') . $locationNotes
|
'notes' => ($validated['intervention']['notes'] ?? '') . $locationNotes
|
||||||
]);
|
]);
|
||||||
$intervention = $this->interventionRepository->create($interventionData);
|
$intervention = $this->interventionRepository->create($interventionData);
|
||||||
|
|||||||
@ -12,6 +12,9 @@ use App\Repositories\InvoiceRepositoryInterface;
|
|||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use App\Mail\DocumentMail;
|
||||||
|
|
||||||
class InvoiceController extends Controller
|
class InvoiceController extends Controller
|
||||||
{
|
{
|
||||||
@ -175,4 +178,45 @@ class InvoiceController extends Controller
|
|||||||
], 500);
|
], 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,9 @@ use Illuminate\Http\JsonResponse;
|
|||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use App\Mail\DocumentMail;
|
||||||
|
|
||||||
class QuoteController extends Controller
|
class QuoteController extends Controller
|
||||||
{
|
{
|
||||||
@ -155,4 +158,45 @@ class QuoteController extends Controller
|
|||||||
], 500);
|
], 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,8 +23,9 @@ class StoreInterventionWithAllDataRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'deceased' => 'required|array',
|
'deceased_id' => ['nullable', 'exists:deceased,id'],
|
||||||
'deceased.last_name' => ['required', 'string', 'max:191'],
|
'deceased' => ['required_without:deceased_id', 'array'],
|
||||||
|
'deceased.last_name' => ['required_without:deceased_id', 'string', 'max:191'],
|
||||||
'deceased.first_name' => ['nullable', 'string', 'max:191'],
|
'deceased.first_name' => ['nullable', 'string', 'max:191'],
|
||||||
'deceased.birth_date' => ['nullable', 'date'],
|
'deceased.birth_date' => ['nullable', 'date'],
|
||||||
'deceased.death_date' => ['nullable', 'date', 'after_or_equal:deceased.birth_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.phone' => ['nullable', 'string', 'max:50'],
|
||||||
'contact.role' => ['nullable', 'string', 'max:191'],
|
'contact.role' => ['nullable', 'string', 'max:191'],
|
||||||
|
|
||||||
'location' => 'nullable|array',
|
'location_id' => ['nullable', 'exists:client_locations,id'],
|
||||||
'location.name' => ['nullable', 'string', 'max:255'],
|
'location' => ['nullable', 'array'],
|
||||||
|
'location.name' => ['required_without:location_id', 'string', 'max:255'],
|
||||||
'location.address' => ['nullable', '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.postal_code' => ['nullable', 'string', 'max:20'],
|
||||||
'location.country_code' => ['nullable', 'string', 'size:2'],
|
'location.country_code' => ['nullable', 'string', 'size:2'],
|
||||||
'location.access_instructions' => ['nullable', 'string'],
|
'location.access_instructions' => ['nullable', 'string'],
|
||||||
|
|||||||
@ -39,6 +39,7 @@ class UpdateClientRequest extends FormRequest
|
|||||||
'is_parent' => 'boolean|nullable',
|
'is_parent' => 'boolean|nullable',
|
||||||
'parent_id' => 'nullable|exists:clients,id',
|
'parent_id' => 'nullable|exists:clients,id',
|
||||||
'default_tva_rate_id' => 'nullable|exists:tva_rates,id',
|
'default_tva_rate_id' => 'nullable|exists:tva_rates,id',
|
||||||
|
'avatar' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg|max:2048',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ namespace App\Http\Resources\Client;
|
|||||||
use App\Http\Resources\Contact\ContactResource;
|
use App\Http\Resources\Contact\ContactResource;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class ClientResource extends JsonResource
|
class ClientResource extends JsonResource
|
||||||
{
|
{
|
||||||
@ -25,6 +26,7 @@ class ClientResource extends JsonResource
|
|||||||
'siret' => $this->siret,
|
'siret' => $this->siret,
|
||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
'phone' => $this->phone,
|
'phone' => $this->phone,
|
||||||
|
'avatar_url' => $this->avatar ? Storage::url($this->avatar) : null,
|
||||||
'billing_address' => [
|
'billing_address' => [
|
||||||
'line1' => $this->billing_address_line1,
|
'line1' => $this->billing_address_line1,
|
||||||
'line2' => $this->billing_address_line2,
|
'line2' => $this->billing_address_line2,
|
||||||
|
|||||||
70
thanasoft-back/app/Mail/DocumentMail.php
Normal file
70
thanasoft-back/app/Mail/DocumentMail.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,6 +28,7 @@ class Client extends Model
|
|||||||
// 'default_tva_rate_id',
|
// 'default_tva_rate_id',
|
||||||
'client_category_id',
|
'client_category_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
|
'avatar',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
|
"barryvdh/laravel-dompdf": "^3.1",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "^4.2",
|
"laravel/sanctum": "^4.2",
|
||||||
"laravel/tinker": "^2.10.1"
|
"laravel/tinker": "^2.10.1"
|
||||||
|
|||||||
520
thanasoft-back/composer.lock
generated
520
thanasoft-back/composer.lock
generated
@ -4,8 +4,85 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "8f387a0734f3bf879214e4aa2fca6e2f",
|
"content-hash": "343ecaac4a8b061c5430a046847047e7",
|
||||||
"packages": [
|
"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",
|
"name": "brick/math",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0",
|
||||||
@ -377,6 +454,161 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-02-05T11:56:58+00:00"
|
"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",
|
"name": "dragonmantank/cron-expression",
|
||||||
"version": "v3.4.0",
|
"version": "v3.4.0",
|
||||||
@ -2074,6 +2306,73 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-12-08T08:18:47+00:00"
|
"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",
|
"name": "monolog/monolog",
|
||||||
"version": "3.9.0",
|
"version": "3.9.0",
|
||||||
@ -3412,6 +3711,80 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-09-04T20:59:21+00:00"
|
"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",
|
"name": "symfony/clock",
|
||||||
"version": "v7.3.0",
|
"version": "v7.3.0",
|
||||||
@ -5890,6 +6263,145 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-08-13T11:49:31+00:00"
|
"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",
|
"name": "tijsverkoyen/css-to-inline-styles",
|
||||||
"version": "v2.3.0",
|
"version": "v2.3.0",
|
||||||
@ -8527,12 +9039,12 @@
|
|||||||
],
|
],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"stability-flags": [],
|
"stability-flags": {},
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.2"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"platform-dev": [],
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.9.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
39
thanasoft-back/resources/views/emails/document.blade.php
Normal file
39
thanasoft-back/resources/views/emails/document.blade.php
Normal 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>
|
||||||
73
thanasoft-back/resources/views/pdf/invoice_pdf.blade.php
Normal file
73
thanasoft-back/resources/views/pdf/invoice_pdf.blade.php
Normal 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>
|
||||||
73
thanasoft-back/resources/views/pdf/quote_pdf.blade.php
Normal file
73
thanasoft-back/resources/views/pdf/quote_pdf.blade.php
Normal 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>
|
||||||
@ -72,9 +72,11 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
Route::apiResource('client-categories', ClientCategoryController::class);
|
Route::apiResource('client-categories', ClientCategoryController::class);
|
||||||
|
|
||||||
// Quote management
|
// Quote management
|
||||||
|
Route::post('/quotes/{id}/send-email', [QuoteController::class, 'sendByEmail']);
|
||||||
Route::apiResource('quotes', QuoteController::class);
|
Route::apiResource('quotes', QuoteController::class);
|
||||||
|
|
||||||
// Invoice management
|
// 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::post('/invoices/from-quote/{quoteId}', [\App\Http\Controllers\Api\InvoiceController::class, 'createFromQuote']);
|
||||||
Route::apiResource('invoices', \App\Http\Controllers\Api\InvoiceController::class);
|
Route::apiResource('invoices', \App\Http\Controllers\Api\InvoiceController::class);
|
||||||
|
|
||||||
|
|||||||
@ -167,7 +167,7 @@ const steps = [
|
|||||||
{ label: "Défunt", title: "Information du Défunt" },
|
{ label: "Défunt", title: "Information du Défunt" },
|
||||||
{ label: "Client", title: "Information du Client" },
|
{ label: "Client", title: "Information du Client" },
|
||||||
{ label: "Lieu", title: "Lieu de l'intervention" },
|
{ 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: "Documents", title: "Documents à joindre" },
|
||||||
{ label: "Intervention", title: "Détails de l'intervention" },
|
{ label: "Intervention", title: "Détails de l'intervention" },
|
||||||
];
|
];
|
||||||
@ -178,6 +178,8 @@ const globalErrors = ref([]);
|
|||||||
|
|
||||||
// Form data
|
// Form data
|
||||||
const deceasedForm = ref({
|
const deceasedForm = ref({
|
||||||
|
id: null,
|
||||||
|
is_existing: false, // UI state to track mode
|
||||||
first_name: "",
|
first_name: "",
|
||||||
last_name: "",
|
last_name: "",
|
||||||
birth_date: "",
|
birth_date: "",
|
||||||
@ -203,6 +205,8 @@ const clientForm = ref({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const locationForm = ref({
|
const locationForm = ref({
|
||||||
|
id: null,
|
||||||
|
is_existing: false,
|
||||||
name: "",
|
name: "",
|
||||||
address: "",
|
address: "",
|
||||||
city: "",
|
city: "",
|
||||||
@ -273,10 +277,14 @@ const addStepError = (stepIndex, field, message) => {
|
|||||||
const validateDeceasedStep = () => {
|
const validateDeceasedStep = () => {
|
||||||
clearStepErrors(0);
|
clearStepErrors(0);
|
||||||
let isValid = true;
|
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.");
|
addStepError(0, "last_name", "Le nom est obligatoire.");
|
||||||
isValid = false;
|
isValid = false;
|
||||||
}
|
}
|
||||||
|
if (deceasedForm.value.is_existing && !deceasedForm.value.id) {
|
||||||
|
addStepError(0, "deceased_id", "Veuillez sélectionner un défunt.");
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
return isValid;
|
return isValid;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -293,10 +301,14 @@ const validateClientStep = () => {
|
|||||||
const validateLocationStep = () => {
|
const validateLocationStep = () => {
|
||||||
clearStepErrors(2);
|
clearStepErrors(2);
|
||||||
let isValid = true;
|
let isValid = true;
|
||||||
if (!locationForm.value.city) {
|
if (!locationForm.value.is_existing && !locationForm.value.city) {
|
||||||
addStepError(2, "city", "La ville est obligatoire.");
|
addStepError(2, "city", "La ville est obligatoire.");
|
||||||
isValid = false;
|
isValid = false;
|
||||||
}
|
}
|
||||||
|
if (locationForm.value.is_existing && !locationForm.value.id) {
|
||||||
|
addStepError(2, "location_id", "Veuillez sélectionner un lieu.");
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
return isValid;
|
return isValid;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -304,7 +316,7 @@ const validateProductStep = () => {
|
|||||||
clearStepErrors(3);
|
clearStepErrors(3);
|
||||||
let isValid = true;
|
let isValid = true;
|
||||||
if (!productForm.value.product_id) {
|
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;
|
isValid = false;
|
||||||
}
|
}
|
||||||
return isValid;
|
return isValid;
|
||||||
@ -419,20 +431,28 @@ const handleSubmit = async () => {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
// Deceased
|
// Deceased
|
||||||
|
if (deceasedForm.value.is_existing && deceasedForm.value.id) {
|
||||||
|
formData.append("deceased_id", deceasedForm.value.id);
|
||||||
|
} else {
|
||||||
Object.keys(deceasedForm.value).forEach((key) => {
|
Object.keys(deceasedForm.value).forEach((key) => {
|
||||||
if (deceasedForm.value[key] != null)
|
if (deceasedForm.value[key] != null && key !== 'id' && key !== 'is_existing')
|
||||||
formData.append(`deceased[${key}]`, deceasedForm.value[key]);
|
formData.append(`deceased[${key}]`, deceasedForm.value[key]);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// Client
|
// Client
|
||||||
Object.keys(clientForm.value).forEach((key) => {
|
Object.keys(clientForm.value).forEach((key) => {
|
||||||
if (clientForm.value[key] != null)
|
if (clientForm.value[key] != null)
|
||||||
formData.append(`client[${key}]`, clientForm.value[key]);
|
formData.append(`client[${key}]`, clientForm.value[key]);
|
||||||
});
|
});
|
||||||
// Location
|
// Location
|
||||||
|
if (locationForm.value.is_existing && locationForm.value.id) {
|
||||||
|
formData.append("location_id", locationForm.value.id);
|
||||||
|
} else {
|
||||||
Object.keys(locationForm.value).forEach((key) => {
|
Object.keys(locationForm.value).forEach((key) => {
|
||||||
if (locationForm.value[key] != null)
|
if (locationForm.value[key] != null && key !== 'id' && key !== 'is_existing')
|
||||||
formData.append(`location[${key}]`, locationForm.value[key]);
|
formData.append(`location[${key}]`, locationForm.value[key]);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// Intervention
|
// Intervention
|
||||||
Object.keys(interventionForm.value).forEach((key) => {
|
Object.keys(interventionForm.value).forEach((key) => {
|
||||||
if (interventionForm.value[key] != null) {
|
if (interventionForm.value[key] != null) {
|
||||||
|
|||||||
@ -2,7 +2,85 @@
|
|||||||
<div class="multisteps-form__panel" :class="isActive ? activeClass : ''">
|
<div class="multisteps-form__panel" :class="isActive ? activeClass : ''">
|
||||||
<h6 class="mb-3 text-dark font-weight-bold">Informations du Défunt</h6>
|
<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">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">Prénom</label>
|
<label class="form-label">Prénom</label>
|
||||||
<input
|
<input
|
||||||
@ -114,7 +192,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, defineEmits, toRefs } from "vue";
|
import { defineProps, defineEmits, ref, watch } from "vue";
|
||||||
|
import DeceasedService from "@/services/deceased";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
formData: {
|
formData: {
|
||||||
@ -141,6 +220,51 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(["next"]);
|
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
|
// Error helpers using props
|
||||||
const getFieldError = (field) => {
|
const getFieldError = (field) => {
|
||||||
const error = props.errors.find((err) => err.field === field);
|
const error = props.errors.find((err) => err.field === field);
|
||||||
|
|||||||
@ -2,7 +2,87 @@
|
|||||||
<div class="multisteps-form__panel" :class="isActive ? activeClass : ''">
|
<div class="multisteps-form__panel" :class="isActive ? activeClass : ''">
|
||||||
<h6 class="mb-3 text-dark font-weight-bold">Lieu de l'intervention</h6>
|
<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">
|
<div class="col-md-12 mb-3">
|
||||||
<label class="form-label">Nom du lieu</label>
|
<label class="form-label">Nom du lieu</label>
|
||||||
<input
|
<input
|
||||||
@ -116,7 +196,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, defineEmits } from "vue";
|
import { defineProps, defineEmits, ref, watch } from "vue";
|
||||||
|
import ClientLocationService from "@/services/client_location";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
formData: {
|
formData: {
|
||||||
@ -143,6 +224,63 @@ const props = defineProps({
|
|||||||
|
|
||||||
defineEmits(["next", "prev"]);
|
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 getFieldError = (field) => {
|
||||||
const error = props.errors.find((err) => err.field === field);
|
const error = props.errors.find((err) => err.field === field);
|
||||||
return error ? error.message : "";
|
return error ? error.message : "";
|
||||||
|
|||||||
@ -1,57 +1,60 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="multisteps-form__panel" :class="isActive ? activeClass : ''">
|
<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="row">
|
||||||
<div class="col-md-12 mb-3">
|
<div class="col-md-12 mb-3 position-relative">
|
||||||
<label class="form-label">Type de produit *</label>
|
<label class="form-label">Type de soins</label>
|
||||||
<div class="d-flex flex-column gap-2">
|
<div class="input-group">
|
||||||
<div v-if="loading" class="text-center py-3">
|
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||||||
<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,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
:id="'product-' + product.id"
|
v-model="searchQuery"
|
||||||
v-model="formData.product_id"
|
type="text"
|
||||||
class="form-check-input"
|
class="form-control"
|
||||||
type="radio"
|
:class="{ 'is-invalid': hasError('product_id') }"
|
||||||
name="productSelection"
|
placeholder="Rechercher un soin..."
|
||||||
:value="product.id"
|
@input="handleSearchInput"
|
||||||
|
@focus="showResults = true"
|
||||||
/>
|
/>
|
||||||
<label
|
<button v-if="formData.product_id" class="btn btn-outline-secondary" type="button" @click="clearSelection">
|
||||||
class="form-check-label fw-bold w-100"
|
<i class="fas fa-times"></i>
|
||||||
:for="'product-' + product.id"
|
</button>
|
||||||
>
|
|
||||||
{{ product.nom }}
|
|
||||||
<div class="text-muted fw-normal small">
|
|
||||||
{{ product.description || product.reference }}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="getFieldError('product_type')"
|
v-if="getFieldError('product_id')"
|
||||||
class="invalid-feedback d-block"
|
class="invalid-feedback d-block"
|
||||||
>
|
>
|
||||||
{{ getFieldError("product_type") }}
|
{{ getFieldError("product_id") }}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -81,7 +84,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, defineEmits, onMounted, ref } from "vue";
|
import { defineProps, defineEmits, onMounted, ref } from "vue";
|
||||||
import { useProductStore } from "@/stores/productStore";
|
import ProductService from "@/services/product";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
formData: {
|
formData: {
|
||||||
@ -108,28 +111,88 @@ const props = defineProps({
|
|||||||
|
|
||||||
defineEmits(["next", "prev"]);
|
defineEmits(["next", "prev"]);
|
||||||
|
|
||||||
const productStore = useProductStore();
|
const productService = new ProductService();
|
||||||
const interventionProducts = ref([]);
|
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 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 () => {
|
onMounted(async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
// Fetch products that belong to intervention categories
|
const service = new ProductService();
|
||||||
const response = await productStore.fetchProducts({
|
// Fetch all intervention products (using existing logic or new method if needed)
|
||||||
is_intervention: true,
|
// Assuming getProductsByCategory or getAllProducts with filter works.
|
||||||
per_page: 50,
|
// 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) {
|
// Check response structure. product.ts says ProductListResponse { data: Product[] ... }
|
||||||
console.error("Error fetching intervention products:", error);
|
allProducts.value = response.data;
|
||||||
|
searchResults.value = allProducts.value;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load products", e);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
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 getFieldError = (field) => {
|
||||||
const error = props.errors.find((err) => err.field === field);
|
const error = props.errors.find((err) => err.field === field);
|
||||||
return error ? error.message : "";
|
return error ? error.message : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasError = (field) => {
|
||||||
|
return props.errors.some((err) => err.field === field);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #client-detail-sidebar>
|
<template #client-detail-sidebar>
|
||||||
<ClientDetailSidebar
|
<ClientDetailSidebar
|
||||||
:avatar-url="clientAvatar"
|
:avatar-url="localAvatar || clientAvatar"
|
||||||
:initials="getInitials(client.name)"
|
:initials="getInitials(client.name)"
|
||||||
:client-name="client.name"
|
:client-name="client.name"
|
||||||
:client-type="client.type_label || 'Client'"
|
:client-type="client.type_label || 'Client'"
|
||||||
@ -29,13 +29,15 @@
|
|||||||
:is-active="client.is_active"
|
:is-active="client.is_active"
|
||||||
:is-parent="client.is_parent"
|
:is-parent="client.is_parent"
|
||||||
:active-tab="activeTab"
|
:active-tab="activeTab"
|
||||||
|
:has-unsaved-changes="hasUnsavedChanges"
|
||||||
@edit-avatar="triggerFileInput"
|
@edit-avatar="triggerFileInput"
|
||||||
|
@save-avatar="handleSaveAvatar"
|
||||||
@change-tab="$emit('change-tab', $event)"
|
@change-tab="$emit('change-tab', $event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #file-input>
|
<template #file-input>
|
||||||
<input
|
<input
|
||||||
:ref="fileInput"
|
ref="fileInputRef"
|
||||||
type="file"
|
type="file"
|
||||||
class="d-none"
|
class="d-none"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
@ -64,7 +66,7 @@
|
|||||||
</client-detail-template>
|
</client-detail-template>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, defineEmits, ref } from "vue";
|
import { defineProps, defineEmits, ref, watch } from "vue";
|
||||||
import ClientDetailTemplate from "@/components/templates/CRM/ClientDetailTemplate.vue";
|
import ClientDetailTemplate from "@/components/templates/CRM/ClientDetailTemplate.vue";
|
||||||
import ClientDetailSidebar from "./client/ClientDetailSidebar.vue";
|
import ClientDetailSidebar from "./client/ClientDetailSidebar.vue";
|
||||||
import ClientDetailContent from "./client/ClientDetailContent.vue";
|
import ClientDetailContent from "./client/ClientDetailContent.vue";
|
||||||
@ -102,10 +104,6 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: "overview",
|
default: "overview",
|
||||||
},
|
},
|
||||||
fileInput: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
contactLoading: {
|
contactLoading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@ -116,16 +114,25 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fileInputRef = ref(null);
|
||||||
const localAvatar = ref(props.clientAvatar);
|
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([
|
const emit = defineEmits([
|
||||||
"updateTheClient",
|
"updateTheClient",
|
||||||
"handleFileInput",
|
|
||||||
"add-new-contact",
|
"add-new-contact",
|
||||||
"updating-contact",
|
"updating-contact",
|
||||||
"add-new-location",
|
"add-new-location",
|
||||||
"modify-location",
|
"modify-location",
|
||||||
"modify-location",
|
|
||||||
"remove-location",
|
"remove-location",
|
||||||
"change-tab",
|
"change-tab",
|
||||||
]);
|
]);
|
||||||
@ -133,22 +140,33 @@ const emit = defineEmits([
|
|||||||
const handleAvatarUpload = (event) => {
|
const handleAvatarUpload = (event) => {
|
||||||
const file = event.target.files[0];
|
const file = event.target.files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
|
selectedFile.value = file;
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
localAvatar.value = e.target.result;
|
localAvatar.value = e.target.result;
|
||||||
// TODO: Upload to server
|
hasUnsavedChanges.value = true;
|
||||||
console.log("Upload avatar to server");
|
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
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) => {
|
const handleUpdateClient = (updateData) => {
|
||||||
emit("updateTheClient", updateData);
|
emit("updateTheClient", updateData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputFile = () => {
|
const triggerFileInput = () => {
|
||||||
emit("handleFileInput");
|
if (fileInputRef.value) {
|
||||||
|
fileInputRef.value.click();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddContact = (data) => {
|
const handleAddContact = (data) => {
|
||||||
|
|||||||
@ -8,7 +8,9 @@
|
|||||||
:client-type="clientType"
|
:client-type="clientType"
|
||||||
:contacts-count="contactsCount"
|
:contacts-count="contactsCount"
|
||||||
:is-active="isActive"
|
:is-active="isActive"
|
||||||
|
:has-unsaved-changes="hasUnsavedChanges"
|
||||||
@edit-avatar="$emit('edit-avatar')"
|
@edit-avatar="$emit('edit-avatar')"
|
||||||
|
@save-avatar="$emit('save-avatar')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<hr class="horizontal dark my-3 mx-3" />
|
<hr class="horizontal dark my-3 mx-3" />
|
||||||
@ -72,9 +74,13 @@ defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
hasUnsavedChanges: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits(["edit-avatar", "change-tab"]);
|
defineEmits(["edit-avatar", "save-avatar", "change-tab"]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -15,19 +15,36 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Edit Avatar Button -->
|
<!-- Edit Avatar Button -->
|
||||||
|
<div class="position-absolute bottom-0 end-0 mb-n2 me-n2 d-flex gap-1">
|
||||||
<a
|
<a
|
||||||
v-if="editable"
|
v-if="editable"
|
||||||
href="javascript:;"
|
href="javascript:;"
|
||||||
class="btn btn-sm btn-icon-only bg-gradient-light position-absolute bottom-0 end-0 mb-n2 me-n2"
|
class="btn btn-sm btn-icon-only bg-gradient-light"
|
||||||
@click="$emit('edit')"
|
@click="$emit('edit')"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
class="fa fa-pen top-0"
|
class="fa fa-pen"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
data-bs-placement="top"
|
data-bs-placement="top"
|
||||||
title="Modifier l'image"
|
title="Modifier l'image"
|
||||||
></i>
|
></i>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -50,9 +67,13 @@ defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
hasUnsavedChanges: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits(["edit"]);
|
defineEmits(["edit", "save"]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 card-body">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -47,6 +47,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(["date-click", "event-click", "month-change"]);
|
const emit = defineEmits(["date-click", "event-click", "month-change"]);
|
||||||
|
|
||||||
|
const calendarEl = ref(null);
|
||||||
let calendar = null;
|
let calendar = null;
|
||||||
const currentDay = ref("");
|
const currentDay = ref("");
|
||||||
const currentYear = ref("");
|
const currentYear = ref("");
|
||||||
@ -88,7 +89,9 @@ const initializeCalendar = () => {
|
|||||||
calendar.destroy();
|
calendar.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
calendar = new Calendar(document.getElementById(props.calendarId), {
|
if (!calendarEl.value) return;
|
||||||
|
|
||||||
|
calendar = new Calendar(calendarEl.value, {
|
||||||
contentHeight: "auto",
|
contentHeight: "auto",
|
||||||
plugins: [dayGridPlugin, interactionPlugin],
|
plugins: [dayGridPlugin, interactionPlugin],
|
||||||
initialView: "dayGridMonth",
|
initialView: "dayGridMonth",
|
||||||
|
|||||||
@ -6,7 +6,9 @@
|
|||||||
:initials="initials"
|
:initials="initials"
|
||||||
:alt="clientName"
|
:alt="clientName"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
|
:has-unsaved-changes="hasUnsavedChanges"
|
||||||
@edit="$emit('edit-avatar')"
|
@edit="$emit('edit-avatar')"
|
||||||
|
@save="$emit('save-avatar')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Client Name -->
|
<!-- Client Name -->
|
||||||
@ -70,7 +72,11 @@ defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
hasUnsavedChanges: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits(["edit-avatar"]);
|
defineEmits(["edit-avatar", "save-avatar"]);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -114,14 +114,29 @@ export const ClientService = {
|
|||||||
/**
|
/**
|
||||||
* Update an existing client
|
* Update an existing client
|
||||||
*/
|
*/
|
||||||
async updateClient(payload: UpdateClientPayload): Promise<ClientResponse> {
|
async updateClient(
|
||||||
const { id, ...updateData } = payload;
|
payload: UpdateClientPayload | FormData
|
||||||
const formattedPayload = this.transformClientPayload(updateData);
|
): 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>({
|
const response = await request<ClientResponse>({
|
||||||
url: `/api/clients/${id}`,
|
url: `/api/clients/${id}`,
|
||||||
method: "put",
|
method: method,
|
||||||
data: formattedPayload,
|
data: data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user